feat(ui): group a month's purchases under date headings
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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8ee579fe7
commit
a159189b01
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Purchase[]>();
|
||||
|
||||
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)!,
|
||||
}));
|
||||
}
|
||||
@@ -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<string | null>(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={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
|
||||
>
|
||||
<ul className="divide-y divide-budget-rule text-sm">
|
||||
{dataset.purchases
|
||||
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
|
||||
.map((purchase) => {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li
|
||||
key={purchase.id}
|
||||
className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
|
||||
financed
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
{/* Held at a fixed width so revealing them on hover cannot
|
||||
shift the amounts, which are the column being read. */}
|
||||
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-1 text-sm text-budget-ink-muted">
|
||||
Nothing recorded for this month yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<h3 className="mb-0.5 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="divide-y divide-budget-rule text-sm">
|
||||
{group.purchases.map((purchase) => (
|
||||
<PurchaseRow
|
||||
key={purchase.id}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
monthId={monthId}
|
||||
onDelete={() =>
|
||||
mutate((data) => deletePurchase(data, purchase.id))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseRow({
|
||||
purchase,
|
||||
monthId,
|
||||
onDelete,
|
||||
}: {
|
||||
purchase: Purchase;
|
||||
monthId: MonthId;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="shrink-0 rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
|
||||
financed
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
{/* Held at a fixed width so revealing the actions on hover cannot shift
|
||||
the amounts, which are the column being read down. */}
|
||||
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend"
|
||||
onClick={onDelete}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** "2026-09" reads as a key; "September 2026" reads as a month. */
|
||||
function monthLabel(monthId: MonthId): string {
|
||||
const [year, month] = monthId.split("-");
|
||||
|
||||
Reference in New Issue
Block a user