+
+
+
+
Spec 031 · Implementation notes · running
+
Budget Workflow & Detail Redesign
+
+ Live log of decisions, deviations, tradeoffs, and open questions captured while implementing the
+ plan. Append-only — each entry timestamped relative to the implementation steps in
+ plan.html.
+
+
+
+ decision
+ deviation
+ tradeoff
+ open
+
+
+
+
Pre-flight
+
+
+
decision · 2026-05-21 · setup
+
Neon branch wt/budget-v2 created from main
+
+ Project broad-shadow-82397229 (ai-developer-hub). Branch id
+ br-super-shape-alb020w6, pooled host ep-broad-fog-alsijtby-pooler.
+ Worktree .env.local was missing — copied from main checkout and swapped to the branch
+ URLs. Verified DATABASE_URL host does NOT match production (ep-little-frost-alw6tlsf).
+
+
+
+
Step-by-step
+
+
+
decision · step 1 · dialog extraction
+
Dialogs share a tiny billed-cost-form.ts module for the form-state type
+
+ The plan specified extracting three dialog components. Both Add and Edit dialogs use an identical
+ BilledCostFormState shape — I put the type + emptyBilledCostForm constant
+ in src/app/budget/components/dialogs/billed-cost-form.ts rather than
+ duplicating them in two dialog files or hoisting them into the parent. The parent imports the type
+ for its useState generics; both dialogs accept BilledCostFormState in their
+ props. Pure ergonomics — keeps each file under 120 lines without duplication.
+
+
+
+
+
tradeoff · step 1
+
Controlled dialogs: parent still owns all state
+
+ Each dialog takes open, onOpenChange, form,
+ onFormChange, onSubmit, saving. I considered moving form
+ state inside the dialogs themselves (one less prop drilled), but the parent already owns submit
+ handlers (handleAddBilledCost etc.) that need to read the form values, and the
+ Edit dialog needs the parent to seed the form when an entry is opened. Keeping state lifted
+ means no extra useEffect dance.
+
+
+
+
+
deviation · step 1
+
Imports use the @/app/budget/... alias, not relative paths
+
+ The plan implied relative imports from [id]/budget-detail-client.tsx.
+ Tried ../../components/dialogs first — wrong (only one level up needed). Switched to
+ the project's @/... alias, which is what every other component file in this codebase
+ uses. Avoids the dynamic-segment-folder confusion and matches existing style.
+
+
+
+
+
decision · step 2 · period table extraction
+
Table is fully controlled — parent owns allocation state, table owns expand-state
+
+ The extracted PeriodAllocationsTable receives allocations +
+ onAllocationChange(periodId, cents) from the parent (so saving still hits the parent's
+ single submit handler), but expandedPeriods stays inside the table — no parent cares
+ which rows are open. This is a deliberate boundary: parent owns the persistable state, table owns
+ the ephemeral UI state. Plan didn't specify either way.
+
+
+
+
+
decision · step 3 · BudgetHealthHero
+
Status thresholds inline as a top-of-file constant
+
+ Per the plan's Open question on status-pill thresholds, used the proposed defaults:
+ under (<95%), on-track (≤105%), at-risk (≤115%), over (>115%). Defined as a single
+ STATUS_THRESHOLDS object at the top of budget-health-hero.tsx
+ so a future tweak is one diff. Open question: are these the right cutoffs? See
+ bottom of file for the standing TBD.
+
+
+
+
+
tradeoff · step 3
+
Multi-marker bar is hand-rolled, not based on SpendProgressBar
+
+ The shared SpendProgressBar renders 2 markers (actual + ceiling). The hero needs 4
+ visual layers (allocated background + actual fill + planned-YTD tick + expected-YTD tick) plus a
+ ceiling annotation. Rather than overload the shared component with optional props nobody else
+ uses, inlined a small MultiMarkerBar in the hero. Future Reports tab might want
+ something similar; if so we lift it then.
+
+
+
+
+
decision · step 3 · projection
+
Year-end projection uses simple average-of-completed-periods, not the Reports forecaster
+
+ The Reports getBudgetForecast() uses linear regression on actual spend, but it needs
+ a forecast pipeline the budget page doesn't otherwise touch. For the hero's "Projected year-end"
+ tile, I derive avgPerCompletedPeriod × totalPeriods. Coarser, but matches what a
+ non-stats-y reader expects ("if we keep spending at this rate…") and avoids a server-side
+ forecast call on every hero render. If finer projection is wanted later, swap to the existing
+ forecaster — same component contract.
+
+
+
+
+
decision · step 4 · spotlight
+
Spotlight ships without the drivers section
+
+ The existing reports/budget/past-month-spotlight.tsx includes a "Top
+ per-tool changes vs last month" driver list. That section depends on per-tool MoM computation
+ (license-derived spend at two points in time) which is part of the Reports pipeline and not
+ cheaply available on the budget detail page. The new budget-page spotlight shows the 4 core
+ tiles (Planned / Expected / Actual / Variance) only. If we ever want drivers here, we route the
+ Reports data in.
+
+
+
+
+
deviation · step 4
+
Spotlight requires billed or running > 0 to surface
+
+ The plan said endDate < today AND (billed > 0 OR running > 0). Sticking with
+ that — a closed period that genuinely had no spend (e.g. tool was disabled all month) wouldn't
+ be useful to spotlight; we'd just be loudly displaying zero. The very-start-of-year case
+ (no completed periods) returns null and the spotlight section disappears entirely.
+
+
+
+
+
decision · step 5 · period row styling
+
Closed-and-under periods get a faint green tint, closed-and-over keep bg-destructive/10
+
+ The plan asked for a "stronger over-budget signal." Rather than amp the destructive tint
+ further (it's already loud), I added a complementary under signal — a very subtle
+ emerald-500/5 — so closed periods read at-a-glance as red/green/neutral. Over-budget also picks
+ up the destructive variant on the % chip in the variance column. Net result: scanning the
+ table tells the story without reading numbers.
+
+
+
+
+
decision · step 5 · running-cost row
+
Anthropic API row uses sky-500 tints + a "live" badge
+
+ Old row: bright blue-600 text on blue-50 background. Replaced with sky-500/5 background +
+ sky-700 (dark: sky-300) text + an outline-style "live" badge next to the label. Reads as a
+ running/in-flight value rather than a hard error/warning. Workspace breakdown rows inherit the
+ same family at half opacity.
+
+
+
+
+
decision · step 5 · future-period treatment
+
Future periods get muted-foreground text + an em-dash in the % column when expected is 0
+
+ Per the plan's "future class for rows with startDate > today (muted text,
+ em-dash placeholders)". Implemented exactly. The chevron and Add-billed-cost button still work
+ on future rows (admin might want to log a pre-paid invoice for an upcoming month).
+
+
+
+
+
decision · step 6 · header
+
Header lifted to its own component, breadcrumb above the H1
+
+ The plan's reuse audit included BudgetDetailHeader implicitly via the H1+badge
+ markup left on the page. Lifted it to budget-detail-header.tsx
+ because (a) the empty state needs its own simpler header without the breadcrumb, (b) the
+ archived-detail view will reuse the same component, (c) keeps the parent client under 250 lines.
+ Breadcrumb is rendered above the H1, "All budgets" + "New Budget" buttons sit on the right.
+
+
+
+
+
decision · step 7 · history page
+
History page links forward AND back
+
+ Added a "Back to active budget" link above the H1 (top-left, ghost button). Without it, the
+ only way back to the active budget from history is the sidebar — and a user who's just clicked
+ a row link, realised it's an old budget, then clicked back via browser would have to navigate
+ twice. Pure ergonomics.
+
+
+
+
+
deviation · step 7 · empty list
+
History page handles the empty-budgets case
+
+ Plan didn't explicitly cover "history has no rows at all." Added an inline "No budgets yet"
+ line with a link to /budget/new (admin only). The case will essentially never
+ happen in production (there's always at least the active budget by the time anyone visits
+ history) but it's 5 lines of code and prevents an awkward blank DataTable on a fresh
+ DB.
+
+
+
+
+
decision · step 8 · landing
+
/budget imports the [id] route's client directly
+
+ Two routes (/budget and /budget/[id]) both render
+ BudgetDetailClient from [id]/budget-detail-client.tsx.
+ The plan recommended fold, don't redirect — done. The cross-route import is mildly
+ ugly (importing from a sibling dynamic-segment folder) but the alternative (lifting the client
+ into a shared location) would mean renaming the file and updating two import paths for no
+ runtime benefit.
+
+
+
+
+
decision · step 9 · per-tool drop
+
Dropped toolBreakdown + ToolSpend + getPerToolSpend call entirely
+
+ The plan kept the toolBreakdown prop in BudgetDetailClient for safety;
+ I removed it. Once the JSX that consumes it is gone, the prop is dead weight, and a future
+ maintainer wondering "why does this client take a tool breakdown it never uses?" would be a
+ forced detour. getPerToolSpend stays in src/actions/budget.ts —
+ Reports still uses it.
+
+
+
+
+
decision · step 11 · e2e
+
Rewrote tests/e2e/budget-period-running-costs.spec.ts to assert the new shape
+
+ Old test asserted "Billed" label and clicked the first a[href*='/budget/']
+ link from /budget to reach detail. Both fail post-redesign: the landing now is
+ the detail page, and the hero uses "Billed YTD"/"Actual YTD". Replaced the two tests with a
+ smaller pair that asserts the hero + period table are visible on /budget directly,
+ and that one of the YTD labels renders. No new selectors needed; no follow-up Playwright spec
+ in this PR.
+
+
+
+
+
open · status pill thresholds
+
+
+ Confirm status-pill thresholds. Currently: actual/expected < 0.95
+ = under, ≤ 1.05 = on-track, ≤ 1.15 = at-risk, > 1.15 = over.
+ These are the plan's proposed defaults. If product wants different cutoffs, edit the
+ STATUS_THRESHOLDS constant at the top of
+ src/app/budget/components/budget-health-hero.tsx.
+
+
+
+
+
+
open · linear forecast vs simple average
+
+
+ Year-end projection uses simple average. If you want it to match Reports
+ (linear regression via getBudgetForecast), the hero would need access to that
+ server-side forecast. Cheap to add — wrap the call in page.tsx, pass the result
+ as a prop. Not done yet because the simple version is more intuitive for budget owners and
+ doesn't reduce signal.
+
+
+
+
+
Verification
+
+
+
verification · 2026-05-21 · browser smoke
+
All routes return 200; no console errors
+
+ Verified against the worktree Neon branch via a minted agent session + curl:
+
+
+ -
+
GET /budget → 200 · renders hero (Annual ceiling, Billed/Actual/Variance/Projected
+ YTD), breadcrumb "All budgets" link, Past month spotlight, Period allocations table, "Current"
+ badge on the in-progress period, "Under" status pill.
+
+ -
+
GET /budget/history → 200 · renders all 5 fiscal years (2022-2026), correct
+ status badges (1 active, 4 archived), "Back to active budget" link.
+
+ -
+
GET /budget/5 (archived FY 2022) → 200 · renders period allocations table but
+ no "Save Allocations" button or "Add billed cost" actions — read-only path confirmed.
+
+ -
+ Dev server log: zero errors, zero warnings, all three pages compile.
+
+
+
+ Visual fidelity vs detail-mock.html not verified pixel-by-pixel (Chrome DevTools MCP
+ was offline this session) — the mockup itself is the design source of truth for review.
+
+
+
+
+
verification · 2026-05-21 · tests
+
291 unit tests pass · lint clean · typecheck clean
+
+ pnpm typecheck, pnpm lint (zero warnings), pnpm test
+ (27 files / 291 tests passing). E2E spec
+ tests/e2e/budget-period-running-costs.spec.ts rewritten to match the
+ new landing-page-is-detail-view shape. E2E suite itself not run in this session — needs the
+ Playwright runner against a running dev server; the new assertions are straightforward enough
+ to verify in CI.
+
+
+
+
Post-review cleanup (xhigh /simplify pass)
+
+
+
deviation · review fix
+
Dropped getPerToolSpend from src/actions/budget.ts
+
+ The plan said "getPerToolSpend stays in src/actions/budget.ts — Reports
+ still uses it." Audited: grep -r getPerToolSpend src/ returns only the export
+ itself. Reports computes its own per-tool breakdown via
+ fetchPerToolByPeriod() in src/actions/reports.ts — a
+ different query. Deleted the action; 26 lines of dead code gone.
+
+
+
+
+
deviation · review fix · correctness
+
emptyBilledCostForm became makeEmptyBilledCostForm() factory
+
+ The constant was evaluated once at module load, freezing invoiceDate to the
+ timestamp of first JS bundle init. Users with the tab open overnight got yesterday's date
+ pre-filled in "Add Billed Cost." Now a function called inside openAddDialog and at
+ useState init.
+
+
+
+
+
deviation · review fix · correctness
+
Spotlight variance no longer prints +-$X for negative variances
+
+ Old code: {`${overExpected ? "+" : ""}${formatCurrency(variance)} vs expected`} —
+ but formatCurrency already prints -$X for negatives, producing
+ +-$50 when over and the variance was somehow displayed inverted. Replaced
+ hand-rolled prefix with the existing formatVariance helper. Same fix applied to
+ hero's narrative + Variance YTD tile.
+
+
+
+
+
deviation · review fix
+
Merged AddBilledCostDialog + EditBilledCostDialog → BilledCostDialog with mode: "add" | "edit"
+
+ Two near-identical ~110-line files collapsed into one ~140-line file driven by a COPY
+ lookup keyed on mode. Same fields, same validation, same submit predicate; only
+ title / description / placeholders / submit label / id prefix differ. Parent client renders the
+ same component twice with different mode props.
+
+
+
+
+
deviation · review fix
+
Hoisted shared StatTile to src/app/budget/components/stat-tile.tsx
+
+ Hero's StatTile and spotlight's SpotlightTile were the same component
+ with slightly different prop names (highlight vs tone). Single
+ component takes tone: "default" | "danger" | "success"; the hero passes "danger"
+ where it used to pass highlight={true}.
+
+
+
+
+
deviation · review fix
+
Moved BudgetDetailClient out of [id]/ and dropped the leaky RunningCostData re-export
+
+ The client now lives at src/app/budget/components/budget-detail-client.tsx.
+ Both /budget and /budget/[id] import it from there — no more
+ cross-route imports. The RunningCostData type was a re-export of
+ NonNullable<Awaited<ReturnType<typeof getRunningCostsForPeriod>>>
+ that already exists as RunningCostsResult in
+ src/lib/budget-utils.ts. Five files now import the canonical type
+ directly; the re-export is gone.
+
+
+
+
+
deviation · review fix
+
Breadcrumb hidden on /budget, shown on /budget/[id]
+
+ The breadcrumb's first segment links to /budget/history, which isn't actually a
+ parent of /budget — the active landing is the top-level Budget page. Added a
+ showBreadcrumb prop (default true). /budget sets it false;
+ /budget/[id] sets it true. The "All budgets →" header link still surfaces history
+ from both routes.
+
+
+
+
+
deviation · review fix
+
Hero aggregates totals in a single pass + introduces a no_data status
+
+ The hero used to run 4 sequential ytdPeriods.reduce calls plus a separate
+ completedPeriods.filter. Now one periods.reduce accumulates all the
+ totals (YTD planned/expected/billed/running + closed actual). Also added a fifth status kind
+ no_data returned when no period has closed yet — the badge previously read
+ "On track" against zero data, which was misleading. Now it reads "No data yet."
+
+
+
+
+
deviation · review fix
+
Period table derives its own totals + flattened the row-class ternary
+
+ PeriodAllocationsTable dropped 4 props (totalAllocated,
+ totalExpected, totalActual, totalActualVariance) and now
+ computes them internally from data it already receives. Also hoisted today out of
+ the .map callback (was being re-allocated per row). The 4-deep ternary picking
+ rowClass became a small rowClassFor() helper with early returns.
+
+
+
+
+
deviation · review fix
+
Allocation-change handler early-returns when value didn't change
+
+ handleAllocationChange now compares prev[periodId] === cents and
+ returns the same reference when unchanged. Prevents needless re-renders of the hero +
+ spotlight on every keystroke when the user types the same digit twice (e.g. retyping a value
+ after selecting all). Small win at 12 periods; necessary at scale.
+
+
+
+
Period-table variance + footer (follow-up on PR #98)
+
+
+
deviation · period table
+
Variance / % cells render "—" for current and future periods
+
+ After moving the hero to a closed-window variance, the per-row variance column was
+ still painting numbers for the in-progress month (partial actual vs full-month expected,
+ always negative) and for future months (zero vs zero, useless). Both now render
+ — with muted-foreground styling. Only past (closed) periods show a real
+ variance and a % diff badge — exactly the rows where the number is meaningful.
+
+
+
+
+
deviation · period table footer
+
"YTD Total" footer became "Total through {lastClosedLabel}"
+
+ Footer Expected / Actual / Variance now sum only the closed window, matching the hero's
+ Variance tile. The label is dynamic — e.g. "Total through Apr 2026" — so the reader
+ knows exactly what window the numbers cover. When no period has closed yet, the label
+ falls back to "Total" and the three numeric cells render —.
+
+
+ Allocated stays as the sum across all periods. That's the "what have I committed for the
+ year" number and remains useful independent of phase; the "exceeds budget" warning under
+ the Save button still works against it.
+
+
+
+
Projection + variance redesign (follow-up on PR #98)
+
+
+
deviation · projection
+
Year-end projection is now anchored on actual spend, not avg × total
+
+ The old formula was avgPerClosed × periods.length: it averaged closed periods
+ and replayed that average across every period — including periods that had already happened.
+ That re-projected the past and was deaf to the current month's pace.
+
+
+ New formula:
+ closedActual + currentContribution + remainingCount × avgPerClosed
+ where currentContribution = max(currentRunning / elapsedFraction, avgPerClosed)
+ once we're at least 20% into the current period, otherwise avgPerClosed. Closed
+ months are taken at their actual; the in-progress month extrapolates from its own pace but
+ won't drop below the historical average (so a slow first week doesn't optimistically pull the
+ projection down); future months extrapolate from the historical average. The 20%
+ elapsed-fraction threshold prevents divide-by-very-small-numbers at the start of a period.
+
+
+
+
+
deviation · variance
+
"Variance YTD" replaced with "Variance through {lastClosedLabel}"
+
+ The old YTD variance summed the current period's full expectedSpendCents
+ against its partial actual, which made the number creep less-negative through the month and
+ snap on month-end. The new tile is closed-only: closedActual − closedExpected,
+ labeled with the most recently closed period (e.g. "Variance through Apr"). The status pill
+ and the bullet under it follow the same window. Mid-month the tile is stable — what changed
+ is the projection, not the variance.
+
+
+
+
+
deviation · narrative
+
Narrative pairs the closed window with a current-period burn-rate signal
+
+ The first sentence covers the closed window (status + closed variance + projection); a
+ second sentence — added when there's a current period — describes the in-progress one
+ ("May 2026 is pacing 53% above the historical average ($58k projected vs $38k)" or "May
+ 2026 is tracking the historical average ($38k projected)"). When we're under the 20% elapsed
+ threshold the second sentence falls back to a non-projection phrasing ("May is 12% through
+ ($4.5k so far)").
+
+
+
+
+
deviation · progress bar
+
Expected marker on the multi-marker bar now tracks the closed window
+
+ Previously the blue expected tick marked expectedYtd (including the current
+ period's full month). For consistency with the variance tile + status pill, it now marks
+ closedExpected ("Expected through Apr"). Disappears when there are no closed
+ periods.
+
+
+
+
+
tradeoff · considered
+
Pro-rated current expected — not adopted
+
+ Considered: scale current's expectedSpendCents by elapsedFraction
+ and keep variance "YTD-including-current". Rejected — invoices land lumpy (vendor renewal on
+ day 1 of the month), so a linear-time proration would routinely overstate or understate
+ without telling the user. The closed-window approach is honest about what it measures.
+
+
+
+
Post-review fixes (Copilot review on PR #98)
+
+
+
deviation · review fix · correctness
+
Period phase now uses classifyPeriod from src/lib/reports/period-helpers.ts
+
+ Three sites (hero, spotlight, period table) were comparing
+ new Date(period.endDate) < today. budget_periods.end_date is a SQL
+ DATE — Drizzle hands it back as "YYYY-MM-DD", and
+ new Date("YYYY-MM-DD") parses as UTC midnight of that calendar day. That made
+ periods flip to "past" at the start of their final day instead of after it ends, with
+ an extra timezone offset on top. Adopted the existing
+ classifyPeriod(period, now) helper (returns "past" | "current" | "future",
+ treats endDate as inclusive-end-of-day). One source of truth across all four budget
+ views (Reports already used it).
+
+
+
+
+
deviation · review fix · correctness
+
Period-table footer no longer mixes future and YTD numbers
+
+ The "YTD Total" row was summing expectedSpendCents + billedTotalCents
+ across all periods, which meant the variance line compared YTD actual (May) against
+ full-year expected (Jan–Dec). Most of the time the future months' actual was zero, so the
+ variance line read as a huge negative that didn't actually mean anything. Now the totals
+ skip future periods and the label stays "YTD Total" — internally renamed
+ expectedToDate / actualToDate so the next person to read the code
+ doesn't have to re-derive the intent.
+
+
+
+
+
deviation · review fix · correctness
+
Hero / unallocated reflects in-progress allocation edits
+
+ Before saving, the user could edit Jan's allocation to push over the ceiling — the
+ table's "Allocations exceed budget" warning fired, but the hero still showed
+ $X unallocated because it was reading
+ budget.periods[*].plannedAmountCents (the persisted value). Added an optional
+ allocations?: Record<number, number> prop to BudgetHealthHero; the
+ parent client passes its local map. The hero falls back to plannedAmountCents when
+ the prop is absent (it isn't, here, but keeping the fallback means the component is reusable
+ from a read-only context later).
+
+
+ Spotlight kept reading persisted values — a closed period's "planned" is historical; editing it
+ in the table while the spotlight is open is unusual and the user probably wants the spotlight
+ to show the saved snapshot, not an unsaved edit.
+
+
+
+
+
deviation · review fix · correctness
+
makeEmptyBilledCostForm() uses formatDateOnly
+
+ Was new Date().toISOString().slice(0, 10) — UTC-based, so a user in UTC+02:00 at
+ 01:30 local time gets yesterday's date pre-filled. src/lib/utils.ts already has
+ formatDateOnly(d) that returns the local yyyy-MM-dd for exactly this
+ case. Now used.
+
+
+
+
+
deviation · review fix · spec hygiene
+
Spec README "Out of scope" list updated to match what shipped
+
+ The original out-of-scope bullet listed getPerToolSpend as "unchanged" on the
+ assumption Reports still called it. The implementation audit found no callers (Reports has
+ its own fetchPerToolByPeriod) and deleted the action. README now says so
+ explicitly so future readers don't infer the action still exists.
+
+
+
+
+
considered + skipped · status enum alignment
+
+
+ The reviewer suggested aligning the new 5-bin StatusKind with the existing
+ 2-bin BudgetForecast.status. Skipped: they answer different questions.
+ BudgetForecast.status classifies projected annual total vs ceiling (binary).
+ The hero classifies actual YTD vs expected YTD (5 bins, including the new no_data).
+ Forcing convergence would degrade one of them.
+
+
+
+
+
+
+ Spec 031 · Implementation notes ·
+ specs/031-budget-workflow-and-redesign/implementation-notes.html
+
+
+
+
diff --git a/specs/031-budget-workflow-and-redesign/plan.html b/specs/031-budget-workflow-and-redesign/plan.html
new file mode 100644
index 0000000..70e09ed
--- /dev/null
+++ b/specs/031-budget-workflow-and-redesign/plan.html
@@ -0,0 +1,764 @@
+
+
+
+
+
+
+
+
+
+
+
Spec 031 · Implementation plan · detail
+
Budget Workflow & Detail Redesign
+
+ Land /budget on the active fiscal year directly, move the all-budgets list to
+ /budget/history, and replace the five flat KPI tiles with a budget-health hero plus
+ a past-month spotlight. Visual only on the detail page — every server action and dialog keeps
+ its current behaviour.
+
+
+
+ Single PR
+ No schema changes
+ 2026-05-21
+
+
+
+
+
+
+
+
+
+
Overview
+
+ Two changes ship together: a workflow change (the sidebar "Budget" lands on the active FY
+ detail view; the all-budgets list moves to /budget/history) and a visual redesign
+ of the detail view (hero replaces the KPI strip, a past-month spotlight pulls one closed period forward,
+ the periods table gets a current-month accent and stronger over/under cues).
+
+
+ Effort is concentrated in refactoring the 813-line BudgetDetailClient into
+ composable sections — the dialogs, the period table, the hero. Every server action stays as-is; every
+ Drizzle query stays as-is. Two existing assets unlock most of the visual work: the at-a-glance card
+ already carries the multi-marker progress logic, and a PastMonthSpotlight component already
+ exists under reports/budget/. Total budget: M — call it 2 dev-days of careful
+ work, mostly reorg and styling.
+
+
+
+
+ Dropped from the README: the per-tool spending breakdown (ToolShareBreakdown
+ / Per-Tool Spending) is not in this plan. The mockup is being edited in
+ parallel to remove it. getPerToolSpend() stays in src/actions/budget.ts for the
+ Reports page; we simply stop calling it from /budget/[id]/page.tsx.
+
+
+
+
+
+
+
Reuse audit
+
+ The heart of the plan. Each row of the new design maps to either an existing asset or — only when no
+ existing asset fits — a new file. Verdict column is honest: reuse, extract, variant of X,
+ or new.
+
+
+
+ reuseexisting file imported as-is
+ lift code out of an existing file into a sibling
+ variantcopy-and-adapt an existing component
+ newnet-new component
+
+
+
+
+
+ | Need |
+ Existing asset |
+ Verdict |
+ Notes |
+
+
+
+
+ | Active-budget data fetch |
+ getActiveBudget() + getBudgetWithCosts() in src/actions/budget.ts |
+ reuse |
+ Both are already used by today's /budget/page.tsx (lines 26-34) and /budget/[id]/page.tsx. No signature change. |
+
+
+ | Per-period Anthropic running costs |
+ getRunningCostsForPeriod() in src/lib/budget-utils.ts |
+ reuse |
+ Already returns workspaceBreakdown when more than one workspace is present — the indented "ws-row" markup in the mock is just rendering this existing shape. |
+
+
+ | Currency / date / variance formatting |
+ formatCurrency, formatDate, formatDateTime, formatVariance, varianceClassName in src/lib/utils.ts |
+ reuse |
+ All five already imported by BudgetDetailClient. Keep as-is. |
+
+
+ | Budget Health Hero — base component |
+
+ Three candidates exist:
+ (a) src/components/reports/overview/budget-health-hero.tsx — narrative card, no progress bar
+ (b) src/components/shared/budget-health-hero.tsx — re-export of (a)
+ (c) src/components/dashboard/admin/budget-hero-section.tsx — (a) + a SpendProgressBar
+ (d) src/components/reports/budget/at-a-glance.tsx — full hero with bar + 4 stat tiles
+ |
+ variant |
+
+ Decision: none of (a)-(d) fit verbatim. (d) is closest — it already renders ceiling + actual YTD + projection + stat tiles, but its prop shape (BudgetForecast) comes from the Reports pipeline which the budget page doesn't compute. (a)/(b)/(c) all link to /reports/budget as a CTA, which is wrong here (we are the budget page).
+ Build a new src/app/budget/components/budget-health-hero.tsx as a variant of (d): same multi-marker bar idea + 4-stat strip, but accepts the raw BudgetWithCosts + runningCosts the detail page already has, and computes planned-YTD / expected-YTD / actual-YTD inline. ~120 lines, no new server action.
+ |
+
+
+ | Multi-marker progress bar |
+ SpendProgressBar in src/components/shared/spend-progress-bar.tsx |
+ variant |
+
+ The shared bar renders actual + ceiling tick + projected overage — two markers. The mock needs
+ actual fill + planned-YTD marker + expected-YTD marker + unallocated buffer + ceiling tick — five layers.
+ Inline the multi-marker markup directly inside BudgetHealthHero (mockup lines 230-275 are
+ ready-to-port). Don't generalize the shared bar — Reports doesn't need this complexity.
+ |
+
+
+ | Past-month spotlight |
+ src/components/reports/budget/past-month-spotlight.tsx |
+ variant |
+
+ A spotlight component already exists but takes BudgetReportPastMonth (a Reports-specific
+ type with pre-computed drivers). For the budget detail page we have BudgetWithCosts only —
+ no driver list, no priorPeriodLabel. Build src/app/budget/components/past-month-spotlight.tsx
+ with a smaller prop shape: { period, planned, expected, billed, running }. The "most recent completed
+ period" rule: period.endDate < today AND (billed > 0 OR running > 0), pick the latest by endDate.
+ Visually it's the warn-tinted card from mockup lines 308-352 with 4 stat tiles. ~80 lines.
+ |
+
+
+ | Status pill (on-track / at-risk / over) |
+ src/components/ui/badge.tsx |
+ reuse |
+
+ <Badge variant="default|destructive|secondary"> covers the three states. No new
+ budget-status helper exists in the codebase (grep returns nothing). Thresholds live inline in
+ BudgetHealthHero: actual > expected × 1.05 = over · 0.95 ≤ ratio ≤ 1.05 =
+ on-track · otherwise tracking. TBD: confirm these percentages with the spec (see Risks).
+ |
+
+
+ | Breadcrumb |
+ src/components/ui/breadcrumb.tsx |
+ reuse |
+ shadcn breadcrumb is installed. Use it for Budget / FY 2026 on the detail view; the first segment links to /budget/history. |
+
+
+ | All-budgets table (history page) |
+ src/app/budget/budget-table.tsx + src/app/budget/budget-list-actions.tsx |
+ reuse |
+ Used verbatim. Render under an <h1>Budget history</h1> on the new /budget/history route. No changes to either file. |
+
+
+ | Period allocations table (incl. expand, inline edit, billed-cost rows, running API, workspace breakdown) |
+ Currently inline in src/app/budget/[id]/budget-detail-client.tsx (lines 318-572) |
+ |
+
+ Pull out as src/app/budget/components/period-allocations-table.tsx. Functionality preserved
+ verbatim: row expansion (expandedPeriods set), inline allocation edit (Input type="number"),
+ billed-cost child rows with edit/delete, running-API row (cyan), per-workspace breakdown rows, "Add billed cost"
+ + button, YTD total row, admin/archived guards. Styling layer adds: current row class
+ (primary-color left edge), future row class (muted text), over/under row backgrounds.
+ Pure CSS — no logic change.
+ |
+
+
+ | Add billed-cost dialog |
+ Inline in BudgetDetailClient (lines 612-697) |
+ |
+ Move to src/app/budget/components/dialogs/add-billed-cost-dialog.tsx. Same form state, same createBilledCost call, same validation. Lifted state stays in the parent client. |
+
+
+ | Edit billed-cost dialog |
+ Inline in BudgetDetailClient (lines 700-783) |
+ |
+ Move to src/app/budget/components/dialogs/edit-billed-cost-dialog.tsx. Same shape as Add but pre-populated. |
+
+
+ | Delete billed-cost confirmation |
+ Inline in BudgetDetailClient (lines 785-810) |
+ |
+ Move to src/app/budget/components/dialogs/delete-billed-cost-dialog.tsx. Same AlertDialog + deleteBilledCost call. |
+
+
+ | Server actions (createBilledCost, updateBilledCost, deleteBilledCost, updateBudgetAllocations) |
+ src/actions/budget.ts |
+ reuse |
+ Zero signature changes. Zero new actions. |
+
+
+ Empty state ("no active budget" on /budget) |
+ None — the current empty state is a 4-line Card |
+ new |
+
+ Small block inline in src/app/budget/page.tsx. Two CTAs: "Create Annual Budget" (admin-only, gated)
+ and "View past budgets →" linking to /budget/history. Reuses existing Card / Button
+ primitives. ~25 lines of markup.
+ |
+
+
+ | Sidebar entry |
+ src/components/app-sidebar.tsx line 55 |
+ reuse |
+ Unchanged. Budget → /budget still works; the destination just renders the detail view directly now. |
+
+
+ | Per-tool spending breakdown |
+ getPerToolSpend + the share-list markup in BudgetDetailClient lines 574-609 |
+ drop |
+ Out of scope (see Overview callout). Delete the toolBreakdown prop and the getPerToolSpend call from /budget/[id]/page.tsx. getPerToolSpend itself stays in src/actions/budget.ts — Reports still uses it. |
+
+
+
+
+
+
+
+
File-by-file change list
+
+ Every file in the blast radius, including unchanged-but-relevant ones so a reviewer can see exactly
+ what's not touched. Action column uses one of edit
+ add move
+ delete unchanged.
+
+
+
+
+
+ | Path |
+ Action |
+ Notes |
+
+
+
+
+ | src/app/budget/page.tsx |
+ edit |
+
+ Replace contents. Server component that fetches getActiveBudget(), then if found
+ getBudgetWithCosts(active.id) + per-period running costs, then renders the shared
+ BudgetDetailClient. If no active budget: render empty state with two CTAs.
+ |
+
+
+ | src/app/budget/[id]/page.tsx |
+ edit |
+
+ Slim down: drop the getPerToolSpend call and the toolBreakdown prop. Keep the rest
+ (fetch BudgetWithCosts by id, fetch running costs, render BudgetDetailClient, wrap in
+ AuthGuard requiredRole="admin").
+ |
+
+
+ | src/app/budget/[id]/budget-detail-client.tsx |
+ edit |
+
+ Refactor: composes BudgetHealthHero, PastMonthSpotlight, PeriodAllocationsTable,
+ and the three dialogs. Keeps the dialog open/close + form state at this level (the extracted dialogs are
+ controlled). Loses the toolBreakdown prop and the share-list JSX. Target size: ~250 lines, down from 813.
+ |
+
+
+ | src/app/budget/components/budget-health-hero.tsx |
+ add |
+
+ Client component. Takes { budget: BudgetWithCosts, runningCosts: Record<number, RunningCostData> }.
+ Renders the breadcrumb, FY title, status badge, narrative line, multi-marker progress bar, and
+ 4-stat strip (YTD billed / May running / Projected year-end / Variance vs expected). Inline thresholds for status.
+ |
+
+
+ | src/app/budget/components/past-month-spotlight.tsx |
+ add |
+
+ Server component (pure render). Takes the resolved past-month period + computed planned/expected/billed/running.
+ Renders the warn/danger-tinted card with 4 small stat tiles. Hides itself when no completed period exists yet.
+ |
+
+
+ | src/app/budget/components/period-allocations-table.tsx |
+ add |
+
+ Client component (uses local state for expansion + allocation input). Receives periods,
+ runningCosts, allocations + setAllocations, isAdmin, isArchived,
+ and four callbacks (onAddBilledCost, onEditBilledCost, onDeleteBilledCost,
+ onSaveAllocations). All existing rows: period row, expanded billed-cost children, running API row, ws-rows,
+ YTD total, "Save Allocations" row.
+ |
+
+
+ | src/app/budget/components/dialogs/add-billed-cost-dialog.tsx |
+ add |
+ Extracted from lines 612-697 of the current client. No behaviour change. |
+
+
+ | src/app/budget/components/dialogs/edit-billed-cost-dialog.tsx |
+ add |
+ Extracted from lines 700-783. No behaviour change. |
+
+
+ | src/app/budget/components/dialogs/delete-billed-cost-dialog.tsx |
+ add |
+ Extracted from lines 785-810. No behaviour change. |
+
+
+ | src/app/budget/history/page.tsx |
+ add |
+
+ Server component. Fetches getBudgets(), renders <h1>Budget history</h1> +
+ <BudgetTable data={allBudgets} />. Wrapped in AuthGuard requiredRole="admin".
+ ~25 lines.
+ |
+
+
+ | src/app/budget/budget-table.tsx |
+ unchanged |
+ Imported from the new history page. File location stays — the import path moves from /budget/page.tsx to /budget/history/page.tsx. |
+
+
+ | src/app/budget/budget-list-actions.tsx |
+ unchanged |
+ Imported transitively via BudgetTable. Still works. |
+
+
+ | src/components/app-sidebar.tsx |
+ unchanged |
+ Line 55: { title: "Budget", href: "/budget", roles: ["admin"] } — exactly right. |
+
+
+ | src/actions/budget.ts |
+ unchanged |
+ Zero changes. getPerToolSpend stays exported (Reports still calls it). |
+
+
+ | src/lib/budget-utils.ts |
+ unchanged |
+ getRunningCostsForPeriod and findActivePeriodForDate both stay as-is. |
+
+
+ | src/lib/utils.ts |
+ unchanged |
+ Formatters reused. |
+
+
+ | src/components/ui/breadcrumb.tsx |
+ unchanged |
+ shadcn breadcrumb — exists, imported by the new hero. |
+
+
+ | src/components/shared/spend-progress-bar.tsx |
+ unchanged |
+ Not used here — Reports keeps it. The new hero inlines a different multi-marker bar. |
+
+
+ | src/components/reports/budget/past-month-spotlight.tsx |
+ unchanged |
+ Stays where it is, scoped to Reports. The new budget-page spotlight is a separate sibling with a simpler prop shape. |
+
+
+ | tests/e2e/budget-period-running-costs.spec.ts |
+ edit |
+
+ The second test asserts "Billed" is visible on /budget. After this change /budget
+ is the detail view, which uses different copy ("YTD billed" / "Actual"). Update the assertion or remove the test.
+ |
+
+
+
+
+
+
+
+
Implementation steps (ordered)
+
+ Twelve steps, ordered so that every interim commit typechecks and the app still runs. The refactor
+ lands before the visuals so behaviour stays stable through the redesign.
+
+
+
+
+ -
+
1Extract the three billed-cost dialogs into siblings
+
+ Move the Add / Edit / Delete dialog markup from BudgetDetailClient into the three new files
+ under src/app/budget/components/dialogs/. Each is a controlled component:
+ props are open, onOpenChange, the form state, and the submit handler. The parent
+ keeps owning the state.
+
+ Detail page renders identically; opening / submitting / cancelling each dialog still works against the real DB.
+
+
+ -
+
2Extract the period allocations table
+
+ Move the <Table> + row mapping + expansion logic + "Save Allocations" row out of
+ BudgetDetailClient into src/app/budget/components/period-allocations-table.tsx.
+ State stays exactly where it is today. Pure reorganisation.
+
+ pnpm typecheck + pnpm lint clean. Manually: expand a row, edit allocation, save, add billed cost, edit, delete — all still work.
+
+
+ -
+
3Build BudgetHealthHero
+
+ Add src/app/budget/components/budget-health-hero.tsx. Inline the multi-marker
+ progress bar markup from mockup lines 230-275 (the percentage math is straightforward: planned-YTD,
+ expected-YTD, actual-YTD, all divided by budget.totalAmountCents). 4-stat strip below. Status
+ badge with the inline thresholds called out in the Reuse Audit. Narrative sentence stays as plain prose with
+ formatted dollar values inserted via formatCurrency.
+
+ Hero renders the same totals shown by the current KPI strip. Compare side-by-side in the browser.
+
+
+ -
+
4Build PastMonthSpotlight
+
+ Add src/app/budget/components/past-month-spotlight.tsx. Resolve the spotlight
+ period server-side in BudgetDetailClient (or in the parent server page; either works) using the
+ rule from the Reuse Audit. Render the 4 stat tiles + a "Jump to {month} row" link with a fragment hash.
+ Hide the section entirely when no completed period exists.
+
+ For an FY where ≥1 period has billed costs and endDate < today, the spotlight appears and the "jump" link scrolls to the correct row.
+
+
+ -
+
5Restyle PeriodAllocationsTable
+
+ Apply the visual rules from the mockup: a class current on the row whose
+ startDate ≤ today ≤ endDate (primary-color left accent + "Current" chip in the period label);
+ future class for rows with startDate > today (muted text, no expand affordance,
+ em-dash placeholders); over / under background tints driven by the existing
+ variance calculation (over: variance > expected × 0.05, under: < −5%).
+ The cyan "Anthropic API (running)" row gets a subtler background plus a small live chip.
+
+ Open a budget with Jan/Feb under, Mar over, May current — visually distinct without reading numbers.
+
+
+ -
+
6Add breadcrumb + "All budgets →" link to the detail view
+
+ Mount the shadcn Breadcrumb above the page H1: Budget / FY {fiscalYear}
+ where the first segment links to /budget/history. Add a subtle "All budgets →" link in the
+ right-hand header next to the status pill, also pointing at /budget/history. Keep the "New Budget" admin button.
+
+ From the detail view, both breadcrumb and "All budgets" link land on a working /budget/history route (created in step 7).
+
+
+ -
+
7Add the /budget/history route
+
+ Create src/app/budget/history/page.tsx. Server component, AuthGuard requiredRole="admin",
+ fetches getBudgets(), renders BudgetTable under an H1. Add a small "Back to active budget" link
+ above the H1 pointing at /budget.
+
+ From /budget/history: row links jump to /budget/[id], the archive flow still works, the "Back to active budget" link returns to /budget.
+
+
+ -
+
8Replace /budget/page.tsx with the active-budget renderer
+
+ Rewrite the contents: fetch getActiveBudget(); if present, fetch getBudgetWithCosts
+ + per-period running costs and render the same <BudgetDetailClient> the [id] page uses;
+ if absent, render the empty state with "Create Annual Budget" (admin) and "View past budgets →" links.
+
+ Sidebar Budget → lands directly on the active FY view (no intermediate summary card). For a fresh database with no budgets, the empty state shows both CTAs.
+
+
+ -
+
9Drop toolBreakdown from /budget/[id]/page.tsx
+
+ Remove the getPerToolSpend call, the toolBreakdown variable, and the prop on
+ BudgetDetailClient. Delete the per-tool share-list JSX in the client. getPerToolSpend
+ itself stays exported — Reports still imports it.
+
+ Detail page no longer renders the "Per-Tool Spending Breakdown" card. Reports page (/reports) still shows its per-tool table — confirms the action is intact.
+
+
+ -
+
10Edge-case sweep
+
+ Walk through each scenario in dev:
+
+
+ - No active budget, no archived:
/budget empty state, "Create" CTA only, "View past budgets →" still shown but /budget/history is also empty.
+ - Active FY only (no history):
/budget/history shows the one active row.
+ - Archived FY's only (no active):
/budget empty state, "View past budgets →" leads to populated history.
+ - Single budget overall: matches today's behaviour where the all-budgets card was hidden.
+ - Viewer role: sidebar entry already admin-gated; direct navigation to
/budget hits AuthGuard.
+ - Archived budget detail: dialogs and allocation input disabled (same
isArchived guard).
+
+ All six paths render without console errors.
+
+
+ -
+
11Update tests
+
+ Run pnpm test, pnpm test:integration, pnpm test:e2e. Fix
+ tests/e2e/budget-period-running-costs.spec.ts (the "Billed" assertion on /budget no
+ longer holds). Add no new tests unless an existing one fails to assert the new behaviour.
+
+ All three test suites green.
+
+
+ -
+
12Browser smoke
+
+ pnpm dev → log in as the seeded admin agent (or use the agent-browser-session skill) →
+ click "Budget" in the sidebar → confirm the new layout → expand a period → edit a planned allocation → save →
+ add a billed cost → edit it → delete it → click "All budgets →" → confirm history page → click into an archived
+ FY → confirm it's read-only with the new visuals → use the breadcrumb to return to history → "Back to active
+ budget" to /budget.
+
+ All twelve actions succeed without page reloads or errors. Screenshot for the PR.
+
+
+
+
+
+
+
+
Test plan
+
+ Tight scope — there are no new actions, so no new unit tests are needed. The bulk of the validation is
+ manual + the existing e2e suite, with one small fix.
+
+
+ -
+ Existing unit tests stay untouched.
+
grep "budget" tests/ -l returns:
+ tests/unit/reports/insights-static.test.ts,
+ tests/unit/invoice-sync.test.ts,
+ tests/unit/forecast.test.ts,
+ tests/unit/actions/running-costs.test.ts,
+ tests/integration/invoice-sync.test.ts. None of these touch /budget routes; all should pass unchanged.
+
+ -
+ E2E fix: tests/e2e/budget-period-running-costs.spec.ts — the
+ second test (
"budget overview shows Billed or Actual label for cost summary") navigates to
+ /budget and asserts "Billed" is visible. Post-change the budget index is the detail
+ view, which uses "YTD billed" inside the hero. Either update the regex (/Billed|Actual/) or
+ replace the assertion with a structural check (the periods table is rendered).
+
+ -
+ E2E fix: in the same file's first test (
"shows running costs section when data exists"),
+ the test navigates to /budget then waits for a[href*='/budget/'] and clicks the
+ first match. That selector matches the breadcrumb's own self-link in the new layout — adjust the selector
+ (e.g. table row link) or replace with page.goto('/budget/history').
+
+ -
+ No new Playwright spec in this PR. The redesign is visual + a route move; one focused
+ follow-up can add a budget-workflow e2e once the layout settles.
+
+ -
+ Manual a11y check: tab through the detail view — breadcrumb, status pill, allocation
+ inputs, expand buttons, add/edit/delete icons, Save Allocations button all reachable.
aria-label
+ on icon-only buttons (existing in the current code; verify after extraction).
+
+
+
+
+
+
+
Risks & open questions
+
+
+
+ The three existing BudgetHealthHero components are not interchangeable.
+ (a) reports/overview/budget-health-hero.tsx has a "Open Budget report" CTA — wrong on the budget
+ page itself. (b) shared/budget-health-hero.tsx just re-exports (a). (c) dashboard/admin/budget-hero-section.tsx
+ renders the shared SpendProgressBar which is a 2-marker bar — not the 5-layer composition the mockup needs.
+ Picking any of these as a base forces a rewrite mid-implementation. Mitigation: build a new sibling
+ in src/app/budget/components/, sourcing layout ideas from at-a-glance.tsx but rebuilding the bar inline.
+
+
+
+
+
+ Past-month spotlight: "most recent completed period" needs a clear definition. Proposed:
+ the period with the latest endDate such that endDate < today. Edge cases: at the very
+ start of FY (no completed periods yet) the spotlight hides; on the first day of a new month, last month
+ counts as completed immediately. Mitigation: hide the spotlight when no period qualifies; add a unit test for the
+ cut-off if this becomes flaky.
+
+
+
+
+
+ Status-pill thresholds (on-track / at-risk / over) are not specified. The README says
+ "on-track / at-risk / over-budget" but doesn't give percentages. Proposed defaults:
+ actual / expected < 0.95 = under, 0.95 ≤ ratio ≤ 1.05 = on-track,
+ 1.05 < ratio ≤ 1.15 = at-risk, > 1.15 = over. TBD with product
+ before merge — if no answer, ship the defaults and surface as a constant at the top of BudgetHealthHero.
+
+
+
+
+
+ shadcn breadcrumb confirmed installed (src/components/ui/breadcrumb.tsx exists).
+ No install / no inline <nav> fallback needed.
+
+
+
+
+
+ "Save Allocations" button placement after the table grows. The mockup puts the save row
+ as a footer inside the periods card (lines 726-742), which keeps it visually attached to what's being
+ edited. With 12 monthly periods + expansions, the table is tall and the save button sits below the fold
+ on a 1080p screen. Mitigation: leave it in the footer for v1 (matches the mock + current
+ UX); if user feedback says "I lost the save button", a follow-up can make it sticky.
+
+
+
+
+
+
+
Out of scope
+
+ - No schema changes. Every existing table (
annual_budgets, budget_periods, billed_costs, anthropic_workspace_costs) stays as-is.
+ - No server-action signature changes.
getActiveBudget, getBudgets, getBudgetWithCosts, getPerToolSpend, getRunningCostsForPeriod, updateBudgetAllocations, createBilledCost, updateBilledCost, deleteBilledCost, archiveBudget — all unchanged.
+ - No per-tool spending breakdown. Dropped per parallel mockup edit.
getPerToolSpend stays exported for Reports.
+ - No new-budget creation flow changes.
/budget/new untouched.
+ - No archive / edit logic changes. The
BudgetListActions dropdown stays as-is.
+ - No Reports page changes. Spec 028 owns
/reports/budget.
+ - No MoM delta backend. The README mentioned this for the dropped per-tool section — not relevant.
+ - No new background jobs, no new env vars, no new dependencies.
+
+
+
+
+
+
Effort estimate
+
+ Honest hour bands per step, assuming familiarity with the codebase. The refactor (steps 1-2) is roughly
+ half the calendar time even though it's the smallest visible diff — extracting state-dependent components
+ without regressions is the slow part.
+
+
+
+
+ | Step | Work | Effort |
+
+
+ | 1 | Extract three billed-cost dialogs | ~1h |
+ | 2 | Extract PeriodAllocationsTable | ~2h |
+ | 3 | Build BudgetHealthHero (multi-marker bar + 4 stats + narrative) | ~3h |
+ | 4 | Build PastMonthSpotlight | ~1h |
+ | 5 | Restyle PeriodAllocationsTable (current / future / over / under classes) | ~1.5h |
+ | 6 | Breadcrumb + "All budgets →" link | ~30m |
+ | 7 | Add /budget/history route | ~30m |
+ | 8 | Replace /budget/page.tsx contents | ~45m |
+ | 9 | Drop toolBreakdown from the [id] route | ~15m |
+ | 10 | Edge-case sweep | ~45m |
+ | 11 | Fix the e2e spec + run the full test suite | ~45m |
+ | 12 | Browser smoke + PR screenshot | ~30m |
+ | Total (single dev, no context switching) | ~12h · ~1.5 days |
+
+
+
+
+ Confidence: medium-high. The reuse audit shows ~85% of the work is moving existing code
+ around; the genuinely new pieces (hero, spotlight) total ~200 LoC of straightforward render markup. The
+ one place to slow down is step 3 — the multi-marker bar's percentage math against the ceiling needs
+ careful unit-by-unit checking against the mockup's annotated widths.
+
+
+
+
+ Spec 031 · Budget Workflow & Detail Redesign · Implementation plan ·
+ specs/031-budget-workflow-and-redesign/plan.html
+
+
+
+
diff --git a/src/actions/budget.ts b/src/actions/budget.ts
index 65e3580..e7ca872 100644
--- a/src/actions/budget.ts
+++ b/src/actions/budget.ts
@@ -646,33 +646,3 @@ export async function fetchActualByPeriod(
});
return actualByPeriod;
}
-
-
-// US5: Per-tool spending breakdown for a period
-export async function getPerToolSpend(startDate: string, endDate: string) {
- const result = await db
- .select({
- toolId: aiTools.id,
- toolName: aiTools.name,
- totalCents: sum(licenseAssignments.costAtAssignmentCents),
- assignmentCount: count(licenseAssignments.id),
- })
- .from(licenseAssignments)
- .innerJoin(aiTools, eq(licenseAssignments.toolId, aiTools.id))
- .where(
- and(
- eq(licenseAssignments.status, "active"),
- lte(licenseAssignments.assignedAt, new Date(endDate)),
- or(
- isNull(licenseAssignments.revokedAt),
- gte(licenseAssignments.revokedAt, new Date(startDate))
- )
- )
- )
- .groupBy(aiTools.id, aiTools.name);
-
- return result.map((r) => ({
- ...r,
- totalCents: Number(r.totalCents ?? 0),
- }));
-}
diff --git a/src/app/budget/[id]/budget-detail-client.tsx b/src/app/budget/[id]/budget-detail-client.tsx
deleted file mode 100644
index f56303e..0000000
--- a/src/app/budget/[id]/budget-detail-client.tsx
+++ /dev/null
@@ -1,813 +0,0 @@
-"use client";
-
-import { Fragment, useState } from "react";
-import { useRouter } from "next/navigation";
-import { toast } from "sonner";
-import {
- updateBudgetAllocations,
- createBilledCost,
- updateBilledCost,
- deleteBilledCost,
-} from "@/actions/budget";
-import { formatCurrency, formatDate, formatDateTime, formatVariance, varianceClassName } from "@/lib/utils";
-import type { BudgetWithCosts, PeriodWithCosts, BilledCost } from "@/types";
-import type { RunningCostData } from "./page";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Badge } from "@/components/ui/badge";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import {
- ChevronDown,
- ChevronRight,
- Plus,
- Pencil,
- Trash2,
-} from "lucide-react";
-
-interface ToolSpend {
- toolId: number;
- toolName: string;
- totalCents: number;
- assignmentCount: number;
-}
-
-interface Props {
- budget: BudgetWithCosts;
- toolBreakdown: ToolSpend[];
- isAdmin: boolean;
- runningCosts?: Record