domma-cms 0.15.0 → 0.17.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 (65) hide show
  1. package/admin/css/dashboard.css +1 -0
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +1 -1
  7. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  8. package/admin/js/templates/dashboard/health-detail.html +2 -0
  9. package/admin/js/templates/dashboard/journeys.html +17 -0
  10. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  11. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  12. package/admin/js/templates/dashboard/top-pages.html +3 -0
  13. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  14. package/admin/js/templates/dashboard.html +22 -44
  15. package/admin/js/views/dashboard/lib/escape.js +1 -0
  16. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  17. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  18. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  19. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  20. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  21. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  22. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  23. package/admin/js/views/dashboard.js +1 -1
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/config/plugins.json +3 -0
  27. package/package.json +2 -2
  28. package/plugins/analytics/admin/templates/analytics.html +52 -1
  29. package/plugins/analytics/admin/views/analytics.js +157 -32
  30. package/plugins/analytics/config.js +10 -2
  31. package/plugins/analytics/daily.json +5 -0
  32. package/plugins/analytics/journeys.json +10 -0
  33. package/plugins/analytics/lifetime.json +25 -0
  34. package/plugins/analytics/plugin.js +429 -25
  35. package/plugins/analytics/plugin.json +9 -5
  36. package/plugins/analytics/public/inject-body.html +49 -7
  37. package/plugins/blog/admin/templates/blog.html +25 -2
  38. package/plugins/blog/admin/views/blog.js +72 -56
  39. package/plugins/blog/admin/views/post-editor.js +98 -79
  40. package/plugins/blog/plugin.js +133 -0
  41. package/plugins/blog/plugin.json +3 -3
  42. package/plugins/blog/templates/post.html +2 -1
  43. package/plugins/invoice/admin/templates/editor.html +129 -0
  44. package/plugins/invoice/admin/templates/index.html +43 -0
  45. package/plugins/invoice/admin/templates/issuers.html +5 -0
  46. package/plugins/invoice/admin/templates/receivers.html +5 -0
  47. package/plugins/invoice/admin/views/editor.js +267 -0
  48. package/plugins/invoice/admin/views/index.js +155 -0
  49. package/plugins/invoice/admin/views/issuers.js +23 -0
  50. package/plugins/invoice/admin/views/party-view.js +148 -0
  51. package/plugins/invoice/admin/views/receivers.js +22 -0
  52. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  53. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  54. package/plugins/invoice/collections/invoices/schema.json +27 -0
  55. package/plugins/invoice/config.js +16 -0
  56. package/plugins/invoice/plugin.js +283 -0
  57. package/plugins/invoice/plugin.json +85 -0
  58. package/plugins/invoice/templates/invoice-print.html +213 -0
  59. package/server/routes/api/dashboard.js +239 -0
  60. package/server/server.js +2 -0
  61. package/server/services/email.js +60 -20
  62. package/server/services/health.js +282 -0
  63. package/server/services/markdown.js +24 -4
  64. package/server/services/plugins.js +37 -5
  65. package/server/services/renderer.js +9 -3
@@ -0,0 +1,25 @@
1
+ {
2
+ "/": 162,
3
+ "/about": 88,
4
+ "/blog": 42,
5
+ "/contact": 31,
6
+ "/resources/typography": 4,
7
+ "/resources": 13,
8
+ "/resources/shortcodes": 14,
9
+ "/resources/cards": 19,
10
+ "/resources/interactive": 13,
11
+ "/resources/grid": 6,
12
+ "/forms": 14,
13
+ "/resources/effects": 6,
14
+ "/blog/hello-world": 28,
15
+ "/feedback": 42,
16
+ "/resources/dependencies": 2,
17
+ "/resources/components": 6,
18
+ "/gdpr": 3,
19
+ "/scratch": 84,
20
+ "/getting-started": 3,
21
+ "/resources/pro": 1,
22
+ "/todo": 23,
23
+ "/thank-you": 1,
24
+ "/test-from-controller": 1
25
+ }
@@ -1,58 +1,462 @@
1
1
  /**
2
2
  * Analytics Plugin — Server
3
- * Tracks page hits in a JSON file alongside the plugin.
4
- * Endpoints:
5
- * POST /api/plugins/analytics/hit - public: record a hit { url }
6
- * GET /api/plugins/analytics/stats - admin: return all hit counts
7
- * DELETE /api/plugins/analytics/stats - admin: reset all stats
3
+ * Tracks page hits with per-day buckets so we can chart trends and
4
+ * filter by time range.
5
+ *
6
+ * Storage:
7
+ * lifetime.json { "/url": totalHits }
8
+ * daily.json { "YYYY-MM-DD": { "/url": hits } }
9
+ *
10
+ * Endpoints (mounted at /api/plugins/analytics):
11
+ * POST /hit public record a hit { url, dnt }
12
+ * GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
13
+ * GET /series admin per-day totals series (?days=N or ?from=&to=)
14
+ * GET /export.csv admin CSV export of top pages for the active range
15
+ * DELETE /stats admin reset all stats
8
16
  */
