thatcher 1.0.90 → 1.0.92

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.90",
3
+ "version": "1.0.92",
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",
@@ -33,6 +33,30 @@ function weightedValueOf(opportunity) {
33
33
  return (value * probability) / 100;
34
34
  }
35
35
 
36
+ // The historical mirror of projectDemandByMonth: past months' ACTUAL won
37
+ // value, not future open-pipeline projection. This is the time series a
38
+ // trend prediction (statistical-forecast.js's linearRegression) needs --
39
+ // projectDemandByMonth's forward-looking open-pipeline sum is the wrong
40
+ // input for "predict next period from history" since it contains no past
41
+ // data points to fit a trend against.
42
+ export function historicalWonValueByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000), monthsBack = 6) {
43
+ const currentBucketKey = monthBucketKey(nowSeconds);
44
+ const buckets = new Map();
45
+
46
+ for (const opp of opportunities) {
47
+ if (opp.stage !== 'won') continue;
48
+ const closeDate = opp.actual_close_date ?? opp.expected_close_date;
49
+ if (closeDate == null) continue;
50
+ const bucketKey = monthBucketKey(Number(closeDate));
51
+ if (bucketKey > currentBucketKey) continue;
52
+ const value = Number(opp.value) || 0;
53
+ buckets.set(bucketKey, (buckets.get(bucketKey) || 0) + value);
54
+ }
55
+
56
+ const sorted = [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0]));
57
+ return sorted.length > monthsBack ? sorted.slice(-monthsBack) : sorted;
58
+ }
59
+
36
60
  export function projectDemandByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000)) {
37
61
  const currentBucketKey = monthBucketKey(nowSeconds);
38
62
  const buckets = new Map();
@@ -7,6 +7,7 @@
7
7
  import { daysUntilStockout, isReorderDue } from './inventory-forecast.js';
8
8
  import { daysUntilExpiry } from './contract-expiry.js';
9
9
  import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
10
+ import { zScoreAnomaly } from './statistical-forecast.js';
10
11
 
11
12
  const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
12
13
 
@@ -87,6 +88,36 @@ export function pipelineDropInsights(monthlyBuckets) {
87
88
  return results;
88
89
  }
89
90
 
91
+ // Statistical, not rule-based: flags a product whose most recent day's
92
+ // consumption is a z-score outlier relative to ITS OWN historical daily
93
+ // consumption -- the same absolute consumption number can be perfectly
94
+ // normal for a high-variance product and anomalous for a low-variance one,
95
+ // which stockoutRiskInsights' fixed day-threshold cannot express since it
96
+ // only reasons about days-until-stockout, never about whether today's rate
97
+ // itself is unusual for this specific product.
98
+ export function statisticalAnomalyInsights(products, movementsByProductId) {
99
+ const results = [];
100
+ for (const product of products) {
101
+ const movements = movementsByProductId[product.id] || [];
102
+ const byDay = new Map();
103
+ for (const m of movements) {
104
+ if (Number(m.quantity) >= 0) continue;
105
+ const day = new Date((Number(m.created_at) || 0) * 1000).toISOString().slice(0, 10);
106
+ byDay.set(day, (byDay.get(day) || 0) + Math.abs(Number(m.quantity) || 0));
107
+ }
108
+ const days = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]));
109
+ if (days.length < 3) continue;
110
+ const latestValue = days[days.length - 1][1];
111
+ const history = days.slice(0, -1).map(([, v]) => v);
112
+ const { isAnomaly, zScore } = zScoreAnomaly(history, latestValue, 2);
113
+ if (isAnomaly) {
114
+ const direction = latestValue > (history.reduce((a, b) => a + b, 0) / history.length) ? 'above' : 'below';
115
+ results.push(insight('warning', 'anomaly', `${product.name || product.id}'s latest daily consumption is statistically ${direction} its historical pattern (z-score ${zScore.toFixed(2)})`, 'product', product.id));
116
+ }
117
+ }
118
+ return results;
119
+ }
120
+
90
121
  export function sortBySeverity(insights) {
91
122
  return [...insights].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3));
92
123
  }
@@ -109,6 +140,7 @@ export async function generateInsights(user, entityType = null) {
109
140
  movementsByProductId[product.id] = await list('stock_movement', { product_id: product.id });
110
141
  }
111
142
  results.push(...stockoutRiskInsights(products, movementsByProductId));
143
+ results.push(...statisticalAnomalyInsights(products, movementsByProductId));
112
144
  }
113
145
 
