diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx
new file mode 100644
index 0000000..4b69cb2
--- /dev/null
+++ b/src/ui/components/YearMatrix.tsx
@@ -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 (
+
+
+
+
+ Post
+ {view.months.map((month) => (
+
+ {month.slice(5)}
+
+ ))}
+ Total
+
+
+
+
+ Income
+ {view.incomeByMonth.map((income, i) => (
+
+ {formatAmount(income)}
+
+ ))}
+ {formatAmount(view.totalIncome)}
+
+
+ {view.rows.map((row) => (
+
+
+
+ {row.post.name}
+
+
+ {row.byMonth.map((figures, i) => {
+ const value = mode === "spend" ? figures.charges : figures.remaining;
+ return (
+
+ {formatAmount(value)}
+
+ );
+ })}
+
+ {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx
index 53d5456..c6c1ca9 100644
--- a/src/ui/routes/PostMonthRoute.tsx
+++ b/src/ui/routes/PostMonthRoute.tsx
@@ -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 That post no longer exists.
;
+
+ 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 (
-
- Post {postId} — {monthId}
-
+
+
+
+ ←
+
+
+ {post.name} · {monthId}
+
+
+ →
+
+
+ year view
+
+
+
+
+
+
Carried in
+ 0
+ ? "text-surplus"
+ : (row?.figures.carriedIn ?? 0) < 0
+ ? "text-overspend"
+ : ""
+ }`}
+ >
+ {formatSignedMoney(row?.figures.carriedIn ?? 0, base)}
+
+
+
+
Allocated
+ {formatMoney(row?.figures.allocation ?? 0, base)}
+
+
+
Spent
+ {formatMoney(row?.figures.charges ?? 0, base)}
+
+
+
Remaining
+
+ {formatSignedMoney(row?.figures.remaining ?? 0, base)}
+
+
+
+
+
+
This month
+
+ {thisMonth.map(({ purchase, amount }) => (
+
+ {purchase.description}
+ {purchase.splits.length > 1 && (
+ split
+ )}
+ {formatMoney(amount, base)}
+
+ ))}
+ {thisMonth.length === 0 && Nothing yet. }
+
+
+
+ {committed.length > 0 && (
+
+
Already committed
+
+ {committed.map(({ purchase, slice }) => (
+
+ {slice.month}
+ {purchase.description}
+
+ {formatMoney(slice.amount.amount, slice.amount.currency)}
+
+
+ ))}
+
+
+ )}
+
);
}
diff --git a/src/ui/routes/PostYearRoute.tsx b/src/ui/routes/PostYearRoute.tsx
index 6243ef3..6d719c6 100644
--- a/src/ui/routes/PostYearRoute.tsx
+++ b/src/ui/routes/PostYearRoute.tsx
@@ -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 That post no longer exists.
;
+
+ const base = dataset.settings.baseCurrency;
+ const view = yearView(dataset, Number(year));
+ const row = view.rows.find((r) => r.post.id === postId);
+ if (!row) return No data for this post in {year}.
;
+
return (
-
- Post {postId} — Year {year}
-
+
+
+ {post.name} · {year}
+
+
+ Allocated {formatMoney(row.totalAllocation, base)} · spent{" "}
+ {formatMoney(row.totalCharges, base)} · closing balance{" "}
+ {formatSignedMoney(row.closingBalance, base)}
+
+
+
+
+
+
+ Month
+ Carried in
+ Allocated
+ Spent
+ Remaining
+
+
+
+ {view.months.map((month, i) => {
+ const figures = row.byMonth[i]!;
+ return (
+
+
+
+ {month}
+
+
+ 0
+ ? "text-surplus"
+ : figures.carriedIn < 0
+ ? "text-overspend"
+ : ""
+ }`}
+ >
+ {formatSignedMoney(figures.carriedIn, base)}
+
+ {formatAmount(figures.allocation)}
+ {formatAmount(figures.charges)}
+
+ {formatSignedMoney(figures.remaining, base)}
+
+
+ );
+ })}
+
+
+
+
);
}
diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx
index f231462..a2bde23 100644
--- a/src/ui/routes/YearRoute.tsx
+++ b/src/ui/routes/YearRoute.tsx
@@ -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 Year {year} ;
+ 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 (
+
+
+
+
+ Income {formatMoney(view.totalIncome, base)} · spent{" "}
+ {formatMoney(view.totalCharges, base)}
+
+
+
+
+ );
}