fix(opportunity): derive days_in_stage from a stored stage_entry_date - #560
Merged
Merged
Conversation
`crm_opportunity.days_in_stage` was a plain number column that nothing ever
incremented. The lifecycle hook reset it to 0 on a stage change and no sweep
or hook anywhere raised it, so `opportunity_stagnation`'s
`days_in_stage > 14` filter matched only the rows the seed had hardcoded —
real deals could sit in a stage indefinitely without ever being nudged.
Add `stage_entry_date`, a stored, indexed date stamped by the lifecycle hook
on insert and re-stamped on every stage change, and turn `days_in_stage` into
a formula counting from it. Correct on every read, with no nightly
full-table pass.
A formula is evaluated after the query (`applyFormulaPlan` walks the returned
rows), so it is not a real column and cannot be a filter or sort key. Every
consumer that selects rather than displays moves to `stage_entry_date`:
- `opportunity_stagnation` filters `stage_entry_date < {TODAY() - 14}` — the
same test, resolved by the flow template engine. Verified against a booted
server: the sweep loops over exactly the 30 open deals with
`days_in_stage > 14`, boundary inclusive-exact, matching the pre-change
selection on main.
- The `stale_opportunities` view drops the threshold filter it could no
longer express (the list data path resolves no date macros either) and
becomes an open-deals list ordered longest-in-stage first, with
`days_in_stage` on show.
Seeds carry `stage_entry_date` on every opportunity — open deals straddling
the 14-day threshold, settled deals entering their closed stage on the close
date — replacing the hardcoded `days_in_stage` numbers.
Two guards in `test/metadata-references.test.ts` pin the class: no list view
may filter or sort on a formula field, and no flow data node may filter on
one. The second instance they caught is fixed here too — the quarterly
forecast view's `attainment_pct` tiebreaker was a dead sort key, now
`closed_amount`.
Refs #489
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJygXUafjc7ckGMmE2AaGz
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
yinlianghui
marked this pull request as ready for review
July 31, 2026 03:10
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
crm_opportunity.days_in_stagewas a plain number column that nothing ever incremented. The lifecycle hook reset it to0on a stage change, and no sweep or hook anywhere raised it — soopportunity_stagnation'sdays_in_stage > 14filter only ever matched the rows the seed had hardcoded. Real deals could sit in a stage indefinitely without a single nudge.This is the one item left open on #489 (everything else in that issue shipped in #536).
The fix follows the approach proposed in the issue thread: add a stored
stage_entry_dateand makedays_in_stagea formula that counts from it. Correct on every read, and no nightly full-table pass.The consequence that shapes the rest of the diff: a formula is evaluated after the query (the engine's
applyFormulaPlanwalks the rows it already fetched), so it is not a real column and cannot be a filter or sort key. Every consumer that selects rather than displays moves tostage_entry_date.Type of Change
Related Issues
Related to #489
Changes Made
crm_opportunity.stage_entry_date— new stored, indexed, readonlydate. The lifecycle hook stamps it on insert and re-stamps it on every stage change. (Before-hook writes survive readonly stripping: only caller-supplied keys are dropped.)days_in_stage→ formula —daysBetween(record.stage_entry_date, today()), guarded withhas()+ null so an unstamped row readsnullrather than faulting there by accident.opportunity_stagnationfiltersstage_entry_date < {TODAY() - 14}— the same test, against something the data engine can see, resolved by the flow template engine (the{TODAY() ± n}tokencontract_renewalalready uses). A nullstage_entry_datefails$lt, preserving "nothing has stagnated yet" for unstamped rows.stale_opportunitiesview drops the threshold filter it could no longer express — the list data path resolves no date macros either, so< {14_days_ago}would ship the literal token and invert the comparison (the trap already documented onstale_articles). It becomes an open-deals list ordered longest-in-stage first, with bothstage_entry_dateanddays_in_stageas columns so the 14-day line is readable per row.stage_entry_dateon every opportunity — open deals straddling the threshold in both directions, settled deals entering their closed stage on their close date — replacing the hardcodeddays_in_stagenumbers.attainment_pctsort removed from the quarterly forecast view. Same defect class, caught by the new guard below: it is a formula, so that tiebreaker was a dead sort key and ordering within a quarter was arbitrary. Nowclosed_amount, the stored numerator behind attainment.stage_entry_datein all four locale packs;docs/developers/api_reference.mdand the three locales ofcontent/docs/sales/opportunities.mdxupdated (the old prose claimed the counter was "recomputed daily").Testing
pnpm test— 147 passing)pnpm lint— no new warnings)pnpm build);pnpm validatecleanNew tests
test/hooks-runtime.test.ts— the lifecycle hook stamps the clock on insert, does not overwrite a caller-supplied value, restarts it on a stage change, leaves it alone otherwise, and never writesdays_in_stage.test/metadata-references.test.ts— two class guards: no list view may filter or sort on a formula field, and no flow data node may filter on one. These are what surfaced theattainment_pctsort.Verified against a booted server (
pnpm start, fresh DB, REST + SQLite probes)datecolumns storeYYYY-MM-DDtext, so the$ltcomparison is lexicographic and chronological — no type-affinity inversion.days_in_stagecomputes live on read (stage_entry_date: 2026-07-19→12) and is correctly absent as a DB column.days_in_stage > 14; the boundary is exact (14 days excluded, 15 included).sys_automation_runshowsquery_stalled→ 30 loop iterations. The same run onmainproduces the identical 30 iterations, so the selection is behaviour-preserving.Additional Notes
While verifying the above, the flow's loop was observed to stop at the
check_not_nudgeddecision node — the idempotency gate never opens, so no notification or follow-up task is created even now that the right rows are selected. This reproduces identically onmainand is out of scope here; it is tracked separately in #562, with the root cause and a verified repro (flow conditions stay bare strings in the build artifact and never reach the CEL branch, soexistingStallTask == nullis compared as the literal string'existingStallTask').Two pre-existing
campaign_enrollmentexpression warnings frompnpm validateand some boot-timecrm_accountupdate errors are likewise unrelated and unchanged.