diff --git a/CLAUDE.md b/CLAUDE.md index d9ad42d..35704ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,8 @@ scripts/ # ES modules (.mjs) item-sync.mjs # Item sync note-sync.mjs # Chronicle Notes ↔ JournalEntry sync shop-widget.mjs # Shop inventory UI - sync-dashboard.mjs # 8-tab dashboard UI + sync-dashboard.mjs # Dashboard UI: Overview cockpit + grouped vertical rail (Everyday/Library/Setup/Diagnostics) + _overview-model.mjs # Pure builder for the Overview landing cockpit (stats + prioritized "needs attention") update-info.mjs # "Update Source" diagnostic dialog (install/update flow) character-claim-indicator.mjs # Per-player character-claim status indicator import-wizard.mjs # Initial-import wizard UI diff --git a/scripts/_overview-model.mjs b/scripts/_overview-model.mjs new file mode 100644 index 0000000..b697cbc --- /dev/null +++ b/scripts/_overview-model.mjs @@ -0,0 +1,123 @@ +/** + * Chronicle Sync — Overview cockpit model + * + * Pure builder for the dashboard's Overview landing tab. Takes already-computed + * pieces from the other tab-data builders and distills them into a "cockpit": + * a connection banner, a few headline stats, and a prioritized "needs attention" + * list that routes the GM straight to the tab that can fix each problem. + * + * Kept pure (no Foundry globals) so it can be unit-tested headlessly — the + * dashboard passes plain values in and renders the returned model. + */ + +/** Pluralize helper: "" for 1, "s" otherwise. */ +function s(n) { + return n === 1 ? '' : 's'; +} + +/** + * Build the Overview cockpit model. + * + * @param {object} p + * @param {string} p.connectionState - 'connected' | 'connecting' | 'reconnecting' | 'disconnected' + * @param {string} p.connectionLabel - human label for the state + * @param {number} p.syncedEntities - count of journals linked to Chronicle entities + * @param {number} p.linkedScenes - count of Chronicle maps materialized + * @param {Array} [p.characters] - synced-actor rows (each may have `.synced`) + * @param {number} [p.issuesCount] - unresolved character-link issues + * @param {number} [p.unmatchedMembers] - campaign members with no Foundry user + * @param {boolean}[p.calendarAvailable] - a calendar module + campaign calendar exist + * @param {boolean}[p.calendarInSync] - Foundry date matches Chronicle date + * @param {number} [p.errorCount] - recent REST/sync errors + * @param {string|null} [p.matchedSystem] - matched Chronicle system slug, or null + * @param {string} [p.lastSyncTime] - last successful sync, human string + * @returns {{connection:object, stats:Array, attention:Array, allClear:boolean}} + */ +export function buildOverviewModel(p = {}) { + const { + connectionState = 'disconnected', + connectionLabel = 'Disconnected', + syncedEntities = 0, + linkedScenes = 0, + characters = [], + issuesCount = 0, + unmatchedMembers = 0, + calendarAvailable = false, + calendarInSync = false, + errorCount = 0, + matchedSystem = null, + lastSyncTime = 'Never', + } = p; + + const ok = connectionState === 'connected'; + const charList = Array.isArray(characters) ? characters : []; + const charsSynced = charList.filter((c) => c && c.synced).length; + + const connection = { + state: connectionState, + label: connectionLabel, + ok, + detail: ok ? `Last sync: ${lastSyncTime}` : 'Sync is paused until the connection is restored.', + }; + + const stats = [ + { label: `Entities Synced`, value: String(syncedEntities), tab: 'entities' }, + { label: `Characters Synced`, value: `${charsSynced}/${charList.length}`, tab: 'characters' }, + { label: `Maps Linked`, value: String(linkedScenes), tab: 'maps' }, + ]; + + // Prioritized problems only — an empty list renders the calm "all clear" state. + // Order matters: errors first (block sync), then warnings (data gaps), then info. + const attention = []; + + if (!ok) { + attention.push({ + severity: 'error', + icon: 'fa-plug-circle-xmark', + text: 'Not connected to Chronicle — open Status to reconnect.', + tab: 'status', + }); + } + if (issuesCount > 0) { + attention.push({ + severity: 'warn', + icon: 'fa-link-slash', + text: `${issuesCount} character${s(issuesCount)} couldn't be matched to Chronicle.`, + tab: 'issues', + }); + } + if (unmatchedMembers > 0) { + attention.push({ + severity: 'warn', + icon: 'fa-user-shield', + text: `${unmatchedMembers} campaign member${s(unmatchedMembers)} not mapped to a Foundry user.`, + tab: 'members', + }); + } + if (calendarAvailable && !calendarInSync) { + attention.push({ + severity: 'warn', + icon: 'fa-calendar-xmark', + text: 'Calendar date is out of sync with Chronicle.', + tab: 'calendar', + }); + } + if (!matchedSystem) { + attention.push({ + severity: 'info', + icon: 'fa-circle-question', + text: 'No matching game system — character fields may not sync.', + tab: 'status', + }); + } + if (errorCount > 0) { + attention.push({ + severity: 'info', + icon: 'fa-triangle-exclamation', + text: `${errorCount} recent sync error${s(errorCount)} — see Status.`, + tab: 'status', + }); + } + + return { connection, stats, attention, allClear: attention.length === 0 }; +} diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 41915e4..66986f9 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -23,6 +23,7 @@ import { CAP_STATUS, } from './capability-inspector.mjs'; import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs'; +import { buildOverviewModel } from './_overview-model.mjs'; import { log, getLogBuffer } from './logger.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; @@ -122,8 +123,17 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { /** @type {string} Current search filter text. */ this._searchFilter = ''; - /** @type {string} Currently active tab (persisted per-client). */ - this._activeTab = getSetting('dashboardActiveTab') || 'entities'; + /** + * @type {string|null} Actor id the GM chose to inspect in the Status tab's + * Capability Inspector. Null = auto-pick (first linked, else first of type). + * Per-session instance state (like _searchFilter): survives re-renders, + * resets when the dashboard window is closed. + */ + this._capabilityActorId = null; + + /** @type {string} Currently active tab (persisted per-client). Defaults to + * the Overview cockpit so the GM lands on a calm summary, not a dense list. */ + this._activeTab = getSetting('dashboardActiveTab') || 'overview'; /** @type {Set} Currently selected entity IDs for bulk operations. */ this._selectedEntities = new Set(); @@ -255,6 +265,23 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { // Build config tab data. const configData = this._buildConfigData(entityGroups); + // Build the Overview cockpit from the pieces already computed above — a + // calm landing view that routes problems to the tab that fixes each one. + const overview = buildOverviewModel({ + connectionState: statusData.connectionState, + connectionLabel: statusData.connectionLabel, + syncedEntities: statusData.syncedEntities, + linkedScenes: statusData.linkedScenes, + characters: characterData.characters, + issuesCount: issuesData.count, + unmatchedMembers: membersData.unmatchedCount, + calendarAvailable: calendarData.available, + calendarInSync: calendarData.inSync, + errorCount: statusData.errorLog?.length ?? 0, + matchedSystem: statusData.matchedSystem, + lastSyncTime: statusData.lastSyncTime, + }); + this._loading = false; return { @@ -265,6 +292,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { loadErrors, hasLoadErrors: loadErrors.length > 0, + // Overview (landing cockpit) tab. + overview, + // Issues (resolver) tab. issues: issuesData, @@ -1026,14 +1056,36 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { ); const actorType = actorSync?._actorType || actorSync?._adapter?.actorType || 'character'; - // Sample a representative actor: prefer a linked one, else the first of the type. const ofType = (game.actors?.contents || []).filter((a) => a?.type === actorType); - const sample = ofType.find((a) => a.getFlag?.(FLAG_SCOPE, 'entityId')) || ofType[0] || null; + + // Which actor to inspect. If the GM picked one (and it still exists), use it; + // otherwise auto-pick a representative actor: prefer a Chronicle-linked one, + // else the first of the type. `autoPicked` drives the UI hint so the GM knows + // the panel chose for them and can switch with the dropdown. + const chosen = this._capabilityActorId + ? ofType.find((a) => a.id === this._capabilityActorId) + : null; + const autoPicked = !chosen; + const sample = chosen + || ofType.find((a) => a.getFlag?.(FLAG_SCOPE, 'entityId')) + || ofType[0] + || null; if (!sample) { this._capabilityReport = null; - return { available: false, actorType, error: `no "${actorType}" actor to sample` }; + return { available: false, actorType, actors: [], error: `no "${actorType}" actor to sample` }; } + // The full pick-list for the dropdown (linked actors flagged so the GM can + // tell which heroes already sync to Chronicle). + const actors = ofType + .map((a) => ({ + id: a.id, + name: a.name || '(unnamed)', + linked: !!a.getFlag?.(FLAG_SCOPE, 'entityId'), + selected: a.id === sample.id, + })) + .sort((x, y) => x.name.localeCompare(y.name)); + // Fetch the system's declared character fields (same source the adapter uses). let fieldDefs = { fields: [] }; try { @@ -1050,6 +1102,8 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { return { available: !!report.ok, actorType, + actors, + autoPicked, summary: report.summary, source: report.source, gaps: this._capabilityGaps(report), @@ -1263,6 +1317,27 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { }); }); + // --- Overview cockpit: jump-links to other tabs --- + // Stat tiles and "needs attention" rows carry data-jump-tab and switch the + // active tab (no data-action, so they bypass the ApplicationV2 click router). + el.querySelectorAll('[data-jump-tab]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + this._switchTab(e.currentTarget.dataset.jumpTab); + }); + }); + + // --- Status tab: Capability Inspector actor picker --- + // ApplicationV2 `actions` only delegate `click`, so the ` + {{#each capability.actors}} + + {{/each}} + +
Synced:{{capability.summary.synced}} / {{capability.summary.totalDeclared}} declared
Declared, unmapped:{{capability.summary.declaredUnmapped}}
@@ -1118,5 +1208,7 @@
+ + {{!-- /.dashboard-body --}} {{/unless}} diff --git a/tools/test-overview-model.mjs b/tools/test-overview-model.mjs new file mode 100644 index 0000000..3ad0527 --- /dev/null +++ b/tools/test-overview-model.mjs @@ -0,0 +1,98 @@ +/** + * Tests for the Overview cockpit model (scripts/_overview-model.mjs). + * Pure function — verifies stat formatting, the all-clear path, and that each + * problem surfaces exactly one prioritized attention row routed to the right tab. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildOverviewModel } from '../scripts/_overview-model.mjs'; + +test('healthy world: connected, no problems → all clear', () => { + const m = buildOverviewModel({ + connectionState: 'connected', + connectionLabel: 'Connected', + syncedEntities: 42, + linkedScenes: 3, + characters: [{ synced: true }, { synced: true }], + issuesCount: 0, + unmatchedMembers: 0, + calendarAvailable: true, + calendarInSync: true, + errorCount: 0, + matchedSystem: 'drawsteel', + lastSyncTime: '12:00', + }); + assert.equal(m.allClear, true); + assert.equal(m.attention.length, 0); + assert.equal(m.connection.ok, true); + assert.match(m.connection.detail, /Last sync: 12:00/); +}); + +test('stats format counts and the synced/total character ratio', () => { + const m = buildOverviewModel({ + syncedEntities: 7, + linkedScenes: 2, + characters: [{ synced: true }, { synced: false }, { synced: true }], + matchedSystem: 'x', + connectionState: 'connected', + }); + const byTab = Object.fromEntries(m.stats.map((s) => [s.tab, s.value])); + assert.equal(byTab.entities, '7'); + assert.equal(byTab.maps, '2'); + assert.equal(byTab.characters, '2/3'); +}); + +test('disconnected surfaces a blocking error routed to status', () => { + const m = buildOverviewModel({ + connectionState: 'disconnected', + connectionLabel: 'Disconnected', + matchedSystem: 'x', + }); + assert.equal(m.connection.ok, false); + const err = m.attention.find((a) => a.severity === 'error'); + assert.ok(err, 'expected a blocking error'); + assert.equal(err.tab, 'status'); + // Error must be first (highest priority). + assert.equal(m.attention[0].severity, 'error'); +}); + +test('each problem routes to the tab that fixes it', () => { + const m = buildOverviewModel({ + connectionState: 'connected', + issuesCount: 2, + unmatchedMembers: 1, + calendarAvailable: true, + calendarInSync: false, + errorCount: 5, + matchedSystem: null, + }); + const tabFor = (needle) => m.attention.find((a) => a.text.includes(needle))?.tab; + assert.equal(tabFor("couldn't be matched"), 'issues'); + assert.equal(tabFor('not mapped'), 'members'); + assert.equal(tabFor('out of sync'), 'calendar'); + assert.equal(tabFor('No matching game system'), 'status'); + assert.equal(tabFor('recent sync error'), 'status'); + assert.equal(m.allClear, false); +}); + +test('pluralization: singular vs plural', () => { + const one = buildOverviewModel({ connectionState: 'connected', issuesCount: 1, matchedSystem: 'x' }); + assert.match(one.attention[0].text, /1 character couldn't/); + const many = buildOverviewModel({ connectionState: 'connected', issuesCount: 3, matchedSystem: 'x' }); + assert.match(many.attention[0].text, /3 characters couldn't/); +}); + +test('in-sync calendar produces no calendar alert; unavailable calendar is ignored', () => { + const synced = buildOverviewModel({ connectionState: 'connected', calendarAvailable: true, calendarInSync: true, matchedSystem: 'x' }); + assert.equal(synced.attention.find((a) => a.tab === 'calendar'), undefined); + const off = buildOverviewModel({ connectionState: 'connected', calendarAvailable: false, calendarInSync: false, matchedSystem: 'x' }); + assert.equal(off.attention.find((a) => a.tab === 'calendar'), undefined); +}); + +test('defensive: empty input does not throw and reports disconnected', () => { + const m = buildOverviewModel(); + assert.equal(m.connection.ok, false); + assert.equal(m.stats.length, 3); + // No matchedSystem → the "no system" info alert is present. + assert.ok(m.attention.some((a) => a.text.includes('No matching game system'))); +});