Skip to content

Commit b10c30b

Browse files
authored
Merge pull request #72 from keyxmakerx/claude/chronicle-sheet-sync-j2m9s4
Sync dashboard redesign (Overview cockpit + vertical rail), actor picker, and the Sync Capability Inspector
2 parents d62ac15 + cd8c823 commit b10c30b

6 files changed

Lines changed: 676 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ scripts/ # ES modules (.mjs)
3030
item-sync.mjs # Item sync
3131
note-sync.mjs # Chronicle Notes ↔ JournalEntry sync
3232
shop-widget.mjs # Shop inventory UI
33-
sync-dashboard.mjs # 8-tab dashboard UI
33+
sync-dashboard.mjs # Dashboard UI: Overview cockpit + grouped vertical rail (Everyday/Library/Setup/Diagnostics)
34+
_overview-model.mjs # Pure builder for the Overview landing cockpit (stats + prioritized "needs attention")
3435
update-info.mjs # "Update Source" diagnostic dialog (install/update flow)
3536
character-claim-indicator.mjs # Per-player character-claim status indicator
3637
import-wizard.mjs # Initial-import wizard UI

scripts/_overview-model.mjs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Chronicle Sync — Overview cockpit model
3+
*
4+
* Pure builder for the dashboard's Overview landing tab. Takes already-computed
5+
* pieces from the other tab-data builders and distills them into a "cockpit":
6+
* a connection banner, a few headline stats, and a prioritized "needs attention"
7+
* list that routes the GM straight to the tab that can fix each problem.
8+
*
9+
* Kept pure (no Foundry globals) so it can be unit-tested headlessly — the
10+
* dashboard passes plain values in and renders the returned model.
11+
*/
12+
13+
/** Pluralize helper: "" for 1, "s" otherwise. */
14+
function s(n) {
15+
return n === 1 ? '' : 's';
16+
}
17+
18+
/**
19+
* Build the Overview cockpit model.
20+
*
21+
* @param {object} p
22+
* @param {string} p.connectionState - 'connected' | 'connecting' | 'reconnecting' | 'disconnected'
23+
* @param {string} p.connectionLabel - human label for the state
24+
* @param {number} p.syncedEntities - count of journals linked to Chronicle entities
25+
* @param {number} p.linkedScenes - count of Chronicle maps materialized
26+
* @param {Array} [p.characters] - synced-actor rows (each may have `.synced`)
27+
* @param {number} [p.issuesCount] - unresolved character-link issues
28+
* @param {number} [p.unmatchedMembers] - campaign members with no Foundry user
29+
* @param {boolean}[p.calendarAvailable] - a calendar module + campaign calendar exist
30+
* @param {boolean}[p.calendarInSync] - Foundry date matches Chronicle date
31+
* @param {number} [p.errorCount] - recent REST/sync errors
32+
* @param {string|null} [p.matchedSystem] - matched Chronicle system slug, or null
33+
* @param {string} [p.lastSyncTime] - last successful sync, human string
34+
* @returns {{connection:object, stats:Array, attention:Array, allClear:boolean}}
35+
*/
36+
export function buildOverviewModel(p = {}) {
37+
const {
38+
connectionState = 'disconnected',
39+
connectionLabel = 'Disconnected',
40+
syncedEntities = 0,
41+
linkedScenes = 0,
42+
characters = [],
43+
issuesCount = 0,
44+
unmatchedMembers = 0,
45+
calendarAvailable = false,
46+
calendarInSync = false,
47+
errorCount = 0,
48+
matchedSystem = null,
49+
lastSyncTime = 'Never',
50+
} = p;
51+
52+
const ok = connectionState === 'connected';
53+
const charList = Array.isArray(characters) ? characters : [];
54+
const charsSynced = charList.filter((c) => c && c.synced).length;
55+
56+
const connection = {
57+
state: connectionState,
58+
label: connectionLabel,
59+
ok,
60+
detail: ok ? `Last sync: ${lastSyncTime}` : 'Sync is paused until the connection is restored.',
61+
};
62+
63+
const stats = [
64+
{ label: `Entities Synced`, value: String(syncedEntities), tab: 'entities' },
65+
{ label: `Characters Synced`, value: `${charsSynced}/${charList.length}`, tab: 'characters' },
66+
{ label: `Maps Linked`, value: String(linkedScenes), tab: 'maps' },
67+
];
68+
69+
// Prioritized problems only — an empty list renders the calm "all clear" state.
70+
// Order matters: errors first (block sync), then warnings (data gaps), then info.
71+
const attention = [];
72+
73+
if (!ok) {
74+
attention.push({
75+
severity: 'error',
76+
icon: 'fa-plug-circle-xmark',
77+
text: 'Not connected to Chronicle — open Status to reconnect.',
78+
tab: 'status',
79+
});
80+
}
81+
if (issuesCount > 0) {
82+
attention.push({
83+
severity: 'warn',
84+
icon: 'fa-link-slash',
85+
text: `${issuesCount} character${s(issuesCount)} couldn't be matched to Chronicle.`,
86+
tab: 'issues',
87+
});
88+
}
89+
if (unmatchedMembers > 0) {
90+
attention.push({
91+
severity: 'warn',
92+
icon: 'fa-user-shield',
93+
text: `${unmatchedMembers} campaign member${s(unmatchedMembers)} not mapped to a Foundry user.`,
94+
tab: 'members',
95+
});
96+
}
97+
if (calendarAvailable && !calendarInSync) {
98+
attention.push({
99+
severity: 'warn',
100+
icon: 'fa-calendar-xmark',
101+
text: 'Calendar date is out of sync with Chronicle.',
102+
tab: 'calendar',
103+
});
104+
}
105+
if (!matchedSystem) {
106+
attention.push({
107+
severity: 'info',
108+
icon: 'fa-circle-question',
109+
text: 'No matching game system — character fields may not sync.',
110+
tab: 'status',
111+
});
112+
}
113+
if (errorCount > 0) {
114+
attention.push({
115+
severity: 'info',
116+
icon: 'fa-triangle-exclamation',
117+
text: `${errorCount} recent sync error${s(errorCount)} — see Status.`,
118+
tab: 'status',
119+
});
120+
}
121+
122+
return { connection, stats, attention, allClear: attention.length === 0 };
123+
}

