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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
+ }