thatcher 1.0.56 → 1.0.57
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.
- package/package.json +1 -1
- package/src/ui/page-handler.js +11 -0
- package/src/ui/report-renderer.js +113 -0
package/package.json
CHANGED
package/src/ui/page-handler.js
CHANGED
|
@@ -9,6 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
|
|
|
9
9
|
import { renderBoardView } from '@/ui/board-view-renderer.js';
|
|
10
10
|
import { renderGridView } from '@/ui/grid-view-renderer.js';
|
|
11
11
|
import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport } from '@/ui/report-renderer.js';
|
|
12
13
|
import { renderClientProgress } from '@/ui/client-progress-renderer.js';
|
|
13
14
|
import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
|
|
14
15
|
import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
|
|
@@ -235,6 +236,16 @@ export async function handlePage(pathname, req, res) {
|
|
|
235
236
|
res.setHeader('Content-Length', Buffer.byteLength(csv, 'utf-8'));
|
|
236
237
|
res.writeHead(200); res.end(csv); return 'HANDLED';
|
|
237
238
|
}
|
|
239
|
+
const report = params.get('report');
|
|
240
|
+
if (report === 'count-by-field') {
|
|
241
|
+
const field = params.get('field') || '';
|
|
242
|
+
return renderCountByFieldReport(user, entityName, spec, items, field);
|
|
243
|
+
}
|
|
244
|
+
if (report === 'count-over-time') {
|
|
245
|
+
const dateField = params.get('field') || '';
|
|
246
|
+
const granularity = params.get('granularity') || 'month';
|
|
247
|
+
return renderCountOverTimeReport(user, entityName, spec, items, dateField, granularity);
|
|
248
|
+
}
|
|
238
249
|
const view = params.get('view');
|
|
239
250
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
240
251
|
if (view === 'grid') return renderGridView(user, entityName, spec, items);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
import { emptyState } from '@/ui/format-helpers.js';
|
|
4
|
+
import { getEntityLabel } from '@/config/spec-helpers.js';
|
|
5
|
+
|
|
6
|
+
const MAX_BUCKETS = 15;
|
|
7
|
+
|
|
8
|
+
function bucketLabel(spec, field, rawValue) {
|
|
9
|
+
if (rawValue === null || rawValue === undefined || rawValue === '') return '(empty)';
|
|
10
|
+
const fieldDef = spec.fields?.[field];
|
|
11
|
+
if (fieldDef?.type === 'enum' && Array.isArray(fieldDef.options)) {
|
|
12
|
+
const opt = fieldDef.options.find(o => String(o.value ?? o) === String(rawValue));
|
|
13
|
+
return opt ? String(opt.label || opt.value || opt) : String(rawValue);
|
|
14
|
+
}
|
|
15
|
+
return String(rawValue);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function countByField(records, spec, field) {
|
|
19
|
+
const counts = new Map();
|
|
20
|
+
for (const r of records) {
|
|
21
|
+
const label = bucketLabel(spec, field, r[field]);
|
|
22
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
25
|
+
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
26
|
+
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
27
|
+
const otherCount = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, c]) => sum + c, 0);
|
|
28
|
+
return [...top, ['(other)', otherCount]];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function barChart(buckets) {
|
|
32
|
+
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
33
|
+
const max = Math.max(...buckets.map(([, c]) => c), 1);
|
|
34
|
+
const rows = buckets.map(([label, count]) => {
|
|
35
|
+
const pct = Math.round((count / max) * 100);
|
|
36
|
+
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
37
|
+
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
38
|
+
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
39
|
+
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
40
|
+
</div>
|
|
41
|
+
<div style="min-width:40px;text-align:right;font-size:13px;font-weight:600">${count}</div>
|
|
42
|
+
</div>`;
|
|
43
|
+
}).join('');
|
|
44
|
+
return `<div class="report-bar-chart">${rows}</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bucketDateKey(dateValue, granularity) {
|
|
48
|
+
if (dateValue === null || dateValue === undefined || dateValue === '') return null;
|
|
49
|
+
const num = Number(dateValue);
|
|
50
|
+
const d = !isNaN(num) && num > 0 ? new Date(num * 1000) : new Date(dateValue);
|
|
51
|
+
if (isNaN(d.getTime())) return null;
|
|
52
|
+
const y = d.getFullYear();
|
|
53
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
54
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
55
|
+
if (granularity === 'day') return `${y}-${m}-${day}`;
|
|
56
|
+
if (granularity === 'week') {
|
|
57
|
+
const onejan = new Date(y, 0, 1);
|
|
58
|
+
const week = Math.ceil((((d - onejan) / 86400000) + onejan.getDay() + 1) / 7);
|
|
59
|
+
return `${y}-W${String(week).padStart(2, '0')}`;
|
|
60
|
+
}
|
|
61
|
+
return `${y}-${m}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function countOverTime(records, dateField, granularity = 'month') {
|
|
65
|
+
const counts = new Map();
|
|
66
|
+
let unresolved = 0;
|
|
67
|
+
for (const r of records) {
|
|
68
|
+
const key = bucketDateKey(r[dateField], granularity);
|
|
69
|
+
if (key === null) { unresolved++; continue; }
|
|
70
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
const sorted = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
73
|
+
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(-MAX_BUCKETS) : sorted, unresolved, truncated: sorted.length > MAX_BUCKETS };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderCountByFieldReport(user, entityName, spec, records, field) {
|
|
77
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
78
|
+
const fieldDef = spec.fields?.[field];
|
|
79
|
+
if (!fieldDef) {
|
|
80
|
+
return page(user, `${label} | Report`, null,
|
|
81
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
82
|
+
<div class="report-empty-state">Unknown field "${esc(field)}" for this entity.</div>`);
|
|
83
|
+
}
|
|
84
|
+
const buckets = countByField(records, spec, field);
|
|
85
|
+
const content = `<div class="page-header">
|
|
86
|
+
<div><h1 class="page-title">${esc(label)} by ${esc(fieldDef.label || field)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
87
|
+
</div>
|
|
88
|
+
${barChart(buckets)}`;
|
|
89
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
|
|
93
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
94
|
+
const fieldDef = spec.fields?.[dateField];
|
|
95
|
+
if (!fieldDef) {
|
|
96
|
+
return page(user, `${label} | Report`, null,
|
|
97
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
98
|
+
<div class="report-empty-state">Unknown date field "${esc(dateField)}" for this entity.</div>`);
|
|
99
|
+
}
|
|
100
|
+
const { buckets, unresolved, truncated } = countOverTime(records, dateField, granularity);
|
|
101
|
+
const notice = unresolved
|
|
102
|
+
? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
|
|
103
|
+
: '';
|
|
104
|
+
const truncatedNotice = truncated
|
|
105
|
+
? `<div class="report-notice">Showing the most recent ${buckets.length} periods only</div>`
|
|
106
|
+
: '';
|
|
107
|
+
const content = `<div class="page-header">
|
|
108
|
+
<div><h1 class="page-title">${esc(label)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
|
|
109
|
+
</div>
|
|
110
|
+
${notice}${truncatedNotice}
|
|
111
|
+
${barChart(buckets)}`;
|
|
112
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
113
|
+
}
|