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("-");