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'); +});