From 63142dbaa7523474095f8f2f60eee32563b926ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 17:36:31 +0000 Subject: [PATCH 1/2] feat(dashboard): actor picker for the Sync Capability Inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Capability Inspector silently auto-sampled the first hero actor (often a stray like "Festival 1"), with no way to choose which character to inspect — confusing GMs who never "selected" anything yet saw a name they didn't pick. Add a dropdown of all actors of the synced type (linked ones flagged "✓ synced"). The panel re-samples against the chosen actor. Until a pick is made it still auto-picks (first linked, else first of type) and now says so explicitly in the hint. Selection is per-session instance state, like the existing search filter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf --- scripts/sync-dashboard.mjs | 49 +++++++++++++++++++++++++++++++++--- styles/chronicle-sync.css | 19 ++++++++++++++ templates/sync-dashboard.hbs | 10 +++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 41915e4..5f373e1 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -122,6 +122,14 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { /** @type {string} Current search filter text. */ this._searchFilter = ''; + /** + * @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). */ this._activeTab = getSetting('dashboardActiveTab') || 'entities'; @@ -1026,14 +1034,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 +1080,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 +1295,17 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { }); }); + // --- 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}}
From cd8c823a06223f5e99f839a8c232aafe9ac4f083 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 17:50:20 +0000 Subject: [PATCH 2/2] feat(dashboard): vertical category rail + Overview cockpit landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10-tab horizontal row was overwhelming and buried the Status tab. Redesign the dashboard navigation to follow Foundry's own high-cardinality idiom (the Settings window's vertical sidebar) and lead with a calm summary view: - Replace the flat 10-tab top row with a grouped vertical rail (Overview · Everyday · Library · Setup · Diagnostics). Each section is labeled so a non-technical GM can tell everyday work from setup from troubleshooting. - Add an Overview landing tab (new default): a connection banner, three headline stats, and a prioritized "needs attention" list. Every stat and alert is a jump-link to the tab that addresses it — so the GM never has to hunt for where a problem lives (notably Status/diagnostics). - The Overview model is a pure, unit-tested builder (_overview-model.mjs); the dashboard feeds it the already-computed tab data. - Tab switching is refactored into _switchTab/_applyActiveTab so rail clicks and Overview jump-links share one path; a persisted-but-missing tab now falls back to Overview instead of a blank panel. Panels are unchanged — only the navigation shell and the new landing view. 564 node tests green (557 + 7 new for the overview model). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf --- CLAUDE.md | 3 +- scripts/_overview-model.mjs | 123 ++++++++++++++++++++++ scripts/sync-dashboard.mjs | 93 +++++++++++++---- styles/chronicle-sync.css | 191 ++++++++++++++++++++++++++++++++++ templates/sync-dashboard.hbs | 142 +++++++++++++++++++------ tools/test-overview-model.mjs | 98 +++++++++++++++++ 6 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 scripts/_overview-model.mjs create mode 100644 tools/test-overview-model.mjs 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 5f373e1..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; @@ -130,8 +131,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { */ this._capabilityActorId = null; - /** @type {string} Currently active tab (persisted per-client). */ - this._activeTab = getSetting('dashboardActiveTab') || 'entities'; + /** @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(); @@ -263,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 { @@ -273,6 +292,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { loadErrors, hasLoadErrors: loadErrors.length > 0, + // Overview (landing cockpit) tab. + overview, + // Issues (resolver) tab. issues: issuesData, @@ -1295,6 +1317,16 @@ 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 `