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} />} > -
    - {dataset.purchases - .filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null) - .map((purchase) => { - const slice = sliceAmountForMonth(purchase, monthId)!; - return ( -
  • - - {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. +

    + ) : ( +
    + {groups.map((group) => ( +
    +

    + {group.label} +

    +
      + {group.purchases.map((purchase) => ( + - edit - + monthId={monthId} + onDelete={() => + mutate((data) => deletePurchase(data, purchase.id)) } /> - - - - ); - })} -
    + ))} +
+ + ))} + + )} ); } +function PurchaseRow({ + purchase, + monthId, + onDelete, +}: { + purchase: Purchase; + monthId: MonthId; + onDelete: () => void; +}) { + const slice = sliceAmountForMonth(purchase, monthId)!; + return ( +
  • + + {purchase.description} + {purchase.note && ( + {purchase.note} + )} + + {purchase.schedule && ( + + financed + + )} + + {formatMoney(slice.amount, slice.currency)} + + {/* Held at a fixed width so revealing the actions on hover cannot shift + the amounts, which are the column being read down. */} + + + edit + + } + /> + + +
  • + ); +} + /** "2026-09" reads as a key; "September 2026" reads as a month. */ function monthLabel(monthId: MonthId): string { const [year, month] = monthId.split("-");