domma-cms 1.6.59 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,1999 @@
1
+ # Dashboard Rebuild Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the current admin dashboard with a site-aware command centre showing traffic, user journeys, spikes, content state, and combined site/server health.
6
+
7
+ **Architecture:** Extend the existing analytics plugin to capture session IDs and referrers (cookie-less, sessionStorage-only) so we can derive entry/exit pages and 2-step paths. Add a `health` service that aggregates server + site checks with 30 s in-memory caching. Add `/api/dashboard/summary` that fans out to all sources in one call (with `?lite=1` for the 30 s polling tick). Rebuild the dashboard view as a thin orchestrator that loads seven self-contained widget render functions.
8
+
9
+ **Tech Stack:** Fastify 5 (ESM) on the backend, Domma SPA on the frontend, `node:test` + `node:assert/strict` for tests, Chart.js (CDN, already used by the analytics plugin) for the trend chart.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md`
12
+
13
+ **Project conventions to follow:**
14
+ - Use Domma aliases (`$`, `S`, `H`, `_`, `M`, `D()`, `T`, `E`, `I`) - never vanilla equivalents
15
+ - `.get(0)` not `[0]` to extract native DOM from Domma collections
16
+ - Domma's `.html()` strips `<button>` - set `innerHTML` directly for static templates with buttons, or build buttons via `createElement`
17
+ - Admin SPA imports use hard-coded `?v=N` cache busters - bump them when modifying imported modules
18
+ - Do NOT commit during implementation - the user commits separately
19
+ - Do NOT claim "tested"/"verified" for UI changes from server-side checks alone - only a real browser load + click counts
20
+ - British English in user-facing copy
21
+ - Run tests with `node --test tests/path/to/file.test.js` (no `npm test` script exists)
22
+
23
+ **Security:** URLs/referrers stored by the analytics plugin come from public POST bodies. They are NOT trusted strings. Every place a widget interpolates a URL, referrer, actor name, or form name into HTML must HTML-escape it. This plan introduces a single shared `escapeHtml` helper and uses it consistently.
24
+
25
+ ---
26
+
27
+ ## File Structure
28
+
29
+ **Modify:**
30
+ - `plugins/analytics/public/inject-body.html` - beacon adds `sid` and `ref`
31
+ - `plugins/analytics/plugin.js` - accept new fields, write to `journeys.json` (capped), add three new endpoints, decorate `fastify.analytics` with helpers
32
+ - `plugins/analytics/CLAUDE.md` - document new storage and endpoints
33
+ - `server/server.js` - register `dashboardRoutes`
34
+ - `server/services/email.js` - record `lastSendResult` so health can read it
35
+ - `server/services/plugins.js` - record plugin load failures so health can read them
36
+ - `admin/js/api.js` - add `api.dashboard.summary` / `summaryLite` namespace
37
+ - `admin/js/views/dashboard.js` - replace with thin orchestrator
38
+ - `admin/js/templates/dashboard.html` - replace with widget grid skeleton
39
+ - `admin/js/views/index.js` - bump dashboard cache buster
40
+ - `admin/index.html` - link the new `dashboard.css`
41
+
42
+ **Create:**
43
+ - `server/services/health.js` - health check aggregator with 30 s cache
44
+ - `server/routes/api/dashboard.js` - `/api/dashboard/summary` endpoint
45
+ - `admin/js/views/dashboard/lib/escape.js` - shared `escapeHtml` helper
46
+ - `admin/js/views/dashboard/widgets/kpi-strip.js`
47
+ - `admin/js/views/dashboard/widgets/traffic-chart.js`
48
+ - `admin/js/views/dashboard/widgets/spike-feed.js`
49
+ - `admin/js/views/dashboard/widgets/top-pages.js`
50
+ - `admin/js/views/dashboard/widgets/journeys.js`
51
+ - `admin/js/views/dashboard/widgets/health-detail.js`
52
+ - `admin/js/views/dashboard/widgets/activity-feed.js`
53
+ - `admin/js/templates/dashboard/kpi-strip.html`
54
+ - `admin/js/templates/dashboard/traffic-chart.html`
55
+ - `admin/js/templates/dashboard/spike-feed.html`
56
+ - `admin/js/templates/dashboard/top-pages.html`
57
+ - `admin/js/templates/dashboard/journeys.html`
58
+ - `admin/js/templates/dashboard/health-detail.html`
59
+ - `admin/js/templates/dashboard/activity-feed.html`
60
+ - `admin/css/dashboard.css`
61
+ - `tests/analytics/journeys.test.js`
62
+ - `tests/analytics/spikes.test.js`
63
+ - `tests/services/health.test.js`
64
+ - `tests/routes/dashboard-summary.test.js`
65
+
66
+ ---
67
+
68
+ ## Phase 1 - Analytics extensions
69
+
70
+ ### Task 1.1: Update beacon to send `sid` and `ref`
71
+
72
+ **Files:**
73
+ - Modify: `plugins/analytics/public/inject-body.html`
74
+
75
+ - [ ] **Replace the entire file** with the new beacon:
76
+
77
+ ```html
78
+ <!-- analytics: page view tracker (DNT-aware, session-deduped) -->
79
+ <script>
80
+ (function () {
81
+ if (typeof fetch !== 'function') return;
82
+
83
+ var dnt = navigator.doNotTrack === '1'
84
+ || window.doNotTrack === '1'
85
+ || navigator.msDoNotTrack === '1';
86
+ if (dnt) return;
87
+
88
+ var url = window.location.pathname;
89
+
90
+ // Session-scoped anonymous ID - clears with the tab. Cookie-less.
91
+ var sid = '';
92
+ try {
93
+ sid = sessionStorage.getItem('dm_sid') || '';
94
+ if (!sid) {
95
+ sid = (crypto && crypto.randomUUID)
96
+ ? crypto.randomUUID()
97
+ : (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
98
+ sessionStorage.setItem('dm_sid', sid);
99
+ }
100
+ } catch (e) {
101
+ // sessionStorage unavailable - proceed without sid
102
+ }
103
+
104
+ // Session dedup (one hit per URL per session)
105
+ try {
106
+ var key = 'dm_analytics_seen';
107
+ var seen = sessionStorage.getItem(key);
108
+ var list = seen ? seen.split('|') : [];
109
+ if (list.indexOf(url) !== -1) return;
110
+ list.push(url);
111
+ sessionStorage.setItem(key, list.join('|'));
112
+ } catch (e) {
113
+ // proceed anyway
114
+ }
115
+
116
+ // Strip query/fragment from referrer; only keep same-origin or external origin+path
117
+ var ref = '';
118
+ try {
119
+ if (document.referrer) {
120
+ var r = new URL(document.referrer);
121
+ ref = r.origin === window.location.origin ? r.pathname : (r.origin + r.pathname);
122
+ }
123
+ } catch (e) { /* ignore */ }
124
+
125
+ fetch('/api/plugins/analytics/hit', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ keepalive: true,
129
+ body: JSON.stringify({ url: url, sid: sid, ref: ref })
130
+ }).catch(function () { /* silent */ });
131
+ })();
132
+ </script>
133
+ ```
134
+
135
+ - [ ] **Verify:** open the file and confirm the beacon now references `sid` and `ref`.
136
+
137
+ ### Task 1.2: Extend `POST /hit` to record journey events
138
+
139
+ **Files:**
140
+ - Modify: `plugins/analytics/plugin.js`
141
+
142
+ - [ ] **Add the journeys file path constant** near the top, after `LEGACY_FILE`:
143
+
144
+ ```js
145
+ const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
146
+ const JOURNEY_DAILY_CAP = 5000;
147
+ ```
148
+
149
+ - [ ] **Replace the existing `migrateLegacy()`** with one that also seeds `journeys.json` if missing:
150
+
151
+ ```js
152
+ async function migrateLegacy() {
153
+ let lifetimeExists = false;
154
+ try {
155
+ await fs.access(LIFETIME_FILE);
156
+ lifetimeExists = true;
157
+ } catch { /* not migrated */ }
158
+
159
+ if (!lifetimeExists) {
160
+ const legacy = await readJson(LEGACY_FILE, null);
161
+ if (legacy && typeof legacy === 'object') {
162
+ await writeJson(LIFETIME_FILE, legacy);
163
+ } else {
164
+ await writeJson(LIFETIME_FILE, {});
165
+ }
166
+ await writeJson(DAILY_FILE, {});
167
+ }
168
+
169
+ try {
170
+ await fs.access(JOURNEYS_FILE);
171
+ } catch {
172
+ await writeJson(JOURNEYS_FILE, {});
173
+ }
174
+ }
175
+ ```
176
+
177
+ - [ ] **Replace the existing `POST /hit` handler body** with this version that validates `sid`/`ref` and appends a journey event under the daily cap:
178
+
179
+ ```js
180
+ fastify.post('/hit', async (request, reply) => {
181
+ const { url, dnt, sid, ref } = request.body || {};
182
+ if (!url || typeof url !== 'string') {
183
+ return reply.status(400).send({ error: 'url is required' });
184
+ }
185
+ if (dnt === true || dnt === '1') {
186
+ return { ok: true, ignored: 'dnt' };
187
+ }
188
+
189
+ const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
190
+ if (settings.excludeAdmin && normalised.startsWith('/admin')) {
191
+ return { ok: true, ignored: 'admin' };
192
+ }
193
+ if (Array.isArray(settings.excludePaths)) {
194
+ for (const prefix of settings.excludePaths) {
195
+ if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
196
+ return { ok: true, ignored: 'excluded' };
197
+ }
198
+ }
199
+ }
200
+
201
+ const today = todayKey();
202
+ const [lifetime, daily, journeys] = await Promise.all([
203
+ readJson(LIFETIME_FILE, {}),
204
+ readJson(DAILY_FILE, {}),
205
+ readJson(JOURNEYS_FILE, {})
206
+ ]);
207
+
208
+ lifetime[normalised] = (lifetime[normalised] || 0) + 1;
209
+ if (!daily[today]) daily[today] = {};
210
+ daily[today][normalised] = (daily[today][normalised] || 0) + 1;
211
+
212
+ // Journey event - only if sid is a string of reasonable shape, and under cap
213
+ const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
214
+ const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
215
+ if (safeSid) {
216
+ if (!journeys[today]) journeys[today] = [];
217
+ if (journeys[today].length < JOURNEY_DAILY_CAP) {
218
+ journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
219
+ }
220
+ }
221
+
222
+ await Promise.all([
223
+ writeJson(LIFETIME_FILE, lifetime),
224
+ writeJson(DAILY_FILE, daily),
225
+ writeJson(JOURNEYS_FILE, journeys)
226
+ ]);
227
+ return { ok: true };
228
+ });
229
+ ```
230
+
231
+ - [ ] **Smoke check** (server is already running on port 4096; do not kill it):
232
+
233
+ ```bash
234
+ curl -s -X POST http://localhost:4096/api/plugins/analytics/hit \
235
+ -H 'Content-Type: application/json' \
236
+ -d '{"url":"/about","sid":"test-sid-1","ref":"/"}'
237
+ ```
238
+
239
+ Expected: `{"ok":true}`. Then `cat plugins/analytics/journeys.json` and confirm the event was appended under today's date.
240
+
241
+ ### Task 1.3: Add journey aggregation helpers
242
+
243
+ **Files:**
244
+ - Modify: `plugins/analytics/plugin.js`
245
+
246
+ - [ ] **Add aggregation helpers** above `export default async function analyticsPlugin`:
247
+
248
+ ```js
249
+ function eventsInRange(journeys, start, end) {
250
+ const startMs = start.getTime();
251
+ const endMs = end.getTime();
252
+ const out = [];
253
+ for (const [day, events] of Object.entries(journeys)) {
254
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
255
+ if (dayMs < startMs || dayMs > endMs) continue;
256
+ for (const ev of events) out.push(ev);
257
+ }
258
+ return out;
259
+ }
260
+
261
+ function groupBySession(events) {
262
+ const sessions = new Map();
263
+ for (const ev of events) {
264
+ if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
265
+ sessions.get(ev.sid).push(ev);
266
+ }
267
+ for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
268
+ return sessions;
269
+ }
270
+
271
+ function aggregateJourneys(events) {
272
+ const sessions = groupBySession(events);
273
+ const entry = new Map();
274
+ const exit = new Map();
275
+ const paths = new Map();
276
+ let bouncedSessions = 0;
277
+
278
+ for (const arr of sessions.values()) {
279
+ if (arr.length === 0) continue;
280
+ entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
281
+ exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
282
+ if (arr.length === 1) bouncedSessions += 1;
283
+ for (let i = 0; i < arr.length - 1; i += 1) {
284
+ const key = arr[i].url + ' → ' + arr[i + 1].url;
285
+ paths.set(key, (paths.get(key) || 0) + 1);
286
+ }
287
+ }
288
+
289
+ const toSorted = (m) => Array.from(m.entries())
290
+ .map(([url, count]) => ({ url, count }))
291
+ .sort((a, b) => b.count - a.count)
292
+ .slice(0, 10);
293
+
294
+ const pathsSorted = Array.from(paths.entries())
295
+ .map(([key, count]) => {
296
+ const [from, to] = key.split(' → ');
297
+ return { from, to, count };
298
+ })
299
+ .sort((a, b) => b.count - a.count)
300
+ .slice(0, 10);
301
+
302
+ const totalSessions = sessions.size;
303
+ return {
304
+ entry: toSorted(entry),
305
+ exit: toSorted(exit),
306
+ paths: pathsSorted,
307
+ bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
308
+ totalSessions
309
+ };
310
+ }
311
+ ```
312
+
313
+ ### Task 1.4: Add `GET /journeys` endpoint
314
+
315
+ **Files:**
316
+ - Modify: `plugins/analytics/plugin.js`
317
+
318
+ - [ ] **Add the route** after the existing `/series` handler:
319
+
320
+ ```js
321
+ fastify.get('/journeys', { preHandler: [authenticate, requireAdmin] }, async (request) => {
322
+ const { range = '7d', from, to } = request.query || {};
323
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
324
+ const journeys = await readJson(JOURNEYS_FILE, {});
325
+ const events = eventsInRange(journeys, dates.start, dates.end);
326
+ const agg = aggregateJourneys(events);
327
+ return {
328
+ range,
329
+ from: todayKey(dates.start),
330
+ to: todayKey(dates.end),
331
+ ...agg
332
+ };
333
+ });
334
+ ```
335
+
336
+ ### Task 1.5: Add `GET /realtime` endpoint
337
+
338
+ **Files:**
339
+ - Modify: `plugins/analytics/plugin.js`
340
+
341
+ - [ ] **Add the route** after `/journeys`:
342
+
343
+ ```js
344
+ fastify.get('/realtime', { preHandler: [authenticate, requireAdmin] }, async () => {
345
+ const journeys = await readJson(JOURNEYS_FILE, {});
346
+ const today = todayKey();
347
+ const events = journeys[today] || [];
348
+ const cutoff = Date.now() - 5 * 60 * 1000;
349
+ const sids = new Set();
350
+ for (const ev of events) {
351
+ if (ev.t >= cutoff) sids.add(ev.sid);
352
+ }
353
+ return { activeSessions: sids.size, windowMinutes: 5 };
354
+ });
355
+ ```
356
+
357
+ ### Task 1.6: Add spike detection helper + `GET /spikes`
358
+
359
+ **Files:**
360
+ - Modify: `plugins/analytics/plugin.js`
361
+
362
+ - [ ] **Add the spike detector helper** above `export default`:
363
+
364
+ ```js
365
+ function detectSpikes(daily, now = new Date()) {
366
+ const todayStr = todayKey(now);
367
+ const currentHour = now.getUTCHours();
368
+
369
+ // Approximation: distribute today's hits evenly across elapsed hours.
370
+ const todayUrls = daily[todayStr] || {};
371
+ const elapsedHours = Math.max(1, currentHour + 1);
372
+
373
+ const flagged = [];
374
+ for (const [url, totalToday] of Object.entries(todayUrls)) {
375
+ const currentHits = totalToday / elapsedHours;
376
+
377
+ // Baseline: previous 7 days' same-hour share (assume 24-hour spread)
378
+ const samples = [];
379
+ for (let i = 1; i <= 7; i += 1) {
380
+ const prev = new Date(now);
381
+ prev.setUTCDate(prev.getUTCDate() - i);
382
+ const key = todayKey(prev);
383
+ const sample = (daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0;
384
+ samples.push(sample);
385
+ }
386
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
387
+ const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
388
+ const stddev = Math.sqrt(variance);
389
+
390
+ if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
391
+ flagged.push({
392
+ url,
393
+ hits: Math.round(currentHits),
394
+ baseline: Math.round(mean),
395
+ ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2)
396
+ });
397
+ }
398
+ }
399
+
400
+ return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
401
+ }
402
+ ```
403
+
404
+ - [ ] **Add the route** after `/realtime`:
405
+
406
+ ```js
407
+ fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
408
+ const daily = await readJson(DAILY_FILE, {});
409
+ return { items: detectSpikes(daily) };
410
+ });
411
+ ```
412
+
413
+ ### Task 1.7: Decorate `fastify.analytics` with internal helpers
414
+
415
+ **Files:**
416
+ - Modify: `plugins/analytics/plugin.js`
417
+
418
+ - [ ] **At the top of `analyticsPlugin`** (right after `await migrateLegacy()`), decorate the Fastify instance:
419
+
420
+ ```js
421
+ fastify.decorate('analytics', {
422
+ async getStats({ range = '7d', from, to } = {}) {
423
+ const dates = rangeToDates(range, from, to);
424
+ const [lifetime, daily] = await Promise.all([
425
+ readJson(LIFETIME_FILE, {}),
426
+ readJson(DAILY_FILE, {})
427
+ ]);
428
+ let totals; let total;
429
+ if (!dates) {
430
+ totals = lifetime;
431
+ total = Object.values(lifetime).reduce((a, n) => a + n, 0);
432
+ } else {
433
+ const agg = aggregateDaily(daily, dates.start, dates.end);
434
+ totals = agg.totals;
435
+ total = agg.total;
436
+ }
437
+ const top = Object.entries(totals)
438
+ .map(([url, hits]) => ({ url, hits }))
439
+ .sort((a, b) => b.hits - a.hits);
440
+ return { total, top, lifetimeTotal: Object.values(lifetime).reduce((a, n) => a + n, 0) };
441
+ },
442
+ async getDaily() { return readJson(DAILY_FILE, {}); },
443
+ async getJourneys({ range = '7d', from, to } = {}) {
444
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
445
+ const journeys = await readJson(JOURNEYS_FILE, {});
446
+ return aggregateJourneys(eventsInRange(journeys, dates.start, dates.end));
447
+ },
448
+ async getRealtime() {
449
+ const journeys = await readJson(JOURNEYS_FILE, {});
450
+ const today = todayKey();
451
+ const events = journeys[today] || [];
452
+ const cutoff = Date.now() - 5 * 60 * 1000;
453
+ const sids = new Set();
454
+ for (const ev of events) if (ev.t >= cutoff) sids.add(ev.sid);
455
+ return { activeSessions: sids.size };
456
+ },
457
+ async getSpikes() {
458
+ const daily = await readJson(DAILY_FILE, {});
459
+ return { items: detectSpikes(daily) };
460
+ }
461
+ });
462
+ ```
463
+
464
+ - [ ] **Smoke check** the new endpoints (substitute your admin credentials):
465
+
466
+ ```bash
467
+ TOKEN=$(curl -s -X POST http://localhost:4096/api/auth/login \
468
+ -H 'Content-Type: application/json' \
469
+ -d '{"email":"YOUR_EMAIL","password":"YOUR_PW"}' | jq -r .token)
470
+
471
+ curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:4096/api/plugins/analytics/journeys?range=7d' | jq .
472
+ curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:4096/api/plugins/analytics/realtime' | jq .
473
+ curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:4096/api/plugins/analytics/spikes' | jq .
474
+ ```
475
+
476
+ Expected: each returns 200 JSON with the documented shape.
477
+
478
+ ### Task 1.8: Tests for journey aggregation
479
+
480
+ **Files:**
481
+ - Modify: `plugins/analytics/plugin.js` (export helpers)
482
+ - Create: `tests/analytics/journeys.test.js`
483
+
484
+ - [ ] **At the very end of `plugin.js`** (after the default export), add a named export:
485
+
486
+ ```js
487
+ export { aggregateJourneys, detectSpikes };
488
+ ```
489
+
490
+ - [ ] **Write the test file:**
491
+
492
+ ```js
493
+ import {test} from 'node:test';
494
+ import assert from 'node:assert/strict';
495
+ import {aggregateJourneys} from '../../plugins/analytics/plugin.js';
496
+
497
+ test('aggregateJourneys returns empty result for no events', () => {
498
+ const r = aggregateJourneys([]);
499
+ assert.deepEqual(r.entry, []);
500
+ assert.deepEqual(r.exit, []);
501
+ assert.deepEqual(r.paths, []);
502
+ assert.equal(r.bounceRate, 0);
503
+ assert.equal(r.totalSessions, 0);
504
+ });
505
+
506
+ test('aggregateJourneys counts entry, exit, paths, bounce', () => {
507
+ const events = [
508
+ // session A: / → /about → /pricing
509
+ { sid: 'A', t: 1, url: '/', ref: '' },
510
+ { sid: 'A', t: 2, url: '/about', ref: '/' },
511
+ { sid: 'A', t: 3, url: '/pricing', ref: '/about' },
512
+ // session B: /pricing only (bounce)
513
+ { sid: 'B', t: 1, url: '/pricing', ref: '' },
514
+ // session C: / → /about
515
+ { sid: 'C', t: 1, url: '/', ref: '' },
516
+ { sid: 'C', t: 2, url: '/about', ref: '/' }
517
+ ];
518
+ const r = aggregateJourneys(events);
519
+ assert.equal(r.totalSessions, 3);
520
+ assert.equal(r.bounceRate, 0.333);
521
+ assert.equal(r.entry.find(e => e.url === '/').count, 2);
522
+ assert.equal(r.entry.find(e => e.url === '/pricing').count, 1);
523
+ assert.equal(r.exit.find(e => e.url === '/pricing').count, 1);
524
+ assert.equal(r.exit.find(e => e.url === '/about').count, 1);
525
+ assert.equal(r.paths.find(p => p.from === '/' && p.to === '/about').count, 2);
526
+ assert.equal(r.paths.find(p => p.from === '/about' && p.to === '/pricing').count, 1);
527
+ });
528
+
529
+ test('aggregateJourneys sorts events within a session by timestamp', () => {
530
+ const events = [
531
+ { sid: 'X', t: 3, url: '/c', ref: '' },
532
+ { sid: 'X', t: 1, url: '/a', ref: '' },
533
+ { sid: 'X', t: 2, url: '/b', ref: '' }
534
+ ];
535
+ const r = aggregateJourneys(events);
536
+ assert.equal(r.entry[0].url, '/a');
537
+ assert.equal(r.exit[0].url, '/c');
538
+ });
539
+ ```
540
+
541
+ - [ ] **Run:** `node --test tests/analytics/journeys.test.js` - expect 3 passing.
542
+
543
+ ### Task 1.9: Tests for spike detection
544
+
545
+ **Files:**
546
+ - Create: `tests/analytics/spikes.test.js`
547
+
548
+ - [ ] **Write:**
549
+
550
+ ```js
551
+ import {test} from 'node:test';
552
+ import assert from 'node:assert/strict';
553
+ import {detectSpikes} from '../../plugins/analytics/plugin.js';
554
+
555
+ function dayKey(offsetDays) {
556
+ const d = new Date();
557
+ d.setUTCHours(0, 0, 0, 0);
558
+ d.setUTCDate(d.getUTCDate() - offsetDays);
559
+ return d.toISOString().slice(0, 10);
560
+ }
561
+
562
+ test('detectSpikes returns nothing when there is no baseline', () => {
563
+ const daily = { [dayKey(0)]: { '/foo': 100 } };
564
+ const now = new Date();
565
+ now.setUTCHours(12, 0, 0, 0);
566
+ const r = detectSpikes(daily, now);
567
+ assert.deepEqual(r, []);
568
+ });
569
+
570
+ test('detectSpikes flags a URL whose current-hour rate exceeds baseline + 2σ', () => {
571
+ const daily = {};
572
+ for (let i = 1; i <= 7; i += 1) daily[dayKey(i)] = { '/pricing': 24 }; // 1/hour baseline
573
+ daily[dayKey(0)] = { '/pricing': 240 }; // 20/hour at 12 elapsed
574
+ const now = new Date();
575
+ now.setUTCHours(11, 0, 0, 0);
576
+ const r = detectSpikes(daily, now);
577
+ assert.equal(r.length, 1);
578
+ assert.equal(r[0].url, '/pricing');
579
+ assert.ok(r[0].ratio > 10);
580
+ });
581
+
582
+ test('detectSpikes ignores low absolute volume even if ratio is high', () => {
583
+ const daily = {};
584
+ for (let i = 1; i <= 7; i += 1) daily[dayKey(i)] = { '/quiet': 24 };
585
+ daily[dayKey(0)] = { '/quiet': 36 }; // 3/hour < noise floor of 5
586
+ const now = new Date();
587
+ now.setUTCHours(11, 0, 0, 0);
588
+ const r = detectSpikes(daily, now);
589
+ assert.deepEqual(r, []);
590
+ });
591
+ ```
592
+
593
+ - [ ] **Run:** `node --test tests/analytics/spikes.test.js` - expect 3 passing.
594
+
595
+ ### Task 1.10: Update analytics plugin docs
596
+
597
+ **Files:**
598
+ - Modify: `plugins/analytics/CLAUDE.md`
599
+
600
+ - [ ] **Append to the Files list:**
601
+
602
+ ```
603
+ - `journeys.json` - `{ "YYYY-MM-DD": [ {sid, t, url, ref} ] }`, daily-capped at 5,000 events
604
+ ```
605
+
606
+ - [ ] **Append to the Routes table:**
607
+
608
+ ```
609
+ | GET | `/journeys` | Admin | `?range=7d` - entry pages, exit pages, top 2-step paths, bounce rate |
610
+ | GET | `/realtime` | Admin | Distinct `sid` count active in the last 5 minutes |
611
+ | GET | `/spikes` | Admin | Current-hour hits per URL vs 7-day same-hour baseline; ratio > 2σ |
612
+ ```
613
+
614
+ - [ ] **Append to Gotchas:**
615
+
616
+ ```
617
+ - **Journey cap:** `journeys.json` accepts at most 5,000 events per day. Hit counts in
618
+ `daily.json` and `lifetime.json` are unaffected - the cap only bounds journey-derived
619
+ features (paths, bounce rate). Increase by editing `JOURNEY_DAILY_CAP` in `plugin.js`.
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Phase 2 - Health service
625
+
626
+ ### Task 2.1: Add last-send tracking to `email.js`
627
+
628
+ **Files:**
629
+ - Modify: `server/services/email.js`
630
+
631
+ - [ ] **Add a module-level result and an exported getter** near the top:
632
+
633
+ ```js
634
+ let lastSendResult = null;
635
+
636
+ export function getLastSendResult() {
637
+ return lastSendResult;
638
+ }
639
+
640
+ function recordSendResult(ok, info) {
641
+ lastSendResult = {
642
+ ok,
643
+ at: new Date().toISOString(),
644
+ info: info || null
645
+ };
646
+ }
647
+ ```
648
+
649
+ - [ ] **Wrap the existing `transport.sendMail(...)` call inside `sendEmail`** so success/failure are recorded:
650
+
651
+ ```js
652
+ try {
653
+ const info = await transport.sendMail({ from: fromHeader, to, subject, html, text });
654
+ recordSendResult(true, info.messageId);
655
+ return info;
656
+ } catch (err) {
657
+ recordSendResult(false, err.message);
658
+ throw err;
659
+ }
660
+ ```
661
+
662
+ If `sendFormEmail` already delegates to `sendEmail`, it inherits the recording. If it has its own `transport.sendMail` call, wrap that too.
663
+
664
+ ### Task 2.2: Add plugin load failure tracking to `plugins.js`
665
+
666
+ **Files:**
667
+ - Modify: `server/services/plugins.js`
668
+
669
+ - [ ] **Add the failure list and exports** near the top of the file:
670
+
671
+ ```js
672
+ const failedLoads = [];
673
+
674
+ export function recordPluginLoadFailure(name, reason) {
675
+ failedLoads.push({ name, reason, at: new Date().toISOString() });
676
+ }
677
+
678
+ export function getPluginLoadFailures() {
679
+ return failedLoads.slice();
680
+ }
681
+ ```
682
+
683
+ - [ ] **Find every place a plugin manifest validation logs a warning** (search for `[plugins]` warning logs) and call `recordPluginLoadFailure(pluginName, reason)` right next to the existing log.
684
+
685
+ ### Task 2.3: Create `server/services/health.js` skeleton
686
+
687
+ **Files:**
688
+ - Create: `server/services/health.js`
689
+
690
+ - [ ] **Write the file:**
691
+
692
+ ```js
693
+ /**
694
+ * Health Service
695
+ * Aggregates server + site health checks for the dashboard.
696
+ * Each check returns { id, level: 'ok'|'warn'|'fail', label, value, detail? }.
697
+ */
698
+ import fs from 'fs/promises';
699
+ import path from 'path';
700
+ import {fileURLToPath} from 'url';
701
+ import {config} from '../config.js';
702
+ import {getLastSendResult} from './email.js';
703
+ import {listPages} from './content.js';
704
+ import {getPluginLoadFailures} from './plugins.js';
705
+ import {FORMS_DIR} from './forms.js';
706
+
707
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
708
+ const ROOT = path.resolve(__dirname, '..', '..');
709
+
710
+ const CACHE_TTL_MS = 30 * 1000;
711
+ let cached = { at: 0, value: null };
712
+
713
+ const LEVELS = { ok: 0, warn: 1, fail: 2 };
714
+ function worstLevel(checks) {
715
+ let worst = 'ok';
716
+ for (const c of checks) if (LEVELS[c.level] > LEVELS[worst]) worst = c.level;
717
+ return worst;
718
+ }
719
+
720
+ function formatBytes(n) {
721
+ if (n < 1024) return n + ' B';
722
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
723
+ if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
724
+ return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
725
+ }
726
+
727
+ function formatDuration(seconds) {
728
+ const d = Math.floor(seconds / 86400);
729
+ const h = Math.floor((seconds % 86400) / 3600);
730
+ const m = Math.floor((seconds % 3600) / 60);
731
+ if (d > 0) return `${d}d ${h}h`;
732
+ if (h > 0) return `${h}h ${m}m`;
733
+ return `${m}m`;
734
+ }
735
+
736
+ async function dirSize(dir) {
737
+ let total = 0;
738
+ let entries;
739
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
740
+ catch { return 0; }
741
+ for (const e of entries) {
742
+ const full = path.join(dir, e.name);
743
+ if (e.isDirectory()) total += await dirSize(full);
744
+ else if (e.isFile()) {
745
+ try { total += (await fs.stat(full)).size; } catch { /* ignore */ }
746
+ }
747
+ }
748
+ return total;
749
+ }
750
+
751
+ // Individual checks - defined in tasks 2.4-2.6 below
752
+
753
+ export async function getHealth() {
754
+ if (cached.value && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
755
+ const checks = await Promise.all([
756
+ checkUptime(),
757
+ checkMemory(),
758
+ checkContentDisk(),
759
+ checkStalePages(),
760
+ checkMissingMeta(),
761
+ checkLastForm(),
762
+ checkSmtp(),
763
+ checkPluginLoad()
764
+ ]);
765
+ const value = { status: worstLevel(checks), checks };
766
+ cached = { at: Date.now(), value };
767
+ return value;
768
+ }
769
+
770
+ export function _resetCache() { cached = { at: 0, value: null }; }
771
+ ```
772
+
773
+ (The `getHealth` export references `checkUptime`/`checkMemory`/etc. - they will be added in tasks 2.4-2.6. The file will not run until those are present; add them all before running tests.)
774
+
775
+ ### Task 2.4: Implement uptime, memory, disk checks
776
+
777
+ **Files:**
778
+ - Modify: `server/services/health.js`
779
+
780
+ - [ ] **Add the check functions** above the `getHealth` export:
781
+
782
+ ```js
783
+ async function checkUptime() {
784
+ return {
785
+ id: 'uptime',
786
+ level: 'ok',
787
+ label: 'Uptime',
788
+ value: formatDuration(Math.floor(process.uptime()))
789
+ };
790
+ }
791
+
792
+ async function checkMemory() {
793
+ const mem = process.memoryUsage();
794
+ const usedMb = mem.heapUsed / 1024 / 1024;
795
+ const totalMb = mem.heapTotal / 1024 / 1024;
796
+ const pct = totalMb === 0 ? 0 : usedMb / totalMb;
797
+ return {
798
+ id: 'memory',
799
+ level: pct > 0.9 ? 'warn' : 'ok',
800
+ label: 'Heap',
801
+ value: `${usedMb.toFixed(0)} / ${totalMb.toFixed(0)} MB`
802
+ };
803
+ }
804
+
805
+ async function checkContentDisk() {
806
+ const contentDir = path.isAbsolute(config.content.contentDir)
807
+ ? config.content.contentDir
808
+ : path.join(ROOT, config.content.contentDir);
809
+ const size = await dirSize(contentDir);
810
+ return {
811
+ id: 'disk-content',
812
+ level: 'ok',
813
+ label: 'Content disk',
814
+ value: formatBytes(size)
815
+ };
816
+ }
817
+ ```
818
+
819
+ ### Task 2.5: Implement stale-pages and missing-meta checks
820
+
821
+ **Files:**
822
+ - Modify: `server/services/health.js`
823
+
824
+ - [ ] **Add both functions:**
825
+
826
+ ```js
827
+ async function checkStalePages() {
828
+ const pages = await listPages().catch(() => []);
829
+ const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
830
+ const stale = pages.filter(p => {
831
+ const updated = p.updatedAt ? new Date(p.updatedAt).getTime() : 0;
832
+ return updated > 0 && updated < cutoff;
833
+ });
834
+ return {
835
+ id: 'stale-pages',
836
+ level: stale.length === 0 ? 'ok' : 'warn',
837
+ label: 'Stale pages (>90d)',
838
+ value: stale.length,
839
+ detail: stale.slice(0, 10).map(p => p.urlPath)
840
+ };
841
+ }
842
+
843
+ async function checkMissingMeta() {
844
+ const pages = await listPages().catch(() => []);
845
+ const missing = pages.filter(p => !p.description || String(p.description).trim() === '');
846
+ return {
847
+ id: 'missing-meta',
848
+ level: missing.length === 0 ? 'ok' : 'warn',
849
+ label: 'Pages missing description',
850
+ value: missing.length,
851
+ detail: missing.slice(0, 10).map(p => p.urlPath)
852
+ };
853
+ }
854
+ ```
855
+
856
+ ### Task 2.6: Implement last-form, SMTP, plugin-load checks
857
+
858
+ **Files:**
859
+ - Modify: `server/services/health.js`
860
+
861
+ - [ ] **Add the three remaining check functions:**
862
+
863
+ ```js
864
+ async function checkLastForm() {
865
+ let formsExist = false;
866
+ let newest = 0;
867
+ try {
868
+ const formFiles = await fs.readdir(FORMS_DIR);
869
+ formsExist = formFiles.some(f => f.endsWith('.json'));
870
+ } catch { /* no forms dir */ }
871
+
872
+ // Submissions live as collection entries under content/collections/<formSlug>/
873
+ const collectionsDir = path.join(ROOT, 'content', 'collections');
874
+ let dirs = [];
875
+ try { dirs = await fs.readdir(collectionsDir, { withFileTypes: true }); } catch { /* none */ }
876
+ for (const d of dirs) {
877
+ if (!d.isDirectory()) continue;
878
+ const slugDir = path.join(collectionsDir, d.name);
879
+ let entries = [];
880
+ try { entries = await fs.readdir(slugDir); } catch { continue; }
881
+ for (const f of entries) {
882
+ if (!f.endsWith('.json')) continue;
883
+ try {
884
+ const stat = await fs.stat(path.join(slugDir, f));
885
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
886
+ } catch { /* ignore */ }
887
+ }
888
+ }
889
+
890
+ if (!formsExist) {
891
+ return { id: 'last-form', level: 'ok', label: 'Last form submission', value: 'no forms defined' };
892
+ }
893
+ if (newest === 0) {
894
+ return { id: 'last-form', level: 'warn', label: 'Last form submission', value: 'never' };
895
+ }
896
+ const ageDays = (Date.now() - newest) / (24 * 60 * 60 * 1000);
897
+ return {
898
+ id: 'last-form',
899
+ level: ageDays > 30 ? 'warn' : 'ok',
900
+ label: 'Last form submission',
901
+ value: new Date(newest).toISOString()
902
+ };
903
+ }
904
+
905
+ async function checkSmtp() {
906
+ const r = getLastSendResult();
907
+ if (!r) return { id: 'smtp', level: 'ok', label: 'Last email send', value: 'never sent' };
908
+ return {
909
+ id: 'smtp',
910
+ level: r.ok ? 'ok' : 'fail',
911
+ label: 'Last email send',
912
+ value: r.at,
913
+ detail: r.info ? [r.info] : []
914
+ };
915
+ }
916
+
917
+ async function checkPluginLoad() {
918
+ const failures = getPluginLoadFailures();
919
+ return {
920
+ id: 'plugin-load',
921
+ level: failures.length === 0 ? 'ok' : 'fail',
922
+ label: 'Plugin failures',
923
+ value: failures.length,
924
+ detail: failures.slice(0, 10).map(f => `${f.name}: ${f.reason}`)
925
+ };
926
+ }
927
+ ```
928
+
929
+ ### Task 2.7: Health service tests
930
+
931
+ **Files:**
932
+ - Create: `tests/services/health.test.js`
933
+
934
+ - [ ] **Write:**
935
+
936
+ ```js
937
+ import {test} from 'node:test';
938
+ import assert from 'node:assert/strict';
939
+ import {getHealth, _resetCache} from '../../server/services/health.js';
940
+
941
+ test('getHealth returns shape with status and checks array', async () => {
942
+ _resetCache();
943
+ const r = await getHealth();
944
+ assert.ok(['ok', 'warn', 'fail'].includes(r.status));
945
+ assert.ok(Array.isArray(r.checks));
946
+ const ids = r.checks.map(c => c.id).sort();
947
+ assert.deepEqual(ids, [
948
+ 'disk-content', 'last-form', 'memory', 'missing-meta',
949
+ 'plugin-load', 'smtp', 'stale-pages', 'uptime'
950
+ ]);
951
+ });
952
+
953
+ test('getHealth caches results within TTL', async () => {
954
+ _resetCache();
955
+ const r1 = await getHealth();
956
+ const r2 = await getHealth();
957
+ assert.equal(r1, r2); // cache hit returns the exact same object
958
+ });
959
+ ```
960
+
961
+ - [ ] **Run:** `node --test tests/services/health.test.js` - expect 2 passing.
962
+
963
+ ---
964
+
965
+ ## Phase 3 - Dashboard summary endpoint
966
+
967
+ ### Task 3.1: Create `server/routes/api/dashboard.js`
968
+
969
+ **Files:**
970
+ - Create: `server/routes/api/dashboard.js`
971
+
972
+ - [ ] **Write:**
973
+
974
+ ```js
975
+ /**
976
+ * Dashboard API
977
+ * Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
978
+ *
979
+ * GET /api/dashboard/summary full payload
980
+ * GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
981
+ */
982
+ import fs from 'fs/promises';
983
+ import path from 'path';
984
+ import {fileURLToPath} from 'url';
985
+ import {authenticate, requireAdmin} from '../../middleware/auth.js';
986
+ import {getHealth} from '../../services/health.js';
987
+
988
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
989
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
990
+
991
+ function todayKey(d = new Date()) {
992
+ return d.toISOString().slice(0, 10);
993
+ }
994
+ function isoDaysAgo(n) {
995
+ const d = new Date();
996
+ d.setUTCHours(0, 0, 0, 0);
997
+ d.setUTCDate(d.getUTCDate() - n);
998
+ return todayKey(d);
999
+ }
1000
+ function sumDay(daily, key) {
1001
+ return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
1002
+ }
1003
+ function sumRange(daily, fromKey, toKey) {
1004
+ let total = 0;
1005
+ for (const [day, urls] of Object.entries(daily)) {
1006
+ if (day < fromKey || day > toKey) continue;
1007
+ for (const n of Object.values(urls)) total += n;
1008
+ }
1009
+ return total;
1010
+ }
1011
+
1012
+ function buildTopPages(daily, days = 7) {
1013
+ const totals = {};
1014
+ const perDay = {};
1015
+ for (let i = days - 1; i >= 0; i -= 1) {
1016
+ const k = isoDaysAgo(i);
1017
+ for (const [url, n] of Object.entries(daily[k] || {})) {
1018
+ totals[url] = (totals[url] || 0) + n;
1019
+ if (!perDay[url]) perDay[url] = new Array(days).fill(0);
1020
+ perDay[url][days - 1 - i] = n;
1021
+ }
1022
+ }
1023
+ const prevTotals = {};
1024
+ for (let i = 2 * days - 1; i >= days; i -= 1) {
1025
+ const k = isoDaysAgo(i);
1026
+ for (const [url, n] of Object.entries(daily[k] || {})) {
1027
+ prevTotals[url] = (prevTotals[url] || 0) + n;
1028
+ }
1029
+ }
1030
+ return Object.entries(totals)
1031
+ .map(([url, hits]) => {
1032
+ const prev = prevTotals[url] || 0;
1033
+ const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
1034
+ return { url, hits, deltaPct, spark: perDay[url] };
1035
+ })
1036
+ .sort((a, b) => b.hits - a.hits)
1037
+ .slice(0, 5);
1038
+ }
1039
+
1040
+ async function buildActivity() {
1041
+ const items = [];
1042
+
1043
+ // Versions: each page directory has _meta.json with { versions: [{at, kind, actor}, …] }
1044
+ const versionsDir = path.join(ROOT, 'content', 'versions');
1045
+ try {
1046
+ const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
1047
+ for (const d of dirs) {
1048
+ if (!d.isDirectory()) continue;
1049
+ const meta = path.join(versionsDir, d.name, '_meta.json');
1050
+ try {
1051
+ const j = JSON.parse(await fs.readFile(meta, 'utf8'));
1052
+ if (Array.isArray(j.versions)) {
1053
+ for (const v of j.versions.slice(-3)) {
1054
+ items.push({
1055
+ type: v.kind === 'publish' ? 'publish' : 'edit',
1056
+ at: v.at,
1057
+ actor: v.actor || 'unknown',
1058
+ target: '/' + (d.name === '_index' ? '' : d.name)
1059
+ });
1060
+ }
1061
+ }
1062
+ } catch { /* skip */ }
1063
+ }
1064
+ } catch { /* none */ }
1065
+
1066
+ // Form submissions: collection entries
1067
+ const collectionsDir = path.join(ROOT, 'content', 'collections');
1068
+ try {
1069
+ const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
1070
+ for (const slug of slugs) {
1071
+ if (!slug.isDirectory()) continue;
1072
+ const slugDir = path.join(collectionsDir, slug.name);
1073
+ const entries = await fs.readdir(slugDir).catch(() => []);
1074
+ for (const f of entries.slice(-3)) {
1075
+ if (!f.endsWith('.json')) continue;
1076
+ try {
1077
+ const stat = await fs.stat(path.join(slugDir, f));
1078
+ items.push({ type: 'form', at: stat.mtime.toISOString(), form: slug.name });
1079
+ } catch { /* skip */ }
1080
+ }
1081
+ }
1082
+ } catch { /* none */ }
1083
+
1084
+ return items
1085
+ .filter(i => i.at)
1086
+ .sort((a, b) => (b.at || '').localeCompare(a.at || ''))
1087
+ .slice(0, 10);
1088
+ }
1089
+
1090
+ export async function dashboardRoutes(fastify) {
1091
+ const guard = { preHandler: [authenticate, requireAdmin] };
1092
+
1093
+ fastify.get('/dashboard/summary', guard, async (request) => {
1094
+ const lite = request.query.lite === '1' || request.query.lite === 'true';
1095
+ const warnings = [];
1096
+ const safe = async (label, fn) => {
1097
+ try { return await fn(); }
1098
+ catch (e) { warnings.push(`${label}: ${e.message}`); return null; }
1099
+ };
1100
+
1101
+ const analytics = fastify.analytics;
1102
+ const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
1103
+
1104
+ const today = todayKey();
1105
+ const yesterday = isoDaysAgo(1);
1106
+ const todayHits = sumDay(daily, today);
1107
+ const yesterdayHits = sumDay(daily, yesterday);
1108
+ const deltaPct = yesterdayHits === 0
1109
+ ? null
1110
+ : +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
1111
+ const traffic = {
1112
+ today: todayHits,
1113
+ yesterday: yesterdayHits,
1114
+ deltaPct,
1115
+ weekToDate: sumRange(daily, isoDaysAgo(6), today),
1116
+ previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
1117
+ };
1118
+
1119
+ const realtime = analytics
1120
+ ? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
1121
+ : { activeSessions: 0 };
1122
+ const spikes = analytics
1123
+ ? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
1124
+ : [];
1125
+ const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
1126
+
1127
+ if (lite) {
1128
+ return {
1129
+ traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
1130
+ realtime,
1131
+ spikes,
1132
+ health: { status: health.status },
1133
+ warnings
1134
+ };
1135
+ }
1136
+
1137
+ const topPages = buildTopPages(daily);
1138
+ const journeys = analytics
1139
+ ? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
1140
+ : null;
1141
+ const activity = (await safe('activity', () => buildActivity())) || [];
1142
+
1143
+ return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
1144
+ });
1145
+ }
1146
+ ```
1147
+
1148
+ ### Task 3.2: Register dashboard route in `server.js`
1149
+
1150
+ **Files:**
1151
+ - Modify: `server/server.js`
1152
+
1153
+ - [ ] **Add the dynamic import** alongside the others (search for `notificationsRoutes`):
1154
+
1155
+ ```js
1156
+ const {dashboardRoutes} = await import('./routes/api/dashboard.js');
1157
+ ```
1158
+
1159
+ - [ ] **Add the registration** after `notificationsRoutes`:
1160
+
1161
+ ```js
1162
+ await app.register(dashboardRoutes, { prefix: '/api' });
1163
+ ```
1164
+
1165
+ - [ ] **Smoke check:**
1166
+
1167
+ ```bash
1168
+ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:4096/api/dashboard/summary | jq '.health.status, .traffic.today, (.topPages | length), (.activity | length)'
1169
+ curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:4096/api/dashboard/summary?lite=1' | jq .
1170
+ ```
1171
+
1172
+ Expected: full call returns the documented shape; `?lite=1` returns only `traffic`, `realtime`, `spikes`, `health.status`, `warnings`.
1173
+
1174
+ ### Task 3.3: Test graceful degradation when analytics is absent
1175
+
1176
+ **Files:**
1177
+ - Create: `tests/routes/dashboard-summary.test.js`
1178
+
1179
+ - [ ] **Write:**
1180
+
1181
+ ```js
1182
+ import {test} from 'node:test';
1183
+ import assert from 'node:assert/strict';
1184
+ import Fastify from 'fastify';
1185
+ import {dashboardRoutes} from '../../server/routes/api/dashboard.js';
1186
+
1187
+ async function buildApp() {
1188
+ const app = Fastify({ logger: false });
1189
+ // The dashboard route guards with [authenticate, requireAdmin] from middleware/auth.js.
1190
+ // We register the route under a child instance whose preHandler short-circuits the guard.
1191
+ await app.register(async (instance) => {
1192
+ instance.addHook('onRequest', async (req) => {
1193
+ req.user = { id: 'test', role: 'admin' };
1194
+ // Bypass JWT verification by setting jwtVerify to a no-op for this scope.
1195
+ req.jwtVerify = async () => {};
1196
+ });
1197
+ await instance.register(dashboardRoutes, { prefix: '/api' });
1198
+ });
1199
+ return app;
1200
+ }
1201
+
1202
+ test('dashboard summary returns 200 with empty data when analytics decorator is absent', async () => {
1203
+ const app = await buildApp();
1204
+ const res = await app.inject({ method: 'GET', url: '/api/dashboard/summary' });
1205
+ assert.equal(res.statusCode, 200);
1206
+ const body = res.json();
1207
+ assert.equal(body.traffic.today, 0);
1208
+ assert.equal(body.journeys, null);
1209
+ assert.deepEqual(body.spikes, []);
1210
+ await app.close();
1211
+ });
1212
+ ```
1213
+
1214
+ - [ ] **Run:** `node --test tests/routes/dashboard-summary.test.js`. If the test fails because the real `authenticate` middleware (imported by `dashboardRoutes`) still calls `request.jwtVerify` on a Fastify instance that was never decorated by `@fastify/jwt`, change the test to mock `jwtVerify` on the Fastify instance itself:
1215
+
1216
+ ```js
1217
+ app.decorateRequest('jwtVerify', async function () { this.user = { id: 'test', role: 'admin' }; });
1218
+ ```
1219
+
1220
+ Or, simpler: **refactor `dashboardRoutes` to accept the auth functions as options** and pass them in from `server.js`. The test then passes its own no-op functions. Recommended.
1221
+
1222
+ ### Task 3.4: Update `MEMORY.md` index
1223
+
1224
+ **Files:**
1225
+ - Modify: `/home/darryl/.claude/projects/-home-darryl-src-js-domma-cms/memory/MEMORY.md`
1226
+
1227
+ - [ ] **Add a single index entry** under a sensible section (Project) - keep under ~150 chars:
1228
+
1229
+ ```
1230
+ - [project_dashboard.md](project_dashboard.md) - Dashboard aggregates analytics + health via /api/dashboard/summary; widgets in admin/js/views/dashboard/widgets/
1231
+ ```
1232
+
1233
+ - [ ] **Create the topic file** at `/home/darryl/.claude/projects/-home-darryl-src-js-domma-cms/memory/project_dashboard.md`:
1234
+
1235
+ ```markdown
1236
+ ---
1237
+ name: Dashboard architecture
1238
+ description: Where dashboard widgets, summary endpoint, and health checks live
1239
+ type: project
1240
+ ---
1241
+
1242
+ Dashboard view is a thin orchestrator that fetches `/api/dashboard/summary` once on mount and polls `?lite=1` every 30 s for KPI strip + spikes + health pill.
1243
+
1244
+ **Why:** Originally the dashboard was three count cards; the user wanted a real command centre. Single-fetch design avoids 8 parallel calls on mount.
1245
+
1246
+ **How to apply:** When adding a new dashboard tile, create a widget in `admin/js/views/dashboard/widgets/` with a default-exported `(slot, data) => void` function and add an entry to the `WIDGETS` array in `admin/js/views/dashboard.js`. Health checks live in `server/services/health.js`. Cross-plugin data flows through the `fastify.analytics` decorator set in `plugins/analytics/plugin.js`.
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ## Phase 4 - Frontend dashboard
1252
+
1253
+ ### Task 4.1: Add `api.dashboard` namespace
1254
+
1255
+ **Files:**
1256
+ - Modify: `admin/js/api.js`
1257
+
1258
+ - [ ] **Inside the `api` object** (before the closing `}`, near the existing `system:` namespace), add:
1259
+
1260
+ ```js
1261
+ dashboard: {
1262
+ summary: () => apiRequest('/dashboard/summary', { method: 'GET' }),
1263
+ summaryLite: () => apiRequest('/dashboard/summary?lite=1', { method: 'GET' })
1264
+ },
1265
+ ```
1266
+
1267
+ ### Task 4.2: Shared `escapeHtml` helper
1268
+
1269
+ **Files:**
1270
+ - Create: `admin/js/views/dashboard/lib/escape.js`
1271
+
1272
+ - [ ] **Write:**
1273
+
1274
+ ```js
1275
+ /**
1276
+ * Escape a string for safe inclusion in HTML.
1277
+ * Handles <, >, &, ", ' which is enough for both element content and double-quoted attributes.
1278
+ * Use whenever interpolating user-controlled data (URLs, referrers, actor names, form names)
1279
+ * into an innerHTML string. The analytics plugin accepts URLs from public POST bodies, so
1280
+ * those values are NOT trusted.
1281
+ */
1282
+ export function escapeHtml(value) {
1283
+ if (value == null) return '';
1284
+ return String(value)
1285
+ .replace(/&/g, '&amp;')
1286
+ .replace(/</g, '&lt;')
1287
+ .replace(/>/g, '&gt;')
1288
+ .replace(/"/g, '&quot;')
1289
+ .replace(/'/g, '&#39;');
1290
+ }
1291
+ ```
1292
+
1293
+ ### Task 4.3: New dashboard grid skeleton
1294
+
1295
+ **Files:**
1296
+ - Modify: `admin/js/templates/dashboard.html`
1297
+
1298
+ - [ ] **Replace the entire file:**
1299
+
1300
+ ```html
1301
+ <div class="view-header">
1302
+ <h1><span data-icon="home"></span> Dashboard</h1>
1303
+ <div class="view-header-actions">
1304
+ <span class="dash-updated" id="dash-updated"></span>
1305
+ <a href="#/pages/new" class="btn btn-primary"><span data-icon="plus"></span> New Page</a>
1306
+ </div>
1307
+ </div>
1308
+
1309
+ <div id="dash-warnings" class="dash-warnings" hidden></div>
1310
+
1311
+ <div class="dashboard-grid">
1312
+ <section id="dash-kpi-strip" class="dash-row dash-row-kpi"></section>
1313
+
1314
+ <section class="dash-row dash-row-traffic">
1315
+ <div id="dash-traffic-chart" class="dash-card dash-card-wide"></div>
1316
+ <div id="dash-spike-feed" class="dash-card"></div>
1317
+ </section>
1318
+
1319
+ <section class="dash-row dash-row-content">
1320
+ <div id="dash-top-pages" class="dash-card"></div>
1321
+ <div id="dash-journeys" class="dash-card"></div>
1322
+ </section>
1323
+
1324
+ <section class="dash-row dash-row-health">
1325
+ <div id="dash-health-detail" class="dash-card"></div>
1326
+ <div id="dash-activity-feed" class="dash-card"></div>
1327
+ </section>
1328
+ </div>
1329
+ ```
1330
+
1331
+ ### Task 4.4: Dashboard CSS
1332
+
1333
+ **Files:**
1334
+ - Create: `admin/css/dashboard.css`
1335
+ - Modify: `admin/index.html`
1336
+
1337
+ - [ ] **Write the CSS file:**
1338
+
1339
+ ```css
1340
+ .dashboard-grid {
1341
+ display: flex;
1342
+ flex-direction: column;
1343
+ gap: var(--dm-spacing-4, 16px);
1344
+ }
1345
+ .dash-row { display: grid; gap: var(--dm-spacing-4, 16px); }
1346
+ .dash-row-kpi { grid-template-columns: repeat(4, 1fr); }
1347
+ .dash-row-traffic { grid-template-columns: 2fr 1fr; }
1348
+ .dash-row-content { grid-template-columns: 1fr 1fr; }
1349
+ .dash-row-health { grid-template-columns: 1fr 1fr; }
1350
+ @media (max-width: 960px) {
1351
+ .dash-row-kpi { grid-template-columns: repeat(2, 1fr); }
1352
+ .dash-row-traffic,
1353
+ .dash-row-content,
1354
+ .dash-row-health { grid-template-columns: 1fr; }
1355
+ }
1356
+
1357
+ .dash-card {
1358
+ background: var(--dm-surface, #fff);
1359
+ border: 1px solid var(--dm-border, #e5e7eb);
1360
+ border-radius: var(--dm-radius, 8px);
1361
+ padding: var(--dm-spacing-4, 16px);
1362
+ }
1363
+
1364
+ .dash-kpi { display: flex; align-items: center; gap: var(--dm-spacing-3, 12px); }
1365
+ .dash-kpi-icon { font-size: 24px; opacity: 0.8; }
1366
+ .dash-kpi-value { font-size: 28px; font-weight: 600; line-height: 1; }
1367
+ .dash-kpi-label { font-size: 12px; color: var(--dm-muted, #6b7280); text-transform: uppercase; letter-spacing: 0.05em; }
1368
+ .dash-kpi-delta { font-size: 12px; margin-top: 4px; }
1369
+ .dash-kpi-delta.up { color: var(--dm-success, #16a34a); }
1370
+ .dash-kpi-delta.down { color: var(--dm-danger, #dc2626); }
1371
+
1372
+ .dash-health-pill { display: inline-flex; align-items: center; gap: 6px; }
1373
+ .dash-health-pill[data-level="ok"] { color: var(--dm-success, #16a34a); }
1374
+ .dash-health-pill[data-level="warn"] { color: var(--dm-warning, #d97706); }
1375
+ .dash-health-pill[data-level="fail"] { color: var(--dm-danger, #dc2626); }
1376
+
1377
+ .dash-spike-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--dm-border, #e5e7eb); }
1378
+ .dash-spike-row:last-child { border-bottom: 0; }
1379
+
1380
+ .dash-spark { display: inline-block; width: 80px; height: 24px; vertical-align: middle; margin-left: 8px; }
1381
+
1382
+ .dash-warnings {
1383
+ background: var(--dm-warning-bg, #fef3c7);
1384
+ border: 1px solid var(--dm-warning, #d97706);
1385
+ color: var(--dm-warning-fg, #78350f);
1386
+ padding: 8px 12px;
1387
+ border-radius: var(--dm-radius, 8px);
1388
+ margin-bottom: 12px;
1389
+ }
1390
+
1391
+ .dash-updated { font-size: 12px; color: var(--dm-muted, #6b7280); margin-right: 8px; }
1392
+
1393
+ .dash-journey-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
1394
+ @media (max-width: 720px) { .dash-journey-grid { grid-template-columns: 1fr; } }
1395
+ .dash-journey-grid h4 { font-size: 12px; text-transform: uppercase; color: var(--dm-muted, #6b7280); margin: 0 0 6px; }
1396
+ .dash-bounce { margin-top: 12px; font-size: 14px; color: var(--dm-muted, #6b7280); }
1397
+ .dash-empty { font-size: 13px; color: var(--dm-muted, #6b7280); margin: 8px 0 0; }
1398
+ ```
1399
+
1400
+ - [ ] **Add the link** to `admin/index.html` alongside the other admin CSS `<link>` tags (search for an existing `/admin/css/` link and add immediately after):
1401
+
1402
+ ```html
1403
+ <link rel="stylesheet" href="/admin/css/dashboard.css?v=1">
1404
+ ```
1405
+
1406
+ ### Task 4.5: Widget - KPI strip
1407
+
1408
+ **Files:**
1409
+ - Create: `admin/js/templates/dashboard/kpi-strip.html`
1410
+ - Create: `admin/js/views/dashboard/widgets/kpi-strip.js`
1411
+
1412
+ - [ ] **Template:**
1413
+
1414
+ ```html
1415
+ <div class="dash-card dash-kpi">
1416
+ <span class="dash-kpi-icon" data-icon="trending-up"></span>
1417
+ <div>
1418
+ <div class="dash-kpi-value" data-field="hits-today">-</div>
1419
+ <div class="dash-kpi-label">Hits today</div>
1420
+ <div class="dash-kpi-delta" data-field="hits-delta"></div>
1421
+ </div>
1422
+ </div>
1423
+ <div class="dash-card dash-kpi">
1424
+ <span class="dash-kpi-icon" data-icon="users"></span>
1425
+ <div>
1426
+ <div class="dash-kpi-value" data-field="active-sessions">-</div>
1427
+ <div class="dash-kpi-label">Active sessions (5m)</div>
1428
+ </div>
1429
+ </div>
1430
+ <div class="dash-card dash-kpi">
1431
+ <span class="dash-kpi-icon" data-icon="inbox"></span>
1432
+ <div>
1433
+ <div class="dash-kpi-value" data-field="submissions-week">-</div>
1434
+ <div class="dash-kpi-label">Submissions this week</div>
1435
+ </div>
1436
+ </div>
1437
+ <div class="dash-card dash-kpi">
1438
+ <span class="dash-kpi-icon" data-icon="activity"></span>
1439
+ <div>
1440
+ <div class="dash-kpi-value">
1441
+ <span class="dash-health-pill" data-field="health-pill" data-level="ok">
1442
+ <span data-icon="circle"></span>
1443
+ <span data-field="health-label">OK</span>
1444
+ </span>
1445
+ </div>
1446
+ <div class="dash-kpi-label">Site health</div>
1447
+ </div>
1448
+ </div>
1449
+ ```
1450
+
1451
+ - [ ] **Render function** - uses `.text()` for all dynamic values (Domma's `.text()` always sets textContent, so no escaping needed for these):
1452
+
1453
+ ```js
1454
+ export default function renderKpiStrip($slot, data) {
1455
+ const today = data?.traffic?.today ?? 0;
1456
+ const delta = data?.traffic?.deltaPct;
1457
+ $slot.find('[data-field="hits-today"]').text(today);
1458
+
1459
+ const $delta = $slot.find('[data-field="hits-delta"]');
1460
+ if (delta == null) {
1461
+ $delta.text('').removeClass('up down');
1462
+ } else if (delta >= 0) {
1463
+ $delta.text('+' + delta + '% vs yesterday').removeClass('down').addClass('up');
1464
+ } else {
1465
+ $delta.text(delta + '% vs yesterday').removeClass('up').addClass('down');
1466
+ }
1467
+
1468
+ $slot.find('[data-field="active-sessions"]').text(data?.realtime?.activeSessions ?? 0);
1469
+ $slot.find('[data-field="submissions-week"]').text(data?._submissionsWeek ?? '-');
1470
+
1471
+ const status = data?.health?.status || 'ok';
1472
+ const labelMap = { ok: 'OK', warn: 'Attention', fail: 'Failing' };
1473
+ $slot.find('[data-field="health-pill"]').attr('data-level', status);
1474
+ $slot.find('[data-field="health-label"]').text(labelMap[status]);
1475
+ }
1476
+ ```
1477
+
1478
+ ### Task 4.6: Widget - Traffic chart
1479
+
1480
+ **Files:**
1481
+ - Create: `admin/js/templates/dashboard/traffic-chart.html`
1482
+ - Create: `admin/js/views/dashboard/widgets/traffic-chart.js`
1483
+
1484
+ - [ ] **Template:**
1485
+
1486
+ ```html
1487
+ <h3>Traffic - last 30 days</h3>
1488
+ <canvas id="dash-traffic-canvas" height="120"></canvas>
1489
+ <p class="dash-empty" hidden>No traffic recorded yet.</p>
1490
+ ```
1491
+
1492
+ - [ ] **Render** (Chart.js loaded once from CDN, reusing the analytics plugin's pattern):
1493
+
1494
+ ```js
1495
+ const CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js';
1496
+ let chartReady = null;
1497
+ function loadChart() {
1498
+ if (window.Chart) return Promise.resolve(window.Chart);
1499
+ if (chartReady) return chartReady;
1500
+ chartReady = new Promise((resolve, reject) => {
1501
+ const s = document.createElement('script');
1502
+ s.src = CHART_CDN;
1503
+ s.onload = () => resolve(window.Chart);
1504
+ s.onerror = reject;
1505
+ document.head.appendChild(s);
1506
+ });
1507
+ return chartReady;
1508
+ }
1509
+
1510
+ let instance = null;
1511
+
1512
+ export default async function renderTrafficChart($slot, data) {
1513
+ const Chart = await loadChart();
1514
+
1515
+ const days = 30;
1516
+ const series = [];
1517
+ for (let i = days - 1; i >= 0; i -= 1) {
1518
+ const d = new Date();
1519
+ d.setDate(d.getDate() - i);
1520
+ series.push({ label: d.toISOString().slice(5, 10), hits: 0 });
1521
+ }
1522
+ if (Array.isArray(data?.topPages)) {
1523
+ for (const p of data.topPages) {
1524
+ if (!Array.isArray(p.spark)) continue;
1525
+ const offset = days - p.spark.length;
1526
+ p.spark.forEach((n, idx) => { series[offset + idx].hits += n; });
1527
+ }
1528
+ }
1529
+
1530
+ const empty = series.every(s => s.hits === 0);
1531
+ if (empty) $slot.find('.dash-empty').removeAttr('hidden'); else $slot.find('.dash-empty').attr('hidden', 'hidden');
1532
+
1533
+ const canvas = $slot.find('#dash-traffic-canvas').get(0);
1534
+ if (!canvas) return;
1535
+ if (instance) { instance.destroy(); instance = null; }
1536
+ if (empty) return;
1537
+
1538
+ instance = new Chart(canvas, {
1539
+ type: 'line',
1540
+ data: {
1541
+ labels: series.map(s => s.label),
1542
+ datasets: [{
1543
+ data: series.map(s => s.hits),
1544
+ borderColor: 'rgba(37, 99, 235, 1)',
1545
+ backgroundColor: 'rgba(37, 99, 235, 0.15)',
1546
+ fill: true,
1547
+ tension: 0.3,
1548
+ pointRadius: 0
1549
+ }]
1550
+ },
1551
+ options: {
1552
+ responsive: true,
1553
+ maintainAspectRatio: false,
1554
+ plugins: { legend: { display: false } },
1555
+ scales: { x: { ticks: { maxTicksLimit: 8 } }, y: { beginAtZero: true } }
1556
+ }
1557
+ });
1558
+ }
1559
+ ```
1560
+
1561
+ > Note: the spec calls for an hourly-today overlay. The summary endpoint does not return per-hour data; adding the overlay is straightforward once the analytics plugin tracks per-hour counts. Out of scope for this plan.
1562
+
1563
+ ### Task 4.7: Widget - Spike feed
1564
+
1565
+ **Files:**
1566
+ - Create: `admin/js/templates/dashboard/spike-feed.html`
1567
+ - Create: `admin/js/views/dashboard/widgets/spike-feed.js`
1568
+
1569
+ - [ ] **Template:**
1570
+
1571
+ ```html
1572
+ <h3>Spikes (last hour)</h3>
1573
+ <div data-field="spike-list"></div>
1574
+ <p class="dash-empty" hidden>No anomalies detected.</p>
1575
+ ```
1576
+
1577
+ - [ ] **Render** (escape every URL - they came from public POST bodies):
1578
+
1579
+ ```js
1580
+ import {escapeHtml} from '../lib/escape.js';
1581
+
1582
+ export default function renderSpikeFeed($slot, data) {
1583
+ const items = Array.isArray(data?.spikes) ? data.spikes : [];
1584
+ if (items.length === 0) $slot.find('.dash-empty').removeAttr('hidden');
1585
+ else $slot.find('.dash-empty').attr('hidden', 'hidden');
1586
+
1587
+ const html = items.map(s => `
1588
+ <div class="dash-spike-row">
1589
+ <span><a href="${escapeHtml(s.url)}">${escapeHtml(s.url)}</a></span>
1590
+ <span><strong>${Number(s.hits) | 0}</strong> · ×${Number(s.ratio) || 0}</span>
1591
+ </div>
1592
+ `).join('');
1593
+ $slot.find('[data-field="spike-list"]').get(0).innerHTML = html;
1594
+ }
1595
+ ```
1596
+
1597
+ ### Task 4.8: Widget - Top pages with sparklines
1598
+
1599
+ **Files:**
1600
+ - Create: `admin/js/templates/dashboard/top-pages.html`
1601
+ - Create: `admin/js/views/dashboard/widgets/top-pages.js`
1602
+
1603
+ - [ ] **Template:**
1604
+
1605
+ ```html
1606
+ <h3>Top pages - last 7 days</h3>
1607
+ <div data-field="rows"></div>
1608
+ <p class="dash-empty" hidden>No traffic recorded yet.</p>
1609
+ ```
1610
+
1611
+ - [ ] **Render** (escape URLs; sparkline uses only numeric values, no escaping needed):
1612
+
1613
+ ```js
1614
+ import {escapeHtml} from '../lib/escape.js';
1615
+
1616
+ function spark(values, width = 80, height = 24) {
1617
+ if (!values || values.length === 0) return '';
1618
+ const safe = values.map(v => Number(v) || 0);
1619
+ const max = Math.max(...safe, 1);
1620
+ const stepX = width / Math.max(safe.length - 1, 1);
1621
+ const points = safe.map((v, i) => `${(i * stepX).toFixed(1)},${(height - (v / max) * height).toFixed(1)}`).join(' ');
1622
+ return `<svg class="dash-spark" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
1623
+ <polyline fill="none" stroke="currentColor" stroke-width="1.5" points="${points}"/>
1624
+ </svg>`;
1625
+ }
1626
+
1627
+ export default function renderTopPages($slot, data) {
1628
+ const rows = Array.isArray(data?.topPages) ? data.topPages : [];
1629
+ if (rows.length === 0) $slot.find('.dash-empty').removeAttr('hidden');
1630
+ else $slot.find('.dash-empty').attr('hidden', 'hidden');
1631
+
1632
+ const html = rows.map(r => {
1633
+ const delta = r.deltaPct == null
1634
+ ? ''
1635
+ : `<span class="${r.deltaPct >= 0 ? 'up' : 'down'} dash-kpi-delta">${r.deltaPct >= 0 ? '+' : ''}${Number(r.deltaPct) || 0}%</span>`;
1636
+ return `
1637
+ <div class="dash-spike-row">
1638
+ <span><a href="${escapeHtml(r.url)}">${escapeHtml(r.url)}</a> ${spark(r.spark)}</span>
1639
+ <span><strong>${Number(r.hits) | 0}</strong> ${delta}</span>
1640
+ </div>
1641
+ `;
1642
+ }).join('');
1643
+ $slot.find('[data-field="rows"]').get(0).innerHTML = html;
1644
+ }
1645
+ ```
1646
+
1647
+ ### Task 4.9: Widget - Journeys
1648
+
1649
+ **Files:**
1650
+ - Create: `admin/js/templates/dashboard/journeys.html`
1651
+ - Create: `admin/js/views/dashboard/widgets/journeys.js`
1652
+
1653
+ - [ ] **Template:**
1654
+
1655
+ ```html
1656
+ <h3>User journeys - last 7 days</h3>
1657
+ <div class="dash-journey-grid">
1658
+ <div>
1659
+ <h4>Top entry pages</h4>
1660
+ <div data-field="entry"></div>
1661
+ </div>
1662
+ <div>
1663
+ <h4>Top exit pages</h4>
1664
+ <div data-field="exit"></div>
1665
+ </div>
1666
+ <div>
1667
+ <h4>Top paths</h4>
1668
+ <div data-field="paths"></div>
1669
+ </div>
1670
+ </div>
1671
+ <p class="dash-bounce">Bounce rate: <strong data-field="bounce">-</strong></p>
1672
+ <p class="dash-empty" hidden>No journey data yet - encourage some traffic and check back.</p>
1673
+ ```
1674
+
1675
+ - [ ] **Render** (escape every URL; counts are numbers):
1676
+
1677
+ ```js
1678
+ import {escapeHtml} from '../lib/escape.js';
1679
+
1680
+ function rowsFromList(items, formatter) {
1681
+ return items.map(i => `<div class="dash-spike-row">${formatter(i)}</div>`).join('');
1682
+ }
1683
+
1684
+ export default function renderJourneys($slot, data) {
1685
+ const j = data?.journeys;
1686
+ const $empty = $slot.find('.dash-empty');
1687
+ const $entry = $slot.find('[data-field="entry"]');
1688
+ const $exit = $slot.find('[data-field="exit"]');
1689
+ const $paths = $slot.find('[data-field="paths"]');
1690
+ const $bounce = $slot.find('[data-field="bounce"]');
1691
+
1692
+ if (!j || j.totalSessions === 0) {
1693
+ $empty.removeAttr('hidden');
1694
+ $entry.empty(); $exit.empty(); $paths.empty();
1695
+ $bounce.text('-');
1696
+ return;
1697
+ }
1698
+ $empty.attr('hidden', 'hidden');
1699
+
1700
+ $entry.get(0).innerHTML = rowsFromList(j.entry, e =>
1701
+ `<span>${escapeHtml(e.url)}</span><span>${Number(e.count) | 0}</span>`);
1702
+ $exit.get(0).innerHTML = rowsFromList(j.exit, e =>
1703
+ `<span>${escapeHtml(e.url)}</span><span>${Number(e.count) | 0}</span>`);
1704
+ $paths.get(0).innerHTML = rowsFromList(j.paths, p =>
1705
+ `<span>${escapeHtml(p.from)} → ${escapeHtml(p.to)}</span><span>${Number(p.count) | 0}</span>`);
1706
+ $bounce.text((Number(j.bounceRate) * 100).toFixed(1) + '%');
1707
+ }
1708
+ ```
1709
+
1710
+ ### Task 4.10: Widget - Health detail
1711
+
1712
+ **Files:**
1713
+ - Create: `admin/js/templates/dashboard/health-detail.html`
1714
+ - Create: `admin/js/views/dashboard/widgets/health-detail.js`
1715
+
1716
+ - [ ] **Template:**
1717
+
1718
+ ```html
1719
+ <h3>Health</h3>
1720
+ <div data-field="rows"></div>
1721
+ ```
1722
+
1723
+ - [ ] **Render** (uses pure DOM API - no innerHTML interpolation needed for health text since checks are server-controlled, but still using `textContent` to be safe):
1724
+
1725
+ ```js
1726
+ const ICON = { ok: 'check-circle', warn: 'alert-triangle', fail: 'x-circle' };
1727
+
1728
+ export default function renderHealthDetail($slot, data) {
1729
+ const checks = data?.health?.checks || [];
1730
+ const $rows = $slot.find('[data-field="rows"]');
1731
+ $rows.empty();
1732
+ const container = $rows.get(0);
1733
+ if (!container) return;
1734
+
1735
+ for (const c of checks) {
1736
+ const row = document.createElement('div');
1737
+ row.className = 'dash-spike-row dash-health-pill';
1738
+ row.setAttribute('data-level', c.level);
1739
+
1740
+ const left = document.createElement('span');
1741
+ const icon = document.createElement('span');
1742
+ icon.setAttribute('data-icon', ICON[c.level] || 'circle');
1743
+ left.appendChild(icon);
1744
+ left.appendChild(document.createTextNode(' ' + (c.label || c.id)));
1745
+
1746
+ const right = document.createElement('span');
1747
+ right.textContent = String(c.value);
1748
+
1749
+ row.appendChild(left);
1750
+ row.appendChild(right);
1751
+ container.appendChild(row);
1752
+
1753
+ if (Array.isArray(c.detail) && c.detail.length > 0) {
1754
+ const det = document.createElement('div');
1755
+ det.className = 'dash-health-detail';
1756
+ det.style.fontSize = '12px';
1757
+ det.style.opacity = '0.75';
1758
+ det.style.padding = '4px 0 8px';
1759
+ det.textContent = c.detail.slice(0, 5).join(', ');
1760
+ container.appendChild(det);
1761
+ }
1762
+ }
1763
+ if (window.Domma && Domma.icons) Domma.icons.scan();
1764
+ }
1765
+ ```
1766
+
1767
+ ### Task 4.11: Widget - Activity feed
1768
+
1769
+ **Files:**
1770
+ - Create: `admin/js/templates/dashboard/activity-feed.html`
1771
+ - Create: `admin/js/views/dashboard/widgets/activity-feed.js`
1772
+
1773
+ - [ ] **Template:**
1774
+
1775
+ ```html
1776
+ <h3>Recent activity</h3>
1777
+ <div data-field="rows"></div>
1778
+ <p class="dash-empty" hidden>No recent activity.</p>
1779
+ ```
1780
+
1781
+ - [ ] **Render** (escape actor and target - actor names come from user accounts, page paths come from filesystem, but defence-in-depth):
1782
+
1783
+ ```js
1784
+ import {escapeHtml} from '../lib/escape.js';
1785
+
1786
+ const TYPE_LABEL = {
1787
+ edit: 'edited',
1788
+ publish: 'published',
1789
+ form: 'received submission'
1790
+ };
1791
+
1792
+ export default function renderActivityFeed($slot, data) {
1793
+ const items = Array.isArray(data?.activity) ? data.activity : [];
1794
+ if (items.length === 0) $slot.find('.dash-empty').removeAttr('hidden');
1795
+ else $slot.find('.dash-empty').attr('hidden', 'hidden');
1796
+
1797
+ const html = items.map(i => {
1798
+ const when = i.at ? D(i.at).fromNow() : '';
1799
+ const subject = i.target || i.form || '';
1800
+ const actor = i.actor ? `<strong>${escapeHtml(i.actor)}</strong> ` : '';
1801
+ const label = escapeHtml(TYPE_LABEL[i.type] || i.type || '');
1802
+ return `<div class="dash-spike-row">
1803
+ <span>${actor}${label} <code>${escapeHtml(subject)}</code></span>
1804
+ <span>${escapeHtml(when)}</span>
1805
+ </div>`;
1806
+ }).join('');
1807
+ $slot.find('[data-field="rows"]').get(0).innerHTML = html;
1808
+ }
1809
+ ```
1810
+
1811
+ ### Task 4.12: Orchestrator (`dashboard.js`)
1812
+
1813
+ **Files:**
1814
+ - Modify: `admin/js/views/dashboard.js`
1815
+
1816
+ - [ ] **Replace the entire file:**
1817
+
1818
+ ```js
1819
+ /**
1820
+ * Dashboard View - orchestrator.
1821
+ * Fetches /api/dashboard/summary, distributes data slices to widget render functions,
1822
+ * and polls a lite summary every 30 s to refresh KPI strip + spike feed + health pill.
1823
+ */
1824
+ import {api} from '../api.js';
1825
+ import renderKpiStrip from './dashboard/widgets/kpi-strip.js?v=1';
1826
+ import renderTrafficChart from './dashboard/widgets/traffic-chart.js?v=1';
1827
+ import renderSpikeFeed from './dashboard/widgets/spike-feed.js?v=1';
1828
+ import renderTopPages from './dashboard/widgets/top-pages.js?v=1';
1829
+ import renderJourneys from './dashboard/widgets/journeys.js?v=1';
1830
+ import renderHealthDetail from './dashboard/widgets/health-detail.js?v=1';
1831
+ import renderActivityFeed from './dashboard/widgets/activity-feed.js?v=1';
1832
+
1833
+ const WIDGETS = [
1834
+ { slot: '#dash-kpi-strip', template: '/admin/js/templates/dashboard/kpi-strip.html', render: renderKpiStrip },
1835
+ { slot: '#dash-traffic-chart', template: '/admin/js/templates/dashboard/traffic-chart.html', render: renderTrafficChart },
1836
+ { slot: '#dash-spike-feed', template: '/admin/js/templates/dashboard/spike-feed.html', render: renderSpikeFeed },
1837
+ { slot: '#dash-top-pages', template: '/admin/js/templates/dashboard/top-pages.html', render: renderTopPages },
1838
+ { slot: '#dash-journeys', template: '/admin/js/templates/dashboard/journeys.html', render: renderJourneys },
1839
+ { slot: '#dash-health-detail', template: '/admin/js/templates/dashboard/health-detail.html', render: renderHealthDetail },
1840
+ { slot: '#dash-activity-feed', template: '/admin/js/templates/dashboard/activity-feed.html', render: renderActivityFeed }
1841
+ ];
1842
+
1843
+ const REFRESH_MS = 30 * 1000;
1844
+
1845
+ async function loadTemplate(url) {
1846
+ const res = await fetch(url, { credentials: 'same-origin' });
1847
+ if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
1848
+ return res.text();
1849
+ }
1850
+
1851
+ function showWarnings($container, warnings) {
1852
+ const $w = $container.find('#dash-warnings');
1853
+ if (!warnings || warnings.length === 0) {
1854
+ $w.attr('hidden', 'hidden');
1855
+ return;
1856
+ }
1857
+ $w.removeAttr('hidden');
1858
+ // Use textContent to avoid HTML interpretation of error messages.
1859
+ $w.get(0).textContent = 'Some sections could not load: ' + warnings.join(' · ');
1860
+ }
1861
+
1862
+ function setUpdated($container) {
1863
+ $container.find('#dash-updated').text('Updated ' + D().format('HH:mm:ss'));
1864
+ }
1865
+
1866
+ function annotateSubmissionsWeek(data) {
1867
+ if (!data || !Array.isArray(data.activity)) return data;
1868
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
1869
+ data._submissionsWeek = data.activity.filter(a =>
1870
+ a.type === 'form' && new Date(a.at).getTime() >= cutoff
1871
+ ).length;
1872
+ return data;
1873
+ }
1874
+
1875
+ let pollHandle = null;
1876
+
1877
+ export const dashboardView = {
1878
+ templateUrl: '/admin/js/templates/dashboard.html',
1879
+
1880
+ async onMount($container) {
1881
+ const loader = E.loader($container.get(0), { type: 'dots' });
1882
+
1883
+ // 1. Load all widget templates in parallel and inject them
1884
+ const tpls = await Promise.all(WIDGETS.map(w => loadTemplate(w.template)));
1885
+ WIDGETS.forEach((w, i) => {
1886
+ const $slot = $container.find(w.slot);
1887
+ // Templates are static, controlled assets - innerHTML is safe here.
1888
+ if ($slot.length) $slot.get(0).innerHTML = tpls[i];
1889
+ });
1890
+
1891
+ // 2. First fetch + render
1892
+ let data;
1893
+ try {
1894
+ data = await api.dashboard.summary();
1895
+ data = annotateSubmissionsWeek(data);
1896
+ } catch (e) {
1897
+ loader.destroy();
1898
+ const $w = $container.find('#dash-warnings');
1899
+ $w.removeAttr('hidden');
1900
+ $w.get(0).textContent = 'Could not load dashboard: ' + e.message;
1901
+ return;
1902
+ }
1903
+
1904
+ for (const w of WIDGETS) {
1905
+ const $slot = $container.find(w.slot);
1906
+ try { await w.render($slot, data); }
1907
+ catch (e) { console.warn('[dashboard]', w.slot, 'render failed:', e); }
1908
+ }
1909
+ showWarnings($container, data.warnings);
1910
+ setUpdated($container);
1911
+ Domma.icons.scan();
1912
+ loader.destroy();
1913
+
1914
+ // 3. Poll lite summary every 30 s for volatile widgets
1915
+ if (pollHandle) clearInterval(pollHandle);
1916
+ pollHandle = setInterval(async () => {
1917
+ // Bail if the user navigated away from the dashboard
1918
+ if (!document.body.contains($container.get(0))) {
1919
+ clearInterval(pollHandle); pollHandle = null;
1920
+ return;
1921
+ }
1922
+ try {
1923
+ const lite = await api.dashboard.summaryLite();
1924
+ const merged = annotateSubmissionsWeek({
1925
+ ...data,
1926
+ traffic: { ...data.traffic, ...lite.traffic },
1927
+ realtime: lite.realtime,
1928
+ spikes: lite.spikes,
1929
+ health: { ...data.health, status: lite.health.status }
1930
+ });
1931
+ await renderKpiStrip($container.find('#dash-kpi-strip'), merged);
1932
+ await renderSpikeFeed($container.find('#dash-spike-feed'), merged);
1933
+ setUpdated($container);
1934
+ } catch (e) {
1935
+ console.warn('[dashboard] lite refresh failed:', e);
1936
+ }
1937
+ }, REFRESH_MS);
1938
+ }
1939
+ };
1940
+ ```
1941
+
1942
+ ### Task 4.13: Bump cache buster on the dashboard view import
1943
+
1944
+ **Files:**
1945
+ - Modify: `admin/js/views/index.js`
1946
+
1947
+ - [ ] **Find the line** `import {dashboardView} from './dashboard.js';` and **replace with:**
1948
+
1949
+ ```js
1950
+ import {dashboardView} from './dashboard.js?v=20260506-1';
1951
+ ```
1952
+
1953
+ (Per the standing rule: admin SPA imports use hard-coded `?v=N` cache busters; bump on every edit to the imported module.)
1954
+
1955
+ ### Task 4.14: Browser smoke verification
1956
+
1957
+ This task is verification only - no code changes.
1958
+
1959
+ - [ ] **Open the admin dashboard in a real browser** at `http://192.168.1.4:4096/admin/#/dashboard` (the user is on SSH; browser sessions need that host) and confirm:
1960
+ - All seven widget tiles render
1961
+ - KPI strip shows numbers (or 0) and a Health pill
1962
+ - Traffic chart renders (line) or shows "No traffic recorded yet"
1963
+ - Spike feed renders an empty state when no anomalies (expected on a quiet site)
1964
+ - Top pages, Journeys, Health detail, Activity feed each render or show their empty state
1965
+ - No console errors
1966
+
1967
+ - [ ] **Generate a few hits** to confirm live data flows:
1968
+
1969
+ ```bash
1970
+ for u in / /about /pricing; do
1971
+ curl -s -X POST http://localhost:4096/api/plugins/analytics/hit \
1972
+ -H 'Content-Type: application/json' \
1973
+ -d "{\"url\":\"$u\",\"sid\":\"smoke-$RANDOM\",\"ref\":\"\"}"
1974
+ done
1975
+ ```
1976
+
1977
+ Wait 30 s; the KPI strip should refresh and "Hits today" should reflect the new traffic.
1978
+
1979
+ - [ ] **State explicitly to the user** what was verified in the browser and what was not. Per the standing rule, only browser-confirmed behaviour can be claimed as working. Do not say "tested" or "verified" without naming the specific click/load you performed.
1980
+
1981
+ ---
1982
+
1983
+ ## Self-review checklist
1984
+
1985
+ After implementing, verify against the spec sections:
1986
+
1987
+ - [ ] Beacon sends `sid` and `ref` (Task 1.1) - covers spec § Beacon
1988
+ - [ ] `journeys.json` capped at 5,000/day (Task 1.2) - covers spec § Storage
1989
+ - [ ] `/journeys`, `/realtime`, `/spikes` endpoints (Tasks 1.4-1.6) - covers spec § New Endpoints
1990
+ - [ ] `fastify.analytics` decorator (Task 1.7) - covers spec § Cross-plugin coupling
1991
+ - [ ] Health checks: uptime, memory, disk, stale, missing-meta, last-form, smtp, plugin-load (Tasks 2.4-2.6) - covers spec § server/services/health.js
1992
+ - [ ] 30 s in-memory cache on health (Task 2.3) - covers spec § Cross-cutting
1993
+ - [ ] `/api/dashboard/summary` with `?lite=1` (Task 3.1) - covers spec § GET /api/dashboard/summary
1994
+ - [ ] Graceful degradation when analytics absent (Task 3.1) - covers spec § Cross-plugin coupling
1995
+ - [ ] Seven widgets, each self-contained module (Tasks 4.5-4.11) - covers spec § Frontend Architecture
1996
+ - [ ] Single fetch on mount; 30 s poll for KPI strip + spike feed only (Task 4.12) - covers spec § Refresh
1997
+ - [ ] Empty states on every widget (Tasks 4.5-4.11) - covers spec § Empty states
1998
+ - [ ] Privacy: `sid` is sessionStorage, no IP/UA collected (Task 1.1) - covers spec § Privacy
1999
+ - [ ] XSS-safe interpolation: every widget that builds innerHTML escapes URLs/refs/actor names via `escapeHtml` (Tasks 4.7-4.11) - covers § Security