Skip to content

Commit 666ac38

Browse files
committed
Add graceful failure handling and error banner for dashboard API calls
- Extract _normalizeArray helper to safely unwrap API responses that may return plain arrays or {data: [...]} / {entity_types: [...]} objects - Apply normalization to /entity-types, /entities, and /maps endpoints - Track load errors per tab and surface them in a visible error banner with a Retry button, so users see what failed instead of empty tabs https://claude.ai/code/session_01FcPMoRaH9FZsmU9JQ1eALo
1 parent 7488d08 commit 666ac38

3 files changed

Lines changed: 71 additions & 6 deletions

File tree

scripts/sync-dashboard.mjs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,28 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
109109
// Data
110110
// ---------------------------------------------------------------------------
111111

112+
/**
113+
* Normalize an API response to an array.
114+
* Chronicle endpoints may return a plain array or an object wrapping
115+
* the array under common keys like `data`, `entity_types`, `entities`,
116+
* `maps`, etc. This helper unwraps whichever shape we get.
117+
* @param {*} raw - The raw parsed JSON from the API.
118+
* @param {...string} keys - Object keys to try (in order) if raw is not an array.
119+
* @returns {Array}
120+
* @private
121+
*/
122+
_normalizeArray(raw, ...keys) {
123+
if (Array.isArray(raw)) return raw;
124+
if (raw && typeof raw === 'object') {
125+
for (const key of keys) {
126+
if (Array.isArray(raw[key])) return raw[key];
127+
}
128+
// Last resort: try the generic `data` wrapper.
129+
if (Array.isArray(raw.data)) return raw.data;
130+
}
131+
return [];
132+
}
133+
112134
/** @override */
113135
async _prepareContext(options = {}) {
114136
if (!this._syncManager || !this.api) {
@@ -117,6 +139,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
117139

118140
this._loading = true;
119141
const exclusions = this._getExclusions();
142+
const loadErrors = [];
120143

121144
// Build entity tab data.
122145
let entityGroups = [];
@@ -126,6 +149,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
126149
foundryOnlyJournals = this._getFoundryOnlyJournals();
127150
} catch (err) {
128151
console.error('Chronicle Dashboard: Failed to load entities', err);
152+
loadErrors.push({ tab: 'entities', message: err.message || 'Failed to load entities' });
129153
}
130154

131155
// Build map tab data.
@@ -134,6 +158,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
134158
mapData = await this._buildMapData();
135159
} catch (err) {
136160
console.error('Chronicle Dashboard: Failed to load maps', err);
161+
loadErrors.push({ tab: 'maps', message: err.message || 'Failed to load maps' });
137162
}
138163

139164
// Build shops tab data.
@@ -142,6 +167,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
142167
shopData = await this._buildShopData();
143168
} catch (err) {
144169
console.error('Chronicle Dashboard: Failed to load shops', err);
170+
loadErrors.push({ tab: 'shops', message: err.message || 'Failed to load shops' });
145171
}
146172

147173
// Build calendar tab data.
@@ -150,6 +176,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
150176
calendarData = await this._buildCalendarData();
151177
} catch (err) {
152178
console.error('Chronicle Dashboard: Failed to load calendar', err);
179+
loadErrors.push({ tab: 'calendar', message: err.message || 'Failed to load calendar' });
153180
}
154181

155182
// Build status tab data.
@@ -168,6 +195,8 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
168195
loading: false,
169196
searchFilter: this._searchFilter,
170197
activeTab: this._activeTab,
198+
loadErrors,
199+
hasLoadErrors: loadErrors.length > 0,
171200

172201
// Config tab.
173202
config: configData,
@@ -209,7 +238,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
209238
// Fetch entity types from Chronicle.
210239
if (!this._cache.entityTypes) {
211240
const raw = await this.api.get('/entity-types');
212-
this._cache.entityTypes = Array.isArray(raw) ? raw : (raw?.data || raw?.entity_types || []);
241+
this._cache.entityTypes = this._normalizeArray(raw, 'entity_types');
213242
}
214243
const types = this._cache.entityTypes;
215244

@@ -220,8 +249,8 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
220249
let hasMore = true;
221250
while (hasMore && page <= 5) {
222251
const result = await this.api.get(`/entities?per_page=100&page=${page}`);
223-
const entities = result?.entities || result || [];
224-
if (Array.isArray(entities) && entities.length > 0) {
252+
const entities = this._normalizeArray(result, 'entities');
253+
if (entities.length > 0) {
225254
allEntities.push(...entities);
226255
hasMore = entities.length === 100;
227256
page++;
@@ -346,9 +375,10 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
346375
async _buildMapData() {
347376
// Fetch Chronicle maps.
348377
if (!this._cache.maps) {
349-
this._cache.maps = await this.api.get('/maps').catch(() => []);
378+
const raw = await this.api.get('/maps').catch(() => []);
379+
this._cache.maps = this._normalizeArray(raw, 'maps');
350380
}
351-
const chronicles = this._cache.maps || [];
381+
const chronicles = this._cache.maps;
352382

353383
// Index Foundry scenes by linked mapId.
354384
const scenesByMapId = new Map();
@@ -397,7 +427,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
397427
// Ensure entity types are cached.
398428
if (!this._cache.entityTypes) {
399429
const raw = await this.api.get('/entity-types');
400-
this._cache.entityTypes = Array.isArray(raw) ? raw : (raw?.data || raw?.entity_types || []);
430+
this._cache.entityTypes = this._normalizeArray(raw, 'entity_types');
401431
}
402432
const types = this._cache.entityTypes;
403433

styles/chronicle-sync.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,27 @@
228228
border-bottom: 1px solid rgba(96, 165, 250, 0.15);
229229
}
230230

231+
/* Load error banner */
232+
.dashboard-load-errors {
233+
display: flex;
234+
align-items: center;
235+
flex-wrap: wrap;
236+
gap: 4px;
237+
padding: 6px 10px;
238+
font-size: 12px;
239+
color: #fca5a5;
240+
background: rgba(248, 113, 113, 0.1);
241+
border-bottom: 1px solid rgba(248, 113, 113, 0.25);
242+
}
243+
244+
.dashboard-load-errors i {
245+
margin-right: 4px;
246+
}
247+
248+
.load-error-item {
249+
font-weight: 600;
250+
}
251+
231252
/* Tab navigation */
232253
.dashboard-tabs {
233254
display: flex;

templates/sync-dashboard.hbs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@
4343
</a>
4444
</nav>
4545

46+
{{!-- Load error banner --}}
47+
{{#if hasLoadErrors}}
48+
<div class="dashboard-load-errors">
49+
<i class="fa-solid fa-triangle-exclamation"></i>
50+
Some data failed to load from Chronicle:
51+
{{#each loadErrors}}
52+
<span class="load-error-item">{{tab}}{{#unless @last}},{{/unless}}</span>
53+
{{/each}}
54+
<button type="button" data-action="refresh" class="dashboard-btn btn-xs" title="Retry">
55+
<i class="fa-solid fa-arrows-rotate"></i> Retry
56+
</button>
57+
</div>
58+
{{/if}}
59+
4660
{{!-- Tab content --}}
4761
<section class="dashboard-content">
4862

0 commit comments

Comments
 (0)