scripts/sync-dashboard.mjs

Lines changed: 121 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
CAP_STATUS,
2424
} from './capability-inspector.mjs';
2525
import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs';
26+
import { buildOverviewModel } from './_overview-model.mjs';
2627
import { log, getLogBuffer } from './logger.mjs';
2728
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
2829

@@ -122,8 +123,17 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
122123
/** @type {string} Current search filter text. */
123124
this._searchFilter = '';
124125

125-
/** @type {string} Currently active tab (persisted per-client). */
126-
this._activeTab = getSetting('dashboardActiveTab') || 'entities';
126+
/**
127+
* @type {string|null} Actor id the GM chose to inspect in the Status tab's
128+
* Capability Inspector. Null = auto-pick (first linked, else first of type).
129+
* Per-session instance state (like _searchFilter): survives re-renders,
130+
* resets when the dashboard window is closed.
131+
*/
132+
this._capabilityActorId = null;
133+
134+
/** @type {string} Currently active tab (persisted per-client). Defaults to
135+
* the Overview cockpit so the GM lands on a calm summary, not a dense list. */
136+
this._activeTab = getSetting('dashboardActiveTab') || 'overview';
127137

128138
/** @type {Set<string>} Currently selected entity IDs for bulk operations. */
129139
this._selectedEntities = new Set();
@@ -255,6 +265,23 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
255265
// Build config tab data.
256266
const configData = this._buildConfigData(entityGroups);
257267

