Skip to content

Commit cd8c823

Browse files
committed
feat(dashboard): vertical category rail + Overview cockpit landing
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf
1 parent 63142db commit cd8c823

6 files changed

Lines changed: 602 additions & 48 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: 75 additions & 18 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

@@ -130,8 +131,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
130131
*/
131132
this._capabilityActorId = null;
132133

133-
/** @type {string} Currently active tab (persisted per-client). */
134-
this._activeTab = getSetting('dashboardActiveTab') || 'entities';
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';
135137

136138
/** @type {Set<string>} Currently selected entity IDs for bulk operations. */
137139
this._selectedEntities = new Set();
@@ -263,6 +265,23 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
263265
// Build config tab data.
264266
const configData = this._buildConfigData(entityGroups);
265267

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+
266285
this._loading = false;
267286

268287
return {
@@ -273,6 +292,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
273292
loadErrors,
274293
hasLoadErrors: loadErrors.length > 0,
275294

295+
// Overview (landing cockpit) tab.
296+
overview,
297+
276298
// Issues (resolver) tab.
277299
issues: issuesData,
278300

@@ -1295,6 +1317,16 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
12951317
});
12961318
});
12971319

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+
12981330
// --- Status tab: Capability Inspector actor picker ---
12991331
// ApplicationV2 `actions` only delegate `click`, so the `<select>` change is
13001332
// wired here directly. Picking an actor re-renders the panel against it.
@@ -1319,32 +1351,57 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
13191351
*/
13201352
_initTabs(el) {
13211353
const tabs = el.querySelectorAll('.dashboard-tabs .item');
1322-
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+
}
13231362

13241363
// Apply the stored active tab.
1325-
tabs.forEach((tab) => {
1326-
const tabName = tab.dataset.tab;
1327-
tab.classList.toggle('active', tabName === this._activeTab);
1328-
});
1329-
panels.forEach((panel) => {
1330-
const tabName = panel.dataset.tab;
1331-
panel.classList.toggle('active', tabName === this._activeTab);
1332-
});
1364+
this._applyActiveTab(el);
13331365

1334-
// Listen for tab clicks.
1366+
// Listen for rail clicks.
13351367
tabs.forEach((tab) => {
13361368
tab.addEventListener('click', (e) => {
13371369
e.preventDefault();
1338-
const tabName = tab.dataset.tab;
1339-
this._activeTab = tabName;
1340-
setSetting('dashboardActiveTab', tabName);
1341-
1342-
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
1343-
panels.forEach(p => p.classList.toggle('active', p.dataset.tab === tabName));
1370+
this._switchTab(tab.dataset.tab);
13441371
});
13451372
});
13461373
}
13471374

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+
13481405
// ---------------------------------------------------------------------------
13491406
// Action handlers (ApplicationV2 actions pattern)
13501407
// Called with `this` bound to the application instance by Foundry.

0 commit comments

Comments
 (0)