feat(month): rethink the header, post rows and carry meter

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
<Meter> 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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 21:37:23 +02:00
1 parent bd5a01e03e
commit 7b71811262
10 files changed
+870 -153

No files matched your search

+25
View File
@@ -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 (
<div
aria-hidden="true"
className={`w-full ${className}`}
style={{ background: gradientFor(segments) }}
/>
);
}
+235 -127
View File
@@ -1,157 +1,265 @@
import { Fragment } from "react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { formatAmount, formatSignedAmount } from "../format.ts";
import { ruleAt } from "../../domain/allocation.ts";
import { carryMeterSegments, type MeterSegment } from "../meterSegments.ts";
import { Meter } from "./Meter.tsx";
import type { MonthPostRow } from "../../domain/views.ts";
import type { PostMonthFigures } from "../../domain/fold.ts";
import type { Currency, MonthId } from "../../domain/types.ts";
interface Props {
monthId: MonthId;
baseCurrency: Currency;
rows: MonthPostRow[];
/** Omitted on read-only mounts; only the month view offers rule editing. */
onChangeRule?: (postId: string) => void;
}
/**
* Carry meter: the design contract's signature element. A 3px rule beneath
* each post row, encoding the rollover fold's four numbers as a single
* hard-stopped gradient so envelope rollover is visible without reading
* digits.
* The month's posts, in two markups: a real table from `sm:` up, a list of
* two-line blocks below it.
*
* Segments, left to right, sized against a per-row scale of
* (carried-in surplus + max(allocation, charges)):
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
* - --budget-accent : spend within this month's allocation, filled from
* the track's left edge
* - --budget-rule : the unspent remainder of the allocation track
* - --overspend : spend beyond the allocation, past the track's end
* NOT one reflowing grid with `role="table"`. At 390px the layout genuinely
* stops being a table — it is a stack of two-line blocks — and leaving table
* roles on it would describe rows and columns to a screen reader that no
* longer exist on screen. Each structure is honest about itself instead. The
* figures appear twice in the DOM at any one width, which at six to thirty
* posts costs nothing, and both markups read their cells from `postRowCells`
* so the numbers are still derived in exactly one place.
*
* The currency is deliberately absent from every cell: it is stated once, in
* the legend, because every figure here is in the dataset's base currency.
* Repeating it bought nothing and wrapped "+4,219.61 DKK" onto two lines on a
* phone, which is the width a grocery trip is actually entered at.
*/
function carryMeterBackground(figures: PostMonthFigures): string {
const surplus = Math.max(figures.carriedIn, 0);
const scale = surplus + Math.max(figures.allocation, figures.charges);
interface RowCells {
carriedIn: string;
carriedInTone: string;
allocation: string;
charges: string;
remaining: string;
remainingTone: string;
segments: MeterSegment[];
}
if (scale <= 0) {
return "var(--budget-rule)";
}
const pSurplus = (surplus / scale) * 100;
const pFillEnd = pSurplus + (Math.min(figures.charges, figures.allocation) / scale) * 100;
const pTrackEnd = pSurplus + (figures.allocation / scale) * 100;
function postRowCells({ figures }: MonthPostRow): RowCells {
return {
carriedIn: formatSignedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: "",
allocation: formatAmount(figures.allocation),
charges: formatAmount(figures.charges),
remaining: formatSignedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
segments: carryMeterSegments(figures),
};
}
/**
* The four colours of the carry meter, named once for the whole table. Sits in
* the Section's hint, so it renders inside a `<p>` — spans only, no divs.
*/
export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) {
return (
`linear-gradient(to right, ` +
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
`var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` +
`var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` +
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>All figures in {baseCurrency}.</span>
<Swatch token="--surplus" label="carried in" />
<Swatch token="--budget-accent" label="spent" />
<Swatch token="--budget-rule" label="unspent" />
<Swatch token="--overspend" label="over" />
</span>
);
}
function CarryMeter({ figures }: { figures: PostMonthFigures }) {
function Swatch({ token, label }: { token: string; label: string }) {
return (
<div
aria-hidden="true"
className="h-[3px] w-full"
style={{ background: carryMeterBackground(figures) }}
/>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-sm"
style={{ background: `var(${token})` }}
/>
{label}
</span>
);
}
export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) {
function PostName({
row: { post, overridden },
monthId,
}: {
row: MonthPostRow;
monthId: MonthId;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{rows.map(({ post, figures, overridden }) => (
<Fragment key={post.id}>
<tr className="group transition-colors hover:bg-accent/60">
<td className="py-2.5">
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
{post.name}
</Link>
{post.archived && (
<Badge variant="outline" className="ml-2">
archived
</Badge>
)}
{overridden && (
<Badge
variant="secondary"
className="ml-2"
title="This month overrides the rule otherwise in effect"
>
overridden
</Badge>
)}
{/* 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 && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
{onChangeRule && (
<button
type="button"
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => onChangeRule(post.id)}
>
change from here
</button>
)}
</td>
<td
className={`font-money py-2.5 pl-6 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: ""
}`}
<>
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
{post.name}
</Link>
{post.archived && (
<Badge variant="outline" className="ml-2">
archived
</Badge>
)}
{overridden && (
<Badge
variant="secondary"
className="ml-2"
title="This month overrides the rule otherwise in effect"
>
overridden
</Badge>
)}
{/* 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 && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
</>
);
}
/**
* 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 (
<button
type="button"
className={`rounded text-budget-ink-muted underline decoration-dotted underline-offset-2 transition-opacity hover:text-budget-accent ${className}`}
onClick={() => onChangeRule(postId)}
>
change from here
</button>
);
}
export function PostTable({ monthId, rows, onChangeRule }: Props) {
return (
<>
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
</tr>
</thead>
{/* 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 <tr>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 (
<tbody key={row.post.id} className="group">
<tr className="transition-colors group-hover:bg-accent/60">
<td className="pb-0 pt-3">
<PostName row={row} monthId={monthId} />
{onChangeRule && (
<ChangeRuleButton
postId={row.post.id}
onChangeRule={onChangeRule}
className="ml-2 text-xs opacity-0 focus-visible:opacity-100 group-hover:opacity-100"
/>
)}
</td>
<td
className={`font-money pb-0 pl-6 pt-3 text-right ${cells.carriedInTone}`}
>
{cells.carriedIn}
</td>
<td className="font-money pb-0 pl-6 pt-3 text-right">
{cells.allocation}
</td>
<td className="font-money pb-0 pl-6 pt-3 text-right">{cells.charges}</td>
<td
className={`font-money pb-0 pl-6 pt-3 text-right font-medium ${cells.remainingTone}`}
>
{cells.remaining}
</td>
</tr>
{/* The meter IS the row divider: tight under its own figures,
with the gap below it. */}
<tr className="transition-colors group-hover:bg-accent/60">
<td colSpan={5} className="px-0 pb-4 pt-1.5">
<Meter segments={cells.segments} className="h-1" />
</td>
</tr>
</tbody>
);
})}
</table>
</div>
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
const cells = postRowCells(row);
return (
<li key={row.post.id} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0">
<PostName row={row} monthId={monthId} />
</span>
<span
className={`font-money shrink-0 text-base font-medium ${cells.remainingTone}`}
>
{formatSignedMoney(figures.carriedIn, baseCurrency)}
</td>
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.allocation, baseCurrency)}
</td>
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.charges, baseCurrency)}
</td>
<td
className={`font-money py-2.5 pl-6 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{formatSignedMoney(figures.remaining, baseCurrency)}
</td>
</tr>
<tr>
<td colSpan={5} className="p-0">
<CarryMeter figures={figures} />
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
{cells.remaining}
</span>
</div>
<Meter segments={cells.segments} className="h-1" />
<p className="flex flex-wrap gap-x-3 text-[0.6875rem] text-budget-ink-muted">
<span>
carried{" "}
<span className={`font-money ${cells.carriedInTone}`}>{cells.carriedIn}</span>
</span>
<span>
allocated <span className="font-money">{cells.allocation}</span>
</span>
<span>
spent <span className="font-money">{cells.charges}</span>
</span>
{/* 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 && (
<ChangeRuleButton
postId={row.post.id}
onChangeRule={onChangeRule}
className="ml-auto"
/>
)}
</p>
</li>
);
})}
</ul>
</>
);
}
+12 -1
View File
@@ -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");
});
+11 -2
View File
@@ -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}`;
}
+190
View File
@@ -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>): 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);
});
+122
View File
@@ -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(", ")})`;
}
+67 -23
View File
@@ -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() {
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
</header>
{/* 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. */}
<Section>
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-10">
<div className="space-y-1.5">
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Income this month ({base})
@@ -74,31 +82,52 @@ export function MonthRoute() {
}}
/>
</div>
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<div className="min-w-0 flex-1 space-y-2">
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
</Stat>
</dl>
<Meter
segments={allocationMeterSegments(view.income, view.totalAllocation)}
className="h-1.5 rounded-full"
/>
<p className="text-xs text-budget-ink-muted">{allocationCaption(view)}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
<dl>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
</Stat>
</dl>
{/* 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. */}
<p
className={`text-sm ${
view.overspentCount > 0 ? "text-overspend" : "text-budget-ink-muted"
}`}
>
{overspentLabel(view.overspentCount)}
</p>
</div>
</Section>
<Section title="Posts" className="overflow-hidden">
<PostTable
monthId={monthId}
baseCurrency={base}
rows={view.rows}
onChangeRule={setChangingRuleFor}
/>
<Section
title="Posts"
hint={<PostTableLegend baseCurrency={base} />}
className="overflow-hidden"
>
<PostTable monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
</Section>
{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("-");