268+
// Build the Overview cockpit from the pieces already computed above — a
269+
// calm landing view that routes problems to the tab that fixes each one.
270+
const overview = buildOverviewModel({
271+
connectionState: statusData.connectionState,
272+
connectionLabel: statusData.connectionLabel,
273+
syncedEntities: statusData.syncedEntities,
274+
linkedScenes: statusData.linkedScenes,
275+
characters: characterData.characters,
276+
issuesCount: issuesData.count,
277+
unmatchedMembers: membersData.unmatchedCount,
278+
calendarAvailable: calendarData.available,
279+
calendarInSync: calendarData.inSync,
280+
errorCount: statusData.errorLog?.length ?? 0,
281+
matchedSystem: statusData.matchedSystem,
282+
lastSyncTime: statusData.lastSyncTime,
283+
});
284+
258285
this._loading = false;
259286

260287
return {
@@ -265,6 +292,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
265292
loadErrors,
266293
hasLoadErrors: loadErrors.length > 0,
267294

295+
// Overview (landing cockpit) tab.
296+
overview,
297+
268298
// Issues (resolver) tab.
269299
issues: issuesData,
270300

@@ -1026,14 +1056,36 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
10261056
);
10271057
const actorType = actorSync?._actorType || actorSync?._adapter?.actorType || 'character';
10281058

1029-
// Sample a representative actor: prefer a linked one, else the first of the type.
10301059
const ofType = (game.actors?.contents || []).filter((a) => a?.type === actorType);
1031-
const sample = ofType.find((a) => a.getFlag?.(FLAG_SCOPE, 'entityId')) || ofType[0] || null;
1060+
1061+
// Which actor to inspect. If the GM picked one (and it still exists), use it;
1062+
// otherwise auto-pick a representative actor: prefer a Chronicle-linked one,
1063+
// else the first of the type. `autoPicked` drives the UI hint so the GM knows
1064+
// the panel chose for them and can switch with the dropdown.
1065+
const chosen = this._capabilityActorId
1066+
? ofType.find((a) => a.id === this._capabilityActorId)
1067+
: null;
1068+
const autoPicked = !chosen;
1069+
const sample = chosen
1070+
|| ofType.find((a) => a.getFlag?.(FLAG_SCOPE, 'entityId'))
1071+
|| ofType[0]
1072+
|| null;
10321073
if (!sample) {
10331074
this._capabilityReport = null;
1034-
return { available: false, actorType, error: `no "${actorType}" actor to sample` };
1075+
return { available: false, actorType, actors: [], error: `no "${actorType}" actor to sample` };
10351076
}
10361077

1078+
// The full pick-list for the dropdown (linked actors flagged so the GM can
1079+
// tell which heroes already sync to Chronicle).
1080+
const actors = ofType
1081+
.map((a) => ({
1082+
id: a.id,
1083+
name: a.name || '(unnamed)',
1084+
linked: !!a.getFlag?.(FLAG_SCOPE, 'entityId'),
1085+
selected: a.id === sample.id,
1086+
}))
1087+
.sort((x, y) => x.name.localeCompare(y.name));
1088+
10371089
// Fetch the system's declared character fields (same source the adapter uses).
10381090
let fieldDefs = { fields: [] };
10391091
try {
@@ -1050,6 +1102,8 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
10501102
return {
10511103
available: !!report.ok,
10521104
actorType,
1105+
actors,
1106+
autoPicked,
10531107
summary: report.summary,
10541108
source: report.source,
10551109
gaps: this._capabilityGaps(report),
@@ -1263,6 +1317,27 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
12631317
});
12641318
});
12651319

