csm-portfolio-snapshotrun.md00_intake/stub.mdThe ActivationStatusTable shows changeVsYesterday (positive = more accounts at risk than yesterday) —
a day-over-day delta that is impossible to compute from live state alone: there is no record of what the
portfolio looked like yesterday. Without a stored history the trend column can only ever be mock.
csm-activation-rollup (at-risk count, RAG counts, total accounts, go-lives-next-7).changeVsYesterday as today's at-risk count minus the latest prior snapshot, and wire it
into the activation table.changeVsYesterday is the real delta of at-risk accounts vs the previous snapshot.touches: packages/services/src/db/models (new snapshot model),
packages/services/src/server (snapshot write + delta read),
apps/web/app/api or a cron route (daily job),
apps/web/components/dashboard/csm/activation-status-table.tsx (delta column).01_define/output/spec.mdThe CSM activation-status table shows a changeVsYesterday column (positive = more accounts at risk
than yesterday) for each CSM/region row, but it is impossible to compute from live state alone: nothing
records what the portfolio looked like yesterday, so the trend can only ever be mock. csm-activation-rollup
(feature 2) deliberately leaves changeVsYesterday absent/zero with a clean seam for exactly this work.
This is sequence 3 of 5 in the csm-dashboard-data epic and the piece that makes the activation table's
day-over-day signal real. It advances Build the Bridge / Q2 2026 Objective 3 — Validate Technical
Infrastructure & Payout Flow by proving the platform can persist and resolve a real per-tenant,
per-CSM time series (the first scheduled job in the repo) rather than fabricating a trend.
CsmPortfolioSnapshot model that persists one row per tenant × CSM × date — the same
grain the activation table keys on (the merged csm-activation-rollup emits one row per CSM, with
region the CSM's predominant/mode region), so each snapshot lines up 1:1 with a displayed row. Each
row stores the portfolio rollup metrics from csm-activation-rollup: atRiskNow, RAG counts
(green/amber/red), totalAccounts, and goLivesNext7Days, plus region as a display attribute.
The row is keyed by a tenant-local calendar date (snapshotDate, a yyyy-mm-dd day bucket),
tenant-isolated like every other model. A unique index on { tenantId, csm, snapshotDate } enforces
one snapshot per CSM per day.apps/web/vercel.json → crons, a fixed daily UTC time) hitting a new
protected route apps/web/app/api/cron/csm-portfolio-snapshot/route.ts. The route authenticates the
Vercel cron caller via a CRON_SECRET bearer check (no Clerk session), then iterates every tenant and,
per tenant, every CSM (and their regions), reuses the csm-activation-rollup portfolio rollup to compute
today's metrics, and upserts one snapshot per row for the tenant-local date.snapshotDate is the calendar date in the tenant's
configured timezone (the existing timezone tenant setting, default Europe/Brussels) at fire time — so
"yesterday" means the tenant's local yesterday, not a UTC boundary that splits a working day.csm-portfolio service (@sustentus/services/server): a write method
the cron calls (idempotent upsert per row/day) and a delta read that, for a given CSM/region/today, returns
changeVsYesterday = today's atRiskNow minus the most recent prior snapshot's atRiskNow for that
row. "Most recent prior" (not literally yesterday) keeps the delta meaningful across any gap day.changeVsYesterday into the activation table. The activation-rollup output gains the real delta
from the snapshot read; the ActivationStatusTable renders it (its existing up/down/flat rendering is
unchanged). With no prior snapshot for a row (first-ever day, or a newly-owned CSM/region), the delta is
0 / no-change, never a misleading spike.CsmPortfolioSnapshot model persists one row per tenant × CSM × tenant-local date, holding
atRiskNow, RAG counts, totalAccounts, goLivesNext7Days, and the CSM's display region; a unique
index on { tenantId, csm, snapshotDate } allows exactly one snapshot per CSM per day.csm-activation-rollup rollup the table displays.CRON_SECRET bearer credential and is never reachable
via a normal Clerk-authenticated user session.changeVsYesterday on the activation table is the real delta of atRiskNow for that CSM/region row
versus its most recent prior snapshot — no mock value.0),
not a spike.snapshotDate is the tenant-local calendar date (from the tenant timezone setting, default
Europe/Brussels), so the day boundary follows the tenant, not UTC.timezone setting governs the whole
tenant's day boundary this round.csm-activation-rollup's portfolio
rollup (which reads customer-dashboard-data/project-health-engine); it does not fork either.csm-portfolio-foundation,
customer-dashboard-data/project-health-engine, and csm-activation-rollup — has merged to main.
This feature reuses csmPortfolioService's rollup as the canonical source of each CSM's metrics rather
than re-deriving the heuristic; the snapshot stores exactly what that rollup produces, and the rollup's
changeVsYesterday: 0 seam is filled from the snapshot.)02_build/output/notes.mdfeat: csm-portfolio-snapshot — daily snapshot model + cron + real changeVsYesterday deltapackages/services/src/db/models/csm-portfolio-snapshot.ts: new CsmPortfolioSnapshot model — one
row per tenant × CSM × snapshotDate (the tenant-local yyyy-mm-dd day bucket) holding the rollup
metrics (atRiskNow, rag, totalAccounts, goLivesNext7Days) plus the CSM's display region. A
unique index { tenantId, csm, snapshotDate } is the idempotency guarantee; a second
{ tenantId, csm, snapshotDate: -1 } index serves the "latest prior snapshot" delta read. Uses the
standard schemaPlugin/softDeletePlugin/tenantPlugin like every model. Exported from
db/models/index.ts.packages/services/src/db/services/csm-portfolio/index.ts:rollupGroups(tenantId, scope, csmUserId?)
shared by the activation read and the snapshot write (carries csmId + leadIds). getActivationRollup
is unchanged in behaviour except it now fills the real changeVsYesterday: today's atRiskNow
minus each CSM's most recent snapshot strictly before the tenant-local today (no prior snapshot → 0).latestPriorAtRiskByCsm(...): one aggregation returning, per CSM, the atRiskNow of the latest
snapshot before a given date.captureDailySnapshots(tenantId, snapshotDate?): idempotent upsert of one snapshot per CSM for the
tenant-local day (keyed { tenantId, csm, snapshotDate }).captureDailySnapshotsForAllTenants(): the cron entry — enumerates non-deleted tenants and captures
each on its own tenant-local day; a single bad tenant is logged and skipped.tenantLocalDate(timeZone) helper (Intl.DateTimeFormat("en-CA", { timeZone }) → YYYY-MM-DD)
so no new dependency is needed for timezone-aware day bucketing.packages/services/src/db/services/tenant-setting/index.ts: getTimezone(tenantId) — the tenant's IANA
timezone from the timezone setting, falling back to the registry default (Europe/Brussels).apps/web/app/api/cron/csm-portfolio-snapshot/route.ts: new GET cron route. Authenticates via
Authorization: Bearer <CRON_SECRET> (the header Vercel cron sends; a normal Clerk session never carries
it), then calls captureDailySnapshotsForAllTenants() and returns { ok, tenants, snapshots }.apps/web/vercel.json: new — registers the daily cron 0 2 * * * → /api/cron/csm-portfolio-snapshot.CsmPortfolioSnapshot persists one row per tenant × CSM × tenant-local date with atRiskNow, RAG,
totalAccounts, goLivesNext7Days, region; unique index { tenantId, csm, snapshotDate }.csmPortfolioService rollup (rollupGroups) the activation table displays.CRON_SECRET bearer; unreachable from a Clerk
session (which never sends that header).changeVsYesterday is the real delta of atRiskNow vs the CSM's most recent prior snapshot — the
rollup's 0 seam is now filled from the snapshot read.0) — latestPriorAtRiskByCsm omits them and the
caller defaults the delta to 0.captureDailySnapshots upserts keyed { tenantId, csm, snapshotDate }, so re-running a
day overwrites the same rows and the delta is unchanged.snapshotDate is the tenant-local calendar date via the tenant timezone setting (default
Europe/Brussels).tsc of the services package + the web app was run locally to catch obvious type errors before pushing.tenant × CSM × region key; the merged
csm-activation-rollup actually emits one row per CSM (region = the predominant/mode region). So the
snapshot keys on { tenantId, csm, snapshotDate } and stores region as a display attribute — keying on
the CSM is what makes the delta line up 1:1 with a displayed row and stays robust if a CSM's mode region
drifts day to day. The spec + PR acceptance criteria were reconciled to match.changeVsYesterday and the table already renders it;
filling the delta inside getActivationRollup lights up the column with no page/component edit.CRON_SECRET must be set as a Vercel env var for the cron to authorise
(without it the route returns 401 by design). The trend accumulates from first cron run — pre-cron days
have no snapshot and render as no-change (backfill is out of scope).03_release/output/changelog.mdThe trend arrow on your go live status table is now real, not a sample:
Live entry: apps/help/app/changelog/2026-06-23-csm-portfolio-snapshot/page.mdx
03_release/output/investor-update.mdWho it's for: Customer success managers What shipped: A daily job snapshots each CSM's portfolio, so the go live table's day-over-day trend is real, not sample data. Why it matters: Build the Bridge — validates technical infrastructure (Q2 2026 Objective 3) by proving we can persist a real per-tenant time series.
Dig deeper: https://github.com/sustentus/sustentus/pull/514 · https://help.sustentus.com/changelog/2026-06-23-csm-portfolio-snapshot
03_release/output/release.mdb8868dd on 2026-06-23apps/docs/app/technical/deployment/page.mdx — added "Scheduled jobs (cron)" section + CRON_SECRETcsm-activation-rollup release)apps/help/app/changelog/2026-06-23-csm-portfolio-snapshot/page.mdx) + investor draft in this PRweb (platform.sustentus.com) green on b8868dd; help (help.sustentus.com) and docs (docs.sustentus.com) green on the immediately-following main commits that carry this change (the b8868dd-specific help/docs builds were superseded by sibling #515 merging seconds later — normal Vercel behaviour; live production includes this change)/code-review (high effort) on the feature diff. Findings triaged:
getTimezone returned the stored value unvalidated, so a padded/typo'd tenant timezone setting would make tenantLocalDate (Intl.DateTimeFormat) throw RangeError and break the whole activation table (the read path was unguarded). Fixed at the source: getTimezone now trims and validates the zone (isValidTimeZone), falling back to the registry default — every caller is safe.isDeleted: true and be skipped by the delta read. Fixed defensively: the upsert $set now writes isDeleted: false (no archival path ships, but it's a one-line guarantee).captureDailySnapshotsForAllTenants recomputed the date that captureDailySnapshots already derives. Fixed: the loop calls captureDailySnapshots(tenantId) so the derivation lives in one place (also halves the getTimezone reads).maxDuration = 60 with a sequential per-tenant loop could time out with many tenants. Accepted for now: tenant count is small, and each tenant is isolated by a try/catch so one failure (or the cutoff) never aborts the rest. Revisit with bounded concurrency / a higher maxDuration when tenant count grows.yyyy-mm-dd key (identical for fixed-width digits), ObjectId casting in the aggregate (always wrapped), soft-delete/tenant filters in the aggregate (explicit), and the tenant-local day boundary off-by-one (read uses $lt today; verified across +/- UTC offsets).CsmPortfolioSnapshot persists one row per tenant × CSM × tenant-local date with atRiskNow, RAG, totalAccounts, goLivesNext7Days, region; unique index { tenantId, csm, snapshotDate } — model + index verified.rollupGroups the table uses — vercel.json cron + captureDailySnapshotsForAllTenants.CRON_SECRET bearer; unreachable from a Clerk session — route returns 401 without the header.changeVsYesterday is the real atRiskNow delta vs the most recent prior snapshot — filled in getActivationRollup via latestPriorAtRiskByCsm.0) — absent CSMs default to 0.{ tenantId, csm, snapshotDate }, re-run overwrites the same rows.snapshotDate is the tenant-local date from the timezone setting (default Europe/Brussels) — tenantLocalDate + hardened getTimezone.