Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion scripts/calendar-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions templates/sync-dashboard.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,14 @@
<div>
<strong>Calendar sync paused — structure mismatch.</strong>
<div>{{calendar.syncStateDetail}}</div>
<div class="action-hint">Import or author the matching calendar in Chronicle. Journals, characters, and maps still sync.</div>
{{!-- 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. --}}
<div class="action-hint">Edit either calendar so the two agree — in Chronicle, Calendar Settings &rarr; Months / Weekdays; in Foundry, your calendar module. Journals, characters, and maps still sync.</div>
</div>
</div>
{{else if calendar.isIncompatible}}
Expand All @@ -902,7 +909,7 @@
<div>
<strong>Calendar structures don’t match.</strong>
<div>{{calendar.syncStateDetail}}</div>
<div class="action-hint">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.</div>
<div class="action-hint">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&rsquo;s Calendar Settings (Months / Weekdays) or in your Foundry calendar module.</div>
</div>
</div>
{{/if}}
Expand Down
166 changes: 166 additions & 0 deletions tools/test-calendar-mismatch-remedy.mjs
Original file line number Diff line number Diff line change
@@ -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(/<div class="action-hint">([\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');
});