From a159189b01915e4e0747aa79e55e63f2b9412666 Mon Sep 17 00:00:00 2001
From: Lucas Winther
Date: Wed, 2 Sep 2026 15:14:52 +0200
Subject: [PATCH] feat(ui): group a month's purchases under date headings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The density the owner chose. Newest day first, weekday named because that is
what a person recognises, and order within a day preserved so a row just
entered stays where it was put.
Two cases the obvious implementation gets wrong, both tested:
A finance plan's later slices belong in this month — money moves this month —
but the purchase's own date does not. Filing them by date would have put
"14 August" inside the September list, so they group separately, last, under
"Financed from earlier months".
A purchase date may legally be "YYYY-MM" with no day at all, which gets its
own trailing group rather than a heading reading "NaN".
The weekday is computed in UTC on both ends. `new Date("2026-09-01")` parses
as UTC midnight, so `getDay()` anywhere west of Greenwich reports the previous
day and every heading would be wrong by one for the user. A test pins three
timezones.
Trade-off worth stating: headings cost vertical space on a day with a single
purchase, and only pay for themselves when a day has several. PRODUCT.md says
the owner records in batches — "a grocery trip is twenty lines entered in one
sitting" — so real data should sit on the paying side of that.
Co-Authored-By: Claude Opus 5 (1M context)
---
src/ui/purchaseGroups.test.ts | 82 ++++++++++++++++++++++
src/ui/purchaseGroups.ts | 90 ++++++++++++++++++++++++
src/ui/routes/MonthRoute.tsx | 128 +++++++++++++++++++++-------------
3 files changed, 253 insertions(+), 47 deletions(-)
create mode 100644 src/ui/purchaseGroups.test.ts
create mode 100644 src/ui/purchaseGroups.ts
diff --git a/src/ui/purchaseGroups.test.ts b/src/ui/purchaseGroups.test.ts
new file mode 100644
index 0000000..073ab55
--- /dev/null
+++ b/src/ui/purchaseGroups.test.ts
@@ -0,0 +1,82 @@
+import { test, expect, describe } from "bun:test";
+import { groupPurchasesByDate } from "./purchaseGroups.ts";
+import type { Purchase } from "../domain/types.ts";
+
+function purchase(id: string, date: string): Purchase {
+ return {
+ id,
+ date,
+ description: id,
+ total: { amount: 100, currency: "DKK" },
+ splitMode: "percent",
+ splits: [{ postId: "p", value: 100, absorbsRemainder: true }],
+ schedule: null,
+ };
+}
+
+describe("grouping a month's purchases by day", () => {
+ test("newest day first, and purchases keep their order within a day", () => {
+ const groups = groupPurchasesByDate(
+ [purchase("a", "2026-09-03"), purchase("b", "2026-09-12"), purchase("c", "2026-09-03")],
+ "2026-09",
+ );
+ expect(groups.map((g) => g.key)).toEqual(["2026-09-12", "2026-09-03"]);
+ expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["a", "c"]);
+ });
+
+ test("the heading names the weekday, which is what a person recognises", () => {
+ const [group] = groupPurchasesByDate([purchase("a", "2026-09-12")], "2026-09");
+ // 2026-09-12 is a Saturday.
+ expect(group!.label).toBe("Sat 12 September");
+ });
+
+ /**
+ * The weekday must not depend on where the machine is. Parsing
+ * "2026-09-01" gives UTC midnight, so a getDay() in any negative-offset
+ * zone reports the previous day — every heading would be wrong by one for
+ * users west of Greenwich.
+ */
+ test("the weekday is timezone-independent", () => {
+ const label = (tz: string) => {
+ const original = process.env.TZ;
+ process.env.TZ = tz;
+ try {
+ return groupPurchasesByDate([purchase("a", "2026-09-01")], "2026-09")[0]!.label;
+ } finally {
+ process.env.TZ = original;
+ }
+ };
+ expect(label("UTC")).toBe("Tue 1 September");
+ expect(label("America/Los_Angeles")).toBe("Tue 1 September");
+ expect(label("Pacific/Kiritimati")).toBe("Tue 1 September");
+ });
+
+ /**
+ * A finance plan's slices land in months after the purchase date, so in
+ * those months the row's own date is not in view. Filing it under its real
+ * date would put "14 August" inside the September list.
+ */
+ test("a slice from an earlier month is grouped apart, and last", () => {
+ const groups = groupPurchasesByDate(
+ [purchase("bike", "2026-08-14"), purchase("rent", "2026-09-01")],
+ "2026-09",
+ );
+ expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "carried"]);
+ expect(groups[1]!.label).toBe("Financed from earlier months");
+ expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["bike"]);
+ });
+
+ /** A purchase date may legally be "YYYY-MM" with no day at all. */
+ test("a purchase with no day sits in its own group, after the dated ones", () => {
+ const groups = groupPurchasesByDate(
+ [purchase("vague", "2026-09"), purchase("rent", "2026-09-01")],
+ "2026-09",
+ );
+ expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "undated"]);
+ expect(groups[1]!.label).toBe("No day recorded");
+ });
+
+ test("an empty month is an empty list, not a group of nothing", () => {
+ expect(groupPurchasesByDate([], "2026-09")).toEqual([]);
+ });
+});
diff --git a/src/ui/purchaseGroups.ts b/src/ui/purchaseGroups.ts
new file mode 100644
index 0000000..d244779
--- /dev/null
+++ b/src/ui/purchaseGroups.ts
@@ -0,0 +1,90 @@
+import type { MonthId, Purchase } from "../domain/types.ts";
+
+export interface PurchaseGroup {
+ /** An ISO date, or the sentinel "carried" / "undated". */
+ key: string;
+ /** "Sat 12 September", or a sentence for the two sentinel groups. */
+ label: string;
+ purchases: Purchase[];
+}
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+const MONTHS = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+];
+
+/**
+ * Sorted last, in this order, after every dated group:
+ * - `carried`: a finance plan's later slices. The row belongs in this month
+ * because money moves this month, but its own date does not, so filing it
+ * under that date would put "14 August" inside the September list.
+ * - `undated`: a purchase date may legally be "YYYY-MM" with no day.
+ */
+const CARRIED = "carried";
+const UNDATED = "undated";
+
+/**
+ * Weekday without a timezone. `new Date("2026-09-01")` is UTC midnight, so
+ * `getDay()` anywhere west of Greenwich reports the previous day and every
+ * heading would be off by one. Building the date in UTC and reading it back in
+ * UTC is the whole fix.
+ */
+function weekday(year: number, month: number, day: number): string {
+ return WEEKDAYS[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]!;
+}
+
+function dayLabel(date: string): string {
+ const [year, month, day] = date.split("-").map(Number) as [number, number, number];
+ return `${weekday(year, month, day)} ${day} ${MONTHS[month - 1]}`;
+}
+
+/**
+ * Groups a month's purchase rows under date headings, most recent day first.
+ * Order within a day is preserved, so a row just entered stays where it was
+ * put rather than jumping.
+ *
+ * Takes the rows already filtered for the month — deciding WHICH purchases
+ * touch a month is `sliceAmountForMonth`'s job, and this must not second-guess
+ * it.
+ */
+export function groupPurchasesByDate(
+ purchases: Purchase[],
+ monthId: MonthId,
+): PurchaseGroup[] {
+ const byKey = new Map();
+
+ for (const purchase of purchases) {
+ const hasDay = /^\d{4}-\d{2}-\d{2}$/.test(purchase.date);
+ const key = !purchase.date.startsWith(monthId)
+ ? CARRIED
+ : hasDay
+ ? purchase.date
+ : UNDATED;
+ const bucket = byKey.get(key);
+ if (bucket) bucket.push(purchase);
+ else byKey.set(key, [purchase]);
+ }
+
+ const dated = [...byKey.keys()]
+ .filter((key) => key !== CARRIED && key !== UNDATED)
+ .sort()
+ .reverse();
+
+ const order = [
+ ...dated,
+ ...(byKey.has(UNDATED) ? [UNDATED] : []),
+ ...(byKey.has(CARRIED) ? [CARRIED] : []),
+ ];
+
+ return order.map((key) => ({
+ key,
+ label:
+ key === CARRIED
+ ? "Financed from earlier months"
+ : key === UNDATED
+ ? "No day recorded"
+ : dayLabel(key),
+ purchases: byKey.get(key)!,
+ }));
+}
diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx
index 6adf240..530f6d8 100644
--- a/src/ui/routes/MonthRoute.tsx
+++ b/src/ui/routes/MonthRoute.tsx
@@ -9,12 +9,13 @@ import { monthView } 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, Rule } from "../../domain/types.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 { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { Section, Stat } from "../components/Section.tsx";
+import { groupPurchasesByDate } from "../purchaseGroups.ts";
export function MonthRoute() {
const { monthId = "" } = useParams();
@@ -23,6 +24,10 @@ export function MonthRoute() {
const view = monthView(dataset, monthId);
const base = dataset.settings.baseCurrency;
const [changingRuleFor, setChangingRuleFor] = useState(null);
+ const monthPurchases = dataset.purchases.filter(
+ (purchase) => sliceAmountForMonth(purchase, monthId) !== null,
+ );
+ const groups = groupPurchasesByDate(monthPurchases, monthId);
const changingPost = changingRuleFor
? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null)
: null;
@@ -113,60 +118,89 @@ export function MonthRoute() {
title="Purchases"
action={Add purchase} />}
>
-
-
- {purchase.description}
- {purchase.note && (
- {purchase.note}
- )}
-
- {purchase.schedule && (
-
- financed
-
- )}
-
- {formatMoney(slice.amount, slice.currency)}
-
- {/* Held at a fixed width so revealing them on hover cannot
- shift the amounts, which are the column being read. */}
-
-
+ Nothing recorded for this month yet.
+