114
146
  if (!entityType || entityType === 'contract') {
@@ -0,0 +1,54 @@
1
+ // Data-derived predictive math, distinct from insights-engine.js's rule-based
2
+ // fixed-threshold checks (days<=14, pct>=90, dropPct>30) -- both functions
3
+ // here derive their parameters FROM the input series itself (slope/intercept,
4
+ // mean/stddev) rather than from a hardcoded constant, the standard
5
+ // lightweight statistical technique available without an ML training
6
+ // pipeline. Same small-pure-module shape as contract-expiry.js/
7
+ // inventory-forecast.js/resource-capacity.js.
8
+
9
+ // Least-squares fit over [x, y] points. x is typically a period index
10
+ // (0, 1, 2...), y the metric value at that period.
11
+ export function linearRegression(points) {
12
+ const n = points.length;
13
+ if (n < 2) return null;
14
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
15
+ for (const [x, y] of points) {
16
+ sumX += x; sumY += y; sumXY += x * y; sumXX += x * x;
17
+ }
18
+ const denominator = n * sumXX - sumX * sumX;
19
+ if (denominator === 0) return null;
20
+ const slope = (n * sumXY - sumX * sumY) / denominator;
21
+ const intercept = (sumY - slope * sumX) / n;
22
+ return { slope, intercept };
23
+ }
24
+
25
+ export function predictNext(points) {
26
+ const fit = linearRegression(points);
27
+ if (!fit) return null;
28
+ const nextX = Math.max(...points.map(([x]) => x)) + 1;
29
+ return fit.slope * nextX + fit.intercept;
30
+ }
31
+
32
+ function mean(values) {
33
+ return values.reduce((a, b) => a + b, 0) / values.length;
34
+ }
35
+
36
+ function stddev(values, avg) {
37
+ const variance = values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
38
+ return Math.sqrt(variance);
39
+ }
40
+
41
+ // Flags latestValue as anomalous relative to series' OWN historical mean/
42
+ // stddev -- a value that would be normal for a high-variance series and
43
+ // anomalous for a low-variance one, which a fixed threshold cannot express.
44
+ // stddev===0 (a perfectly flat history) means ANY deviation is infinitely
45
+ // many stddevs away, so any latestValue != mean is reported as anomalous
46
+ // with zScore=Infinity rather than dividing by zero into NaN.
47
+ export function zScoreAnomaly(series, latestValue, threshold = 2) {
48
+ if (series.length < 2) return { isAnomaly: false, zScore: null, mean: null, stddev: null };
49
+ const avg = mean(series);
50
+ const sd = stddev(series, avg);
51
+ if (sd === 0) return { isAnomaly: latestValue !== avg, zScore: latestValue === avg ? 0 : Infinity, mean: avg, stddev: 0 };
52
+ const zScore = (latestValue - avg) / sd;
53
+ return { isAnomaly: Math.abs(zScore) >= threshold, zScore, mean: avg, stddev: sd };
54
+ }
@@ -9,7 +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, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport } from '@/ui/report-renderer.js';
12
+ import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport, renderCohortOverTimeReport } from '@/ui/report-renderer.js';
13
13
  import { renderClientProgress } from '@/ui/client-progress-renderer.js';
14
14
  import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
15
15
  import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
@@ -377,6 +377,12 @@ export async function handlePage(pathname, req, res) {
377
377
  const agg = params.get('agg') || 'count';
378
378
  return renderPivotReport(user, entityName, spec, items, rowField, colField, valueField, agg);
379
379
  }
