From 7af2e6586290923caf026102a7c712cc48a87887 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:49:20 +0000 Subject: [PATCH] feat(calendar): pause date-push for real-time calendars (RC-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chronicle #529 now serves tracks_real_time on GET /calendar/date (the composed UsesRealTime() predicate). Gate all four PUT /calendar/date sites through a shared scripts/_realtime-date-guard.mjs: fetch-before-push check re-run on every push (self-heals a mid-session flag flip), plus treating a 422 W3 rejection as the same condition rather than a retryable error. One GM notice per session, shared across calendar-sync.mjs and sync-dashboard.mjs via a module-level singleton. Pull/event sync untouched. Cites: 2026-05-21-core-tenets §T-O2, §T-O5 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BgwKzUUUY26C1oFjxY4Hfu --- .ai.md | 24 ++- API-CONTRACT.md | 21 ++- CLAUDE.md | 12 ++ scripts/_realtime-date-guard.mjs | 98 ++++++++++ scripts/calendar-sync.mjs | 7 + scripts/sync-dashboard.mjs | 3 + tools/test-realtime-date-signal.mjs | 279 ++++++++++++++++++++++++++++ 7 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 scripts/_realtime-date-guard.mjs create mode 100644 tools/test-realtime-date-signal.mjs diff --git a/.ai.md b/.ai.md index 11d6c94..4b82d03 100644 --- a/.ai.md +++ b/.ai.md @@ -42,6 +42,27 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops: - Foundry's change hooks fire but see `_syncing === true` → skip pushing back - Reset to `false` after the change is applied +### Real-time calendar date-push guard (RC-4, FM-REALTIME-DATE-SIGNAL) + +`GET /calendar/date` carries `tracks_real_time` — the composed +`UsesRealTime()` predicate (`syncapi/calendar_api_handler.go:95`); `GET +/calendar` never does. When true, Chronicle's calendar is wall-clock +authoritative, so the module treats dates as **read-only**: it pauses only +its own `PUT /calendar/date` pushes (pull and event sync are untouched). +`scripts/_realtime-date-guard.mjs` centralizes this so `calendar-sync.mjs`'s +three hook-triggered pushes (`_onCalendariaDateTimeChange`, +`_onLocalDateChange`, `_onSimpleCalendarDateChange`) and +`sync-dashboard.mjs`'s manual push button (`_onPushDate`) share one +implementation: a fetch-before-push `GET` re-run on every push attempt (push +is rare enough that the round trip is cheap, and it self-heals a mid-session +flag flip — never trust a cached value alone), plus treating a `PUT` 422 as +the identical condition (Chronicle's W3 write guard), never a retryable sync +error. The one-time-per-session GM notice is a module-level singleton shared +by both files, mirroring the structure guard's session-scoped state +(`calendar-sync.mjs`'s `_calendarSyncDisabled`, ~line 327) but without tying +the flag to either class instance. Tested at +`tools/test-realtime-date-signal.mjs`. + ## Files | File | Purpose | @@ -70,6 +91,7 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops: | `tools/test-sync-dashboard-calendar-diagnostics.mjs` | Static source pin + pure-function tests for the dashboard's Copy Calendar Diagnostics button. Asserts the action is registered in DEFAULT_OPTIONS, the import from sync-calendar-diagnostics.mjs is present, the template has the button and feedback element, and buildCalendarDiagnostics produces a valid report from dashboard-shaped input. Run: `node --test tools/test-sync-dashboard-calendar-diagnostics.mjs`. | | `tools/test-actor-sync-pc-claim.mjs` | Structural + behavioral tests for PC-CLAIM-4 addon-awareness. Verifies SyncManager._fetchAddonState / isPlayerClaimingEnabled, ActorSync._resolvePcSubTypeId / _maybeShowClaimingHint, entity_type_id selection ternary, and the _claimHintShown once-per-session guard. Run: `node --test tools/test-actor-sync-pc-claim.mjs`. | | `scripts/calendar-sync.mjs` | Adapter pattern for Calendaria and SimpleCalendar. 0-indexed↔1-indexed conversion | +| `scripts/_realtime-date-guard.mjs` | Pure/session-singleton helper gating `PUT /calendar/date` when Chronicle's `tracks_real_time` is set (RC-4). `shouldSkipDatePush(api)` does the fetch-before-push `GET /calendar/date` check; `isRealTimeRejection(err)` classifies a 422 backstop; `notifyRealTimePushPaused()` shows the once-per-session GM notice. Shared by `calendar-sync.mjs` and `sync-dashboard.mjs`. Unit-tested at `tools/test-realtime-date-signal.mjs`. | | `scripts/shop-widget.mjs` | Shop window (Application v1). Context menu, drag-to-character-sheet, real-time updates | | `scripts/actor-sync.mjs` | Actor ↔ character entity bidirectional sync. System adapter loading, hook registration. PC-CLAIM-4: addon detection via `isPlayerClaimingEnabled()` (SyncManager), PC sub-type routing (`_resolvePcSubTypeId` matches `preset_category=player_character` or `slug=player-character`), `owner_user_id` gated on addon state, `_maybeShowClaimingHint()` surfaces a one-time advisory when the addon is off but player-owned actors exist. | | `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations. System data sync (quantity, equipped) via relation metadata | @@ -102,7 +124,7 @@ The table below summarizes which module uses which API area: | Relations | `GET /entities/:id/relations`, `POST/PUT/DELETE .../relations/*` | `item-sync.mjs`, `shop-widget.mjs` | | Maps | `GET /maps` | `map-sync.mjs` | | Map Markers | `GET/POST/PUT/DELETE /maps/:id/markers/*` | `map-sync.mjs` | -| Calendar | `GET /calendar`, `GET/PUT /calendar/date`, `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs` | +| Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push, RC-4), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs` | | Calendar Events | `GET/POST/PUT/DELETE /calendar/events/*` | `calendar-sync.mjs` | | Calendar Sub-Resources | `GET/PUT /calendar/{seasons,moons,eras,event-categories,cycles,festivals,weather}` | Not yet used | | Calendar Structure | `GET /calendar/structure`, `PUT /calendar/{settings,months,weekdays}`, `GET /calendar/export`, `POST /calendar/import` | Not yet used | diff --git a/API-CONTRACT.md b/API-CONTRACT.md index eca081d..08f95ed 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -630,7 +630,8 @@ moons, seasons, eras, event_categories, cycles, festivals. #### GET /calendar/date Returns current date/time with computed state: current season, moon phases, era, weather. -**Used by:** `calendar-sync.mjs` → poll current state +**Used by:** `calendar-sync.mjs` → poll current state; `_realtime-date-guard.mjs` → +fetch-before-push real-time check (see below) **Response:** ```json @@ -641,6 +642,7 @@ Returns current date/time with computed state: current season, moon phases, era, "day": 15, "hour": 14, "minute": 30, + "tracks_real_time": false, "current_season": { "id": 1, "name": "Winter", "color": "#a0c4ff" }, "current_moon_phases": [ { "moon_id": 1, "moon_name": "Selûne", "phase_name": "Full Moon", "phase_position": 0.5, "phase_icon": "moon" } @@ -664,8 +666,23 @@ Returns current date/time with computed state: current season, moon phases, era, **Key:** `current_season`, `current_moon_phases`, `current_era`, and `current_weather` are computed server-side. They may be `null`/absent if no data is configured. +**`tracks_real_time`** (RC-4, FM-REALTIME-DATE-SIGNAL): the composed +`UsesRealTime()` predicate (`mode == reallife AND` the real-time flag), so the +wire signal can never disagree with the write-guard below. Read it +defensively — `payload?.tracks_real_time === true` — never assume the field +is present (older Chronicle deployments and `GET /calendar`, the structure +endpoint, never carry it). When `true`, the module treats dates as +**read-only**: it skips its own `PUT /calendar/date` pushes (fetch-before-push +check via `scripts/_realtime-date-guard.mjs`, re-probed on every push attempt +so a mid-session enable self-heals) and shows one GM notice per session. Pull +and event sync are unaffected. + #### PUT /calendar/date -Sets current calendar date/time to an absolute value. +Sets current calendar date/time to an absolute value. Rejected with **422** +(Chronicle's W3 guard) when the target calendar's `tracks_real_time` is true — +the module treats a 422 here identically to a pre-emptive `tracks_real_time` +read (sets the same session guard, shows the same one-time notice), never as +a retryable sync error. **Request:** ```json diff --git a/CLAUDE.md b/CLAUDE.md index 12eb289..c2fefe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,18 @@ Integration — Install & Updates". caller that consumes an envelope endpoint as a bare array is a silent no-op (the #77 back-catalog bug). All call sites were audited under FM-ENVELOPE-AUDIT (see `tools/test-envelope-audit.mjs`). +- **Real-time calendars are read-only for dates.** `GET /calendar/date` carries + `tracks_real_time` (the composed `UsesRealTime()` predicate; RC-4, + FM-REALTIME-DATE-SIGNAL) — `GET /calendar` never does. When true, the module + pauses its own date-**push** only (pull/event sync unaffected). All four push + sites (`calendar-sync.mjs`'s three hook-triggered pushes, + `sync-dashboard.mjs`'s manual push button) route through the shared + `scripts/_realtime-date-guard.mjs`: a fetch-before-push `GET` re-probed on + every push (never trust a session-long cached value — pushes are rare, so + the extra round trip is cheap and self-heals a mid-session enable), plus a + 422-from-`PUT`-is-the-same-condition backstop (never a retryable sync + error). The GM notice fires once per session, shared across both files via + a module-level singleton. See `tools/test-realtime-date-signal.mjs`. - WebSocket messages are routed by type through `SyncManager`. - Chronicle-side serving rules live in `chronicle-package.json` at repo root; CI validates it against `module.json` via `tools/check-package-descriptor.mjs`. diff --git a/scripts/_realtime-date-guard.mjs b/scripts/_realtime-date-guard.mjs new file mode 100644 index 0000000..d9cb0c4 --- /dev/null +++ b/scripts/_realtime-date-guard.mjs @@ -0,0 +1,98 @@ +/** + * Chronicle Sync — real-time calendar date-push guard (RC-4, FM-REALTIME-DATE-SIGNAL). + * + * When a Chronicle calendar tracks real-world time, `GET /calendar/date` carries + * `tracks_real_time: true` — the composed `UsesRealTime()` predicate (mode == + * reallife AND flag; `syncapi/calendar_api_handler.go:95`) — and Chronicle's W3 + * guard rejects date writes with a 422. This module centralizes the push-side + * reaction so calendar-sync.mjs's three hook-triggered pushes and + * sync-dashboard.mjs's manual push button share ONE guard implementation + * instead of forking the same logic twice (dispatch stop-and-flag). + * + * Pull/event sync is untouched — this only gates `PUT /calendar/date`. + * `GET /calendar` (the structure payload) does NOT carry this field; never + * read it from there. + */ + +/** + * Session-scoped notice state. A module-level singleton (not a class field) + * so calendar-sync.mjs and sync-dashboard.mjs — two independent classes — + * share one "shown already" flag without either owning the other. Same + * lifetime as the structure guard's per-instance session state + * (calendar-sync.mjs's `_calendarSyncDisabled`, set up around line 327). + */ +const state = { + noticeShown: false, +}; + +/** + * Reset session state. Test-only — production code never needs to un-show + * the notice mid-session. + */ +export function _resetRealtimeDateGuardForTests() { + state.noticeShown = false; +} + +/** + * Read the `tracks_real_time` field defensively (FM-ENVELOPE-AUDIT + * convention — never assume a payload shape). `/calendar/date` is a bare + * single-object endpoint, but a network hiccup or an old Chronicle deploy + * can still hand back `null`/`undefined`/something unexpected. + * @param {{tracks_real_time?: boolean}|null|undefined} payload + * @returns {boolean} + */ +export function tracksRealTime(payload) { + return payload?.tracks_real_time === true; +} + +/** + * Classify a thrown api-client error as Chronicle's W3 real-time rejection + * (422). Mirrors `_calendar-probe-state.mjs`'s `calendarStateFromError`: + * prefer an explicit numeric `err.status`, else parse the api-client's + * authoritative "Chronicle API error :" prefix — never key on a bare + * digit run in the message body. + * @param {{status?: number, message?: string}|null|undefined} err + * @returns {boolean} + */ +export function isRealTimeRejection(err) { + const msg = String(err?.message || ''); + const status = (typeof err?.status === 'number' && err.status) + || Number(msg.match(/Chronicle API error (\d{3})\b/)?.[1]) + || 0; + return status === 422; +} + +/** + * Show the one-time-per-session GM notice that date pushes are paused. + * Idempotent — safe to call from every gated push site (including the 422 + * backstop path); only the first call in a session actually notifies. + */ +export function notifyRealTimePushPaused() { + if (state.noticeShown) return; + state.noticeShown = true; + const msg = 'Chronicle Sync: this calendar tracks real-world time — ' + + 'Foundry date changes are not pushed to Chronicle. Pulls still work.'; + console.warn(msg); + try { globalThis.ui?.notifications?.warn(msg); } catch { /* headless */ } +} + +/** + * Fetch-before-push guard for `PUT /calendar/date`. Date pushes are rare + * (GM advances), so the extra `GET /calendar/date` is cheap and self-heals a + * mid-session flag flip to enabled — never rely on a session-long cached + * value alone. A probe failure is not this guard's concern; let the push + * attempt proceed and fail (or succeed) on its own terms. + * @param {import('./api-client.mjs').ChronicleAPI} api + * @returns {Promise} true if the caller should SKIP the push. + */ +export async function shouldSkipDatePush(api) { + let payload; + try { + payload = await api.get('/calendar/date'); + } catch { + return false; + } + if (!tracksRealTime(payload)) return false; + notifyRealTimePushPaused(); + return true; +} diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index e657cea..a31a5d9 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -21,6 +21,7 @@ import { getSetting, getCalendarSyncExclusions } from './settings.mjs'; import { FLAG_SCOPE } from './constants.mjs'; +import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; /** * Canonical wire-visibility values per the calendar-sync wire contract @@ -728,6 +729,7 @@ export class CalendarSync { if (!date) return; try { + if (await shouldSkipDatePush(this._api)) return; await this._api.put('/calendar/date', { year: date.year, month: date.month, @@ -736,6 +738,7 @@ export class CalendarSync { minute: src.minute ?? 0, }); } catch (err) { + if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } console.error('Chronicle: Failed to push Calendaria date/time to Chronicle', err); } } @@ -879,6 +882,7 @@ export class CalendarSync { if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs try { + if (await shouldSkipDatePush(this._api)) return; await this._api.put('/calendar/date', { year: dateData.year, month: dateData.month, @@ -887,6 +891,7 @@ export class CalendarSync { minute: dateData.minute || 0, }); } catch (err) { + if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } console.error('Chronicle: Failed to push date to Chronicle', err); } } @@ -908,6 +913,7 @@ export class CalendarSync { if (!date) return; try { + if (await shouldSkipDatePush(this._api)) return; await this._api.put('/calendar/date', { year: date.year, // SimpleCalendar months are 0-indexed; Chronicle is 1-indexed. @@ -917,6 +923,7 @@ export class CalendarSync { minute: date.minute || 0, }); } catch (err) { + if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } console.error('Chronicle: Failed to push SimpleCalendar date to Chronicle', err); } } diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 589cbf7..8139eba 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -25,6 +25,7 @@ import { import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs'; import { buildOverviewModel } from './_overview-model.mjs'; import { log, getLogBuffer } from './logger.mjs'; +import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; /** @@ -2054,6 +2055,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { if (!localDate) return; try { + if (await shouldSkipDatePush(this.api)) return; await this.api.put('/calendar/date', { year: localDate.year, month: localDate.month, @@ -2064,6 +2066,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { this._logActivity('push', 'Pushed calendar date to Chronicle'); this.render({ force: true }); } catch (err) { + if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } console.error('Chronicle Dashboard: Push date failed', err); } } diff --git a/tools/test-realtime-date-signal.mjs b/tools/test-realtime-date-signal.mjs new file mode 100644 index 0000000..9f09972 --- /dev/null +++ b/tools/test-realtime-date-signal.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +/** + * test-realtime-date-signal.mjs — FM-REALTIME-DATE-SIGNAL regression tests. + * + * Run: node --test tools/test-realtime-date-signal.mjs + * + * Pins the RC-4 wire-signal consumption: `GET /calendar/date` now carries + * `tracks_real_time` (the composed `UsesRealTime()` predicate, + * syncapi/calendar_api_handler.go:95). This module pauses date-PUSH only + * (pull/event sync untouched) across all four push sites: + * - calendar-sync.mjs: _onCalendariaDateTimeChange, _onLocalDateChange, + * _onSimpleCalendarDateChange + * - sync-dashboard.mjs: _onPushDate + * + * Covers: + * 1. Skip push when tracks_real_time === true (fetch-before-push). + * 2. Proceed when false/absent (legacy Chronicle deploys never emit the + * field at all — must not be treated as "blocked"). + * 3. A 422 backstop rejection sets the guard WITHOUT throwing/logging as a + * generic sync error. + * 4. The GM notice fires exactly once per session, shared across both + * calendar-sync.mjs and sync-dashboard.mjs call sites. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Stub the Foundry globals calendar-sync.mjs / sync-dashboard.mjs touch at +// module-load time (mirrors tools/test-calendar-backcatalog-fix.mjs). +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, +}; +globalThis.game = globalThis.game || { + settings: { get: () => null, register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; + +const { + tracksRealTime, + isRealTimeRejection, + shouldSkipDatePush, + notifyRealTimePushPaused, + _resetRealtimeDateGuardForTests, +} = await import('../scripts/_realtime-date-guard.mjs'); +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); +const { SyncDashboard } = await import('../scripts/sync-dashboard.mjs'); + +function makeCalendarSync(overrides) { + return Object.assign( + Object.create(CalendarSync.prototype), + { _hasModernCalendariaApi: true, _syncDepth: 0, _calendarSyncDisabled: false, _isActiveCalendarExcluded: () => false }, + overrides, + ); +} + +function makeDashboard({ api, ...overrides }) { + return Object.assign(Object.create(SyncDashboard.prototype), { _syncManager: { api } }, overrides); +} + +// A fake ChronicleAPI: `get` answers the fetch-before-push probe, `put` +// records pushes (or throws, per test). +function makeApi({ getResult, getError, putImpl } = {}) { + const gets = []; + const puts = []; + return { + gets, + puts, + async get(path) { + gets.push(path); + if (getError) throw getError; + return getResult; + }, + async put(path, body) { + puts.push({ path, body }); + if (putImpl) return putImpl(path, body); + return { id: 'ok' }; + }, + }; +} + +test.beforeEach(() => { + _resetRealtimeDateGuardForTests(); + globalThis.ui = { notifications: { warn: () => {} } }; +}); + +// ── Pure helpers ────────────────────────────────────────────────────────── + +test('tracksRealTime: reads the field defensively (envelope-audit convention)', () => { + assert.equal(tracksRealTime({ tracks_real_time: true }), true); + assert.equal(tracksRealTime({ tracks_real_time: false }), false); + assert.equal(tracksRealTime({}), false); + assert.equal(tracksRealTime(null), false); + assert.equal(tracksRealTime(undefined), false); + assert.equal(tracksRealTime({ tracks_real_time: 1 }), false, 'truthy non-boolean must not pass'); + assert.equal(tracksRealTime('nope'), false); +}); + +test('isRealTimeRejection: 422 via explicit err.status', () => { + assert.equal(isRealTimeRejection({ status: 422, message: 'whatever' }), true); +}); + +test('isRealTimeRejection: 422 via the api-client "Chronicle API error 422:" message prefix', () => { + assert.equal(isRealTimeRejection(new Error('Chronicle API error 422: {"message":"real-time calendar"}')), true); +}); + +test('isRealTimeRejection: other statuses (404, 409, 500) are not the real-time guard', () => { + assert.equal(isRealTimeRejection(new Error('Chronicle API error 404: not found')), false); + assert.equal(isRealTimeRejection({ status: 409, message: 'conflict' }), false); + assert.equal(isRealTimeRejection(new Error('Chronicle API error 500: boom')), false); +}); + +test('isRealTimeRejection: a bare "422" substring in an unrelated message does not misclassify', () => { + assert.equal(isRealTimeRejection(new Error('Entity named "Room 422" not found')), false); +}); + +test('isRealTimeRejection: null/undefined error does not throw', () => { + assert.equal(isRealTimeRejection(null), false); + assert.equal(isRealTimeRejection(undefined), false); +}); + +test('shouldSkipDatePush: true when the probe reports tracks_real_time', async () => { + const api = makeApi({ getResult: { tracks_real_time: true } }); + assert.equal(await shouldSkipDatePush(api), true); + assert.deepEqual(api.gets, ['/calendar/date']); +}); + +test('shouldSkipDatePush: false when absent (legacy Chronicle) or explicitly false', async () => { + assert.equal(await shouldSkipDatePush(makeApi({ getResult: { year: 1492 } })), false); + assert.equal(await shouldSkipDatePush(makeApi({ getResult: { tracks_real_time: false } })), false); +}); + +test('shouldSkipDatePush: a probe failure does not block the push (proceed, let the PUT itself fail/succeed)', async () => { + const api = makeApi({ getError: new Error('network down') }); + assert.equal(await shouldSkipDatePush(api), false); +}); + +test('notifyRealTimePushPaused: fires the notification exactly once per session', () => { + let warnCount = 0; + globalThis.ui = { notifications: { warn: () => { warnCount += 1; } } }; + notifyRealTimePushPaused(); + notifyRealTimePushPaused(); + notifyRealTimePushPaused(); + assert.equal(warnCount, 1); +}); + +// ── calendar-sync.mjs: the three hook-triggered push sites ───────────────── + +for (const [label, method, payload] of [ + ['_onCalendariaDateTimeChange', 'onCalendariaDateTimeChange', { current: { year: 1492, month: 2, dayOfMonth: 14, hour: 9, minute: 30 } }], + ['_onLocalDateChange', 'onLocalDateChange', { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }], + ['_onSimpleCalendarDateChange', 'onSimpleCalendarDateChange', { date: { year: 1492, month: 2, day: 14, hour: 9, minute: 30 } }], +]) { + test(`${label}: skips the push when GET /calendar/date reports tracks_real_time`, async () => { + const api = makeApi({ getResult: { tracks_real_time: true } }); + const cs = makeCalendarSync({ _api: api }); + await cs[`_${method}`](payload); + assert.equal(api.puts.length, 0, 'must not PUT when the calendar tracks real time'); + assert.deepEqual(api.gets, ['/calendar/date'], 'must probe before deciding to skip'); + }); + + test(`${label}: proceeds when tracks_real_time is false/absent`, async () => { + const api = makeApi({ getResult: { tracks_real_time: false } }); + const cs = makeCalendarSync({ _api: api }); + await cs[`_${method}`](payload); + assert.equal(api.puts.length, 1, 'must still PUT when the flag is false'); + }); + + test(`${label}: legacy Chronicle (field entirely absent) still proceeds`, async () => { + const api = makeApi({ getResult: { year: 1492, month: 3, day: 15 } }); + const cs = makeCalendarSync({ _api: api }); + await cs[`_${method}`](payload); + assert.equal(api.puts.length, 1); + }); + + test(`${label}: a 422 backstop on the PUT sets the guard WITHOUT throwing`, async () => { + const api = makeApi({ + getResult: { tracks_real_time: false }, + putImpl: () => { throw new Error('Chronicle API error 422: {"message":"real-time calendar is read-only for dates"}'); }, + }); + const cs = makeCalendarSync({ _api: api }); + await assert.doesNotReject(cs[`_${method}`](payload)); + }); + + test(`${label}: a genuine non-422 PUT failure still logs as an error (not swallowed as the guard)`, async () => { + const api = makeApi({ + getResult: { tracks_real_time: false }, + putImpl: () => { throw new Error('Chronicle API error 500: boom'); }, + }); + const cs = makeCalendarSync({ _api: api }); + const originalError = console.error; + let loggedErr = null; + console.error = (...args) => { loggedErr = args; }; + try { + await assert.doesNotReject(cs[`_${method}`](payload)); + } finally { + console.error = originalError; + } + assert.ok(loggedErr, 'a non-422 PUT failure must still be logged'); + }); +} + +// ── sync-dashboard.mjs: the manual push button ────────────────────────────── + +test('SyncDashboard._onPushDate: skips the push and does not log activity when tracks_real_time', async () => { + const api = makeApi({ getResult: { tracks_real_time: true } }); + const activities = []; + let rendered = false; + const dash = makeDashboard({ + api, + _detectCalendarModule: () => 'calendaria', + _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), + _logActivity: (...args) => activities.push(args), + render: () => { rendered = true; }, + }); + await dash._onPushDate(); + assert.equal(api.puts.length, 0); + assert.equal(activities.length, 0); + assert.equal(rendered, false); +}); + +test('SyncDashboard._onPushDate: proceeds and logs activity when not real-time', async () => { + const api = makeApi({ getResult: { tracks_real_time: false } }); + const activities = []; + let rendered = false; + const dash = makeDashboard({ + api, + _detectCalendarModule: () => 'calendaria', + _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), + _logActivity: (...args) => activities.push(args), + render: () => { rendered = true; }, + }); + await dash._onPushDate(); + assert.equal(api.puts.length, 1); + assert.equal(activities.length, 1); + assert.equal(rendered, true); +}); + +test('SyncDashboard._onPushDate: a 422 backstop sets the guard without throwing or logging activity', async () => { + const api = makeApi({ + getResult: { tracks_real_time: false }, + putImpl: () => { throw new Error('Chronicle API error 422: real-time calendar'); }, + }); + const activities = []; + const dash = makeDashboard({ + api, + _detectCalendarModule: () => 'calendaria', + _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), + _logActivity: (...args) => activities.push(args), + render: () => {}, + }); + await assert.doesNotReject(dash._onPushDate()); + assert.equal(activities.length, 0, 'must not log a successful push that never happened'); +}); + +// ── Cross-module: the notice is a shared, session-scoped singleton ───────── + +test('the one-time notice is shared across calendar-sync.mjs AND sync-dashboard.mjs call sites', async () => { + let warnCount = 0; + globalThis.ui = { notifications: { warn: () => { warnCount += 1; } } }; + + const api1 = makeApi({ getResult: { tracks_real_time: true } }); + const cs = makeCalendarSync({ _api: api1 }); + await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 2, dayOfMonth: 14, hour: 9, minute: 30 } }); + assert.equal(warnCount, 1, 'first blocked push notifies'); + + const api2 = makeApi({ getResult: { tracks_real_time: true } }); + const dash = makeDashboard({ + api: api2, + _detectCalendarModule: () => 'calendaria', + _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15 }), + _logActivity: () => {}, + render: () => {}, + }); + await dash._onPushDate(); + assert.equal(warnCount, 1, 'the dashboard push reuses the same already-shown session notice'); +});