feat(ui): add year matrix and per-post month and year views

Future finance-plan slices are surfaced as already-committed so a plan is
visible before it lands.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-01 20:59:59 +02:00
co-authored by Claude Opus 5
parent 5b879809d2
commit 6c0fc67f1c
4 changed files with 307 additions and 13 deletions
+75
View File
@@ -0,0 +1,75 @@
import { Link } from "react-router";
import { formatAmount } from "../format.ts";
import type { YearViewModel } from "../../domain/views.ts";
interface Props {
view: YearViewModel;
mode: "spend" | "balance";
}
/**
* Posts x 12 months, plus a total column. A quiet table by design-contract
* rule: the carry meter is the month view's signature element and does not
* belong here. Every figure comes from `yearView`'s rows; nothing here
* recomputes rollover.
*/
export function YearMatrix({ view, mode }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[48rem] text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
{view.months.map((month) => (
<th key={month} className="py-2 text-right font-normal">
{month.slice(5)}
</th>
))}
<th className="py-2 text-right">Total</th>
</tr>
</thead>
<tbody>
<tr className="border-b text-muted-foreground">
<td className="py-2">Income</td>
{view.incomeByMonth.map((income, i) => (
<td key={i} className="font-money py-2 text-right">
{formatAmount(income)}
</td>
))}
<td className="font-money py-2 text-right">{formatAmount(view.totalIncome)}</td>
</tr>
{view.rows.map((row) => (
<tr key={row.post.id} className="border-b last:border-0">
<td className="py-2">
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
{row.post.name}
</Link>
</td>
{row.byMonth.map((figures, i) => {
const value = mode === "spend" ? figures.charges : figures.remaining;
return (
<td
key={i}
className={`font-money py-2 text-right ${
mode === "balance" && value < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(value)}
</td>
);
})}
<td
className={`font-money py-2 text-right font-medium ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+114 -5
View File
@@ -1,10 +1,119 @@
import { useParams } from "react-router";
import { Link, useParams } from "react-router";
import { useDataset } from "../hooks/useDataset.ts";
import { monthView } from "../../domain/views.ts";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../../domain/charges.ts";
import { addMonths, compareMonths } from "../../domain/months.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
export function PostMonthRoute() {
const { postId, monthId } = useParams();
const { postId = "", monthId = "" } = useParams();
const dataset = useDataset();
const post = dataset.posts.find((p) => p.id === postId);
if (!post) return <p>That post no longer exists.</p>;
const base = dataset.settings.baseCurrency;
const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId);
const thisMonth = dataset.purchases.flatMap((purchase) => {
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
const mine = charges.find((c) => c.postId === postId);
return mine ? [{ purchase, amount: mine.amount }] : [];
});
// Future finance-plan slices, for purchases split with this post, that have
// not yet arrived at `monthId`. This is what surfaces a committed plan
// before it lands. Strictly-after so the current month's slice stays in
// "This month" rather than also appearing here.
const committed = dataset.purchases.flatMap((purchase) => {
if (!purchase.schedule) return [];
if (!purchase.splits.some((s) => s.postId === postId)) return [];
return purchase.schedule.slices
.filter((slice) => compareMonths(slice.month, monthId) > 0)
.map((slice) => ({ purchase, slice }));
});
return (
<h1 className="text-2xl font-semibold">
Post {postId} — {monthId}
</h1>
<section className="space-y-6">
<header className="flex items-center gap-4">
<Link to={`/post/${postId}/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
&larr;
</Link>
<h1 className="text-2xl font-semibold">
{post.name} &middot; {monthId}
</h1>
<Link to={`/post/${postId}/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
&rarr;
</Link>
<Link to={`/post/${postId}/year/${monthId.slice(0, 4)}`} className="ml-auto text-sm hover:underline">
year view
</Link>
</header>
<dl className="flex gap-6 text-sm">
<div>
<dt className="text-muted-foreground">Carried in</dt>
<dd
className={`font-money ${
(row?.figures.carriedIn ?? 0) > 0
? "text-surplus"
: (row?.figures.carriedIn ?? 0) < 0
? "text-overspend"
: ""
}`}
>
{formatSignedMoney(row?.figures.carriedIn ?? 0, base)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{formatMoney(row?.figures.allocation ?? 0, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
<dd className="font-money">{formatMoney(row?.figures.charges ?? 0, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Remaining</dt>
<dd
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
>
{formatSignedMoney(row?.figures.remaining ?? 0, base)}
</dd>
</div>
</dl>
<div>
<h2 className="mb-2 text-lg font-medium">This month</h2>
<ul className="divide-y text-sm">
{thisMonth.map(({ purchase, amount }) => (
<li key={purchase.id} className="flex gap-3 py-2">
<span className="flex-1">{purchase.description}</span>
{purchase.splits.length > 1 && (
<span className="text-xs text-muted-foreground">split</span>
)}
<span className="font-money">{formatMoney(amount, base)}</span>
</li>
))}
{thisMonth.length === 0 && <li className="py-2 text-muted-foreground">Nothing yet.</li>}
</ul>
</div>
{committed.length > 0 && (
<div>
<h2 className="mb-2 text-lg font-medium">Already committed</h2>
<ul className="divide-y text-sm text-muted-foreground">
{committed.map(({ purchase, slice }) => (
<li key={`${purchase.id}-${slice.month}`} className="flex gap-3 py-2">
<span className="w-20">{slice.month}</span>
<span className="flex-1">{purchase.description}</span>
<span className="font-money">
{formatMoney(slice.amount.amount, slice.amount.currency)}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
+72 -5
View File
@@ -1,10 +1,77 @@
import { useParams } from "react-router";
import { Link, useParams } from "react-router";
import { useDataset } from "../hooks/useDataset.ts";
import { yearView } from "../../domain/views.ts";
import { formatAmount, formatMoney, formatSignedMoney } from "../format.ts";
export function PostYearRoute() {
const { postId, year } = useParams();
const { postId = "", year = "" } = useParams();
const dataset = useDataset();
const post = dataset.posts.find((p) => p.id === postId);
if (!post) return <p>That post no longer exists.</p>;
const base = dataset.settings.baseCurrency;
const view = yearView(dataset, Number(year));
const row = view.rows.find((r) => r.post.id === postId);
if (!row) return <p>No data for this post in {year}.</p>;
return (
<h1 className="text-2xl font-semibold">
Post {postId} — Year {year}
</h1>
<section className="space-y-6">
<h1 className="text-2xl font-semibold">
{post.name} &middot; {year}
</h1>
<p className="text-sm text-muted-foreground">
Allocated {formatMoney(row.totalAllocation, base)} &middot; spent{" "}
{formatMoney(row.totalCharges, base)} &middot; closing balance{" "}
{formatSignedMoney(row.closingBalance, base)}
</p>
<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">Month</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>
{view.months.map((month, i) => {
const figures = row.byMonth[i]!;
return (
<tr key={month} className="border-b last:border-0">
<td className="py-2">
<Link to={`/post/${postId}/month/${month}`} className="hover:underline">
{month}
</Link>
</td>
<td
className={`font-money py-2 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: ""
}`}
>
{formatSignedMoney(figures.carriedIn, base)}
</td>
<td className="font-money py-2 text-right">{formatAmount(figures.allocation)}</td>
<td className="font-money py-2 text-right">{formatAmount(figures.charges)}</td>
<td
className={`font-money py-2 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{formatSignedMoney(figures.remaining, base)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
+46 -3
View File
@@ -1,6 +1,49 @@
import { useParams } from "react-router";
import { useState } from "react";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { yearView } from "../../domain/views.ts";
import { formatMoney } from "../format.ts";
import { YearMatrix } from "../components/YearMatrix.tsx";
export function YearRoute() {
const { year } = useParams();
return <h1 className="text-2xl font-semibold">Year {year}</h1>;
const { year = "" } = useParams();
const dataset = useDataset();
const [mode, setMode] = useState<"spend" | "balance">("spend");
const numericYear = Number(year);
const view = yearView(dataset, numericYear);
const base = dataset.settings.baseCurrency;
return (
<section className="space-y-6">
<header className="flex items-center gap-4">
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
&larr; {numericYear - 1}
</Link>
<h1 className="text-2xl font-semibold">{year}</h1>
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
{numericYear + 1} &rarr;
</Link>
<div className="ml-auto flex gap-1">
{(["spend", "balance"] as const).map((option) => (
<Button
key={option}
size="sm"
variant={mode === option ? "default" : "outline"}
onClick={() => setMode(option)}
>
{option === "spend" ? "Spent" : "Closing balance"}
</Button>
))}
</div>
</header>
<p className="text-sm text-muted-foreground">
Income {formatMoney(view.totalIncome, base)} &middot; spent{" "}
{formatMoney(view.totalCharges, base)}
</p>
<YearMatrix view={view} mode={mode} />
</section>
);
}