feat(budget): workflow + detail redesign (#031)#98
Conversation
- /budget lands directly on the active fiscal year's detail view; the all-budgets list moves to /budget/history (reachable from the header "All budgets" link + breadcrumb). - Replace the 5 flat KPI tiles with a budget-health hero: multi-marker progress bar (planned/expected/actual against the annual ceiling), status pill (under / on-track / at-risk / over / no-data), narrative, and 4 stat tiles (Billed YTD / Actual YTD / Projected year-end / Variance YTD). - Add a past-month spotlight card for the most recent completed period with billed or running spend. - Period table: current-month accent + "Current" pill, distinct over / under / future treatments, dialed-down "live" sky-tinted Anthropic API row. - Drop the per-tool spending breakdown from the detail page (Reports owns that view). - Refactor BudgetDetailClient (~813 → ~245 lines) into composable sections: BudgetDetailHeader, BudgetHealthHero, PastMonthSpotlight, PeriodAllocationsTable, BilledCostDialog (mode="add"|"edit"), DeleteBilledCostDialog. All server actions unchanged. - Delete orphaned getPerToolSpend (no remaining callers; Reports has its own per-tool query). Spec, mockup, plan, and running implementation notes under specs/031-budget-workflow-and-redesign/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR implements Spec 031’s budget workflow + detail-page redesign by making /budget render the active fiscal year’s detail view directly, moving the all-budgets list to /budget/history, and refactoring the budget detail UI into smaller components (hero, spotlight, period table, dialogs). It also removes dead per-tool spend code and updates the Playwright e2e spec to match the new layout.
Changes:
- Update
/budgetto fetch the active budget and render the shared detail client (with an empty state when no active budget exists). - Add
/budget/historyto host the full budgets table and update navigation (header link + optional breadcrumb). - Refactor/detail redesign: new
BudgetHealthHero,PastMonthSpotlight,PeriodAllocationsTable, dialog components, sharedStatTile; remove deadgetPerToolSpend; update e2e assertions.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/e2e/budget-period-running-costs.spec.ts | Updates Playwright checks to match the new /budget landing + hero content. |
| src/app/budget/page.tsx | Makes /budget render active budget detail directly; adds empty state CTAs and history link. |
| src/app/budget/history/page.tsx | New history page showing all budgets in a table with navigation back to active budget. |
| src/app/budget/components/stat-tile.tsx | New shared stat-tile component used by hero/spotlight. |
| src/app/budget/components/period-allocations-table.tsx | New extracted period table with styling for current/future/over/under and expandable running-cost rows. |
| src/app/budget/components/past-month-spotlight.tsx | New “most recent closed period” spotlight card. |
| src/app/budget/components/dialogs/* | Extracted/combined billed-cost add/edit and delete dialogs; shared form state module. |
| src/app/budget/components/budget-health-hero.tsx | New budget-health hero (status + narrative + multi-marker bar + stat tiles). |
| src/app/budget/components/budget-detail-header.tsx | New header with optional breadcrumb and “All budgets”/“New Budget” actions. |
| src/app/budget/components/budget-detail-client.tsx | New composed client detail view; wires hero/spotlight/table/dialogs together. |
| src/app/budget/[id]/page.tsx | Simplifies budget detail route; removes per-tool spend usage; reuses shared client. |
| src/actions/budget.ts | Removes dead getPerToolSpend action. |
| specs/031-budget-workflow-and-redesign/* | Adds spec artifacts (README + mock/plan/notes HTML). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const start = new Date(period.startDate); | ||
| const end = new Date(period.endDate); | ||
| const isCurrent = start <= today && today <= end; | ||
| const isFuture = start > today; | ||
| const isClosed = end < today; |
There was a problem hiding this comment.
Good catch — fixed in a357c83. The hero, spotlight, and table now all share the existing classifyPeriod(period, today) helper from src/lib/reports/period-helpers.ts, which treats endDate as inclusive end-of-day. One source of truth across all three views.
| const totals = periods.reduce( | ||
| (a, p) => { | ||
| const running = runningCosts[p.id]?.runningCostCents ?? 0; | ||
| a.allocated += allocations[p.id] ?? 0; | ||
| a.expected += p.expectedSpendCents; | ||
| a.actual += p.billedTotalCents + running; | ||
| return a; | ||
| }, | ||
| { allocated: 0, expected: 0, actual: 0 } | ||
| ); | ||
| const totalVariance = totals.actual - totals.expected; | ||
|
|
There was a problem hiding this comment.
Fixed in a357c83. The footer totals now exclude future periods (using classifyPeriod), and the internal fields were renamed expectedToDate / actualToDate so the intent is obvious to the next reader. Kept the "YTD Total" label — the meaning now matches.
| (a, p) => { | ||
| const start = new Date(p.startDate); | ||
| const end = new Date(p.endDate); | ||
| const isYtd = start <= today; | ||
| const isClosed = end < today; | ||
| const billed = p.billedTotalCents; |
There was a problem hiding this comment.
Fixed in a357c83 — the hero now calls classifyPeriod(p, today) for the phase decision instead of the homegrown end < today comparison. Same shared helper the period table and spotlight use.
| export function PastMonthSpotlight({ budget, runningCosts }: Props) { | ||
| const today = new Date(); | ||
| let latest: { period: BudgetWithCosts["periods"][number]; endTime: number; actual: number } | null = null; | ||
| for (const p of budget.periods) { | ||
| const end = new Date(p.endDate); | ||
| if (end >= today) continue; | ||
| const running = runningCosts[p.id]?.runningCostCents ?? 0; | ||
| const actual = p.billedTotalCents + running; | ||
| if (actual <= 0) continue; | ||
| const endTime = end.getTime(); |
There was a problem hiding this comment.
Fixed in a357c83 — the spotlight uses classifyPeriod(p, today) === "past" now, matching the hero and the table.
| <BudgetHealthHero budget={budget} runningCosts={runningCosts} /> | ||
|
|
||
| <PastMonthSpotlight budget={budget} runningCosts={runningCosts} /> | ||
|
|
||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle>Period allocations & billed costs</CardTitle> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <PeriodAllocationsTable | ||
| budget={budget} | ||
| runningCosts={runningCosts} | ||
| allocations={allocations} | ||
| onAllocationChange={handleAllocationChange} | ||
| isAdmin={isAdmin} | ||
| isArchived={isArchived} | ||
| saving={saving} | ||
| onSaveAllocations={handleSave} | ||
| onAddBilledCost={openAddDialog} | ||
| onEditBilledCost={openEditDialog} | ||
| onDeleteBilledCost={openDeleteDialog} | ||
| /> | ||
| </CardContent> | ||
| </Card> | ||
|
|
There was a problem hiding this comment.
Fixed for the hero in a357c83 — BudgetHealthHero now accepts an optional allocations prop, and the parent client passes its live map, so the "unallocated" tile reflects unsaved edits and stops disagreeing with the period-table warning when the user pushes allocations over the ceiling.
I intentionally kept the spotlight reading persisted values: it's showing a closed-period snapshot, and editing a past period's planned amount while the spotlight is on it would be an unusual flow — showing the saved value there feels more correct than reflecting an unsaved edit. Happy to revisit if you'd rather have both stay perfectly in sync.
| export function makeEmptyBilledCostForm(): BilledCostFormState { | ||
| return { | ||
| amountDollars: "", | ||
| invoiceDate: new Date().toISOString().slice(0, 10), | ||
| description: "", |
There was a problem hiding this comment.
Fixed in a357c83 — now uses formatDateOnly(new Date()) from @/lib/utils. Thanks for catching this; the previous toISOString().slice(0, 10) would have prefilled the wrong date for non-UTC users around midnight.
| ## Out of scope | ||
|
|
||
| - Data model: no schema changes. All redesign uses existing `annual_budgets`, `budget_periods`, `billed_costs`, `anthropic_workspace_costs`. | ||
| - Server actions: `getActiveBudget`, `getBudgets`, `getBudgetWithCosts`, `getPerToolSpend`, `getRunningCostsForPeriod`, `updateBudgetAllocations`, `createBilledCost`, `updateBilledCost`, `deleteBilledCost` — all unchanged. | ||
| - Validation, the new-budget creation flow (`/budget/new`), and the budget edit/archive actions in `BudgetListActions`. | ||
| - The Reports → Budget tab — that's spec 028 and lives at `/reports`. | ||
| - Per-tool spending breakdown — removed from the detail view entirely. It's already covered by the Reports → Budget page (spec 028); duplicating it here was noise. |
There was a problem hiding this comment.
Fair point — updated in a357c83. The "Out of scope" list now states explicitly that getPerToolSpend was deleted, with the reason: the implementation audit found no remaining callers (Reports has its own fetchPerToolByPeriod in src/actions/reports.ts).
Vercel deploy on d4e6076 failed in 1.4s before build start (empty logs) — appears infra-only; CI build job passed. Retriggering.
- Period phase: hero, spotlight, and table now share `classifyPeriod` from `src/lib/reports/period-helpers.ts` instead of comparing `new Date(endDate) < today`. `endDate` is a SQL DATE; the old comparison flipped periods to "past" at the start of their last day (with extra timezone skew). The shared helper treats `endDate` as inclusive end-of-day. - Period table footer: "YTD Total" was summing `expectedSpendCents` across all periods, so the variance line compared YTD actual to full-year expected. Future periods are now excluded from the expected/actual totals; internal fields renamed to `expectedToDate` / `actualToDate` to keep the intent legible. - Hero now receives the parent's in-progress `allocations` map, so "unallocated" and YTD-planned reflect unsaved edits and stop disagreeing with the period-table warning when the user pushes the allocations over the ceiling. Falls back to `plannedAmountCents` when the prop is absent. - `makeEmptyBilledCostForm()` uses `formatDateOnly(new Date())` for the default invoice date instead of `new Date().toISOString().slice(0, 10)`, which prefilled yesterday/tomorrow for non-UTC users near midnight. - Spec README "Out of scope" list updated to reflect the `getPerToolSpend` deletion (no callers; Reports has its own per-tool query). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Projection: the old formula was `avgPerClosed × periods.length`, which
re-projected the past and ignored the current month's pace. New:
`closedActual + currentContribution + remainingCount × avgPerClosed`,
where `currentContribution = max(currentRunning / elapsedFraction,
avgPerClosed)` once at least 20% of the current period has elapsed,
otherwise the historical average. Closed months are taken at their
actual; the in-progress month surfaces its own pace but can't drop
the projection below the historical average; future months still
average. The 20% threshold avoids divide-by-very-small at the start
of a period.
Variance: the "Variance YTD" tile included the current period's full
`expectedSpendCents` against its partial actual, so the number drifted
through the month and snapped at month-end. Replaced with a
closed-only tile labeled "Variance through {lastClosedLabel}". The
status pill, the bullet under it, and the blue tick on the multi-
marker bar all follow the same closed window, so they read
consistently.
Narrative pairs the closed-window sentence with a current-period
burn-rate signal — "{currentLabel} is pacing 53% above the historical
average ($X projected vs $Y)" once the pace is trustworthy; falls
back to "{currentLabel} is 12% through ($X so far)" before then.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up to the discussion thread: Projection — was Closed months are taken at actual. The current month surfaces its own burn rate but won't drag the projection below the historical average (a slow first week shouldn't optimistically pull it down). Future months extrapolate from history. The 20% threshold avoids divide-by-very-small at the start of a period. Variance — "Variance YTD" included the current month's full expected against its partial actual, so the tile drifted less-negative through the month and snapped at month-end. Replaced with a closed-only tile labeled "Variance through {lastClosedLabel}" (e.g. "Variance through Apr"). The status pill, the small bullet under it, and the blue tick on the multi-marker bar all follow the same closed window — they read consistently now. Narrative pairs the closed-window sentence with a current-period burn-rate signal — e.g. "May 2026 is pacing 53% above the historical average ($58k projected vs $38k)" — or, before we trust the pace, "May is 12% through ($4.5k so far)". Verified locally: See |
- Per-row variance and % diff cells now render "—" for current and
future periods. The in-progress month was showing partial actual vs
full-month expected (always negative, always misleading); future
months were showing 0 vs 0 (always useless). Only closed periods
display a real variance + % diff, with the over/under badges that
signal something happened.
- "YTD Total" footer became "Total through {lastClosedLabel}" (e.g.
"Total through Apr 2026") and sums expected/actual/variance over
the closed window only — matching the hero's "Variance through
Apr 2026" tile so the two reads agree. Falls back to "Total" with
em-dashes when no period has closed yet. Allocated stays summed
across all periods (it answers a different question: "what have I
committed", independent of phase).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@copilot review |
Summary
Spec 031 — Budget Workflow & Detail Redesign. Two changes land together:
Budgetopens the active fiscal year's detail view directly. No more summary card with a "View Details" button. The all-budgets list moved to/budget/history, reachable via the header "All budgets →" link and the detail-view breadcrumb.Functionality is unchanged — every server action (
createBilledCost,updateBilledCost,deleteBilledCost,updateBudgetAllocations,archiveBudget,getActiveBudget, etc.) keeps its current signature. No schema changes.Notable refactors
BudgetDetailClient~813 → ~245 lines, composed fromBudgetDetailHeader,BudgetHealthHero,PastMonthSpotlight,PeriodAllocationsTable,BilledCostDialog(mode="add"|"edit"),DeleteBilledCostDialog.StatTilededuped between hero + spotlight.getPerToolSpenddeleted fromsrc/actions/budget.ts— it was dead code (Reports has its own per-tool query).Spec artifacts
specs/031-budget-workflow-and-redesign/README.md— workflow proposal + comparison table.specs/031-budget-workflow-and-redesign/detail-mock.html— visual reference.specs/031-budget-workflow-and-redesign/plan.html— implementation plan + reuse audit.specs/031-budget-workflow-and-redesign/implementation-notes.html— running log of decisions, deviations, tradeoffs, and open questions captured while implementing (including the post-/simplify review fixes).Open questions
STATUS_THRESHOLDSat the top ofbudget-health-hero.tsx.Test plan
pnpm typecheckcleanpnpm lintclean (zero warnings)pnpm test— 291/291 unit tests pass/budget(200),/budget/history(200, all 5 fiscal years),/budget/5(200, archived → read-only) — hero, spotlight, period table, breadcrumb, allocations editor, dialogs all render and submittests/e2e/budget-period-running-costs.spec.tsupdated for the new layout; run in CI🤖 Generated with Claude Code