9
17
  import fs from 'fs/promises';
10
18
  import path from 'path';
11
19
  import {fileURLToPath} from 'url';
12
20
 
13
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- const STATS_FILE = path.join(__dirname, 'stats.json');
22
+ const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
23
+ const DAILY_FILE = path.join(__dirname, 'daily.json');
24
+ const LEGACY_FILE = path.join(__dirname, 'stats.json');
25
+ const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
26
+ const JOURNEY_DAILY_CAP = 5000;
27
+ const REALTIME_WINDOW_MS = 5 * 60 * 1000;
15
28
 
16
- async function readStats() {
29
+ async function readJson(file, fallback) {
17
30
  try {
18
- return JSON.parse(await fs.readFile(STATS_FILE, 'utf8'));
31
+ return JSON.parse(await fs.readFile(file, 'utf8'));
19
32
  } catch {
20
- return {};
33
+ return fallback;
21
34
  }
22
35
  }
23
36
 
24
- async function writeStats(stats) {
25
- await fs.writeFile(STATS_FILE, JSON.stringify(stats, null, 2) + '\n', 'utf8');
37
+ async function writeJson(file, data) {
38
+ const tmp = file + '.tmp';
39
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
40
+ await fs.rename(tmp, file);
41
+ }
42
+
43
+ /**
44
+ * One-shot migration from the legacy flat stats.json.
45
+ * Preserves existing totals as lifetime numbers; daily history starts empty.
46
+ * Also seeds journeys.json if missing.
47
+ */
48
+ async function migrateLegacy() {
49
+ let lifetimeExists = false;
50
+ try {
51
+ await fs.access(LIFETIME_FILE);
52
+ lifetimeExists = true;
53
+ } catch { /* not migrated */ }
54
+
55
+ if (!lifetimeExists) {
56
+ const legacy = await readJson(LEGACY_FILE, null);
57
+ if (legacy && typeof legacy === 'object') {
58
+ await writeJson(LIFETIME_FILE, legacy);
59
+ } else {
60
+ await writeJson(LIFETIME_FILE, {});
61
+ }
62
+ await writeJson(DAILY_FILE, {});
63
+ }
64
+
65
+ try {
66
+ await fs.access(JOURNEYS_FILE);
67
+ } catch {
68
+ await writeJson(JOURNEYS_FILE, {});
69
+ }
70
+ }
71
+
72
+ function todayKey(date = new Date()) {
73
+ return date.toISOString().slice(0, 10);
74
+ }
75
+
76
+ function rangeToDates(range, from, to) {
77
+ const today = new Date();
78
+ today.setUTCHours(0, 0, 0, 0);
79
+ const end = to ? new Date(to + 'T00:00:00Z') : today;
80
+ let start;
81
+ switch (range) {
82
+ case 'today':
83
+ start = today;
84
+ break;
85
+ case '7d':
86
+ start = new Date(today);
87
+ start.setUTCDate(start.getUTCDate() - 6);
88
+ break;
89
+ case '30d':
90
+ start = new Date(today);
91
+ start.setUTCDate(start.getUTCDate() - 29);
92
+ break;
93
+ case '90d':
94
+ start = new Date(today);
95
+ start.setUTCDate(start.getUTCDate() - 89);
96
+ break;
97
+ case 'custom':
98
+ start = from ? new Date(from + 'T00:00:00Z') : today;
99
+ break;
100
+ case 'all':
101
+ default:
102
+ return null; // all-time → use lifetime aggregate
103
+ }
104
+ return { start, end };
105
+ }
106
+
107
+ function aggregateDaily(daily, start, end) {
108
+ const totals = {};
109
+ let total = 0;
110
+ const startMs = start.getTime();
111
+ const endMs = end.getTime();
112
+ for (const [day, urls] of Object.entries(daily)) {
113
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
114
+ if (dayMs < startMs || dayMs > endMs) continue;
115
+ for (const [url, hits] of Object.entries(urls)) {
116
+ totals[url] = (totals[url] || 0) + hits;
117
+ total += hits;
118
+ }
119
+ }
120
+ return { totals, total };
121
+ }
122
+
123
+ function buildSeries(daily, start, end) {
124
+ const series = [];
125
+ const cursor = new Date(start);
126
+ while (cursor.getTime() <= end.getTime()) {
127
+ const key = todayKey(cursor);
128
+ const day = daily[key] || {};
129
+ const hits = Object.values(day).reduce((acc, n) => acc + n, 0);
130
+ series.push({ date: key, hits });
131
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
132
+ }
133
+ return series;
134
+ }
135
+
136
+ function escapeCsvCell(value) {
137
+ const str = String(value ?? '');
138
+ if (/[",\n\r]/.test(str)) {
139
+ return '"' + str.replace(/"/g, '""') + '"';
140
+ }
141
+ return str;
142
+ }
143
+
144
+ function eventsInRange(journeys, start, end) {
145
+ const startMs = start.getTime();
146
+ const endMs = end.getTime();
147
+ const out = [];
148
+ for (const [day, events] of Object.entries(journeys)) {
149
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
150
+ if (dayMs < startMs || dayMs > endMs) continue;
151
+ for (const ev of events) out.push(ev);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function groupBySession(events) {
157
+ const sessions = new Map();
158
+ for (const ev of events) {
159
+ if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
160
+ sessions.get(ev.sid).push(ev);
161
+ }
162
+ for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
163
+ return sessions;
164
+ }
165
+
166
+ function aggregateJourneys(events) {
167
+ const sessions = groupBySession(events);
168
+ const entry = new Map();
169
+ const exit = new Map();
170
+ const paths = new Map(); // key: '\x00'-joined, value: { from, to, count }
171
+ let bouncedSessions = 0;
172
+
173
+ for (const arr of sessions.values()) {
174
+ if (arr.length === 0) continue;
175
+ entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
176
+ exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
177
+ if (arr.length === 1) bouncedSessions += 1;
178
+ for (let i = 0; i < arr.length - 1; i += 1) {
179
+ const from = arr[i].url;
180
+ const to = arr[i + 1].url;
181
+ const key = from + '\x00' + to; // NUL is invalid in URL pathnames, so unambiguous
182
+ const existing = paths.get(key);
183
+ if (existing) {
184
+ existing.count += 1;
185
+ } else {
186
+ paths.set(key, { from, to, count: 1 });
187
+ }
188
+ }
189
+ }
190
+
191
+ const toSorted = (m) => Array.from(m.entries())
192
+ .map(([url, count]) => ({ url, count }))
193
+ .sort((a, b) => b.count - a.count)
194
+ .slice(0, 10);
195
+
196
+ const pathsSorted = Array.from(paths.values())
197
+ .sort((a, b) => b.count - a.count)
198
+ .slice(0, 10);
199
+
200
+ const totalSessions = sessions.size;
201
+ return {
202
+ entry: toSorted(entry),
203
+ exit: toSorted(exit),
204
+ paths: pathsSorted,
205
+ bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
206
+ totalSessions
207
+ };
208
+ }
209
+
210
+ function detectSpikes(daily, now = new Date()) {
211
+ const todayStr = todayKey(now);
212
+ const currentHour = now.getUTCHours();
213
+
214
+ // Approximation: distribute today's hits evenly across elapsed hours.
215
+ const todayUrls = daily[todayStr] || {};
216
+ const elapsedHours = Math.max(1, currentHour + 1);
217
+
218
+ const flagged = [];
219
+ for (const [url, totalToday] of Object.entries(todayUrls)) {
220
+ const currentHits = totalToday / elapsedHours;
221
+
222
+ // Baseline: previous 7 days' same-hour share (assume 24-hour spread)
223
+ const samples = [];
224
+ for (let i = 1; i <= 7; i += 1) {
225
+ const prev = new Date(now);
226
+ prev.setUTCDate(prev.getUTCDate() - i);
227
+ const key = todayKey(prev);
228
+ const sample = (daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0;
229
+ samples.push(sample);
230
+ }
231
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
232
+ const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
233
+ const stddev = Math.sqrt(variance);
234
+
235
+ if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
236
+ flagged.push({
237
+ url,
238
+ hits: Math.round(currentHits),
239
+ baseline: Math.round(mean),
240
+ ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2)
241
+ });
242
+ }
243
+ }
244
+
245
+ return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
246
+ }
247
+
248
+ function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
249
+ return Array.isArray(dayEntries) && dayEntries.length < cap;
26
250
  }
27
251
 
28
252
  export default async function analyticsPlugin(fastify, options) {
29
- const {authenticate, requireAdmin} = options.auth;
253
+ const { authenticate, requireAdmin } = options.auth;
254
+ const settings = options.settings || {};
255
+
256
+ await migrateLegacy();
257
+
258
+ fastify.decorate('analytics', {
259
+ async getStats({ range = '7d', from, to } = {}) {
260
+ const dates = rangeToDates(range, from, to);
261
+ const [lifetime, daily] = await Promise.all([
262
+ readJson(LIFETIME_FILE, {}),
263
+ readJson(DAILY_FILE, {})
264
+ ]);
265
+ let totals; let total;
266
+ if (!dates) {
267
+ totals = lifetime;
268
+ total = Object.values(lifetime).reduce((a, n) => a + n, 0);
269
+ } else {
270
+ const agg = aggregateDaily(daily, dates.start, dates.end);
271
+ totals = agg.totals;
272
+ total = agg.total;
273
+ }
274
+ const top = Object.entries(totals)
275
+ .map(([url, hits]) => ({ url, hits }))
276
+ .sort((a, b) => b.hits - a.hits);
277
+ return { total, top, lifetimeTotal: Object.values(lifetime).reduce((a, n) => a + n, 0) };
278
+ },
279
+ async getDaily() { return readJson(DAILY_FILE, {}); },
280
+ async getJourneys({ range = '7d', from, to } = {}) {
281
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
282
+ const journeys = await readJson(JOURNEYS_FILE, {});
283
+ return aggregateJourneys(eventsInRange(journeys, dates.start, dates.end));
284
+ },
285
+ async getRealtime() {
286
+ const journeys = await readJson(JOURNEYS_FILE, {});
287
+ const today = todayKey();
288
+ const events = journeys[today] || [];
289
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
290
+ const sids = new Set();
291
+ for (const ev of events) if (ev.t >= cutoff) sids.add(ev.sid);
292
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
293
+ },
294
+ async getSpikes() {
295
+ const daily = await readJson(DAILY_FILE, {});
296
+ return { items: detectSpikes(daily) };
297
+ }
298
+ });
30
299
 
31
- // Record a page hit — called by the client-side injection script (public)
32
300
  fastify.post('/hit', async (request, reply) => {
33
- const { url } = request.body || {};
301
+ const { url, dnt, sid, ref } = request.body || {};
34
302
  if (!url || typeof url !== 'string') {
35
303
  return reply.status(400).send({ error: 'url is required' });
36
304
  }
305
+ if (dnt === true || dnt === '1') {
306
+ return { ok: true, ignored: 'dnt' };
307
+ }
308
+
309
+ const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
310
+ if (settings.excludeAdmin && normalised.startsWith('/admin')) {
311
+ return { ok: true, ignored: 'admin' };
312
+ }
313
+ if (Array.isArray(settings.excludePaths)) {
314
+ for (const prefix of settings.excludePaths) {
315
+ if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
316
+ return { ok: true, ignored: 'excluded' };
317
+ }
318
+ }
319
+ }
37
320
 
38
- const normalised = url.split('?')[0].replace(/\/$/, '') || '/';
39
- const stats = await readStats();
40
- stats[normalised] = (stats[normalised] || 0) + 1;
41
- await writeStats(stats);
321
+ const today = todayKey();
322
+ const [lifetime, daily, journeys] = await Promise.all([
323
+ readJson(LIFETIME_FILE, {}),
324
+ readJson(DAILY_FILE, {}),
325
+ readJson(JOURNEYS_FILE, {})
326
+ ]);
327
+
328
+ lifetime[normalised] = (lifetime[normalised] || 0) + 1;
329
+ if (!daily[today]) daily[today] = {};
330
+ daily[today][normalised] = (daily[today][normalised] || 0) + 1;
331
+
332
+ // Journey event — only if sid is a string of reasonable shape, and under cap
333
+ const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
334
+ const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
335
+ if (safeSid) {
336
+ if (!journeys[today]) journeys[today] = [];
337
+ if (shouldRecordJourney(journeys[today])) {
338
+ journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
339
+ }
340
+ }
341
+
342
+ await Promise.all([
343
+ writeJson(LIFETIME_FILE, lifetime),
344
+ writeJson(DAILY_FILE, daily),
345
+ writeJson(JOURNEYS_FILE, journeys)
346
+ ]);
42
347
  return { ok: true };
43
348
  });
44
349
 
45
- // Return all stats admin only
46
- fastify.get('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
47
- const stats = await readStats();
48
- return Object.entries(stats)
350
+ fastify.get('/stats', { preHandler: [authenticate, requireAdmin] }, async (request) => {
351
+ const { range = '30d', from, to } = request.query || {};
352
+ const dates = rangeToDates(range, from, to);
353
+ const [lifetime, daily] = await Promise.all([
354
+ readJson(LIFETIME_FILE, {}),
355
+ readJson(DAILY_FILE, {})
356
+ ]);
357
+
358
+ let totals;
359
+ let total;
360
+ if (!dates) {
361
+ totals = lifetime;
362
+ total = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
363
+ } else {
364
+ const agg = aggregateDaily(daily, dates.start, dates.end);
365
+ totals = agg.totals;
366
+ total = agg.total;
367
+ }
368
+
369
+ const top = Object.entries(totals)
49
370
  .map(([url, hits]) => ({ url, hits }))
50
371
  .sort((a, b) => b.hits - a.hits);
372
+
373
+ const lifetimeTotal = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
374
+ return {
375
+ range,
376
+ from: dates ? todayKey(dates.start) : null,
377
+ to: dates ? todayKey(dates.end) : null,
378
+ total,
379
+ lifetimeTotal,
380
+ uniquePages: top.length,
381
+ top
382
+ };
51
383
  });
52
384
 
53
- // Reset stats admin only
54
- fastify.delete('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
55
- await writeStats({});
385
+ fastify.get('/series', { preHandler: [authenticate, requireAdmin] }, async (request) => {
386
+ const { range = '30d', from, to } = request.query || {};
387
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
388
+ const daily = await readJson(DAILY_FILE, {});
389
+ return { series: buildSeries(daily, dates.start, dates.end) };
390
+ });
391
+
392
+ fastify.get('/journeys', { preHandler: [authenticate, requireAdmin] }, async (request) => {
393
+ const { range = '7d', from, to } = request.query || {};
394
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
395
+ const journeys = await readJson(JOURNEYS_FILE, {});
396
+ const events = eventsInRange(journeys, dates.start, dates.end);
397
+ const agg = aggregateJourneys(events);
398
+ return {
399
+ range,
400
+ from: todayKey(dates.start),
401
+ to: todayKey(dates.end),
402
+ ...agg
403
+ };
404
+ });
405
+
406
+ fastify.get('/realtime', { preHandler: [authenticate, requireAdmin] }, async () => {
407
+ const journeys = await readJson(JOURNEYS_FILE, {});
408
+ const today = todayKey();
409
+ const events = journeys[today] || [];
410
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
411
+ const sids = new Set();
412
+ for (const ev of events) {
413
+ if (ev.t >= cutoff) sids.add(ev.sid);
414
+ }
415
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
416
+ });
417
+
418
+ fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
419
+ const daily = await readJson(DAILY_FILE, {});
420
+ return { items: detectSpikes(daily) };
421
+ });
422
+
423
+ fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
424
+ const { range = '30d', from, to } = request.query || {};
425
+ const dates = rangeToDates(range, from, to);
426
+ const [lifetime, daily] = await Promise.all([
427
+ readJson(LIFETIME_FILE, {}),
428
+ readJson(DAILY_FILE, {})
429
+ ]);
430
+ const totals = dates ? aggregateDaily(daily, dates.start, dates.end).totals : lifetime;
431
+ const rows = Object.entries(totals)
432
+ .map(([url, hits]) => ({ url, hits }))
433
+ .sort((a, b) => b.hits - a.hits);
434
+
435
+ const lines = ['url,hits'];
436
+ for (const row of rows) {
437
+ lines.push(`${escapeCsvCell(row.url)},${row.hits}`);
438
+ }
439
+ const filename = `analytics-${range}-${new Date().toISOString().slice(0, 10)}.csv`;
440
+ reply
441
+ .header('Content-Type', 'text/csv; charset=utf-8')
442
+ .header('Content-Disposition', `attachment; filename="${filename}"`);
443
+ return lines.join('\n') + '\n';
444
+ });
445
+
446
+ fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
447
+ await Promise.all([
448
+ writeJson(LIFETIME_FILE, {}),
449
+ writeJson(DAILY_FILE, {}),
450
+ writeJson(JOURNEYS_FILE, {})
451
+ ]);
452
+ try {
453
+ await fs.unlink(LEGACY_FILE);
454
+ } catch {
455
+ // ignore
456
+ }
56
457
  return { ok: true };
57
458
  });
