From fc46bb9d61f990c6e807dde9b747c928447568d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:04:02 +0000 Subject: [PATCH] calv4 fix: the mismatch banner printed a remedy nobody could follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three places this module reports a calendar-structure mismatch — the permanent ui.notifications.warn from _pauseCalendarSyncForMismatch, and the dashboard's paused and incompatible banners — told the operator to "Import or author the matching calendar in Chronicle." NEITHER HALF IS REACHABLE, measured against Chronicle's source rather than guessed: IMPORT. POST /api/v1/campaigns/:cid/calendar (api_handler.go CreateCalendar) answers a structured 409 `calendar_already_exists` whenever GetCalendar(campaignID) returns anything, and that repo query is `... WHERE campaign_id = ? ORDER BY is_default DESC, sort_order ASC LIMIT 1` — ANY calendar. A campaign showing this banner HAS one by construction: the mismatch was computed by comparing against it. The import door is closed 100% of the time this advice is on screen. AUTHOR. calendarService.CreateCalendar sets `IsDefault: isFirst`, so only the first calendar a campaign ever gets is the default; this module is served the default by that same ordering; and Chronicle's SetDefaultCalendar exists on the service interface with NO route, NO handler and NO control on any page. An authored calendar therefore never reaches the wire, and there is nothing the operator can click to change that. THE REMEDY IS AN EDIT, and that one is genuinely one page away: make the two structures agree by editing a calendar that already exists. Chronicle's Calendar Settings owns months and weekdays — exactly the facts this guard compares — and Calendaria / Simple Calendar own the Foundry side. All three prints now say so, and say the same thing as each other. WHAT IS STILL IMPOSSIBLE IS BOOKED, NOT PRINTED. "Point the module at a different Chronicle calendar" needs Chronicle to wire SetDefaultCalendar to a route and a control, and/or honour a ?calId= on the syncapi reads. That is recorded in CLAUDE.md → Blocked on Chronicle with the source facts to re-verify it, and in Chronicle's own .ai/todo.md and .ai/status.md. Advice that cannot be followed is worse than no advice: the operator spends the session believing the fix is theirs to make. tools/test-calendar-mismatch-remedy.mjs reads all three strings from where they ship — the toast by invoking the pause path against a stub, the banners out of sync-dashboard.hbs — and holds each to the same verdict: never recommend an import or a new calendar, always name a reachable action, and do not lose the three facts the old toast got right (sync is paused, a reload clears it, journals/characters/maps still sync). RED (before): 2 of 3 subtests fail — the toast and both banner hints GREEN (after): 3/3, and the full module suite is 782 pass / 0 fail Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Aa645128uaFxbQyvabMizq --- CLAUDE.md | 22 ++++ scripts/calendar-sync.mjs | 35 ++++- templates/sync-dashboard.hbs | 11 +- tools/test-calendar-mismatch-remedy.mjs | 166 ++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 tools/test-calendar-mismatch-remedy.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 20e1bcc..8cfd690 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,28 @@ Integration — Install & Updates". confirm) and the dashboard Calendar tab (Foundry local date now renders, the four-state sync badge — in-sync / date-drift with direction / incompatible-structures / paused, FM-SYNC-WIRE-FIX — and Push-date button). +- **Blocked on Chronicle (calv4 fix R1, item 6) — THE MODULE CANNOT BE POINTED + AT A DIFFERENT CHRONICLE CALENDAR, and until it can, "author the matching + calendar in Chronicle" is not advice.** Measured against Chronicle's source: + `POST /api/v1/campaigns/:cid/calendar` answers a structured 409 + `calendar_already_exists` whenever `GetCalendar(campaignID)` returns anything, + and that query is `… WHERE campaign_id = ? ORDER BY is_default DESC, + sort_order ASC LIMIT 1` — ANY calendar. A campaign showing a structure + mismatch has one by construction, so the import door is closed 100% of the + time. And `calendarService.CreateCalendar` sets `IsDefault: isFirst`, so a + calendar authored in the builder is never the default, while the module is + served the default by that same ordering; `SetDefaultCalendar` is on + Chronicle's service interface with **no route, no handler and no control on + any page**. The mismatch remedy therefore now says the reachable thing — edit + either calendar so the two agree — and the unreachable thing is booked here + rather than printed. **What Chronicle owes:** a way to mark a calendar as the + campaign default (wire `SetDefaultCalendar` to a route and a control), and/or + a `?calId=` the syncapi honours so the module can name the calendar it wants. + Either one turns "author the matching calendar" back into real advice. + Guarded by `tools/test-calendar-mismatch-remedy.mjs`, which fails if any of + the three mismatch prints starts recommending an import or a new calendar + again. + - **Blocked on Chronicle (FM-SYNC-SUBRESOURCES-P1 Step 0):** `calendar.worldstate.changed` is published by `internal/plugins/calendar/worldstate_service.go` but has no `case` in diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 340d598..25d25f2 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -733,8 +733,41 @@ export class CalendarSync { const foundryShape = `${(foundryStruct?.monthDays || []).length}mo/${foundryStruct?.weekdayCount ?? 0}wd`; this._calendarMismatchDetail = `Chronicle: ${chronicleName} ${chronicleShape} · Foundry: ${foundryName} ${foundryShape} — ${detail}`; + // THE REMEDY IS AN EDIT, NOT AN IMPORT AND NOT A NEW CALENDAR. + // + // This line used to read "import or author the matching calendar in + // Chronicle" and NEITHER HALF WAS REACHABLE, measured against Chronicle's + // source: + // + // IMPORT — `POST /api/v1/campaigns/:cid/calendar` answers a structured + // 409 `calendar_already_exists` whenever the campaign has ANY calendar + // (`… ORDER BY is_default DESC, sort_order ASC LIMIT 1`). When this + // warning fires the campaign HAS one by construction: the mismatch was + // computed by comparing against it. The door is closed 100% of the time + // this advice appears. + // + // AUTHOR — Chronicle's `CreateCalendar` sets `IsDefault: isFirst`, so a + // newly authored calendar is not the default; this module is served the + // default (same ordering); and `SetDefaultCalendar` exists on Chronicle's + // service interface with NO route, handler or control anywhere. An + // authored calendar therefore never reaches the wire and the operator has + // nothing to click that would change that. + // + // What IS reachable is making the two structures agree by editing one of + // the calendars that already exist — Chronicle's Calendar Settings owns + // months and weekdays, which are exactly the facts compared here, and + // Calendaria / Simple Calendar own the Foundry side. Either edit clears it. + // + // Pointing this module at a DIFFERENT Chronicle calendar remains genuinely + // impossible and is BOOKED (CLAUDE.md → Blocked on Chronicle) rather than + // printed as an instruction. Advice that cannot be followed is worse than + // no advice: the operator spends the session believing the fix is theirs. + // + // Pinned by tools/test-calendar-mismatch-remedy.mjs, which holds this + // string and the dashboard's two banners to the same verdict. const msg = `Chronicle Sync: calendar structures differ (${this._calendarMismatchDetail}). ` - + 'Calendar sync is paused for this session — import or author the matching calendar in Chronicle, ' + + 'Calendar sync is paused for this session — edit either calendar so the two agree ' + + '(Chronicle: Calendar Settings → Months / Weekdays; Foundry: your calendar module), ' + 'then reload the world. ' + '(Journals, characters, and maps still sync.)'; console.warn(msg); diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 4f280a7..ec11fa1 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -893,7 +893,14 @@
Calendar sync paused — structure mismatch.
{{calendar.syncStateDetail}}
-
Import or author the matching calendar in Chronicle. Journals, characters, and maps still sync.
+ {{!-- THE REMEDY IS AN EDIT. "Import or author the matching calendar + in Chronicle" was unreachable both ways: the create endpoint + 409s whenever the campaign has any calendar (always true here), + and an authored calendar is not is_default with no control + anywhere to make it one, so this module keeps being served the + old one. Editing either calendar's months/weekdays IS one page + away. See tools/test-calendar-mismatch-remedy.mjs. --}} +
Edit either calendar so the two agree — in Chronicle, Calendar Settings → Months / Weekdays; in Foundry, your calendar module. Journals, characters, and maps still sync.
{{else if calendar.isIncompatible}} @@ -902,7 +909,7 @@
Calendar structures don’t match.
{{calendar.syncStateDetail}}
-
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — import or author the matching calendar in Chronicle.
+
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — edit either calendar so the two agree, in Chronicle’s Calendar Settings (Months / Weekdays) or in your Foundry calendar module.
{{/if}} diff --git a/tools/test-calendar-mismatch-remedy.mjs b/tools/test-calendar-mismatch-remedy.mjs new file mode 100644 index 0000000..e725327 --- /dev/null +++ b/tools/test-calendar-mismatch-remedy.mjs @@ -0,0 +1,166 @@ +// test-calendar-mismatch-remedy.mjs — THE STRUCTURE-MISMATCH ADVICE HAS TO BE +// SOMETHING THE OPERATOR CAN ACTUALLY DO. +// +// Run: node --test tools/test-calendar-mismatch-remedy.mjs +// +// WHAT WAS WRONG. All three places the module reports a calendar-structure +// mismatch — the permanent `ui.notifications.warn` from +// `_pauseCalendarSyncForMismatch`, and the dashboard's two Calendar-tab banners +// (paused / incompatible) — printed the same remedy: +// +// "Import or author the matching calendar in Chronicle." +// +// NEITHER HALF OF THAT IS REACHABLE, and both were measured against Chronicle's +// source rather than guessed: +// +// IMPORT. `POST /api/v1/campaigns/:cid/calendar` (api_handler.go's +// CreateCalendar) answers a structured 409 `calendar_already_exists` whenever +// `GetCalendar(campaignID)` returns anything, and that repo query is +// `… WHERE campaign_id = ? ORDER BY is_default DESC, sort_order ASC LIMIT 1` +// — i.e. ANY calendar at all. When this banner is on screen the campaign HAS +// a calendar, by construction: the mismatch was computed by comparing against +// it. So the import door is closed 100% of the time this advice is shown. +// +// AUTHOR. A calendar authored in Chronicle's builder is NOT the default: +// `calendarService.CreateCalendar` sets `IsDefault: isFirst` — only the first +// calendar in a campaign is ever marked. And the module reads Chronicle's +// calendar through the same `is_default DESC, sort_order ASC LIMIT 1` +// ordering, so it keeps being served the OLD calendar. `SetDefaultCalendar` +// exists on Chronicle's service interface and HAS NO CALLER — no route, no +// handler, no control on any page. So the authored calendar is invisible +// across the wire and there is nothing the operator can click to change that. +// +// WHAT IS REACHABLE, and is what the advice now says: make the two structures +// match by EDITING one of the calendars that already exist. Chronicle's +// 10-tab structure editor (Calendar Settings → Months / Weekdays) is exactly +// the surface for the month-count / month-length / weekday-count facts this +// guard compares, and the Foundry side is editable in Calendaria / Simple +// Calendar. Either edit closes the mismatch, and both are one page away. +// +// THE BOOKED GAP. "Point the module at a DIFFERENT Chronicle calendar" remains +// genuinely impossible and is recorded as such (CLAUDE.md → Blocked on +// Chronicle) rather than printed as an instruction. That is the difference this +// file is guarding: advice that cannot be followed is worse than no advice, +// because the operator spends the session believing the fix is theirs to make. +// +// THE THREE STRINGS ARE ALSO PINNED TO EACH OTHER. Three prints of one remedy +// drift; this test reads all three from where they ship and requires the same +// verdict from each. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// --- Foundry global stubs --------------------------------------------------- +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (b) => b } }, +}; +globalThis.game = globalThis.game || { + settings: { get: () => '', set: () => {}, register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + modules: { get: () => null }, + users: [], + user: { id: 'u1', isGM: true }, + journal: { find: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, once: () => {}, off: () => {} }; + +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); + +/** The permanent toast the pause path raises. */ +function toastText() { + const cs = new CalendarSync({}, {}); + let msg = ''; + const prevUI = globalThis.ui; + globalThis.ui = { notifications: { warn: (m) => { msg = m; } } }; + const prevWarn = console.warn; + console.warn = () => {}; + try { + cs._pauseCalendarSyncForMismatch( + { name: 'Harptos', months: new Array(12), weekdays: new Array(10) }, + { name: 'Gregorian', monthDays: new Array(12), weekdayCount: 7 }, + 'weekday count 10 vs 7', + ); + } finally { + console.warn = prevWarn; + globalThis.ui = prevUI; + } + return msg; +} + +/** The dashboard's two Calendar-tab mismatch hints, read from the template. */ +function bannerHints() { + const hbs = readFileSync(join(root, 'templates', 'sync-dashboard.hbs'), 'utf8'); + const block = hbs.slice(hbs.indexOf('calendar-mismatch-banner')); + const hints = [...block.matchAll(/
([\s\S]*?)<\/div>/g)].map((m) => m[1].trim()); + assert.equal(hints.length, 2, + 'expected exactly two calendar-mismatch action hints (paused + incompatible) — ' + + 'if the template grew a third, it has to be held to the same standard'); + return hints; +} + +/** + * The unreachable instructions, as patterns. Each is paired with the Chronicle + * source fact that closes it, so a future reader can re-verify rather than + * trust this file. + */ +const UNREACHABLE = [ + { + re: /\bimport(ing)?\b[^.]*\bcalendar\b|\bcalendar\b[^.]*\bimport(ing)?\b/i, + why: 'POST /api/v1/campaigns/:cid/calendar returns 409 calendar_already_exists ' + + 'whenever the campaign has ANY calendar, which is always true when a ' + + 'structure mismatch has been computed against one', + }, + { + re: /\bauthor\b|\bcreate a (new )?calendar\b|\bnew calendar\b/i, + why: 'calendarService.CreateCalendar sets IsDefault only for the FIRST ' + + 'calendar in a campaign, the module is served ' + + '`ORDER BY is_default DESC, sort_order ASC LIMIT 1`, and Chronicle\'s ' + + 'SetDefaultCalendar has no route, handler or control — so an authored ' + + 'calendar never reaches the wire', + }, +]; + +/** The reachable remedy has to be named, not merely implied. */ +const REACHABLE = /calendar settings|months|weekdays|structure|edit/i; + +test('the mismatch remedy never tells the operator to import or author a calendar', () => { + const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])]; + for (const [label, text] of texts) { + assert.ok(text && text.length > 0, `${label} is empty — there is nothing to check`); + for (const { re, why } of UNREACHABLE) { + assert.equal(re.test(text), false, + `${label} tells the operator to do something that cannot be done:\n` + + ` ${text}\n` + + ` ${why}`); + } + } +}); + +test('the mismatch remedy names a reachable action instead', () => { + const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])]; + for (const [label, text] of texts) { + assert.ok(REACHABLE.test(text), + `${label} removes the impossible advice without offering the possible one:\n` + + ` ${text}\n` + + ' Editing either calendar so the two structures match IS reachable — ' + + 'Chronicle\'s Calendar Settings owns months and weekdays, and Calendaria / ' + + 'Simple Calendar own the Foundry side. An empty remedy is honest but useless.'); + } +}); + +test('the pause toast still says sync is paused and what keeps working', () => { + // The two facts the remedy must not cost while it is being rewritten: the + // operator has to know calendar sync stopped, and that the rest did not. + const text = toastText(); + assert.match(text, /paused/i, 'the toast must still say calendar sync is paused'); + assert.match(text, /journals/i, + 'the toast must still say journals, characters and maps keep syncing — that ' + + 'sentence is what stops a structure mismatch reading as a dead integration'); + assert.match(text, /reload the world/i, + 'the pause is for the session, so the toast must still say a reload is what clears it'); +});