1320+
// --- Overview cockpit: jump-links to other tabs ---
1321+
// Stat tiles and "needs attention" rows carry data-jump-tab and switch the
1322+
// active tab (no data-action, so they bypass the ApplicationV2 click router).
1323+
el.querySelectorAll('[data-jump-tab]').forEach((btn) => {
1324+
btn.addEventListener('click', (e) => {
1325+
e.preventDefault();
1326+
this._switchTab(e.currentTarget.dataset.jumpTab);
1327+
});
1328+
});
1329+
1330+
// --- Status tab: Capability Inspector actor picker ---
1331+
// ApplicationV2 `actions` only delegate `click`, so the `<select>` change is
1332+
// wired here directly. Picking an actor re-renders the panel against it.
1333+
const capActorSelect = el.querySelector('.capability-actor-select');
1334+
if (capActorSelect) {
1335+
capActorSelect.addEventListener('change', (e) => {
1336+
this._capabilityActorId = e.currentTarget.value || null;
1337+
this.render({ force: true });
1338+
});
1339+
}
1340+
12661341
// Map tab handlers are wired via the `open-map-journal` action above;
12671342
// no additional select listeners are needed in Path B.
12681343
}
@@ -1276,32 +1351,57 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
12761351
*/
12771352
_initTabs(el) {
12781353
const tabs = el.querySelectorAll('.dashboard-tabs .item');
1279-
const panels = el.querySelectorAll('.dashboard-content .tab');
1354+
1355+
// If the persisted tab no longer exists in the DOM (renamed/removed), fall
1356+
// back to the Overview cockpit (or the first available tab) so the window
1357+
// never opens to a blank panel.
1358+
const known = new Set([...tabs].map((t) => t.dataset.tab));
1359+
if (!known.has(this._activeTab)) {
1360+
this._activeTab = known.has('overview') ? 'overview' : (tabs[0]?.dataset.tab || this._activeTab);
1361+
}
12801362

12811363
// Apply the stored active tab.
1282-
tabs.forEach((tab) => {
1283-
const tabName = tab.dataset.tab;
1284-
tab.classList.toggle('active', tabName === this._activeTab);
1285-
});
1286-
panels.forEach((panel) => {
1287-
const tabName = panel.dataset.tab;
1288-
panel.classList.toggle('active', tabName === this._activeTab);
1289-
});
1364+
this._applyActiveTab(el);
12901365

1291-
// Listen for tab clicks.
1366+
// Listen for rail clicks.
12921367
tabs.forEach((tab) => {
12931368
tab.addEventListener('click', (e) => {
12941369
e.preventDefault();
1295-
const tabName = tab.dataset.tab;
1296-
this._activeTab = tabName;
1297-
setSetting('dashboardActiveTab', tabName);
1298-
1299-
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
1300-
panels.forEach(p => p.classList.toggle('active', p.dataset.tab === tabName));
1370+
this._switchTab(tab.dataset.tab);
13011371
});
13021372
});
13031373
}
13041374

1375+
/**
1376+
* Toggle the `.active` class on the nav item + content panel matching
1377+
* `this._activeTab`. Shared by initial render and programmatic switches.
1378+
* @param {HTMLElement} el
1379+
* @private
1380+
*/
1381+
_applyActiveTab(el) {
1382+
el.querySelectorAll('.dashboard-tabs .item').forEach((t) =>
1383+
t.classList.toggle('active', t.dataset.tab === this._activeTab));
1384+
el.querySelectorAll('.dashboard-content .tab').forEach((p) =>
1385+
p.classList.toggle('active', p.dataset.tab === this._activeTab));
1386+
}
1387+
1388+
/**
1389+
* Switch to a tab by name and persist it. Used by rail clicks and by the
1390+
* Overview cockpit's jump-links (`data-jump-tab`). Scrolls the content pane
1391+
* back to the top so the jumped-to tab starts at its heading.
1392+
* @param {string} tabName
1393+
* @private
1394+
*/
1395+
_switchTab(tabName) {
1396+
if (!tabName) return;
1397+
this._activeTab = tabName;
1398+
setSetting('dashboardActiveTab', tabName);
1399+
const el = this.element;
1400+
if (!el) return;
1401+
this._applyActiveTab(el);
1402+
el.querySelector('.dashboard-content')?.scrollTo?.({ top: 0 });
1403+
}
1404+
13051405
// ---------------------------------------------------------------------------
13061406
// Action handlers (ApplicationV2 actions pattern)
13071407
// Called with `this` bound to the application instance by Foundry.

0 commit comments

Comments
 (0)