58
459
  }
460
+
461
+ export { aggregateJourneys, detectSpikes, shouldRecordJourney };
462
+
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "analytics",
3
3
  "displayName": "Analytics",
4
- "version": "1.0.0",
5
- "description": "Basic page view analytics. Tracks hits per page using a simple JSON store.",
4
+ "version": "2.0.0",
5
+ "description": "Page view analytics with daily trends, time-range filtering, top-pages table and CSV export. Honours Do-Not-Track and dedups per session.",
6
6
  "author": "Darryl Waterhouse",
7
- "date": "2026-03-01",
7
+ "date": "2026-05-04",
8
8
  "icon": "chart-bar",
9
9
  "admin": {
10
10
  "sidebar": [
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "views": {
27
27
  "plugin-analytics": {
28
- "entry": "analytics/admin/views/analytics.js?v=80339cdc",
28
+ "entry": "analytics/admin/views/analytics.js?v=2c26480d",
29
29
  "exportName": "analyticsView"
30
30
  }
31
31
  }
@@ -37,7 +37,11 @@
37
37
  "scaffold": {
38
38
  "reset": [
39
39
  {
40
- "path": "stats.json",
40
+ "path": "lifetime.json",
41
+ "content": "{}"
42
+ },
43
+ {
44
+ "path": "daily.json",
41
45
  "content": "{}"
42
46
  }
43
47
  ]
@@ -1,13 +1,55 @@
1
- <!-- analytics: page view tracker -->
1
+ <!-- analytics: page view tracker (DNT-aware, session-deduped) -->
2
2
  <script>
3
3
  (function () {
4
+ if (typeof fetch !== 'function') return;
5
+
6
+ var dnt = navigator.doNotTrack === '1'
7
+ || window.doNotTrack === '1'
8
+ || navigator.msDoNotTrack === '1';
9
+ if (dnt) return;
10
+
4
11
  var url = window.location.pathname;
5
- if (typeof fetch === 'function') {
6
- fetch('/api/plugins/analytics/hit', {
7
- method: 'POST',
8
- headers: { 'Content-Type': 'application/json' },
9
- body: JSON.stringify({ url: url })
10
- }).catch(function () { /* silent fail */ });
12
+
13
+ // Session-scoped anonymous ID — clears with the tab. Cookie-less.
14
+ var sid = '';
15
+ try {
16
+ sid = sessionStorage.getItem('dm_sid') || '';
17
+ if (!sid) {
18
+ sid = (crypto && crypto.randomUUID)
19
+ ? crypto.randomUUID()
20
+ : (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
21
+ sessionStorage.setItem('dm_sid', sid);
22
+ }
23
+ } catch (e) {
24
+ // sessionStorage unavailable — proceed without sid
11
25
  }
26
+
27
+ // Session dedup (one hit per URL per session)
28
+ try {
29
+ var key = 'dm_analytics_seen';
30
+ var seen = sessionStorage.getItem(key);
31
+ var list = seen ? seen.split('|') : [];
32
+ if (list.indexOf(url) !== -1) return;
33
+ list.push(url);
34
+ sessionStorage.setItem(key, list.join('|'));
35
+ } catch (e) {
36
+ // proceed anyway
37
+ }
38
+
39
+ // Strip query/fragment from referrer; only keep same-origin or external origin+path
40
+ var ref = '';
41
+ try {
42
+ if (document.referrer) {
43
+ var r = new URL(document.referrer);
44
+ ref = r.origin === window.location.origin ? r.pathname : (r.origin + r.pathname);
45
+ }
46
+ } catch (e) { /* ignore */ }
47
+
48
+ fetch('/api/plugins/analytics/hit', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ keepalive: true,
52
+ body: JSON.stringify({ url: url, sid: sid, ref: ref })
53
+ }).catch(function () { /* silent */ });
12
54
  })();
13
55
  </script>
@@ -1,10 +1,29 @@
1
1
  <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0">Blog Posts</h1>
2
+ <h1 class="h3 mb-0"><span data-icon="file-text"></span> Blog Posts</h1>
3
3
  <button id="btn-new-post" class="btn btn-primary">
4
4
  <span data-icon="plus"></span> New Post
5
5
  </button>
6
6
  </div>
7
7
 
8
+ <div class="grid grid-cols-4 gap-3 mb-4">
9
+ <div class="card"><div class="card-body">
10
+ <div class="text-muted small">Total</div>
11
+ <div class="h3 mb-0" id="kpi-total">—</div>
12
+ </div></div>
13
+ <div class="card"><div class="card-body">
14
+ <div class="text-muted small">Published</div>
15
+ <div class="h3 mb-0" id="kpi-published">—</div>
16
+ </div></div>
17
+ <div class="card"><div class="card-body">
18
+ <div class="text-muted small">Drafts</div>
19
+ <div class="h3 mb-0" id="kpi-drafts">—</div>
20
+ </div></div>
21
+ <div class="card"><div class="card-body">
22
+ <div class="text-muted small">Scheduled</div>
23
+ <div class="h3 mb-0" id="kpi-scheduled">—</div>
24
+ </div></div>
25
+ </div>
26
+
8
27
  <div class="card mb-3">
9
28
  <div class="card-body">
10
29
  <div class="d-flex gap-2 flex-wrap">
@@ -14,7 +33,11 @@
14
33
  <option value="scheduled">Scheduled</option>
15
34
  <option value="published">Published</option>
16
35
  </select>
17
- <input id="filter-search" type="text" class="form-control" style="width:200px;" placeholder="Search posts…">
36
+ <select id="filter-category" class="form-select" style="width:auto;">
37
+ <option value="">All categories</option>
38
+ </select>
39
+ <input id="filter-search" type="text" class="form-control" style="width:240px;" placeholder="Search title or excerpt…">
40
+ <button id="filter-reset" class="btn btn-ghost btn-sm">Reset</button>
18
41
  </div>
19
42
  </div>
20
43
  </div>