thatcher 1.0.90 → 1.0.91

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.91",
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,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') || '';
@@ -329,6 +329,62 @@ export function renderPivotReport(user, entityName, spec, records, rowField, col
329
329
  return page(user, `${label} Report | Thatcher`, null, content);
330
330
  }
331
331
 
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
+
332
388
  export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
333
389
  const label = getEntityLabel(spec, true) || 'Opportunities';
334
390
  const valueFieldDef = spec.fields?.value || { type: 'currency' };