feat(analytics): scenario engine + timeline/snapshot model#109
feat(analytics): scenario engine + timeline/snapshot model#109kayodebristol merged 2 commits intomainfrom
Conversation
Co-authored-by: kayodebristol <3579196+kayodebristol@users.noreply.github.com> Agent-Logs-Url: https://github.com/plures/FinancialAdvisor/sessions/45b4b1c2-bdde-4f4e-93ff-ed4fa8b70972
There was a problem hiding this comment.
Pull request overview
Adds a composable “what-if” scenario comparison engine plus a monthly timeline/snapshot model in @financialadvisor/analytics, and extends @financialadvisor/advice to support an income_change scenario and composing multiple scenario results.
Changes:
- Introduces
compareScenarioToBaseline()for baseline vs projected state comparisons (burn/income/net/runway/net worth). - Adds
FinancialTimelineSnapshotutilities (build/sort/compare) andbuildTrendSeries()to emit SVG polyline-ready trend data. - Extends advice scenarios with
income_changeandcomposeScenarios(), and adds unit tests covering the new functionality.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/unit/analytics.test.ts | Adds unit tests for scenario comparison + timeline/snapshot + trend series utilities. |
| test/unit/advice.test.ts | Adds unit tests for income_change and composeScenarios(). |
| packages/analytics/src/timeline.ts | New timeline snapshot model + trend-series generation (SVG polyline points). |
| packages/analytics/src/scenario.ts | New scenario-vs-baseline comparison function and associated types. |
| packages/analytics/src/index.ts | Re-exports new analytics modules. |
| packages/advice/src/types.ts | Adds IncomeChangeScenarioInput and extends ScenarioInput union. |
| packages/advice/src/scenarios.ts | Adds income_change scenario implementation + composeScenarios(). |
| package.json | Adds local eslint plugin devDependency and bumps @types/node. |
| package-lock.json | Locks updated @types/node version. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return { | ||
| periodLabel: params.periodLabel, | ||
| computedAt: params.computedAt ?? new Date(), | ||
| liquidBalanceCents: params.liquidBalanceCents, |
There was a problem hiding this comment.
buildFinancialTimelineSnapshot() defaults computedAt to new Date(), which makes snapshots time-dependent. That conflicts with the file’s "Deterministic" claim and can make snapshot outputs hard to reproduce in tests/exports. Consider requiring computedAt from callers or using a deterministic default and documenting it.
| readonly monthlyNetCents: number; | ||
| /** | ||
| * Liquidity runway in months at the projected burn rate. | ||
| * Returns `Infinity` when projected burn is zero or negative. |
There was a problem hiding this comment.
ScenarioProjectedState.runwayMonths doc says it returns Infinity when projected burn is "zero or negative", but projectedBurn is clamped with Math.max(0, ...) so it can never be negative. Consider updating the comment to match the actual behavior (Infinity when burn is zero).
| * Returns `Infinity` when projected burn is zero or negative. | |
| * Returns `Infinity` when projected burn is zero. |
|
|
||
| for (const r of results) { |
There was a problem hiding this comment.
composeScenarios() assumes all ScenarioResult values share the currency of results[0]. If any result has a different currency, addMoney() will throw (and interestSaved/monthly/annual aggregation could become misleading). Consider validating currencies up-front and throwing a clearer error, or requiring/accepting an explicit currency for composition.
| for (const r of results) { | |
| // Ensure all scenario results use a consistent currency before aggregation. | |
| for (const r of results) { | |
| const scenarioCurrencies = new Set<string>(); | |
| if (r.monthlyDelta) { | |
| scenarioCurrencies.add(r.monthlyDelta.currency); | |
| } | |
| if (r.annualDelta) { | |
| scenarioCurrencies.add(r.annualDelta.currency); | |
| } | |
| if (r.interestSaved) { | |
| scenarioCurrencies.add(r.interestSaved.currency); | |
| } | |
| for (const c of scenarioCurrencies) { | |
| if (c !== currency) { | |
| throw new Error( | |
| `composeScenarios: currency mismatch for scenario "${r.name}". Expected "${currency}", got "${c}".`, | |
| ); | |
| } | |
| } | |
| } | |
| for (const r of results) { |
| name: `Income ${direction} of $${_fmt(absCents)}/month`, | ||
| description: | ||
| `A monthly income ${direction} of $${_fmt(absCents)} ` + | ||
| `would change annual income by $${_fmt(Math.abs(input.monthlyDeltaCents * 12))}.`, | ||
| monthlyDelta, |
There was a problem hiding this comment.
_incomeChangeScenario() hardcodes "$" in name/description and formats amounts via _fmt (cents/100). Since this scenario supports non-USD currencies (input.currency), the generated text can be incorrect/misleading (e.g., EUR still shows "$" and US-style formatting). Consider using the domain formatCurrency() helper (or at least the provided currency code) when building user-facing strings.
| computedAt: params.computedAt ?? new Date(), | ||
| liquidBalanceCents: params.liquidBalanceCents, | ||
| monthlyIncomeCents: params.monthlyIncomeCents, | ||
| monthlyBurnCents: params.monthlyBurnCents, |
There was a problem hiding this comment.
buildFinancialTimelineSnapshot() computes runwayMonths using a clamped burn (Math.max(0, params.monthlyBurnCents)) but returns monthlyBurnCents as the original input. If monthlyBurnCents is negative, the snapshot becomes internally inconsistent (runway computed as if burn were 0). Consider throwing on negative burn or clamping and storing the clamped value.
| monthlyBurnCents: params.monthlyBurnCents, | |
| monthlyBurnCents: burn, |
kayodebristol
left a comment
There was a problem hiding this comment.
Auto-approved: CI green + Copilot code review complete.
Builds the composable "what-if" scenario comparison engine and a temporal financial snapshot system with SVG-ready trend data.
Scenario Engine
New
income_changescenario type (packages/advice/src/types.ts,scenarios.ts)IncomeChangeScenarioInput—monthlyDeltaCents(positive = raise, negative = loss)runScenario()alongside existing scenario typescomposeScenarios()— composable scenarios (packages/advice/src/scenarios.ts)Combines independent
ScenarioResult[]into a single result by summing deltas:compareScenarioToBaseline()(packages/analytics/src/scenario.ts)Full financial impact comparison against a baseline state — monthly burn, runway, and net worth:
Timeline / Snapshot Model (
packages/analytics/src/timeline.ts)FinancialTimelineSnapshot— monthly snapshot: liquid balance, income, burn, net worth, runway (all integer cents)buildFinancialTimelineSnapshot()— constructs from raw figures; auto-computesrunwayMonthssortTimelineSnapshots()— chronological ordering by"YYYY-MM"labelcompareTimelineSnapshots()— delta between any two snapshots (auto-detects earlier/later)buildTrendSeries()— extracts anyTimelineFieldfrom a snapshot array and emits structuredTrendPoint[]plus a pre-scaled SVG<polyline points="...">string for direct use in design-dojo chart componentsBoth new analytics modules are re-exported from
packages/analytics/src/index.ts.Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.