0014 — Dashboard stats library + KPI snapshot Lambda
Renamed KPI → Metric on 2026-04-30 (PR-followup to PR #525). This ADR's body preserves the original wording from when KPI was the canonical term. Code, data, AWS resources, and active docs all use "metric" now; this file is kept verbatim as historical record. See
docs/planning/kpi-to-metric-rename.md.
Context
ADR-0012 carved out a narrow exception for Vercel Cron: the KPI snapshot job
lived in the Next.js app so it could reuse the ~700 lines of computation
inside /api/dashboard/stats/route.ts without duplication or HTTP self-call
from a Lambda. The tradeoff was "one more place to check when listing
scheduled jobs."
A follow-up (tracked as A.3 in docs/planning/dashboard-stats-extraction.md)
did the refactor that made the carve-out unnecessary: the computation moved
into a standalone library that any caller — HTTP route, cron, Lambda,
future reports — can import directly.
With the library in place, there is no longer a reason for the cron to live on Vercel. The job moves to Lambda like every other scheduled job.
Decision
Three linked decisions, shipped together:
| Location | Consumed by | |
|---|---|---|
| Dashboard stats library | src/lib/dashboard-stats/ |
HTTP route, KPI Lambda, tests, future reports/exports |
| Report-data library | src/lib/dashboard-stats/report-data.ts |
HTTP route, KPI Lambda |
| KPI snapshot Lambda | lambda/kpi-snapshot/ |
EventBridge cron(0 4 * * ? *) |
ADR-0003 applies in full again. Vercel Cron is not used. Every scheduled job runs on Lambda + EventBridge.
Consequences
Easier
- One place to find all scheduled jobs:
aws scheduler list-schedules(per ADR-0003). - One alerting story: CloudWatch alarms on Lambda errors/timeouts.
- Any future caller — weekly email digest, CSV export, Clara context,
mobile API — imports the same
computeDashboardStats()function instead of HTTP-calling the dashboard route. - Unit tests on the computation layer (NOI, delinquency, evictions, self-serve rate) lock the math as a spec. When real data flows later, these catch regressions dev fixtures won't.
Harder
- Adding new computation now requires choosing: does it belong on the HTTP
response type (
DashboardStats) or in the library only? Default: library only, lift to response type only when the dashboard UI needs it. - Lambda bundling:
lambda/kpi-snapshot/build.tsskips theclara-fallbackplugin because it needs the fullsrc/lib/dataexports (getLatestBalancePerTenant,getFinancialPeriods,getOperationalSignals) that the Clara-trimmed module doesn't have. This divergence is documented in the build file.
Follow-up
- Delete ADR-0012 from the Accepted list — it's superseded, kept only as historical record.
- When adding a new scheduled job, default to Lambda. If someone proposes Vercel Cron again, point them at this ADR and ADR-0003.
Alternatives considered
Keep Vercel Cron for KPI snapshot (ADR-0012's position). Rejected once A.3 landed — the only argument for it was "don't duplicate the computation into a Lambda bundle." With the library, the bundle is trivial (~30 lines of handler), so the argument disappears.
Lambda self-calls HTTP to the Vercel endpoint. Rejected. Same pattern ADR-0003 rejected for listings-sync: adds a Lambda that does nothing but trigger Vercel.
Skip the library extraction and leave stats computation inline. Rejected per A.3 — multiple future callers (reports, exports, Clara context, mobile API) will want direct access to the same computation. Extracting once amortizes across all of them.
File index (implementation)
src/lib/dashboard-stats/compute.ts—computeDashboardStats(), plus pure helpers (computeTrend,computeActiveEvictions,parseDollarRefs).src/lib/dashboard-stats/financial-summary.ts—buildFinancialSummary()+ expense category color maps.src/lib/dashboard-stats/report-data.ts—computeReportDataSummary().src/app/api/dashboard/stats/route.ts— thin HTTP wrapper (~30 lines).src/app/api/report-data/route.ts— thin HTTP wrapper.src/app/api/cron/snapshot-kpis/route.ts— retained for manual trigger + backfill from staging. Not on any schedule.lambda/kpi-snapshot/{handler,build,deploy}.ts— scheduled daily at 04:00 UTC.src/__tests__/dashboard-stats-lib.test.ts— 23 unit tests locking the math.src/__tests__/dashboard-stats.test.ts— 73 existing integration tests, unchanged, still green.vercel.json—cronsblock removed.