thatcher 1.0.87 → 1.0.88

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.87",
3
+ "version": "1.0.88",
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",
@@ -0,0 +1,133 @@
1
+ // A synthesis layer over data already computed this session -- reads the
2
+ // outputs of inventory-forecast.js/demand-forecast.js/resource-capacity.js/
3
+ // contract-expiry.js, never reimplements their math. Deliberately rule-based
4
+ // rather than an external LLM call: no API key, no network dependency, no
5
+ // cost, and every rule is deterministic and directly witnessable via
6
+ // exec_js the same way every other feature this session has been.
7
+ import { daysUntilStockout, isReorderDue } from './inventory-forecast.js';
8
+ import { daysUntilExpiry } from './contract-expiry.js';
9
+ import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
10
+
11
+ const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
12
+
13
+ function insight(severity, category, message, entity, entityId) {
14
+ return { severity, category, message, entity, entity_id: entityId };
15
+ }
16
+
17
+ // Product X will stock out in N days with no reorder currently scheduled --
18
+ // "no reorder scheduled" here means the reorder isn't yet due (isReorderDue
19
+ // false) but stockout is imminent, distinct from the existing product
20
+ // detail-view warning which only flags the reorder-due boundary itself.
21
+ export function stockoutRiskInsights(products, movementsByProductId) {
22
+ const results = [];
23
+ for (const product of products) {
24
+ const movements = movementsByProductId[product.id] || [];
25
+ const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
26
+ const avgDailyConsumption = movements
27
+ .filter(m => Number(m.quantity) < 0)
28
+ .reduce((sum, m) => sum + Math.abs(Number(m.quantity) || 0), 0) / 90;
29
+ const days = daysUntilStockout(currentStock, avgDailyConsumption);
30
+ if (days == null) continue;
31
+ if (days <= 14) {
32
+ const severity = days <= 3 ? 'critical' : 'warning';
33
+ results.push(insight(severity, 'inventory', `${product.name || product.id} will stock out in ${Math.round(days)} days`, 'product', product.id));
34
+ }
35
+ }
36
+ return results;
37
+ }
38
+
39
+ // Contract Y expires in N days with no renewal opportunity linked --
40
+ // cross-references contract.opportunity_id, since a contract already headed
41
+ // toward renewal via a tracked deal is a materially different situation
42
+ // from one with nothing lined up.
43
+ export function contractExpiryInsights(contracts) {
44
+ const results = [];
45
+ const nowSeconds = Math.floor(Date.now() / 1000);
46
+ for (const contract of contracts) {
47
+ if (contract.status !== 'active' || contract.end_date == null) continue;
48
+ const days = daysUntilExpiry(contract.end_date, nowSeconds);
49
+ if (days > (contract.notice_period_days ?? 30)) continue;
50
+ if (contract.opportunity_id) continue;
51
+ const severity = days <= 0 ? 'critical' : 'warning';
52
+ results.push(insight(severity, 'contract', `${contract.name || contract.id} expires in ${days} days with no renewal opportunity linked`, 'contract', contract.id));
53
+ }
54
+ return results;
55
+ }
56
+
57
+ // User Z is at N% capacity across active allocations -- a straightforward
58
+ // utilization-threshold rule reusing the exact load calculation
59
+ // resource-optimizer.js's suggestion ranking already relies on.
60
+ export async function capacityInsights(users, windowStart, windowEnd) {
61
+ const results = [];
62
+ for (const user of users) {
63
+ const committed = await userCommittedHours(user.id, windowStart, windowEnd);
64
+ const pct = Math.round((committed / DEFAULT_WEEKLY_CAPACITY_HOURS) * 100);
65
+ if (pct >= 90) {
66
+ const severity = pct >= 100 ? 'critical' : 'warning';
67
+ results.push(insight(severity, 'resource', `${user.name || user.id} is at ${pct}% capacity`, 'user', user.id));
68
+ }
69
+ }
70
+ return results;
71
+ }
72
+
73
+ // Opportunity pipeline for month N shows a >30% drop from month N-1 --
74
+ // compares adjacent buckets from demand-forecast.js's own projectDemandByMonth
75
+ // output, never recomputing the bucket sums itself.
76
+ export function pipelineDropInsights(monthlyBuckets) {
77
+ const results = [];
78
+ for (let i = 1; i < monthlyBuckets.length; i++) {
79
+ const [prevKey, prevValue] = monthlyBuckets[i - 1];
80
+ const [curKey, curValue] = monthlyBuckets[i];
81
+ if (prevValue <= 0) continue;
82
+ const dropPct = ((prevValue - curValue) / prevValue) * 100;
83
+ if (dropPct > 30) {
84
+ results.push(insight('warning', 'pipeline', `Projected pipeline for ${curKey} is ${Math.round(dropPct)}% lower than ${prevKey}`, 'opportunity', curKey));
85
+ }
86
+ }
87
+ return results;
88
+ }
89
+
90
+ export function sortBySeverity(insights) {
91
+ return [...insights].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3));
92
+ }
93
+
94
+ // Orchestrator: every list() call here passes {user}, the SAME row/org-access
95
+ // scoping every entity route in the codebase already applies -- an insight
96
+ // can never surface a record the requesting user couldn't otherwise see,
97
+ // because it is generated from the identical scoped query that record's own
98
+ // list view would use. entityType optionally narrows which rule categories
99
+ // run (e.g. 'product' -> only stockout insights), skipping the rest rather
100
+ // than fetching data for rules that won't run.
101
+ export async function generateInsights(user, entityType = null) {
102
+ const { list } = await import('./busybase/store.js');
103
+ const results = [];
104
+
105
+ if (!entityType || entityType === 'product') {
106
+ const products = await list('product', {}, { user });
107
+ const movementsByProductId = {};
108
+ for (const product of products) {
109
+ movementsByProductId[product.id] = await list('stock_movement', { product_id: product.id });
110
+ }
111
+ results.push(...stockoutRiskInsights(products, movementsByProductId));
112
+ }
113
+
114
+ if (!entityType || entityType === 'contract') {
115
+ const contracts = await list('contract', {}, { user });
116
+ results.push(...contractExpiryInsights(contracts));
117
+ }
118
+
119
+ if (!entityType || entityType === 'user' || entityType === 'resource_allocation') {
120
+ const users = await list('user', {}, { user });
121
+ const nowSeconds = Math.floor(Date.now() / 1000);
122
+ results.push(...(await capacityInsights(users, nowSeconds, nowSeconds + 30 * 86400)));
123
+ }
124
+
125
+ if (!entityType || entityType === 'opportunity') {
126
+ const { projectDemandByMonth } = await import('./demand-forecast.js');
127
+ const opportunities = await list('opportunity', {}, { user });
128
+ const buckets = projectDemandByMonth(opportunities);
129
+ results.push(...pipelineDropInsights(buckets));
130
+ }
131
+
132
+ return sortBySeverity(results);
133
+ }
@@ -0,0 +1,47 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc, emptyRow } from '@/ui/render-helpers.js';
3
+
4
+ const SEVERITY_PILL = { critical: 'pill-danger', warning: 'pill-warning', info: 'pill-neutral' };
5
+ const ENTITY_ROUTE = { product: 'product', contract: 'contract', user: 'user', resource_allocation: 'resource_allocation' };
6
+
7
+ function insightRow(insight) {
8
+ const pillCls = SEVERITY_PILL[insight.severity] || 'pill-neutral';
9
+ const entityPath = ENTITY_ROUTE[insight.entity];
10
+ // opportunity-pipeline insights carry a month-string entity_id (e.g.
11
+ // '2026-09'), not a real record id -- there is no single record to link
12
+ // to for a whole-pipeline observation, so only entities with a real
13
+ // per-record id get a link.
14
+ const link = entityPath && insight.entity !== 'opportunity'
15
+ ? `<a href="/${esc(entityPath)}/${esc(insight.entity_id)}" class="text-primary hover:underline">View</a>`
16
+ : '';
17
+ return `<tr>
18
+ <td><span class="pill ${pillCls}">${esc(insight.severity)}</span></td>
19
+ <td>${esc(insight.category)}</td>
20
+ <td>${esc(insight.message)}</td>
21
+ <td>${link}</td>
22
+ </tr>`;
23
+ }
24
+
25
+ export function renderInsightsPage(user, insights = []) {
26
+ const rows = insights.map(insightRow).join('') || emptyRow(4, 'No insights right now');
27
+ const counts = insights.reduce((acc, i) => { acc[i.severity] = (acc[i.severity] || 0) + 1; return acc; }, {});
28
+
29
+ const content = `
30
+ <div class="page-header">
31
+ <h1 class="page-title">Insights</h1>
32
+ <p class="page-subtitle">${esc(String(counts.critical || 0))} critical, ${esc(String(counts.warning || 0))} warning, ${esc(String(counts.info || 0))} info</p>
33
+ </div>
34
+ <div class="card-clean">
35
+ <div class="card-clean-body">
36
+ <div class="table-wrap">
37
+ <table class="data-table">
38
+ <thead><tr><th>Severity</th><th>Category</th><th>Insight</th><th>Link</th></tr></thead>
39
+ <tbody>${rows}</tbody>
40
+ </table>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ `;
45
+
46
+ return page(user, 'Insights | Thatcher', null, content, []);
47
+ }
@@ -241,6 +241,16 @@ export async function handlePage(pathname, req, res) {
241
241
  if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
242
242
  if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
243
243
  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); }
244
+ if (normalized === '/insights') {
245
+ // generateInsights internally scopes every underlying list() call to
246
+ // {user} -- no separate access check needed here, the insights
247
+ // themselves can never surface a record this user couldn't already see.
248
+ const { generateInsights } = await import('@/lib/insights-engine.js');
249
+ let insights = [];
250
+ try { insights = await generateInsights(user); } catch {}
251
+ const { renderInsightsPage } = await lazyRenderer('insights-renderer.js');
252
+ return renderInsightsPage(user, insights);
253
+ }
244
254
  if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
245
255
  if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user, req);
246
256
  if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);