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:
1 parent
ccb97818d7
commit
dd612565cb
16 files changed
+914
-75
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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), []) };
|
||||
}
|
||||
@@ -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">
|
||||
← {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)} →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user