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 +1 -1
- package/src/ui/page-handler.js +7 -1
- package/src/ui/report-renderer.js +56 -0
package/package.json
CHANGED
package/src/ui/page-handler.js
CHANGED
|
@@ -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' };
|