380
+ if (report === 'cohort-over-time') {
381
+ const dateField = params.get('date_field') || '';
382
+ const cohortField = params.get('cohort_field') || '';
383
+ const granularity = params.get('granularity') || 'month';
384
+ return renderCohortOverTimeReport(user, entityName, spec, items, dateField, cohortField, granularity);
385
+ }
380
386
  if (report === 'rollup') {
381
387
  const refField = params.get('ref_field') || '';
382
388
  const rollupField = params.get('rollup_field') || '';
@@ -415,9 +421,18 @@ export async function handlePage(pathname, req, res) {
415
421
  // entity route uses) -- no new unscoped query, and each item already
416
422
  // carries a computed weighted_value from list()'s own formula-field
417
423
  // pass, reused directly rather than recomputed.
418
- const { projectDemandByMonth } = await import('@/lib/demand-forecast.js');
424
+ const { projectDemandByMonth, historicalWonValueByMonth } = await import('@/lib/demand-forecast.js');
425
+ const { predictNext } = await import('@/lib/statistical-forecast.js');
419
426
  const buckets = projectDemandByMonth(items);
420
- return renderDemandForecastReport(user, spec, buckets, items.length);
427
+ // Trend prediction over PAST won-value, distinct from `buckets`'
428
+ // forward-looking open-pipeline sum -- reuses the same {user}-scoped
429
+ // `items` list, no new query. Fewer than 2 historical months means no
430
+ // regression can be fit (linearRegression's own null-return case).
431
+ const historical = historicalWonValueByMonth(items);
432
+ const predictedNextValue = historical.length >= 2
433
+ ? predictNext(historical.map(([, v], i) => [i, v]))
434
+ : null;
435
+ return renderDemandForecastReport(user, spec, buckets, items.length, predictedNextValue);
421
436
  }
422
437
  const view = params.get('view');
423
438
  if (view === 'board') return renderBoardView(user, entityName, spec, items);
@@ -329,17 +329,82 @@ export function renderPivotReport(user, entityName, spec, records, rowField, col
329
329
  return page(user, `${label} Report | Thatcher`, null, content);
330
330
  }
331
331
 
332
- export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
332
+ // Trend-by-cohort: how a metric evolves over time (rows = date periods),
333
+ // broken down by a cohort dimension (cols = cohort values) -- a genuinely
334
+ // distinct shape from pivotByFields' point-in-time cross-tab and
335
+ // countOverTime's ungrouped time series. Reuses pivotByFields directly
336
+ // rather than a parallel implementation: a synthetic '__period' field is
337
+ // pre-computed onto cloned records via the SAME bucketDateKey granularity
338
+ // logic countOverTime already uses, and a matching plain-text spec.fields
339
+ // entry is added so bucketLabel's enum-lookup branch is skipped (falls
340
+ // through to String(rawValue), exactly what a period string needs) -- then
341
+ // it's an ordinary two-field pivot. ISO-formatted period keys (YYYY-MM,
342
+ // YYYY-MM-DD, YYYY-Www) sort correctly under pivotByFields' existing
343
+ // lexical row sort, so no separate chronological sort is needed.
344
+ export function cohortOverTime(records, spec, dateField, cohortField, granularity = 'month') {
345
+ const periodField = '__period';
346
+ const augmentedRecords = [];
347
+ let unresolved = 0;
348
+ for (const r of records) {
349
+ const key = bucketDateKey(r[dateField], granularity);
350
+ if (key === null) { unresolved++; continue; }
351
+ augmentedRecords.push({ ...r, [periodField]: key });
352
+ }
353
+ const augmentedSpec = { ...spec, fields: { ...spec.fields, [periodField]: { type: 'text', label: 'Period' } } };
354
+ const { rows, cols, cells } = pivotByFields(augmentedRecords, augmentedSpec, periodField, cohortField, null, 'count');
355
+ return { rows, cols, cells, unresolved };
356
+ }
357
+
358
+ export function renderCohortOverTimeReport(user, entityName, spec, records, dateField, cohortField, granularity) {
359
+ const label = getEntityLabel(spec, true) || entityName;
360
+ const dateFieldDef = spec.fields?.[dateField];
361
+ const cohortFieldDef = spec.fields?.[cohortField];
362
+ if (!dateFieldDef || !cohortFieldDef) {
363
+ const badField = !dateFieldDef ? dateField : cohortField;
364
+ return page(user, `${label} | Report`, null,
365
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
366
+ <div class="report-empty-state">Unknown field "${esc(badField)}" for this entity.</div>`);
367
+ }
368
+ const { rows, cols, cells, unresolved } = cohortOverTime(records, spec, dateField, cohortField, granularity);
369
+ const notice = unresolved
370
+ ? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
371
+ : '';
372
+ const headerCells = cols.map(c => `<th>${esc(c)}</th>`).join('');
373
+ const bodyRows = rows.map((r, i) => {
374
+ const dataCells = cells[i].map(v => `<td style="text-align:right">${esc(String(v))}</td>`).join('');
375
+ return `<tr><td>${esc(r)}</td>${dataCells}</tr>`;
376
+ }).join('') || emptyState('No data to report on', 'bar-chart');
377
+ const content = `<div class="page-header">
378
+ <div><h1 class="page-title">${esc(label)}: ${esc(cohortFieldDef.label || cohortField)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
379
+ </div>
380
+ ${notice}
381
+ <div class="table-wrap"><table class="data-table">
382
+ <thead><tr><th>Period</th>${headerCells}</tr></thead>
383
+ <tbody>${bodyRows}</tbody>
384
+ </table></div>`;
385
+ return page(user, `${label} Report | Thatcher`, null, content);
386
+ }
387
+
388
+ // predictedNextValue (optional) is a statistical trend prediction --
389
+ // distinct from `buckets`' own totalProjected sum, which is a sum of
390
+ // already-weighted open pipeline, not a prediction fitted from historical
391
+ // won-value data. null/undefined renders no trend line, same page shape as
392
+ // before this parameter existed.
393
+ export function renderDemandForecastReport(user, spec, buckets, totalOpportunities, predictedNextValue = null) {
333
394
  const label = getEntityLabel(spec, true) || 'Opportunities';
334
395
  const valueFieldDef = spec.fields?.value || { type: 'currency' };
335
396
  const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
336
397
  const notice = !buckets.length
337
398
  ? `<div class="report-notice">No open opportunities with a future expected close date</div>`
338
399
  : '';
400
+ const trendNotice = predictedNextValue != null
401
+ ? `<div class="report-notice">Statistical trend prediction (linear regression over historical won value): next month ~${esc(formatSumValue(predictedNextValue, valueFieldDef))}</div>`
402
+ : '';
339
403
  const content = `<div class="page-header">
340
404
  <div><h1 class="page-title">${esc(label)}: Demand Forecast</h1><p class="page-subtitle">${totalOpportunities} total opportunities, ${esc(formatSumValue(totalProjected, valueFieldDef))} projected across ${buckets.length} future month${buckets.length === 1 ? '' : 's'}</p></div>
341
405
  </div>
342
406
  ${notice}
407
+ ${trendNotice}
343
408
  ${sumBarChart(buckets, valueFieldDef)}`;
344
409
  return page(user, `Demand Forecast | Thatcher`, null, content);
345
410
  }