From d4e6076ea7abb554e46b974fe25a95ea848d883f Mon Sep 17 00:00:00 2001
From: Tobias Studer
Date: Thu, 21 May 2026 16:39:38 +0200
Subject: [PATCH 1/5] feat(budget): workflow + detail redesign (spec 031)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /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)
---
.../README.md | 84 ++
.../detail-mock.html | 793 +++++++++++++++++
.../implementation-notes.html | 519 +++++++++++
.../plan.html | 764 ++++++++++++++++
src/actions/budget.ts | 30 -
src/app/budget/[id]/budget-detail-client.tsx | 813 ------------------
src/app/budget/[id]/page.tsx | 24 +-
.../components/budget-detail-client.tsx | 249 ++++++
.../components/budget-detail-header.tsx | 75 ++
.../budget/components/budget-health-hero.tsx | 326 +++++++
.../components/dialogs/billed-cost-dialog.tsx | 152 ++++
.../components/dialogs/billed-cost-form.ts | 15 +
.../dialogs/delete-billed-cost-dialog.tsx | 57 ++
src/app/budget/components/dialogs/index.ts | 2 +
.../components/past-month-spotlight.tsx | 94 ++
.../components/period-allocations-table.tsx | 384 +++++++++
src/app/budget/components/stat-tile.tsx | 26 +
src/app/budget/history/page.tsx | 73 ++
src/app/budget/page.tsx | 233 ++---
tests/e2e/budget-period-running-costs.spec.ts | 27 +-
20 files changed, 3698 insertions(+), 1042 deletions(-)
create mode 100644 specs/031-budget-workflow-and-redesign/README.md
create mode 100644 specs/031-budget-workflow-and-redesign/detail-mock.html
create mode 100644 specs/031-budget-workflow-and-redesign/implementation-notes.html
create mode 100644 specs/031-budget-workflow-and-redesign/plan.html
delete mode 100644 src/app/budget/[id]/budget-detail-client.tsx
create mode 100644 src/app/budget/components/budget-detail-client.tsx
create mode 100644 src/app/budget/components/budget-detail-header.tsx
create mode 100644 src/app/budget/components/budget-health-hero.tsx
create mode 100644 src/app/budget/components/dialogs/billed-cost-dialog.tsx
create mode 100644 src/app/budget/components/dialogs/billed-cost-form.ts
create mode 100644 src/app/budget/components/dialogs/delete-billed-cost-dialog.tsx
create mode 100644 src/app/budget/components/dialogs/index.ts
create mode 100644 src/app/budget/components/past-month-spotlight.tsx
create mode 100644 src/app/budget/components/period-allocations-table.tsx
create mode 100644 src/app/budget/components/stat-tile.tsx
create mode 100644 src/app/budget/history/page.tsx
diff --git a/specs/031-budget-workflow-and-redesign/README.md b/specs/031-budget-workflow-and-redesign/README.md
new file mode 100644
index 0000000..83dfd6b
--- /dev/null
+++ b/specs/031-budget-workflow-and-redesign/README.md
@@ -0,0 +1,84 @@
+# Spec 031 — Budget Workflow & Detail Redesign
+
+Status: research / mockup phase
+Created: 2026-05-21
+Owner: tobias.studer@unic.com
+
+## TL;DR
+
+Sidebar "Budget" should land directly on the current fiscal year's detail view, not a summary card that requires a second "View Details" click. The all-budgets list moves to `/budget/history`. The detail view itself gets a visual redesign — see [`detail-mock.html`](detail-mock.html).
+
+## The workflow problem
+
+Today, `src/app/budget/page.tsx` (lines 86–155) renders a Card summarising the active FY with six KPI tiles and a "View Details" button that links to `/budget/[activeBudget.id]`. The detail page (`src/app/budget/[id]/page.tsx`) re-fetches and re-renders the same totals with more depth. 95% of admin visits to "Budget" want the current year — they pay one extra click and a full second page render to get there. The "All Budgets" card below (line 169, only shown when `allBudgets.length > 1`) is the only thing the index page provides that the detail view doesn't, and it's the rarer use case.
+
+## Proposed workflow
+
+Sidebar "Budget" → `/budget` resolves to the **current active budget detail view**. No intermediate summary card, no second click.
+
+**Recommendation: fold, don't redirect.** Replace the contents of `src/app/budget/page.tsx` with the detail-view rendering, fetching `getActiveBudget()` and treating the result as the page's primary data. Keep `src/app/budget/[id]/page.tsx` as the route for *specific* (typically past / archived) budgets reached from history. The redirect alternative — `redirect('/budget/' + activeBudgetId)` from the index — is cleaner URL-wise but adds a server round-trip on every sidebar click and creates a non-deterministic "what is /budget?" answer. Folding pays for itself by making the landing instant; the duplication is one shared client component (`BudgetDetailClient`) imported from both routes, not two implementations.
+
+The all-budgets list (currently the second Card on `/budget`) moves to **`/budget/history`**. "History" is preferred over "all" because it signals *past + archived* — the active budget is right there on `/budget`, not on the list page. Surface it from the detail view in two places:
+
+- A small `All budgets →` link in the page header next to the FY title.
+- A breadcrumb row above the H1: `Budget / FY 2026` where `Budget` deep-links to `/budget/history`.
+
+The "New Budget" button stays on the detail view header (admin-only, already gated).
+
+**Edge case — no active budget exists:** `/budget` renders an empty state with two CTAs: "Create Annual Budget" (admin-only) and "View past budgets →" pointing at `/budget/history`. Without the second link, an admin landing on an empty `/budget` would have no way to discover that archived budgets exist.
+
+### New URL structure
+
+```text
+/budget → active FY detail view (was: summary card + View Details)
+/budget/history → all budgets table (was: bottom Card on /budget)
+/budget/[id] → a specific (past/archived) budget detail view (unchanged route, same client)
+/budget/new → create-budget form (unchanged)
+```
+
+## Visual redesign of the detail view
+
+See [`detail-mock.html`](detail-mock.html) for the proposed visual treatment (dark-themed, matches the codebase styling from 028/029). The redesign is **visual only** — every server action, dialog, allocation editor, and CRUD path in `BudgetDetailClient` (`src/app/budget/[id]/budget-detail-client.tsx`) keeps its current behaviour. What's changing:
+
+- **Replace the five flat KPI cards** (lines 271–316: Total Budget / Allocated / Expected / Actual / Variance) with a single **budget-health hero**: planned, expected, and actual rendered as overlaid markers on one multi-segment progress bar against the annual ceiling, plus a status pill (on-track / at-risk / over-budget) and a one-sentence narrative ("Tracking $14k under expected as of mid-May").
+- **Collapse the conceptual overlap** of `Total Budget` / `Allocated` / `Unallocated` — the same number expressed three ways. Surface "Unallocated $X remaining" as a small annotation under the hero progress bar, not its own card.
+- **Highlight the current month** in the periods table (line 338 onwards). Today there's no visual cue that "May" is *now* — all 12 rows look identical until one happens to be `bg-destructive/10`.
+- **Strengthen the over-budget signal.** `bg-destructive/10` is the only cue today (line 359) — easy to miss against the muted table background. Add a left-edge accent bar plus a status chip in the variance column.
+- **Keep the expandable period rows** (billed-cost line items + Anthropic API running breakdown) — they're the most useful part of the current page. Just style the indent and the "Anthropic API (running)" callout consistently with the new palette.
+
+What's **not** changing: the add/edit/delete billed-cost dialogs, allocation save flow, the `runningCosts` data shape, archived-budget read-only behaviour, or any server action.
+
+## Today vs proposed
+
+| Area | Today | Proposed | Why |
+|---|---|---|---|
+| Landing page | `/budget` shows a summary Card + "View Details" button | `/budget` renders the active FY detail view directly | One click less for the common case; the summary card duplicated data already on the detail page |
+| All-budgets list | Second Card on `/budget`, only visible when >1 budget exists | Dedicated `/budget/history` page, reachable from header + breadcrumb | Frees the landing page for the active budget; discoverable regardless of count |
+| KPI strip | 5 (detail) / 6 (index) flat tiles, conceptually overlapping | One budget-health hero with multi-marker progress bar, status pill, narrative | Tells the user the answer ("on track / not on track") in one glance; current tiles require mental arithmetic |
+| Period highlighting | Single `bg-destructive/10` row tint when over budget | Current-month accent + stronger over-budget signal (left-edge bar + variance chip) | "When is now?" and "what's broken?" should be answerable in <1 second |
+
+## 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.
+- Per-tool budgets (still derived from `expected_spend_cents`; spec 028 open question still applies but is not blocked by this).
+
+## Implementation sketch
+
+- `src/app/budget/page.tsx` — replace the index rendering with the same logic currently in `src/app/budget/[id]/page.tsx`, sourced from `getActiveBudget()`. Empty state gets a "View past budgets →" link to `/budget/history`.
+- `src/app/budget/[id]/page.tsx` — unchanged route handler; keeps serving specific budgets reached from history. The client component is shared.
+- `src/app/budget/[id]/budget-detail-client.tsx` — refactor into composable sections: `BudgetHealthHero`, `PeriodAllocationsTable`. Each takes the same props it computes from today. Dialogs (`AddBilledCostDialog`, `EditBilledCostDialog`, `DeleteBilledCostDialog`) extracted into siblings to keep the parent under 300 lines.
+- `src/app/budget/history/page.tsx` — new server component. Renders an `
Budget History
` + the existing `BudgetTable` from `src/app/budget/budget-table.tsx`. Reuses `getBudgets()`.
+- `src/app/budget/budget-table.tsx` — unchanged.
+- `src/components/app-sidebar.tsx` line 55 — unchanged (`Budget` still points at `/budget`).
+- Breadcrumb component — either use a shared `` if one exists, or inline a two-segment `
+
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.
+
+ 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
diff --git a/src/app/budget/components/budget-detail-client.tsx b/src/app/budget/components/budget-detail-client.tsx
index b8a5260..e455ebe 100644
--- a/src/app/budget/components/budget-detail-client.tsx
+++ b/src/app/budget/components/budget-detail-client.tsx
@@ -192,7 +192,11 @@ export function BudgetDetailClient({
showBreadcrumb={showBreadcrumb}
/>
-
+
diff --git a/src/app/budget/components/budget-health-hero.tsx b/src/app/budget/components/budget-health-hero.tsx
index 6313a9e..58bf1d8 100644
--- a/src/app/budget/components/budget-health-hero.tsx
+++ b/src/app/budget/components/budget-health-hero.tsx
@@ -3,11 +3,19 @@ import { Card, CardContent } from "@/components/ui/card";
import { formatCurrency, formatVariance } from "@/lib/utils";
import type { BudgetWithCosts } from "@/types";
import type { RunningCostsResult } from "@/lib/budget-utils";
+import { classifyPeriod } from "@/lib/reports/period-helpers";
import { StatTile } from "./stat-tile";
interface Props {
budget: BudgetWithCosts;
runningCosts: Record;
+ /**
+ * Optional in-progress allocation edits keyed by period id. When omitted,
+ * the hero reads `period.plannedAmountCents` (the persisted value). The
+ * parent client passes this so the hero's "unallocated" / "planned YTD"
+ * figures stay in sync while the user edits the period table.
+ */
+ allocations?: Record;
}
type StatusKind = "no_data" | "under" | "on_track" | "at_risk" | "over";
@@ -57,29 +65,27 @@ function statusBadgeVariant(
}
}
-export function BudgetHealthHero({ budget, runningCosts }: Props) {
+export function BudgetHealthHero({ budget, runningCosts, allocations }: Props) {
const today = new Date();
const periods = budget.periods;
const ceiling = budget.totalAmountCents;
const totals = periods.reduce(
(a, p) => {
- const start = new Date(p.startDate);
- const end = new Date(p.endDate);
- const isYtd = start <= today;
- const isClosed = end < today;
+ const phase = classifyPeriod(p, today);
+ const planned = allocations?.[p.id] ?? p.plannedAmountCents;
const billed = p.billedTotalCents;
const running = runningCosts[p.id]?.runningCostCents ?? 0;
const actual = billed + running;
- a.totalPlanned += p.plannedAmountCents;
- if (isYtd) {
+ a.totalPlanned += planned;
+ if (phase !== "future") {
a.ytdPeriodCount += 1;
- a.plannedYtd += p.plannedAmountCents;
+ a.plannedYtd += planned;
a.expectedYtd += p.expectedSpendCents;
a.billedYtd += billed;
a.runningYtd += running;
}
- if (isClosed && actual > 0) {
+ if (phase === "past" && actual > 0) {
a.closedPeriodCount += 1;
a.closedActual += actual;
}
diff --git a/src/app/budget/components/dialogs/billed-cost-form.ts b/src/app/budget/components/dialogs/billed-cost-form.ts
index e9a3c64..48735eb 100644
--- a/src/app/budget/components/dialogs/billed-cost-form.ts
+++ b/src/app/budget/components/dialogs/billed-cost-form.ts
@@ -1,3 +1,5 @@
+import { formatDateOnly } from "@/lib/utils";
+
export interface BilledCostFormState {
amountDollars: string;
invoiceDate: string;
@@ -8,7 +10,7 @@ export interface BilledCostFormState {
export function makeEmptyBilledCostForm(): BilledCostFormState {
return {
amountDollars: "",
- invoiceDate: new Date().toISOString().slice(0, 10),
+ invoiceDate: formatDateOnly(new Date()),
description: "",
vendorReference: "",
};
diff --git a/src/app/budget/components/past-month-spotlight.tsx b/src/app/budget/components/past-month-spotlight.tsx
index fdebb4c..9294b71 100644
--- a/src/app/budget/components/past-month-spotlight.tsx
+++ b/src/app/budget/components/past-month-spotlight.tsx
@@ -8,6 +8,7 @@ import {
import { formatCurrency, formatVariance } from "@/lib/utils";
import type { BudgetWithCosts } from "@/types";
import type { RunningCostsResult } from "@/lib/budget-utils";
+import { classifyPeriod } from "@/lib/reports/period-helpers";
import { StatTile } from "./stat-tile";
interface Props {
@@ -19,12 +20,11 @@ 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;
+ if (classifyPeriod(p, today) !== "past") continue;
const running = runningCosts[p.id]?.runningCostCents ?? 0;
const actual = p.billedTotalCents + running;
if (actual <= 0) continue;
- const endTime = end.getTime();
+ const endTime = new Date(p.endDate + "T00:00:00Z").getTime();
if (!latest || endTime > latest.endTime) {
latest = { period: p, endTime, actual };
}
diff --git a/src/app/budget/components/period-allocations-table.tsx b/src/app/budget/components/period-allocations-table.tsx
index c2d18a8..5453a21 100644
--- a/src/app/budget/components/period-allocations-table.tsx
+++ b/src/app/budget/components/period-allocations-table.tsx
@@ -22,6 +22,7 @@ import {
} from "@/lib/utils";
import type { BilledCost, BudgetWithCosts } from "@/types";
import type { RunningCostsResult } from "@/lib/budget-utils";
+import { classifyPeriod } from "@/lib/reports/period-helpers";
interface Props {
budget: BudgetWithCosts;
@@ -78,17 +79,24 @@ export function PeriodAllocationsTable({
const canEdit = isAdmin && !isArchived;
+ // Footer aggregates everything ever allocated (planned), but only the
+ // expected/actual numbers for periods that have started — otherwise the
+ // variance line would compare YTD actual against full-year expected and
+ // always read as a massive negative.
const totals = periods.reduce(
(a, p) => {
const running = runningCosts[p.id]?.runningCostCents ?? 0;
+ const isFuture = classifyPeriod(p, today) === "future";
a.allocated += allocations[p.id] ?? 0;
- a.expected += p.expectedSpendCents;
- a.actual += p.billedTotalCents + running;
+ if (!isFuture) {
+ a.expectedToDate += p.expectedSpendCents;
+ a.actualToDate += p.billedTotalCents + running;
+ }
return a;
},
- { allocated: 0, expected: 0, actual: 0 }
+ { allocated: 0, expectedToDate: 0, actualToDate: 0 }
);
- const totalVariance = totals.actual - totals.expected;
+ const totalVariance = totals.actualToDate - totals.expectedToDate;
return (
<>
@@ -119,11 +127,10 @@ export function PeriodAllocationsTable({
expected > 0
? Math.round(((actualCents - expected) / expected) * 100)
: 0;
- 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;
+ const phase = classifyPeriod(period, today);
+ const isCurrent = phase === "current";
+ const isFuture = phase === "future";
+ const isClosed = phase === "past";
const isOverExpected = isClosed && variance > expected * 0.05;
const isUnderExpected = isClosed && variance < -expected * 0.05;
const isExpanded = expandedPeriods.has(period.id);
@@ -355,8 +362,8 @@ export function PeriodAllocationsTable({
YTD Total{formatCurrency(totals.allocated)}
- {formatCurrency(totals.expected)}
- {formatCurrency(totals.actual)}
+ {formatCurrency(totals.expectedToDate)}
+ {formatCurrency(totals.actualToDate)}
{formatVariance(totalVariance)}
From 405f4ed311fd4fc42a82d81e897cd7a0750fc0e0 Mon Sep 17 00:00:00 2001
From: Tobias Studer
Date: Thu, 21 May 2026 16:59:24 +0000
Subject: [PATCH 4/5] refactor(budget/hero): anchored projection +
closed-window variance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../implementation-notes.html | 70 +++++
.../budget/components/budget-health-hero.tsx | 282 ++++++++++++------
2 files changed, 267 insertions(+), 85 deletions(-)
diff --git a/specs/031-budget-workflow-and-redesign/implementation-notes.html b/specs/031-budget-workflow-and-redesign/implementation-notes.html
index ebcf7d5..43eaf22 100644
--- a/specs/031-budget-workflow-and-redesign/implementation-notes.html
+++ b/specs/031-budget-workflow-and-redesign/implementation-notes.html
@@ -496,6 +496,76 @@
Allocation-change handler early-returns when value didn't change
+
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)
diff --git a/src/app/budget/components/budget-health-hero.tsx b/src/app/budget/components/budget-health-hero.tsx
index 58bf1d8..48ed4f9 100644
--- a/src/app/budget/components/budget-health-hero.tsx
+++ b/src/app/budget/components/budget-health-hero.tsx
@@ -1,7 +1,7 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { formatCurrency, formatVariance } from "@/lib/utils";
-import type { BudgetWithCosts } from "@/types";
+import type { BudgetWithCosts, PeriodWithCosts } from "@/types";
import type { RunningCostsResult } from "@/lib/budget-utils";
import { classifyPeriod } from "@/lib/reports/period-helpers";
import { StatTile } from "./stat-tile";
@@ -26,9 +26,19 @@ const STATUS_THRESHOLDS = {
atRisk: 1.15,
} as const;
-function getStatus(actual: number, expected: number, completed: number): StatusKind {
- if (completed === 0 || expected <= 0) return "no_data";
- const ratio = actual / expected;
+/**
+ * Below this elapsed fraction we don't trust the current period's burn rate
+ * as a projection signal — too few data points to extrapolate honestly.
+ */
+const MIN_ELAPSED_FRACTION_FOR_PACE = 0.2;
+
+function getStatus(
+ closedActual: number,
+ closedExpected: number,
+ closedCount: number
+): StatusKind {
+ if (closedCount === 0 || closedExpected <= 0) return "no_data";
+ const ratio = closedActual / closedExpected;
if (ratio < STATUS_THRESHOLDS.under) return "under";
if (ratio <= STATUS_THRESHOLDS.onTrack) return "on_track";
if (ratio <= STATUS_THRESHOLDS.atRisk) return "at_risk";
@@ -65,70 +75,122 @@ function statusBadgeVariant(
}
}
+interface CurrentPeriodState {
+ period: PeriodWithCosts;
+ actual: number;
+ /** 0..1 — how far through the current period we are right now. */
+ elapsedFraction: number;
+ /** `actual / elapsedFraction` once we trust the pace; null otherwise. */
+ extrapolated: number | null;
+}
+
+function describeCurrent(
+ current: CurrentPeriodState,
+ avgPerClosed: number
+): string {
+ const { period, actual, elapsedFraction, extrapolated } = current;
+ if (extrapolated === null) {
+ return `${period.periodLabel} is ${Math.round(elapsedFraction * 100)}% through (${formatCurrency(actual)} so far).`;
+ }
+ const delta = extrapolated - avgPerClosed;
+ if (avgPerClosed <= 0) {
+ return `${period.periodLabel} is on track for ${formatCurrency(extrapolated)} at current pace.`;
+ }
+ const pct = Math.abs((delta / avgPerClosed) * 100);
+ if (Math.abs(delta) < avgPerClosed * 0.05) {
+ return `${period.periodLabel} is tracking the historical average (${formatCurrency(extrapolated)} projected).`;
+ }
+ return delta > 0
+ ? `${period.periodLabel} is pacing ${pct.toFixed(0)}% above the historical average (${formatCurrency(extrapolated)} projected vs ${formatCurrency(avgPerClosed)}).`
+ : `${period.periodLabel} is pacing ${pct.toFixed(0)}% below the historical average (${formatCurrency(extrapolated)} projected vs ${formatCurrency(avgPerClosed)}).`;
+}
+
export function BudgetHealthHero({ budget, runningCosts, allocations }: Props) {
const today = new Date();
const periods = budget.periods;
const ceiling = budget.totalAmountCents;
- const totals = periods.reduce(
- (a, p) => {
- const phase = classifyPeriod(p, today);
- const planned = allocations?.[p.id] ?? p.plannedAmountCents;
- const billed = p.billedTotalCents;
- const running = runningCosts[p.id]?.runningCostCents ?? 0;
- const actual = billed + running;
- a.totalPlanned += planned;
- if (phase !== "future") {
- a.ytdPeriodCount += 1;
- a.plannedYtd += planned;
- a.expectedYtd += p.expectedSpendCents;
- a.billedYtd += billed;
- a.runningYtd += running;
- }
- if (phase === "past" && actual > 0) {
- a.closedPeriodCount += 1;
- a.closedActual += actual;
- }
- return a;
- },
- {
- totalPlanned: 0,
- ytdPeriodCount: 0,
- plannedYtd: 0,
- expectedYtd: 0,
- billedYtd: 0,
- runningYtd: 0,
- closedPeriodCount: 0,
- closedActual: 0,
+ let closedCount = 0;
+ let closedActual = 0;
+ let closedExpected = 0;
+ let totalPlanned = 0;
+ let plannedYtd = 0;
+ let billedYtd = 0;
+ let runningYtd = 0;
+ let lastClosed: PeriodWithCosts | null = null;
+ let currentRaw: { period: PeriodWithCosts; actual: number } | null = null;
+
+ for (const p of periods) {
+ const phase = classifyPeriod(p, today);
+ const planned = allocations?.[p.id] ?? p.plannedAmountCents;
+ const running = runningCosts[p.id]?.runningCostCents ?? 0;
+ const actual = p.billedTotalCents + running;
+ totalPlanned += planned;
+ if (phase !== "future") {
+ plannedYtd += planned;
+ billedYtd += p.billedTotalCents;
+ runningYtd += running;
}
- );
+ if (phase === "past") {
+ closedCount += 1;
+ closedActual += actual;
+ closedExpected += p.expectedSpendCents;
+ if (!lastClosed || p.endDate > lastClosed.endDate) lastClosed = p;
+ }
+ if (phase === "current") {
+ currentRaw = { period: p, actual };
+ }
+ }
+
+ const actualYtd = billedYtd + runningYtd;
+ const unallocated = ceiling - totalPlanned;
+ const avgPerClosed = closedCount > 0 ? closedActual / closedCount : 0;
+
+ const current: CurrentPeriodState | null = currentRaw
+ ? buildCurrentState(currentRaw, today)
+ : null;
- const actualYtd = totals.billedYtd + totals.runningYtd;
- const unallocated = ceiling - totals.totalPlanned;
- const avgPerClosed =
- totals.closedPeriodCount > 0
- ? totals.closedActual / totals.closedPeriodCount
- : 0;
+ // Anchored projection: actuals already in the books + an honest estimate
+ // for the in-progress period + average for everything still ahead.
+ const remainingCount = periods.length - closedCount - (current ? 1 : 0);
+ const currentContribution = current
+ ? current.extrapolated !== null
+ ? Math.max(current.extrapolated, avgPerClosed)
+ : avgPerClosed
+ : 0;
const projectedYearEnd =
- totals.closedPeriodCount > 0 ? avgPerClosed * periods.length : actualYtd;
+ closedCount > 0
+ ? closedActual + currentContribution + remainingCount * avgPerClosed
+ : actualYtd;
const projectedVsCeiling = projectedYearEnd - ceiling;
- const expectedVariance = actualYtd - totals.expectedYtd;
- const status = getStatus(actualYtd, totals.expectedYtd, totals.closedPeriodCount);
+
+ // Variance is closed-only — the current period drags the YTD comparison
+ // because its full expected lands on day one against a partial actual.
+ const closedVariance = closedActual - closedExpected;
+ const status = getStatus(closedActual, closedExpected, closedCount);
const actualPct = ceiling > 0 ? Math.min((actualYtd / ceiling) * 100, 100) : 0;
- const plannedPct =
- ceiling > 0 ? Math.min((totals.plannedYtd / ceiling) * 100, 100) : 0;
- const expectedPct =
- ceiling > 0 ? Math.min((totals.expectedYtd / ceiling) * 100, 100) : 0;
+ const plannedPct = ceiling > 0 ? Math.min((plannedYtd / ceiling) * 100, 100) : 0;
+ // Expected marker reflects the closed window — the same scope as the
+ // variance tile and the status pill, so they read consistently.
+ const closedExpectedPct =
+ ceiling > 0 ? Math.min((closedExpected / ceiling) * 100, 100) : 0;
const unallocatedPct =
ceiling > 0 ? Math.max((unallocated / ceiling) * 100, 0) : 0;
const narrative = buildNarrative({
status,
- expectedVariance,
+ closedVariance,
projectedVsCeiling,
+ lastClosedLabel: lastClosed?.periodLabel ?? null,
+ current,
+ avgPerClosed,
});
+ const varianceLabel = lastClosed
+ ? `Variance through ${lastClosed.periodLabel}`
+ : "Variance";
+
return (
@@ -138,9 +200,11 @@ export function BudgetHealthHero({ budget, runningCosts, allocations }: Props) {
{statusLabel(status)}
-
- {formatVariance(expectedVariance)} vs expected YTD
-
+ {lastClosed && (
+
+ {formatVariance(closedVariance)} through {lastClosed.periodLabel}
+
+ )}
@@ -214,55 +285,89 @@ export function BudgetHealthHero({ budget, runningCosts, allocations }: Props) {
);
}
+function buildCurrentState(
+ raw: { period: PeriodWithCosts; actual: number },
+ today: Date
+): CurrentPeriodState {
+ const startMs = new Date(raw.period.startDate + "T00:00:00Z").getTime();
+ const endExclusiveMs =
+ new Date(raw.period.endDate + "T00:00:00Z").getTime() + 86_400_000;
+ const totalMs = endExclusiveMs - startMs;
+ const elapsedMs = Math.max(0, Math.min(today.getTime() - startMs, totalMs));
+ const elapsedFraction = totalMs > 0 ? elapsedMs / totalMs : 0;
+ const extrapolated =
+ elapsedFraction >= MIN_ELAPSED_FRACTION_FOR_PACE
+ ? raw.actual / elapsedFraction
+ : null;
+ return { ...raw, elapsedFraction, extrapolated };
+}
+
function buildNarrative({
status,
- expectedVariance,
+ closedVariance,
projectedVsCeiling,
+ lastClosedLabel,
+ current,
+ avgPerClosed,
}: {
status: StatusKind;
- expectedVariance: number;
+ closedVariance: number;
projectedVsCeiling: number;
+ lastClosedLabel: string | null;
+ current: CurrentPeriodState | null;
+ avgPerClosed: number;
}): string {
if (status === "no_data") {
- return "No completed periods yet — projections will appear once at least one period closes.";
+ const head =
+ "No completed periods yet — variance and projection appear once a period closes.";
+ if (current) {
+ return `${head} ${describeCurrent(current, avgPerClosed)}`;
+ }
+ return head;
}
const overUnder =
- expectedVariance >= 0
- ? `${formatCurrency(expectedVariance)} over expected YTD`
- : `${formatCurrency(Math.abs(expectedVariance))} under expected YTD`;
+ closedVariance >= 0
+ ? `${formatCurrency(closedVariance)} over expected through ${lastClosedLabel}`
+ : `${formatCurrency(Math.abs(closedVariance))} under expected through ${lastClosedLabel}`;
const projection =
projectedVsCeiling > 0
? `projected to finish ${formatCurrency(projectedVsCeiling)} over the annual ceiling.`
: `projected to land ${formatCurrency(Math.abs(projectedVsCeiling))} under the ceiling.`;
const lead = narrativeLead(status);
- return `${lead} ${overUnder}, ${projection}`;
+ const closedSentence = `${lead} ${overUnder}, ${projection}`;
+ if (current) {
+ return `${closedSentence} ${describeCurrent(current, avgPerClosed)}`;
+ }
+ return closedSentence;
}
function narrativeLead(status: Exclude): string {
switch (status) {
case "over":
- return "Spend is running hot —";
+ return "Spend ran hot through the closed window —";
case "at_risk":
- return "Trending above expected —";
+ return "Trending above expected through the closed window —";
case "under":
- return "Tracking comfortably below expected —";
+ return "Tracking comfortably below expected through the closed window —";
case "on_track":
- return "On pace —";
+ return "On pace through the closed window —";
}
}
function MultiMarkerBar({
actualPct,
plannedPct,
- expectedPct,
+ closedExpectedPct,
unallocatedPct,
ceiling,
+ lastClosedLabel,
}: {
actualPct: number;
plannedPct: number;
- expectedPct: number;
+ closedExpectedPct: number;
unallocatedPct: number;
ceiling: number;
+ lastClosedLabel: string | null;
}) {
const allocatedPct = Math.max(100 - unallocatedPct, 0);
return (
@@ -284,18 +389,25 @@ function MultiMarkerBar({
style={{ left: `${plannedPct}%` }}
title="Planned YTD"
/>
-
+ {lastClosedLabel && (
+
+ )}
-
+ {lastClosedLabel && (
+
+ )}
Ceiling {formatCurrency(ceiling)}
From 070cd60491ac00b4f8221226852ab98e8c1a203b Mon Sep 17 00:00:00 2001
From: Tobias Studer
Date: Thu, 21 May 2026 20:00:22 +0200
Subject: [PATCH 5/5] fix(budget/table): closed-only variance + corrected
footer total
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
.../implementation-notes.html | 30 ++++++
.../components/period-allocations-table.tsx | 94 +++++++++++++------
2 files changed, 93 insertions(+), 31 deletions(-)
diff --git a/specs/031-budget-workflow-and-redesign/implementation-notes.html b/specs/031-budget-workflow-and-redesign/implementation-notes.html
index 43eaf22..88d7be2 100644
--- a/specs/031-budget-workflow-and-redesign/implementation-notes.html
+++ b/specs/031-budget-workflow-and-redesign/implementation-notes.html
@@ -496,6 +496,36 @@
Allocation-change handler early-returns when value didn't change
+
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)
diff --git a/src/app/budget/components/period-allocations-table.tsx b/src/app/budget/components/period-allocations-table.tsx
index 5453a21..f6a8c0a 100644
--- a/src/app/budget/components/period-allocations-table.tsx
+++ b/src/app/budget/components/period-allocations-table.tsx
@@ -79,24 +79,30 @@ export function PeriodAllocationsTable({
const canEdit = isAdmin && !isArchived;
- // Footer aggregates everything ever allocated (planned), but only the
- // expected/actual numbers for periods that have started — otherwise the
- // variance line would compare YTD actual against full-year expected and
- // always read as a massive negative.
- const totals = periods.reduce(
- (a, p) => {
- const running = runningCosts[p.id]?.runningCostCents ?? 0;
- const isFuture = classifyPeriod(p, today) === "future";
- a.allocated += allocations[p.id] ?? 0;
- if (!isFuture) {
- a.expectedToDate += p.expectedSpendCents;
- a.actualToDate += p.billedTotalCents + running;
- }
- return a;
- },
- { allocated: 0, expectedToDate: 0, actualToDate: 0 }
- );
- const totalVariance = totals.actualToDate - totals.expectedToDate;
+ // Footer scopes expected/actual/variance to the closed window — the same
+ // window the hero's "Variance through {lastClosed}" tile uses. A partial
+ // current period vs full-month expected is a misleading variance; future
+ // periods contribute zero on both sides and just dilute the signal.
+ // Allocated stays as the sum of every period's planned amount (it answers
+ // "what have I committed to spend over the year", which is meaningful
+ // independent of phase).
+ let allocatedTotal = 0;
+ let closedExpected = 0;
+ let closedActual = 0;
+ let lastClosedLabel: string | null = null;
+ let lastClosedEndDate = "";
+ for (const p of periods) {
+ allocatedTotal += allocations[p.id] ?? 0;
+ if (classifyPeriod(p, today) !== "past") continue;
+ closedExpected += p.expectedSpendCents;
+ closedActual +=
+ p.billedTotalCents + (runningCosts[p.id]?.runningCostCents ?? 0);
+ if (p.endDate > lastClosedEndDate) {
+ lastClosedEndDate = p.endDate;
+ lastClosedLabel = p.periodLabel;
+ }
+ }
+ const closedVariance = closedActual - closedExpected;
return (
<>
@@ -211,18 +217,22 @@ export function PeriodAllocationsTable({
)}
-
- {formatVariance(variance)}
+
+ {isClosed ? formatVariance(variance) : "—"}
{isOverExpected ? (
{pctDiff}%
) : isUnderExpected ? (
{pctDiff}%
- ) : isFuture && expected === 0 ? (
- —
- ) : (
+ ) : isClosed ? (
{pctDiff}%
+ ) : (
+ —
)}
{canEdit && (
@@ -360,12 +370,34 @@ export function PeriodAllocationsTable({
})}
- YTD Total
- {formatCurrency(totals.allocated)}
- {formatCurrency(totals.expectedToDate)}
- {formatCurrency(totals.actualToDate)}
-
- {formatVariance(totalVariance)}
+
+ {lastClosedLabel
+ ? `Total through ${lastClosedLabel}`
+ : "Total"}
+
+ {formatCurrency(allocatedTotal)}
+
+ {lastClosedLabel ? (
+ formatCurrency(closedExpected)
+ ) : (
+ —
+ )}
+
+
+ {lastClosedLabel ? (
+ formatCurrency(closedActual)
+ ) : (
+ —
+ )}
+
+
+ {lastClosedLabel ? formatVariance(closedVariance) : "—"}
{canEdit && }
@@ -378,10 +410,10 @@ export function PeriodAllocationsTable({
{saving ? "Saving..." : "Save Allocations"}
- {totals.allocated > budget.totalAmountCents && (
+ {allocatedTotal > budget.totalAmountCents && (