From 0f49f835e33f64db9298c30a75cce73b6cfadb75 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Fri, 14 Aug 2026 11:26:13 +0100 Subject: [PATCH 1/7] fix: prevent duration fields wrapping on small screens The hour/minute duration inputs shared a .row class with the completed-date/time row. The small-screen media query flipped all .row elements to flex-direction: column, stacking the duration fields instead of keeping them side by side. Scope the column layout to the date/time row only. Co-Authored-By: Claude Sonnet 5 --- .../LogOfflineActivityModal/LogOfflineActivityModal.module.scss | 2 +- .../LogOfflineActivityModal/LogOfflineActivityModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss index 8c7e4788..4c7de25f 100644 --- a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss @@ -120,7 +120,7 @@ } @include m.respond-to(sm, down) { - .row { + .row:not(.durationRow) { flex-direction: column; } } diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx index bd3f6c83..0dae974f 100644 --- a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx @@ -88,7 +88,7 @@ export default function LogOfflineActivityModal({ onClose }: LogOfflineActivityM Duration * -
+
Date: Fri, 14 Aug 2026 11:43:16 +0100 Subject: [PATCH 2/7] fix: add note to credit contributors --- .github/PULL_REQUEST_TEMPLATE/development-to-staging.md | 3 +++ .github/PULL_REQUEST_TEMPLATE/staging-to-main.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md b/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md index ef9219d5..7e4648d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md +++ b/.github/PULL_REQUEST_TEMPLATE/development-to-staging.md @@ -19,6 +19,9 @@ --- +## Contributors + + ## Technical notes diff --git a/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md b/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md index c27096c0..4230910c 100644 --- a/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md +++ b/.github/PULL_REQUEST_TEMPLATE/staging-to-main.md @@ -19,6 +19,9 @@ --- +## Contributors + + ## Technical notes From c2e8a7c28392ae0aaeab095448430bf5da856ad8 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Fri, 14 Aug 2026 12:09:52 +0100 Subject: [PATCH 3/7] feat: broadcast announcement badge updates in real time Publishing an Announcement now pushes a WebSocket "announcement_published" event to online players, which invalidates the announcements/unread-count queries so the Navbar badge appears immediately instead of only on refresh or remount. - Announcement.save() broadcasts on the unpublished->published transition - publish_selected_announcements admin action saves rows individually (not queryset.update()) so the broadcast actually fires - frontend wires the new action into handleGlobalWebSocketEvent / WebSocketContext to invalidate the relevant react-query keys Co-Authored-By: Claude Sonnet 5 --- core/admin.py | 7 ++++- core/models.py | 27 +++++++++++++++++++ frontend/src/context/WebSocketContext.tsx | 21 +++++++++++++-- frontend/src/types/timers.ts | 8 ++++-- .../websockets/handleGlobalWebSocketEvent.ts | 18 +++++++++++-- 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/core/admin.py b/core/admin.py index 87c83e5b..bdc0cfc7 100644 --- a/core/admin.py +++ b/core/admin.py @@ -133,7 +133,12 @@ def has_add_permission(self, request): @admin.action(description="Publish selected announcements") def publish_selected_announcements(_modeladmin, _request, queryset): now = timezone.now() - queryset.update(is_published=True, published_at=now) + # Save individually (not queryset.update()) so Announcement.save() + # broadcasts the "announcement_published" WebSocket event per row. + for announcement in queryset: + announcement.is_published = True + announcement.published_at = now + announcement.save() @admin.action(description="Unpublish selected announcements") diff --git a/core/models.py b/core/models.py index da57cd33..0bb0ecff 100644 --- a/core/models.py +++ b/core/models.py @@ -247,6 +247,33 @@ class Meta: def __str__(self): return self.title + def save(self, *args, **kwargs): + was_published = ( + Announcement.objects.filter(pk=self.pk, is_published=True).exists() + if self.pk + else False + ) + super().save(*args, **kwargs) + if self.is_published and not was_published: + from django.db import transaction + + transaction.on_commit(self._broadcast_published) + + def _broadcast_published(self): + from asgiref.sync import async_to_sync + + from gameplay.utils import send_group_message + + async_to_sync(send_group_message)( + "online_users", + { + "type": "action", + "action": "announcement_published", + "data": {"id": self.id}, + "success": True, + }, + ) + class PlayerAnnouncementState(models.Model): player = models.ForeignKey( diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx index aabc3b7d..6de4e5af 100644 --- a/frontend/src/context/WebSocketContext.tsx +++ b/frontend/src/context/WebSocketContext.tsx @@ -1,6 +1,7 @@ // context/WebSocketContext.tsx import { useRef, useCallback, useEffect } from 'react'; import type { ReactNode, ReactElement } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useGame } from '../hooks/useGame'; import { useOnlineCount } from './OnlineCountContext'; import { useToast } from '../hooks/useToast'; @@ -10,6 +11,10 @@ import { handleGlobalWebSocketEvent } from '../websockets/handleGlobalWebSocketE import { useMaintenanceStatus } from '../hooks/useMaintenanceStatus'; import { useMaintenanceContext } from './MaintenanceContext'; import { WebSocketContext } from './webSocketContext'; +import { + ANNOUNCEMENTS_QUERY_KEY, + ANNOUNCEMENT_UNREAD_QUERY_KEY, +} from '../hooks/useAnnouncements'; import type { ActivityTimerApiData, IncomingWebSocketMessage, OutgoingWebSocketMessage } from '../types'; // --------------------------------------------------------------------------- @@ -36,6 +41,7 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement => const { showToast } = useToast(); const { refetch: maintenanceRefetch } = useMaintenanceStatus(); const { setMaintenance } = useMaintenanceContext(); + const queryClient = useQueryClient(); // Set stores message handler callbacks registered by child components const eventHandlersRef = useRef void>>(new Set()); const wsEnabled = Boolean(!authLoading && isAuthenticated && player?.id); @@ -52,14 +58,25 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement => }); }, [loadFromServer, player?.is_premium, freeTimerLimitSeconds]); + const onAnnouncementPublished = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ANNOUNCEMENTS_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: ANNOUNCEMENT_UNREAD_QUERY_KEY }); + }, [queryClient]); + const onMessage = useCallback((data: IncomingWebSocketMessage) => { if (data.type === 'online_count') { setOnlinePlayerCount(data.count); } //console.log("[WS Provider] showToast:", showToast); - handleGlobalWebSocketEvent(data, { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }); + handleGlobalWebSocketEvent(data, { + showToast, + maintenanceRefetch, + setMaintenance, + onActivityTimerUpdate, + onAnnouncementPublished, + }); eventHandlersRef.current.forEach((handler) => handler(data)); - }, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate]); + }, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate, onAnnouncementPublished]); const onError = useCallback(() => { console.error('WebSocket connection error'); diff --git a/frontend/src/types/timers.ts b/frontend/src/types/timers.ts index 6dca6d7e..6ac00197 100644 --- a/frontend/src/types/timers.ts +++ b/frontend/src/types/timers.ts @@ -157,7 +157,7 @@ export interface WebSocketErrorMessage extends WebSocketMessageBase { /** Server-initiated action message (maintenance refresh, game events) */ export interface WebSocketActionMessage { type: "action"; - action: "refresh" | "load-game" | "activity_timer_update"; + action: "refresh" | "load-game" | "activity_timer_update" | "announcement_published"; message?: string; maintenance_active?: boolean; name?: string; @@ -168,8 +168,12 @@ export interface WebSocketActionMessage { * Present when action is "activity_timer_update" — pushed whenever another * of this player's sessions (tabs/devices) starts, labels, or submits the * activity timer, so every open session can reconcile to server state. + * + * Present when action is "announcement_published" — the id of the + * newly-published Announcement, so callers can invalidate the + * announcements list and unread-count queries. */ - data?: { activity_timer: ActivityTimerApiData }; + data?: { activity_timer: ActivityTimerApiData } | { id: number }; } /** Generic server message (currently unused payload) */ diff --git a/frontend/src/websockets/handleGlobalWebSocketEvent.ts b/frontend/src/websockets/handleGlobalWebSocketEvent.ts index a12599a2..140d54d2 100644 --- a/frontend/src/websockets/handleGlobalWebSocketEvent.ts +++ b/frontend/src/websockets/handleGlobalWebSocketEvent.ts @@ -13,11 +13,22 @@ interface HandleGlobalWebSocketEventOptions { * useActivityTimer's loadFromServer. */ onActivityTimerUpdate?: (activityTimer: ActivityTimerApiData) => void; + /** + * Called when the server announces a newly-published Announcement, so the + * caller can refetch the announcements list / unread-count queries. + */ + onAnnouncementPublished?: () => void; } export async function handleGlobalWebSocketEvent( data: IncomingWebSocketMessage, - { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }: HandleGlobalWebSocketEventOptions, + { + showToast, + maintenanceRefetch, + setMaintenance, + onActivityTimerUpdate, + onAnnouncementPublished, + }: HandleGlobalWebSocketEventOptions, ): Promise { switch (data.type) { case 'notification': @@ -74,10 +85,13 @@ export async function handleGlobalWebSocketEvent( console.log("[WS] Django consumer 'load-game' message not currently in use."); break; case 'activity_timer_update': - if (data.data?.activity_timer) { + if (data.data && 'activity_timer' in data.data) { onActivityTimerUpdate?.(data.data.activity_timer); } break; + case 'announcement_published': + onAnnouncementPublished?.(); + break; default: console.warn('[WS] Unknown action:', data); } From 58495b772cee1a8fe8f542f5ab51b758e31af670 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:49:59 +0000 Subject: [PATCH 4/7] docs: document expand/contract pattern for dropping DB columns Root-cause the 2026-08-14 production UndefinedColumn errors on Character.can_link: web, celery, and celery-beat deploy independently on Render, and only web runs migrate via preDeployCommand. The migration dropping can_link landed in the same deploy as the code change that stopped using it, so celery/celery-beat kept erroring on the old column reference until their own deploys caught up. The current can_link code (derived property, is_reserved field) is already correct - this documents the two-deploy process needed to avoid the same stale-worker window on future column removals. --- docs/operations/deployment-runbook.md | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/operations/deployment-runbook.md b/docs/operations/deployment-runbook.md index 69941b6a..fc076a72 100644 --- a/docs/operations/deployment-runbook.md +++ b/docs/operations/deployment-runbook.md @@ -50,6 +50,38 @@ This is a lightweight runbook for backend deployment and immediate verification. - Prefer additive schema changes prior to destructive cleanups - Announce deploy windows for higher-risk changes +### Removing a database column safely + +`web`, `celery`, and `celery-beat` (and their `-staging` equivalents) are +separate Render services, each with its own `autoDeployTrigger: commit` and +build queue. Only `web` has a `preDeployCommand` that runs `migrate`; `celery` +and `celery-beat` have none. All three deploy independently off the same +commit, with no guarantee they finish building/restarting at the same time — +`celery`/`celery-beat` builds can lag `web` by anywhere from seconds to a +couple of hours depending on Render's build queue. + +Because of this, a migration that drops a column in the same deploy as the +code change that stops using it creates a window where `web` has already +migrated the (shared) database but `celery`/`celery-beat` are still running +the *old* image — which still references the now-dropped column — until +their own deploys catch up. Any query touching that model from the old +worker code raises `UndefinedColumn` for the whole window. (This is what +happened with `Character.can_link` on 2026-08-14: the migration removing the +column landed in the same deploy as the code that stopped needing it, and +`commute_tick`/`wander_tick` errored on the stale `celery`/`celery-beat` +workers until they finished redeploying.) + +To remove a column without a stale-worker error window, split it across two +separate deploys (expand/contract): + +1. **Deploy 1**: ship the code change that stops reading/writing the column, + but leave the column itself in the DB (no migration removing it yet). +2. Confirm `web`, `celery`, and `celery-beat` (and `-staging` equivalents) + have all finished redeploying and are healthy. +3. **Deploy 2**: add the migration that drops the column, with no + accompanying code change needed (the code already stopped touching it in + deploy 1). + ## Note on PR workflow The rule for which branch a *feature* PR should target (normally `development`, with a documented exception for basing on `staging`) is a development workflow concern, not a deployment-execution step — that lives in `CLAUDE.md`, not here. From 2b386259210ac2ff9f4df3806556bafc752bb3ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:36:57 +0000 Subject: [PATCH 5/7] fix: correct DailySunTimesAdmin field name DailySunTimesAdmin.fields referenced "name", a field that doesn't exist on DailySunTimes (it has date, world, sunrise, sunset, dawn, dusk) - list_display/list_filter already correctly use "date". Every GET to /admin/gameworld/dailysuntimes/add/ raised FieldError before rendering the form. --- gameworld/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gameworld/admin.py b/gameworld/admin.py index cfdb664e..3ba49685 100644 --- a/gameworld/admin.py +++ b/gameworld/admin.py @@ -15,7 +15,7 @@ class DailySunTimesAdmin(admin.ModelAdmin): ] fields = [ "id", - "name", + "date", ("dawn", "dusk"), ("sunrise", "sunset"), ] From d861b06a0c9acafbf1280c10256761c5c168b278 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:47:01 +0000 Subject: [PATCH 6/7] fix: batch CharacterLocation lookups in commute_tick to remove N+1 target_role_for (via work_hours_for) queried each character's WORK CharacterLocation individually, and sync_character_location redundantly called target_role_for again per character even when the caller already knew the role - both run once per idle character every commute_tick, reported by Sentry as an N+1 (67 extra queries in one tick, PROGRESS-BACKEND-PRODUCTION-EJ). Introduced in e12199f9 when target_role_for started resolving hours from the character's work building instead of fixed constants. Batch-fetch every character's primary (home + work) CharacterLocations in commute_tick with one query, and thread work_location/target_role through to work_hours_for/target_role_for/sync_character_location so they use the pre-fetched values instead of querying per character. Other callers (behaviour_services.generate_day, existing tests) are unaffected - the new parameters default to the previous per-character lookup behaviour. --- locations/services/schedule.py | 49 ++++++++++++++++++++-------------- locations/tasks.py | 26 +++++++++++++++--- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/locations/services/schedule.py b/locations/services/schedule.py index c2fffbba..783bc86f 100644 --- a/locations/services/schedule.py +++ b/locations/services/schedule.py @@ -14,6 +14,8 @@ # seconds either way, so the whole village doesn't flip home/work in lockstep. MAX_STAGGER_SECONDS = 20 * 60 +_UNSET = object() + def _stagger_offset_seconds(character_id: int) -> int: """Deterministic per-character offset in [-MAX_STAGGER_SECONDS, MAX_STAGGER_SECONDS].""" @@ -21,7 +23,7 @@ def _stagger_offset_seconds(character_id: int) -> int: return (character_id % span) - MAX_STAGGER_SECONDS -def work_hours_for(character) -> tuple[time, time]: +def work_hours_for(character, work_location=_UNSET) -> tuple[time, time]: """The (open, close) window a character should be at work, from their assigned work building's open_time/close_time if set, else the fixed WORK_START/WORK_END constants. Shared by target_role_for (drives @@ -29,17 +31,23 @@ def work_hours_for(character) -> tuple[time, time]: scheduled CharacterActivity blocks), so a character's actual work building's hours - e.g. an inn open until 23:00 - govern both rather than generate_day assuming a fixed 8-17 workday that leaves late - building hours showing as an unrelated leisure/"Relaxing" block.""" + building hours showing as an unrelated leisure/"Relaxing" block. + + `work_location` may be passed in pre-fetched (e.g. by a caller batching + lookups across many characters, such as commute_tick) to avoid a + per-character query. Left unset, it's looked up here as before. + """ from character.models import CharacterLocation work_start, work_end = WORK_START, WORK_END - work_location = ( - CharacterLocation.objects.filter( - character=character, role=CharacterLocation.Role.WORK, is_primary=True + if work_location is _UNSET: + work_location = ( + CharacterLocation.objects.filter( + character=character, role=CharacterLocation.Role.WORK, is_primary=True + ) + .select_related("location") + .first() ) - .select_related("location") - .first() - ) if work_location is not None: building = work_location.location if building.open_time is not None and building.close_time is not None: @@ -47,19 +55,22 @@ def work_hours_for(character) -> tuple[time, time]: return work_start, work_end -def target_role_for(character, now=None) -> str: +def target_role_for(character, now=None, work_location=_UNSET) -> str: """Which role (home/work) a character should currently be at. The work window comes from the character's assigned work building's open_time/close_time if set, else falls back to the fixed WORK_START/ WORK_END constants. A per-character stagger is applied to whichever - window is resolved, so the whole village doesn't flip in lockstep.""" + window is resolved, so the whole village doesn't flip in lockstep. + + `work_location` is forwarded to work_hours_for - see its docstring. + """ from character.models import CharacterLocation now = now or timezone.localtime() seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second - work_start, work_end = work_hours_for(character) + work_start, work_end = work_hours_for(character, work_location=work_location) offset = _stagger_offset_seconds(character.id) work_start_seconds = work_start.hour * 3600 + work_start.minute * 60 + offset @@ -70,21 +81,18 @@ def target_role_for(character, now=None) -> str: return CharacterLocation.Role.HOME -_UNSET = object() - - def sync_character_location( - character, target_location=_UNSET, entrance_node=_UNSET + character, target_role=_UNSET, target_location=_UNSET, entrance_node=_UNSET ) -> None: """Compare a character's current/target position against their schedule and, if they should be elsewhere, send them there via the existing Journey/set_destination movement stack. No-op if already there, already heading there, mid-journey, or no matching CharacterLocation/path exists. - `target_location` and `entrance_node` may be passed in pre-fetched (e.g. - by a caller batching lookups across many characters, such as - commute_tick) to avoid a per-character query. Left unset, they're looked - up here as before. + `target_role`, `target_location`, and `entrance_node` may be passed in + pre-fetched (e.g. by a caller batching lookups across many characters, + such as commute_tick) to avoid a per-character query. Left unset, + they're looked up here as before. """ from character.models import CharacterLocation from locations.models import Node @@ -92,7 +100,8 @@ def sync_character_location( if character.is_moving: return - target_role = target_role_for(character) + if target_role is _UNSET: + target_role = target_role_for(character) if target_location is _UNSET: target_location = ( diff --git a/locations/tasks.py b/locations/tasks.py index 8f0dbc6d..e7be9402 100644 --- a/locations/tasks.py +++ b/locations/tasks.py @@ -150,15 +150,32 @@ def commute_tick(): if not characters: return + # Batch every character's primary locations (home + work) in one query, + # rather than letting target_role_for's work_hours_for lookup issue its + # own per-character CharacterLocation query in the loop below (was an + # N+1 flagged by Sentry). + primary_locations = list( + CharacterLocation.objects.filter( + character_id__in=[character.id for character in characters], + is_primary=True, + ).select_related("location") + ) + work_locations_by_character = { + char_location.character_id: char_location + for char_location in primary_locations + if char_location.role == CharacterLocation.Role.WORK + } + target_roles = { - character.id: target_role_for(character) for character in characters + character.id: target_role_for( + character, work_location=work_locations_by_character.get(character.id) + ) + for character in characters } target_locations = { char_location.character_id: char_location - for char_location in CharacterLocation.objects.filter( - character_id__in=target_roles, is_primary=True - ).select_related("location") + for char_location in primary_locations if char_location.role == target_roles[char_location.character_id] } @@ -181,6 +198,7 @@ def commute_tick(): ) sync_character_location( character, + target_role=target_roles[character.id], target_location=target_location, entrance_node=entrance_node, ) From 62b282b67577165a4211215e024e03f4f8204737 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:51:11 +0000 Subject: [PATCH 7/7] fix: include geom in Path .only() to remove map viewport N+1 PathFeatureSerializer.get_geometry reads obj.geom, but both MapViewportView and PopulationCentreMapView fetched paths with .only("id", "from_node__location", "to_node__location"), omitting geom. Every access triggered a separate per-object deferred-field query - Sentry reported 364 extra queries (75% of transaction time) on a single /api/v1/map/viewport/ request (PROGRESS-BACKEND-PRODUCTION-EH). Add "geom" to both .only() calls. --- locations/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/locations/views.py b/locations/views.py index 386c2aba..554e1333 100644 --- a/locations/views.py +++ b/locations/views.py @@ -90,6 +90,7 @@ def get(self, request, pk): .select_related("from_node", "to_node") .only( "id", + "geom", "from_node__location", "to_node__location", ) @@ -240,7 +241,7 @@ def get(self, request): paths = ( Path.objects.filter(geom__isnull=False, geom__bboverlaps=bbox) .select_related("from_node", "to_node") - .only("id", "from_node__location", "to_node__location") + .only("id", "geom", "from_node__location", "to_node__location") ) roads = Road.objects.filter(geom__bboverlaps=bbox) characters = (