From 7b7181126239b42520ba4bae4bcc748721d67bc2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 21:37:23 +0200 Subject: [PATCH] feat(month): rethink the header, post rows and carry meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-project 2 of 4 of the visual redesign. Spec in docs/superpowers/specs/2026-09-02-month-view-design.md; the owner chose each of the three directions from concrete alternatives. Three problems, all found by looking at the rendered page rather than at the code: - The totals block was four peer figures with no relationship between them, and the income *input* sat in the same visual register as three numbers derived from it. Nothing answered PRODUCT.md's second question at the month level: with Transport at -3,200 you had to read down the Remaining column to find out. It now reads income -> allocation meter -> spend, with the overspent-post count stated in both directions. - The carry meter rendered 21px below its own row and 22px above the next, so it read as a divider rather than as a property of a post. One tbody per post now holds the figures AND their meter, which is both what pulls it tight under its own row and the only way to hover the pair as one block. - Phone width was broken and nothing recorded it: the 5-column table overflowed at 390px with Spent and Remaining off-screen, and every figure wrapped its currency onto a second line. Below sm: each post is now a two-line block, and the currency is stated once in the legend because every figure in that table is base currency. Meter geometry moves to src/ui/meterSegments.ts, pure and tested, with as the only thing that paints it. The allocation meter scales to max(income, allocated) so over-allocating overflows instead of capping at a full bar — over-budget is always allowed, so it has to be drawable. Negatives clamp rather than invert: a segment of negative width puts the gradient's stops out of order, which paints something arbitrary instead of failing. Rejected: one reflowing CSS grid carrying role="table". It deduplicates the markup, but at 390px the layout genuinely is not a table any more, and table roles would describe rows and columns to a screen reader that no longer exist on screen. Verified: 443 tests, tsc clean, build clean, and read in both themes at 1280px and 390px. Not verified, because it is not verifiable here — whether it reads better than what it replaced. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-09-02-month-view-design.md | 177 +++++++++ src/domain/views.test.ts | 23 ++ src/domain/views.ts | 8 + src/ui/components/Meter.tsx | 25 ++ src/ui/components/PostTable.tsx | 362 ++++++++++++------ src/ui/format.test.ts | 13 +- src/ui/format.ts | 13 +- src/ui/meterSegments.test.ts | 190 +++++++++ src/ui/meterSegments.ts | 122 ++++++ src/ui/routes/MonthRoute.tsx | 90 +++-- 10 files changed, 870 insertions(+), 153 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-02-month-view-design.md create mode 100644 src/ui/components/Meter.tsx create mode 100644 src/ui/meterSegments.test.ts create mode 100644 src/ui/meterSegments.ts diff --git a/docs/superpowers/specs/2026-09-02-month-view-design.md b/docs/superpowers/specs/2026-09-02-month-view-design.md new file mode 100644 index 0000000..6d53f35 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-month-view-design.md @@ -0,0 +1,177 @@ +# Month view + +Sub-project 2 of the visual redesign, decomposed in +`2026-09-02-design-foundation-design.md`: *"Month view — income and totals, +post rows and carry meter, purchases grouped by date."* Date-grouped purchases +shipped ahead of the rest (`a159189`); this spec covers what was left open. + +The owner's standing instruction for the redesign still applies — *"override +everything you want for this step. This needs to look good."* — so the three +forks below were put to them as concrete alternatives rather than as open +questions. All three were answered with the recommended option. + +## The diagnosis + +Three problems, found by looking at the rendered page in Chrome at 1280px and +390px rather than by reading the code. + +**The totals block was four peer figures with no relationship between them.** +Income, Allocated, Spent and Unallocated sat in one row at one weight. The +income *input* — the only editable thing on the screen — was in the same visual +register as three numbers derived from it. And nothing at the month level +answered the second of PRODUCT.md's two questions, *did I overspend*: with +Transport at −3,200 DKK, you had to read down the Remaining column to find out. + +**The carry meter belonged to the wrong row.** It is the design contract's +signature element, and it was rendering 21px below its own row and 22px above +the next one — visually a divider, not a property of a post. There was also no +legend anywhere, so its four segments were unreadable to anyone who had not +read `PostTable.tsx`. + +**Phone width was broken, and nothing recorded it.** The five-column table +overflowed its container at 390px: *Spent* and *Remaining*, the two columns +that answer the app's two questions, were off-screen behind a horizontal +scroll, and every figure wrapped its currency onto a second line +("+4,219.61 / DKK"). This is the width at which a grocery trip actually gets +entered. + +## The month header + +Three zones in reading order, in the one `Section` card, on the paper ground +below the unchanged month `
`. + +1. **Income** — the field. Unchanged in behaviour: still `type="number"`, still + base-currency only. Section 6 of TODO.md records the reasoning — a month has + one income, so making the field parse a currency the way the purchase total + does is arguably wrong rather than merely undone. It keeps field affordance + so it stays visibly the one editable thing on the screen. +2. **Allocated and Unallocated**, with the allocation meter beneath them and + "N% of income allocated" beside it. +3. **Spent**, under a hairline, with the month-level overspend answer beside + it. + +### The allocation meter scales to `max(income, allocated)` + +Not to income. Over-budget is always allowed (CLAUDE.md), and allocating past +income is the ordinary way that shows up here, so the meter has to render it +rather than cap at a full bar: the overflow past the income mark paints +`--overspend`, and the label reads past 100%. Capping would make +over-allocation look exactly like allocating income to the last krone. + +Edge cases, all of them reachable: + +- income ≤ 0 with allocation > 0 — no denominator, so the percentage is + omitted and the whole track is overflow. +- both zero — an empty track, no percentage. +- allocation ≤ 0 — an empty track; a fixed rule may be negative, and no + percentage can be below zero-length. + +### "1 post overspent" is text, not a link + +The Posts table is ~40px below this line. A control that scrolls the page by +less than a viewport is noise, so the line states the answer and stops. + +It renders in both directions — *"no posts overspent"* in muted ink, *"1 post +overspent"* in `--overspend` — because a line that appears and disappears +shifts the layout of the block above it, and because "no" is a real answer to +the question rather than an absence of one. + +The count is added to `MonthViewModel` as `overspentCount` rather than derived +in the component. It is a count over rows the fold already produced, and +putting it in the domain means it is tested where the rest of the month's +arithmetic is tested, with the UI left dumb. + +## Post rows: two markups, one per structure + +Below `sm:` each post becomes a two-line block — name and Remaining large on +the first line, the meter full width, then carried / allocated / spent as one +small caption line. Nothing is hidden and nothing scrolls sideways. + +This is implemented as two sibling markups, `hidden sm:table` and `sm:hidden`, +not as one reflowing grid. + +**Rejected: a single CSS grid carrying `role="table"` and friends.** It +deduplicates the markup, which is the whole of its appeal. But at 390px the +layout genuinely is not a table any more — it is a list of two-line blocks — +and leaving table roles on it would describe a grid of rows and columns to a +screen reader that no longer exists visually. Keeping a real `` at the +width where it is a table, and a real list at the width where it is a list, +means each structure is honest about itself. The cost is that the figures +appear twice in the DOM at any one width; at six to thirty posts that is +nothing, and both markups read their cells from one shared `postRowCells` +helper, so the figures are still derived in exactly one place. + +### The currency moves to the header + +Every figure in this table is in the dataset's base currency, so repeating the +code on all thirty cells buys nothing and costs the line wrap that made the +phone layout unreadable. The `Section`'s hint carries "All figures in DKK" +once; the cells print `formatAmount`. + +This is deliberately not a change to `formatMoney`, which is still right +everywhere an amount can be in some other currency — every purchase row, for +one, where a 27.50 EUR line sits among DKK. + +## The carry meter becomes the row divider + +One `` per post, holding both the figures row and the meter row. +Multiple `` elements in one table are valid, and this is what makes +`group-hover` tint a post's figures and its meter as one block; two loose +`` siblings cannot express that relationship at all. + +The meter then sits tight under its own row with that row's hairline removed, +so the meter *is* the divider, and the whitespace moves below it. 3px to 4px. +The ambiguity was never about the meter's design — it was that the gaps above +and below it were within a pixel of each other. + +A legend sits beside the hint, once for the table: four swatches, carried in / +spent / unspent / over. The meter itself stays `aria-hidden`; the four figures +beside it carry the same information as text, and a screen reader reading a +gradient twice is worse than not reading it. + +Note what the meter still does not show: a *negative* carried-in balance. The +surplus segment is `max(carriedIn, 0)`, so Transport carrying −1,600 forward +shows a meter that starts at the allocation, and only the red Carried-in figure +says why the post is behind. That is existing, documented behaviour and this +sub-project does not change it. + +## Where the code goes + +The two meters are the same idea rendered at two scales, so the math comes out +of the component into `src/ui/meterSegments.ts` — pure, no DOM: + +- `carryMeterSegments(figures)` — the four rollover segments. +- `allocationMeterSegments(income, allocated)` — the header's two or three. +- `gradientFor(segments)` — the hard-stopped `linear-gradient` string. + +A thin `` renders it. The token names stay in TypeScript +source, which is what keeps `src/ui/cssTokens.test.ts` guarding them — that +test exists because renaming `--rule` silently made this exact element paint +nothing, and moving the gradient into a `.css` file would have moved it out of +the guard's reach. + +## Testing + +- `src/ui/meterSegments.test.ts` (new): zero scale, negative carried-in, + charges past allocation, over-allocation, income 0 against allocation > 0, + and the two invariants that hold for every input — stops ascend, and every + stop lands within [0, 100]. +- `src/domain/views.test.ts`: `overspentCount`, including that a post at + exactly zero is not counted. +- `src/cssPairs.test.ts` and `src/ui/cssTokens.test.ts` keep guarding the + tokens; no new pair is introduced. +- Both themes at 1280px and 390px through `scripts/screenshot.ts`. + +## Not changed + +No new validation anywhere: over-allocation still renders, negative figures +still render, and nothing here can block a write. `src/domain/` changes only by +gaining the count. The purchases list, the rule-from-month editor and the month +`
` are untouched. + +## Verification limits + +A screenshot proves the layout exists and is not broken. It cannot say the +month view now reads well, and this container's fonts are not the owner's +fonts. Whether the header answers the two questions faster than four peer +figures did is a judgement only the owner can make. diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index bab9337..b6b7691 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -75,6 +75,29 @@ describe("monthView", () => { expect(monthView(greedy, "2026-01").unallocated).toBe(-2000); }); + test("counts the posts that are overspent, so the month can say so at a glance", () => { + expect(monthView(data, "2026-01").overspentCount).toBe(0); + + // food is allocated 2000 and spent 3000: one post behind. + const over: Dataset = { + ...data, + purchases: [spend("a", "food", 3000, "2026-01-10")], + }; + expect(monthView(over, "2026-01").overspentCount).toBe(1); + }); + + test("a post at exactly zero remaining is not overspent", () => { + // Rent-shaped: allocated 2000, spent 2000. Nothing left is not the same + // as owing something, and the header must not claim otherwise. + const exact: Dataset = { + ...data, + purchases: [spend("a", "food", 2000, "2026-01-10")], + }; + const view = monthView(exact, "2026-01"); + expect(view.rows.find((r) => r.post.id === "food")!.figures.remaining).toBe(0); + expect(view.overspentCount).toBe(0); + }); + test("archived posts appear only when they have activity", () => { const withArchived: Dataset = { ...data, diff --git a/src/domain/views.ts b/src/domain/views.ts index 9a13b71..6ebc7f4 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -18,6 +18,13 @@ export interface MonthViewModel { totalCharges: number; /** income - totalAllocation. Negative when allocations exceed income. */ unallocated: number; + /** + * How many rows are overspent — `remaining < 0`, so a post at exactly zero + * does not count. Computed here rather than in the component because it is + * the month-level answer to "did I overspend", and it belongs where the + * rest of the month's arithmetic is tested. + */ + overspentCount: number; rows: MonthPostRow[]; } @@ -61,6 +68,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { totalAllocation, totalCharges, unallocated: roundMoney(income - totalAllocation, baseDigits), + overspentCount: rows.filter((r) => r.figures.remaining < 0).length, rows, }; } diff --git a/src/ui/components/Meter.tsx b/src/ui/components/Meter.tsx new file mode 100644 index 0000000..405bd80 --- /dev/null +++ b/src/ui/components/Meter.tsx @@ -0,0 +1,25 @@ +import { gradientFor, type MeterSegment } from "../meterSegments.ts"; + +/** + * Draws a segment list as one hard-stopped gradient. Geometry and edge cases + * live in src/ui/meterSegments.ts; this is only the paint. + * + * `aria-hidden` because every meter in the app sits beside the same numbers + * as text. A screen reader announcing a gradient it cannot describe is worse + * than one skipping a decoration of figures it has already read. + */ +export function Meter({ + segments, + className = "", +}: { + segments: MeterSegment[]; + className?: string; +}) { + return ( +
- - - - - - - - - - - {rows.map(({ post, figures, overridden }) => ( - - - - - - - - - - - - - ))} - -
PostCarried inAllocatedSpentRemaining
- - {post.name} - - {post.archived && ( - - archived - - )} - {overridden && ( - - overridden - - )} - {/* A month can carry BOTH badges: a version starting in a - month the user also overrode is unusual but legal, and - hiding either would obscure why the number is what it - is. The override still wins the allocation. */} - {ruleAt(post, monthId)?.from === monthId && ( - - rule changes here - - )} - {onChangeRule && ( - - )} - 0 - ? "text-surplus" - : figures.carriedIn < 0 - ? "text-overspend" - : "" - }`} + <> + + {post.name} + + {post.archived && ( + + archived + + )} + {overridden && ( + + overridden + + )} + {/* A month can carry BOTH badges: a version starting in a month the user + also overrode is unusual but legal, and hiding either would obscure + why the number is what it is. The override still wins. */} + {ruleAt(post, monthId)?.from === monthId && ( + + rule changes here + + )} + + ); +} + +/** + * Placed differently in each markup, which is why it is not part of PostName. + * In the table it is revealed on hover, beside the name. On a phone there is + * no hover, so it has to be permanently visible — and beside the name at that + * size it competes with the name itself, so it goes at the end of the quiet + * caption line instead. + */ +function ChangeRuleButton({ + postId, + onChangeRule, + className = "", +}: { + postId: string; + onChangeRule: (postId: string) => void; + className?: string; +}) { + return ( + + ); +} + +export function PostTable({ monthId, rows, onChangeRule }: Props) { + return ( + <> +
+ + + + + + + + + + + {/* One tbody per post, holding the figures AND their meter. Multiple + tbodies are valid, and this is the only way to hover the pair as + one block — two loose sibling s cannot express that they + belong together, which is exactly how the meter came to look like + a divider between two rows rather than part of one. */} + {rows.map((row) => { + const cells = postRowCells(row); + return ( + + + + + + + + + {/* The meter IS the row divider: tight under its own figures, + with the gap below it. */} + + + + + ); + })} +
PostCarried inAllocatedSpentRemaining
+ + {onChangeRule && ( + + )} + + {cells.carriedIn} + + {cells.allocation} + {cells.charges} + {cells.remaining} +
+ +
+
+ +
    + {rows.map((row) => { + const cells = postRowCells(row); + return ( +
  • +
    + + + + - {formatSignedMoney(figures.carriedIn, baseCurrency)} -
- {formatMoney(figures.allocation, baseCurrency)} - - {formatMoney(figures.charges, baseCurrency)} - - {formatSignedMoney(figures.remaining, baseCurrency)} -
- -
- + {cells.remaining} + + + +

+ + carried{" "} + {cells.carriedIn} + + + allocated {cells.allocation} + + + spent {cells.charges} + + {/* Pushed to the right end of the line it wraps onto, so six + of these down a phone screen read as a quiet affordance + rather than as each row's primary action. */} + {onChangeRule && ( + + )} +

+ + ); + })} + + ); } diff --git a/src/ui/format.test.ts b/src/ui/format.test.ts index 86e28df..658157b 100644 --- a/src/ui/format.test.ts +++ b/src/ui/format.test.ts @@ -1,5 +1,10 @@ import { test, expect } from "bun:test"; -import { formatAmount, formatMoney, formatSignedMoney } from "./format.ts"; +import { + formatAmount, + formatMoney, + formatSignedAmount, + formatSignedMoney, +} from "./format.ts"; test("formatAmount groups thousands and always shows two decimals", () => { expect(formatAmount(1234.5)).toBe("1,234.50"); @@ -16,3 +21,9 @@ test("formatSignedMoney marks positive balances with a plus", () => { expect(formatSignedMoney(-200, "DKK")).toBe("-200.00 DKK"); expect(formatSignedMoney(0, "DKK")).toBe("0.00 DKK"); }); + +test("formatSignedAmount signs the number without naming a currency", () => { + expect(formatSignedAmount(200)).toBe("+200.00"); + expect(formatSignedAmount(-200)).toBe("-200.00"); + expect(formatSignedAmount(0)).toBe("0.00"); +}); diff --git a/src/ui/format.ts b/src/ui/format.ts index 5e184f5..dc29e77 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -14,7 +14,16 @@ export function formatMoney(amount: number, currency: Currency): string { return `${formatAmount(amount)} ${currency}`; } +/** + * A signed amount with no currency code. For columns whose currency is stated + * once in the header — the month view's post table, where every figure is in + * the dataset's base currency, and repeating the code on all thirty cells only + * bought a line wrap at phone width. + */ +export function formatSignedAmount(amount: number): string { + return `${amount > 0 ? "+" : ""}${formatAmount(amount)}`; +} + export function formatSignedMoney(amount: number, currency: Currency): string { - const sign = amount > 0 ? "+" : ""; - return `${sign}${formatMoney(amount, currency)}`; + return `${formatSignedAmount(amount)} ${currency}`; } diff --git a/src/ui/meterSegments.test.ts b/src/ui/meterSegments.test.ts new file mode 100644 index 0000000..e189104 --- /dev/null +++ b/src/ui/meterSegments.test.ts @@ -0,0 +1,190 @@ +import { test, expect } from "bun:test"; +import { + allocatedPercentOfIncome, + allocationMeterSegments, + carryMeterSegments, + gradientFor, + type MeterSegment, +} from "./meterSegments.ts"; +import type { PostMonthFigures } from "../domain/fold.ts"; + +function figures(partial: Partial): PostMonthFigures { + const carriedIn = partial.carriedIn ?? 0; + const allocation = partial.allocation ?? 0; + const charges = partial.charges ?? 0; + return { + carriedIn, + allocation, + charges, + remaining: partial.remaining ?? carriedIn + allocation - charges, + }; +} + +/** The shape assertions read better as a list of "token at width" pairs. */ +function widths(segments: MeterSegment[]): [string, number][] { + return segments.map((s) => [s.token, Number(s.percent.toFixed(4))]); +} + +test("a post with no activity is an empty track", () => { + expect(widths(carryMeterSegments(figures({})))).toEqual([["--budget-rule", 100]]); +}); + +test("an untouched allocation is all unspent track", () => { + expect(widths(carryMeterSegments(figures({ allocation: 600 })))).toEqual([ + ["--budget-rule", 100], + ]); +}); + +test("spending half the allocation fills half the track", () => { + expect(widths(carryMeterSegments(figures({ allocation: 600, charges: 300 })))).toEqual([ + ["--budget-accent", 50], + ["--budget-rule", 50], + ]); +}); + +test("a carried-in surplus takes its share of the track before the allocation", () => { + // 1000 carried in + 1000 allocated, half of the allocation spent: the + // surplus is half the scale, the spend a quarter, the remainder a quarter. + const segments = carryMeterSegments( + figures({ carriedIn: 1000, allocation: 1000, charges: 500 }), + ); + expect(widths(segments)).toEqual([ + ["--surplus", 50], + ["--budget-accent", 25], + ["--budget-rule", 25], + ]); +}); + +test("overspending paints past the end of the allocation and leaves no unspent track", () => { + // Allocation 1000, spend 1500: the scale is the spend, so the allocation is + // the first two thirds and the overspend the last third. + const segments = carryMeterSegments(figures({ allocation: 1000, charges: 1500 })); + expect(widths(segments)).toEqual([ + ["--budget-accent", 66.6667], + ["--overspend", 33.3333], + ]); +}); + +test("a negative carried-in balance contributes no surplus segment", () => { + // Transport in the demo data: behind by 1600, allocated 1100, spent 2700. + // The debt is not on the meter — only the red Carried-in figure says why the + // post is behind. Documented behaviour, asserted so it cannot drift. + const segments = carryMeterSegments( + figures({ carriedIn: -1600, allocation: 1100, charges: 2700 }), + ); + expect(segments.some((s) => s.token === "--surplus")).toBe(false); + expect(widths(segments)).toEqual([ + ["--budget-accent", 40.7407], + ["--overspend", 59.2593], + ]); +}); + +test("a net refund clamps to an empty spend rather than a negative width", () => { + // Refunds are a normal line, so charges can come out below zero. + const segments = carryMeterSegments(figures({ allocation: 500, charges: -200 })); + expect(widths(segments)).toEqual([["--budget-rule", 100]]); +}); + +test("a negative allocation is an empty track, not an inverted one", () => { + const segments = carryMeterSegments(figures({ allocation: -500 })); + expect(widths(segments)).toEqual([["--budget-rule", 100]]); +}); + +test("allocating under income leaves unallocated track", () => { + expect(widths(allocationMeterSegments(28000, 21000))).toEqual([ + ["--budget-accent", 75], + ["--budget-rule", 25], + ]); +}); + +test("allocating past income overflows instead of capping at a full bar", () => { + // Over-budget is always allowed, so this is an ordinary state and the meter + // has to be able to draw it. Income 1000, allocated 2000: half the track is + // the income, half is the overflow. + expect(widths(allocationMeterSegments(1000, 2000))).toEqual([ + ["--budget-accent", 50], + ["--overspend", 50], + ]); +}); + +test("allocating against no income is entirely overflow", () => { + expect(widths(allocationMeterSegments(0, 2000))).toEqual([["--overspend", 100]]); +}); + +test("no income and no allocation is an empty track", () => { + expect(widths(allocationMeterSegments(0, 0))).toEqual([["--budget-rule", 100]]); +}); + +test("a negative income is treated as none", () => { + expect(widths(allocationMeterSegments(-500, 100))).toEqual([["--overspend", 100]]); +}); + +/** + * The two properties that must hold for every input, because a segment list + * that breaks either produces a gradient with stops out of order — which + * paints something arbitrary rather than failing. + */ +test("every segment list is non-negative and sums to exactly 100", () => { + const cases: PostMonthFigures[] = [ + figures({}), + figures({ allocation: 1 }), + figures({ charges: 1 }), + figures({ carriedIn: -1, charges: 1 }), + figures({ carriedIn: 0.005, allocation: 0.01, charges: 0.005 }), + figures({ carriedIn: 1e6, allocation: 1, charges: 1e7 }), + figures({ carriedIn: -1e6, allocation: -1, charges: -1 }), + figures({ allocation: 8200, charges: 8200 }), + ]; + + for (const f of cases) { + const segments = carryMeterSegments(f); + const total = segments.reduce((sum, s) => sum + s.percent, 0); + expect(segments.every((s) => s.percent > 0)).toBe(true); + expect(total).toBeCloseTo(100, 8); + } + + for (const [income, allocated] of [ + [0, 0], + [1, 0], + [0, 1], + [-1, -1], + [28000, 19300], + [1, 1e9], + ] as const) { + const segments = allocationMeterSegments(income, allocated); + const total = segments.reduce((sum, s) => sum + s.percent, 0); + expect(segments.every((s) => s.percent > 0)).toBe(true); + expect(total).toBeCloseTo(100, 8); + } +}); + +test("the gradient hard-stops each segment and ends at 100%", () => { + const css = gradientFor([ + { token: "--surplus", percent: 25 }, + { token: "--budget-accent", percent: 75 }, + ]); + expect(css).toBe( + "linear-gradient(to right, " + + "var(--surplus) 0%, var(--surplus) 25%, " + + "var(--budget-accent) 25%, var(--budget-accent) 100%)", + ); +}); + +test("gradient stops never run backwards, whatever the segment widths", () => { + const css = gradientFor(carryMeterSegments(figures({ carriedIn: 1, allocation: 3, charges: 7 }))); + const stops = [...css.matchAll(/ ([\d.]+)%/g)].map((m) => Number(m[1])); + const sorted = [...stops].sort((a, b) => a - b); + expect(stops).toEqual(sorted); + expect(stops.at(-1)).toBe(100); +}); + +test("the allocated percentage is null when there is no income to be a share of", () => { + expect(allocatedPercentOfIncome(0, 500)).toBeNull(); + expect(allocatedPercentOfIncome(-1, 500)).toBeNull(); +}); + +test("the allocated percentage rounds to whole percent and can exceed 100", () => { + expect(allocatedPercentOfIncome(28000, 19300)).toBe(69); + expect(allocatedPercentOfIncome(1000, 2000)).toBe(200); + expect(allocatedPercentOfIncome(1000, 0)).toBe(0); +}); diff --git a/src/ui/meterSegments.ts b/src/ui/meterSegments.ts new file mode 100644 index 0000000..11cd3ae --- /dev/null +++ b/src/ui/meterSegments.ts @@ -0,0 +1,122 @@ +import type { PostMonthFigures } from "../domain/fold.ts"; + +/** + * Meter geometry, kept out of the components that draw it. + * + * Two meters in the app encode money as a hard-stopped gradient: the carry + * meter under every post row, and the allocation meter in the month header. + * They are the same idea at two scales, so the arithmetic lives here — pure, + * no DOM — where every edge case it has to survive can be asserted directly. + * + * The token names stay in TypeScript on purpose. `src/ui/cssTokens.test.ts` + * fails if a token referenced here is not one `src/index.css` defines, and + * that guard exists because renaming `--rule` silently made this exact element + * paint nothing: an undefined custom property inside `linear-gradient()` + * invalidates the whole declaration without warning. (That test reads source + * text, so it will flag a token named in a comment too — as this one was.) + */ +export interface MeterSegment { + /** A custom property defined in src/index.css, e.g. "--surplus". */ + token: string; + /** Width as a percentage of the track. Always > 0; the list sums to 100. */ + percent: number; +} + +/** An empty track: the same hairline colour as an unspent remainder. */ +const EMPTY_TRACK: MeterSegment[] = [{ token: "--budget-rule", percent: 100 }]; + +function scaled(parts: [string, number][], scale: number): MeterSegment[] { + const segments: MeterSegment[] = []; + for (const [token, size] of parts) { + if (size <= 0) continue; + segments.push({ token, percent: (size / scale) * 100 }); + } + return segments.length > 0 ? segments : EMPTY_TRACK; +} + +/** + * The rollover fold's four numbers as one track, left to right: + * + * --surplus carried-in surplus + * --budget-accent spend within this month's allocation + * --budget-rule the unspent remainder of the allocation + * --overspend spend beyond the allocation + * + * Negatives are clamped rather than inverted. A carried-in *debt* therefore + * does not appear here at all — only the red Carried-in figure beside the + * meter says why the post is behind — and a net refund reads as no spend. + * Both are deliberate: a segment of negative width would put the gradient's + * stops out of order, which paints something arbitrary instead of failing. + */ +export function carryMeterSegments(figures: PostMonthFigures): MeterSegment[] { + const surplus = Math.max(figures.carriedIn, 0); + const allocation = Math.max(figures.allocation, 0); + const charges = Math.max(figures.charges, 0); + const scale = surplus + Math.max(allocation, charges); + + if (scale <= 0) return EMPTY_TRACK; + + return scaled( + [ + ["--surplus", surplus], + ["--budget-accent", Math.min(charges, allocation)], + ["--budget-rule", allocation - Math.min(charges, allocation)], + ["--overspend", charges - Math.min(charges, allocation)], + ], + scale, + ); +} + +/** + * The month header's meter: how much of this month's income is allocated. + * + * Scaled to `max(income, allocated)`, NOT to income. Over-budget is always + * allowed, and allocating past income is the ordinary way that shows up here, + * so the overflow has to be drawable: capping at a full bar would make + * over-allocation look identical to allocating income to the last krone. + */ +export function allocationMeterSegments(income: number, allocated: number): MeterSegment[] { + const earned = Math.max(income, 0); + const spent = Math.max(allocated, 0); + const scale = Math.max(earned, spent); + + if (scale <= 0) return EMPTY_TRACK; + + return scaled( + [ + ["--budget-accent", Math.min(spent, earned)], + ["--budget-rule", earned - Math.min(spent, earned)], + ["--overspend", spent - Math.min(spent, earned)], + ], + scale, + ); +} + +/** Null when there is no income for the allocation to be a share of. */ +export function allocatedPercentOfIncome(income: number, allocated: number): number | null { + if (income <= 0) return null; + return Math.round((allocated / income) * 100); +} + +/** Trimmed so a stop reads `25%` rather than `25.0000%`. */ +function stop(percent: number): string { + return `${Number(percent.toFixed(4))}%`; +} + +/** + * Hard stops, so the track reads as discrete quantities rather than a blend: + * each segment ends and the next begins at the same position. + */ +export function gradientFor(segments: MeterSegment[]): string { + const stops: string[] = []; + let at = 0; + segments.forEach((segment, index) => { + const from = at; + at += segment.percent; + // The last segment closes on a literal 100% so accumulated float error + // can never leave a hairline of unpainted track at the right edge. + const to = index === segments.length - 1 ? 100 : at; + stops.push(`var(${segment.token}) ${stop(from)}`, `var(${segment.token}) ${stop(to)}`); + }); + return `linear-gradient(to right, ${stops.join(", ")})`; +} diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 530f6d8..7c9fd4c 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -5,16 +5,18 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { useDataset } from "../hooks/useDataset.ts"; import { useMutate } from "../hooks/useMutate.ts"; -import { monthView } from "../../domain/views.ts"; +import { monthView, type MonthViewModel } from "../../domain/views.ts"; import { addMonths } from "../../domain/months.ts"; import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts"; import { ruleAt } from "../../domain/allocation.ts"; import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts"; import { sliceAmountForMonth } from "../../domain/charges.ts"; import { formatMoney, formatSignedMoney } from "../format.ts"; -import { PostTable } from "../components/PostTable.tsx"; +import { PostTable, PostTableLegend } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; +import { Meter } from "../components/Meter.tsx"; import { Section, Stat } from "../components/Section.tsx"; +import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts"; import { groupPurchasesByDate } from "../purchaseGroups.ts"; export function MonthRoute() { @@ -54,8 +56,14 @@ export function MonthRoute() {
+ {/* Income is the origin of every figure below it, so the block reads + left to right as one statement: what came in, how much of it is + committed, what is left to commit — then, under a rule, what has + actually been spent and whether any post is behind. The four peer + figures this replaced stated the same numbers at the same weight and + answered neither question. */}
-
+
-
- {formatMoney(view.totalAllocation, base)} +
+
+ {formatMoney(view.totalAllocation, base)} + + {formatSignedMoney(view.unallocated, base)} + +
+ +

{allocationCaption(view)}

+
+
+ +
+
{formatMoney(view.totalCharges, base)} - - {formatSignedMoney(view.unallocated, base)} -
+ {/* Rendered in both directions. "No posts overspent" is a real answer + to the app's second question, and a line that only appears when + something is wrong moves everything above it when it arrives. */} +

0 ? "text-overspend" : "text-budget-ink-muted" + }`} + > + {overspentLabel(view.overspentCount)} +

-
- +
} + className="overflow-hidden" + > +
{changingPost && ( @@ -201,6 +230,21 @@ function PurchaseRow({ ); } +function allocationCaption(view: MonthViewModel): string { + const percent = allocatedPercentOfIncome(view.income, view.totalAllocation); + if (percent === null) { + return view.totalAllocation > 0 + ? "Allocated against no recorded income." + : "No income recorded for this month yet."; + } + return `${percent}% of income allocated`; +} + +function overspentLabel(count: number): string { + if (count === 0) return "No posts overspent"; + return `${count} post${count === 1 ? "" : "s"} overspent`; +} + /** "2026-09" reads as a key; "September 2026" reads as a month. */ function monthLabel(monthId: MonthId): string { const [year, month] = monthId.split("-");