feat(ui): add month view with income entry and post table

Negative remaining balances are shown in a warning treatment; nothing
blocks going over budget.

Applies the Tasks 18-23 design contract: --paper/--ink/--rule/--accent/
--surplus/--overspend tokens and the three type-role font variables in
src/index.css, --font-money on every money figure, and the carry meter
(a 3px hard-stopped gradient beneath each PostTable row) encoding
carried-in surplus, allocation track, spend fill and overrun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 20:35:35 +02:00
1 parent ccb97818d7
commit dd612565cb
16 files changed
+914 -75

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
import { Fragment } from "react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { formatMoney, formatSignedMoney } from "../format.ts";
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[];
}
/**
* 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.
*
* 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)
* - --accent : spend within this month's allocation, filled from the
* track's left edge
* - --rule : the unspent remainder of the allocation track
* - --overspend: spend beyond the allocation, past the track's end
*/
function carryMeterBackground(figures: PostMonthFigures): string {
const surplus = Math.max(figures.carriedIn, 0);
const scale = surplus + Math.max(figures.allocation, figures.charges);
if (scale <= 0) {
return "var(--rule)";
}
const pSurplus = (surplus / scale) * 100;
const pFillEnd = pSurplus + (Math.min(figures.charges, figures.allocation) / scale) * 100;
const pTrackEnd = pSurplus + (figures.allocation / scale) * 100;
return (
`linear-gradient(to right, ` +
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
`var(--accent) ${pSurplus}%, var(--accent) ${pFillEnd}%, ` +
`var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` +
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
);
}
function CarryMeter({ figures }: { figures: PostMonthFigures }) {
return (
<div
aria-hidden="true"
className="h-[3px] w-full"
style={{ background: carryMeterBackground(figures) }}
/>
);
}
export function PostTable({ monthId, baseCurrency, rows }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
<th className="py-2 text-right">Carried in</th>
<th className="py-2 text-right">Allocated</th>
<th className="py-2 text-right">Spent</th>
<th className="py-2 text-right">Remaining</th>
</tr>
</thead>
<tbody>
{rows.map(({ post, figures, overridden }) => (
<Fragment key={post.id}>
<tr>
<td className="py-2">
<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 standing rule"
>
overridden
</Badge>
)}
</td>
<td
className={`font-money py-2 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: ""
}`}
>
{formatSignedMoney(figures.carriedIn, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
{formatMoney(figures.allocation, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
{formatMoney(figures.charges, baseCurrency)}
</td>
<td
className={`font-money py-2 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>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from "bun:test";
import { formatAmount, formatMoney, formatSignedMoney } from "./format.ts";
test("formatAmount groups thousands and always shows two decimals", () => {
expect(formatAmount(1234.5)).toBe("1,234.50");
expect(formatAmount(0)).toBe("0.00");
expect(formatAmount(-42)).toBe("-42.00");
});
test("formatMoney appends the currency code", () => {
expect(formatMoney(1234.5, "DKK")).toBe("1,234.50 DKK");
});
test("formatSignedMoney marks positive balances with a plus", () => {
expect(formatSignedMoney(200, "DKK")).toBe("+200.00 DKK");
expect(formatSignedMoney(-200, "DKK")).toBe("-200.00 DKK");
expect(formatSignedMoney(0, "DKK")).toBe("0.00 DKK");
});
+20
View File
@@ -0,0 +1,20 @@
import type { Currency } from "../domain/types.ts";
// Pinned locale: output must not vary between the user's machine and CI.
const NUMBER = new Intl.NumberFormat("en-GB", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function formatAmount(amount: number): string {
return NUMBER.format(amount);
}
export function formatMoney(amount: number, currency: Currency): string {
return `${formatAmount(amount)} ${currency}`;
}
export function formatSignedMoney(amount: number, currency: Currency): string {
const sign = amount > 0 ? "+" : "";
return `${sign}${formatMoney(amount, currency)}`;
}
+20
View File
@@ -0,0 +1,20 @@
import { useCallback, useState } from "react";
import { store } from "../../store/index.ts";
import type { Dataset } from "../../domain/types.ts";
/**
* Wraps store.mutate so a failed write surfaces to the user instead of
* vanishing into an unhandled rejection. The snapshot is unchanged on failure,
* so the UI keeps showing the last saved state.
*/
export function useMutate() {
const [error, setError] = useState<string | null>(null);
const mutate = useCallback((fn: (draft: Dataset) => void) => {
store.mutate(fn).catch((cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
});
}, []);
return { mutate, error, clearError: useCallback(() => setError(null), []) };
}
+84 -3
View File
@@ -1,6 +1,87 @@
import { useParams } from "react-router";
import { Link, useParams } from "react-router";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { monthView } from "../../domain/views.ts";
import { addMonths } from "../../domain/months.ts";
import { setIncome } from "../../store/actions.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { PostTable } from "../components/PostTable.tsx";
export function MonthRoute() {
const { monthId } = useParams();
return <h1 className="text-2xl font-semibold">{monthId}</h1>;
const { monthId = "" } = useParams();
const dataset = useDataset();
const { mutate, error, clearError } = useMutate();
const view = monthView(dataset, monthId);
const base = dataset.settings.baseCurrency;
return (
<section className="space-y-6">
{error && (
<div className="flex items-center justify-between rounded border border-destructive p-3 text-sm">
<span>Could not save: {error}</span>
<button onClick={clearError} className="underline">
dismiss
</button>
</div>
)}
<header className="flex items-center gap-4">
<Link to={`/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
&larr; {addMonths(monthId, -1)}
</Link>
<h1 className="text-2xl font-semibold">{monthId}</h1>
<Link to={`/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
{addMonths(monthId, 1)} &rarr;
</Link>
</header>
<div className="flex flex-wrap items-end gap-6">
<div className="space-y-1">
<Label htmlFor="income">Income this month ({base})</Label>
<Input
id="income"
type="number"
step="0.01"
className="font-money w-40"
value={view.income}
onChange={(event) =>
mutate((draft) =>
setIncome(draft, monthId, {
amount: Number(event.target.value) || 0,
currency: base,
}),
)
}
/>
</div>
<dl className="flex gap-6 text-sm">
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{formatMoney(view.totalAllocation, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Unallocated</dt>
<dd
className={`font-money ${view.unallocated < 0 ? "text-overspend" : ""}`}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
</dd>
</div>
</dl>
</div>
<PostTable monthId={monthId} baseCurrency={base} rows={view.rows} />
</section>
);
}