thatcher 1.0.56 → 1.0.58
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 +33 -22
- 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';
|
|
@@ -27,13 +28,13 @@ function reqUrl(req) {
|
|
|
27
28
|
|
|
28
29
|
async function handleEngagementDetail(user, engId) {
|
|
29
30
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
30
|
-
const engagement = await get('engagement', engId);
|
|
31
|
+
const engagement = await get('engagement', engId, { user });
|
|
31
32
|
if (!engagement) return null;
|
|
32
33
|
let client = null; try { client = engagement.client_id ? await get('client', engagement.client_id) : null; } catch {}
|
|
33
|
-
let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }); } catch {}
|
|
34
|
+
let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }, { user }); } catch {}
|
|
34
35
|
let sections = [];
|
|
35
36
|
try {
|
|
36
|
-
sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' } });
|
|
37
|
+
sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' }, user });
|
|
37
38
|
} catch {}
|
|
38
39
|
let team = null; try { team = engagement.team_id ? await get('team', engagement.team_id) : null; } catch {}
|
|
39
40
|
let assignedUsers = [];
|
|
@@ -48,7 +49,7 @@ async function handleEngagementDetail(user, engId) {
|
|
|
48
49
|
}
|
|
49
50
|
async function handleEngagementList(user, req) {
|
|
50
51
|
if (!canList(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'list');
|
|
51
|
-
let engagements = await list('engagement', {});
|
|
52
|
+
let engagements = await list('engagement', {}, { user });
|
|
52
53
|
const clientMap = Object.fromEntries((await list('client', {})).map(c => [c.id, c.name]));
|
|
53
54
|
engagements = engagements.map(e => ({ ...e, client_name: clientMap[e.client_id] || e.client_name || '-' }));
|
|
54
55
|
const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
|
|
@@ -90,14 +91,14 @@ async function handleGenericEntityView(user, entityName, id) {
|
|
|
90
91
|
}
|
|
91
92
|
async function handleClientSubRoute(user, clientId, subRoute) {
|
|
92
93
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
93
|
-
const client = await get('client', clientId); if (!client) return null;
|
|
94
|
+
const client = await get('client', clientId, { user }); if (!client) return null;
|
|
94
95
|
if (isClientUser(user) && user.client_id && user.client_id !== clientId) return renderAccessDenied(user, 'client', 'view');
|
|
95
96
|
if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, await getClientDashboardStats(clientId));
|
|
96
97
|
if (subRoute === 'progress') {
|
|
97
|
-
let engagements = []; try { engagements = (await list('engagement', {})).filter(e => e.client_id === clientId); } catch {}
|
|
98
|
+
let engagements = []; try { engagements = (await list('engagement', {}, { user })).filter(e => e.client_id === clientId); } catch {}
|
|
98
99
|
const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
|
|
99
100
|
let rfiStats = { total: 0, responded: 0, overdue: 0 };
|
|
100
|
-
try { const allRfis = (await list('rfi', {})).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
|
|
101
|
+
try { const allRfis = (await list('rfi', {}, { user })).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
|
|
101
102
|
return renderClientProgress(user, client, engagements, rfiStats);
|
|
102
103
|
}
|
|
103
104
|
return null;
|
|
@@ -105,7 +106,7 @@ async function handleClientSubRoute(user, clientId, subRoute) {
|
|
|
105
106
|
async function handleGenericEntityEdit(user, entityName, id) {
|
|
106
107
|
const spec = getSpec(entityName); if (!spec) return null;
|
|
107
108
|
if (!canEdit(user, entityName)) return renderAccessDenied(user, entityName, 'edit');
|
|
108
|
-
const item = await get(entityName, id); if (!item) return null;
|
|
109
|
+
const item = await get(entityName, id, { user }); if (!item) return null;
|
|
109
110
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'edit');
|
|
110
111
|
const resolvedSpec = resolveEnumOptions(spec);
|
|
111
112
|
const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
|
|
@@ -130,7 +131,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
130
131
|
const user = await getUser();
|
|
131
132
|
if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
|
|
132
133
|
if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
|
|
133
|
-
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
134
|
+
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
134
135
|
if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
|
|
135
136
|
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
|
|
136
137
|
if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
|
|
@@ -140,7 +141,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
140
141
|
let myReviews = [], sharedReviews = [], recentActivity = [];
|
|
141
142
|
// OR across two columns: busybase eq() is single-column, so fetch + filter in JS.
|
|
142
143
|
try {
|
|
143
|
-
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
|
|
144
|
+
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' }, user });
|
|
144
145
|
myReviews = reviews.filter(r => r.created_by === user.id || r.assigned_to === user.id).slice(0, 100);
|
|
145
146
|
} catch {}
|
|
146
147
|
// Old JOIN collaborator -> two-step: collaborator rows for this user, then their reviews.
|
|
@@ -148,12 +149,12 @@ export async function handlePage(pathname, req, res) {
|
|
|
148
149
|
const collabs = await list('collaborator', { user_id: user.id });
|
|
149
150
|
const ids = new Set(collabs.map(c => c.review_id));
|
|
150
151
|
if (ids.size) {
|
|
151
|
-
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
|
|
152
|
+
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' }, user });
|
|
152
153
|
sharedReviews = reviews.filter(r => ids.has(r.id)).slice(0, 100);
|
|
153
154
|
}
|
|
154
155
|
} catch {}
|
|
155
156
|
try {
|
|
156
|
-
recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 50);
|
|
157
|
+
recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' }, user })).slice(0, 50);
|
|
157
158
|
} catch {}
|
|
158
159
|
const all = [...myReviews, ...sharedReviews];
|
|
159
160
|
const stats = { myReviews, sharedReviews, recentActivity, totalReviews: all.length, activeReviews: all.filter(r => (r.status||'open') !== 'archived' && (r.status||'open') !== 'completed' && (r.status||'open') !== 'closed').length, flaggedReviews: all.filter(r => r.flagged).length, overdueReviews: 0 };
|
|
@@ -178,11 +179,11 @@ export async function handlePage(pathname, req, res) {
|
|
|
178
179
|
let candidates = []; let reviewMap = {};
|
|
179
180
|
try {
|
|
180
181
|
// Old: highlights with a non-trivial comment (LIKE '%?' OR length>40), newest 50.
|
|
181
|
-
const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' } });
|
|
182
|
+
const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' }, user });
|
|
182
183
|
candidates = all.filter(c => c.comment && (c.comment.includes('?') || c.comment.length > 40)).slice(0, 50);
|
|
183
184
|
const revIds = [...new Set(candidates.map((c) => c.review_id))];
|
|
184
185
|
if (revIds.length) {
|
|
185
|
-
const reviews = await list('review', {});
|
|
186
|
+
const reviews = await list('review', {}, { user });
|
|
186
187
|
const byId = new Map(reviews.map(r => [r.id, r.name || '-']));
|
|
187
188
|
reviewMap = Object.fromEntries(revIds.map(id => [id, byId.get(id) || '-']));
|
|
188
189
|
}
|
|
@@ -193,19 +194,19 @@ export async function handlePage(pathname, req, res) {
|
|
|
193
194
|
if (segments.length === 2 && (segments[0] === 'engagements' || segments[0] === 'engagement') && segments[1] !== 'new') return handleEngagementDetail(user, segments[1]);
|
|
194
195
|
if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'letter') {
|
|
195
196
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
196
|
-
const engagement = await get('engagement', segments[1]); if (!engagement) return null;
|
|
197
|
+
const engagement = await get('engagement', segments[1], { user }); if (!engagement) return null;
|
|
197
198
|
return renderLetterWorkflow(user, engagement);
|
|
198
199
|
}
|
|
199
200
|
if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'report') {
|
|
200
201
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
201
|
-
const engId = segments[1]; const engagement = await get('engagement', engId); if (!engagement) return null;
|
|
202
|
+
const engId = segments[1]; const engagement = await get('engagement', engId, { user }); if (!engagement) return null;
|
|
202
203
|
let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
|
|
203
204
|
try{client=engagement.client_id?await get('client',engagement.client_id):null}catch{}
|
|
204
205
|
try{team=engagement.team_id?await get('team',engagement.team_id):null}catch{}
|
|
205
|
-
try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
|
|
206
|
-
try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
|
|
207
|
-
try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
|
|
208
|
-
try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'}})).slice(0,20)}catch{}
|
|
206
|
+
try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'},user})}catch{}
|
|
207
|
+
try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'},user})}catch{}
|
|
208
|
+
try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{},{user});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
|
|
209
|
+
try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'},user})).slice(0,20)}catch{}
|
|
209
210
|
const{renderFlexupReport}=await lazyRenderer('flexup-report-renderer.js');
|
|
210
211
|
return renderFlexupReport(user,engagement,client,rfis,reviews,highlights,activity,team);
|
|
211
212
|
}
|
|
@@ -214,7 +215,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
214
215
|
if (segments.length === 1 && segments[0] === 'rfi') return handleRfiList(user);
|
|
215
216
|
if (segments.length === 1 && segments[0] === 'client') {
|
|
216
217
|
if (!canList(user, 'client')) return renderAccessDenied(user, 'client', 'list');
|
|
217
|
-
let clients = await list('client', {});
|
|
218
|
+
let clients = await list('client', {}, { user });
|
|
218
219
|
if (isClientUser(user) && user.client_id) clients = clients.filter(c => c.id === user.client_id);
|
|
219
220
|
return renderClientList(user, clients);
|
|
220
221
|
}
|
|
@@ -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);
|
|
@@ -250,7 +261,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
250
261
|
if (segments.length === 2 && segments[0] === 'client' && segments[1] !== 'new') {
|
|
251
262
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
252
263
|
if (isClientUser(user) && user.client_id && user.client_id !== segments[1]) return renderAccessDenied(user, 'client', 'view');
|
|
253
|
-
const client = await get('client', segments[1]); if (!client) return null;
|
|
264
|
+
const client = await get('client', segments[1], { user }); if (!client) return null;
|
|
254
265
|
return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
|
|
255
266
|
}
|
|
256
267
|
if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
|
|
@@ -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
|
+
}
|