From 61e377373385a9ee7e310478b466912cc6d3e8e9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 23:08:10 +0200 Subject: [PATCH] feat(month): an anchored cost's future row is informational MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This month's row keeps its one-click Confirm for both anchorings — for an anchored cost that records "paid on time" at the due date, and paying a bill on schedule is the common case that must not get slower. A future row is different. Only a calendar cost can be paid early in a way a button can express, because its grid does not move. Confirming a future ANCHORED charge is the refresh, and a refresh needs a real date and possibly a different amount — the purchase dialog's job. A button here would have to invent a date, and inventing it is what produced the round-1 Critical. Also rewrites expectedGroups' docstring, which still described the deleted X1/wouldAdvancePast rule as current behaviour, and drops a stray "X1" label left over a defensive null-guard it no longer has anything to do with. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/components/ExpectedBand.test.ts | 58 +++++++++- src/ui/components/ExpectedBand.tsx | 144 ++++++++++++++++++------- 2 files changed, 158 insertions(+), 44 deletions(-) diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts index ce25d06..a9b229f 100644 --- a/src/ui/components/ExpectedBand.test.ts +++ b/src/ui/components/ExpectedBand.test.ts @@ -46,11 +46,11 @@ describe("resolveExpectedAmount", () => { // are exchanged (verified by hand: swapping them and re-running does fail). describe("recordedDateFor", () => { test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => { - expect(recordedDateFor("pending", "2026-09-04")).toBeNull(); + expect(recordedDateFor("pending", "2026-09-04", "calendar")).toBeNull(); }); - test("a 'coming up' row records at today — paying early is the whole point", () => { - expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04"); + test("a 'coming up' row on a calendar cost records at today — paying early is the whole point", () => { + expect(recordedDateFor("comingUp", "2026-09-04", "calendar")).toBe("2026-09-04"); }); }); @@ -93,6 +93,28 @@ function confirmed(recurringId: string, occurrenceDate: string, id: string): Pur // calendar-anchored cost advances regardless of it). const TODAY = "2026-02-15"; +// A `lastCharge` cost whose latest charge (September's, paid on time) puts +// the next due date in October — so viewed from September, October's row is +// a "coming up" one for an anchored cost. +function anchoredDatasetDueNextMonth(): Dataset { + const data = baseDataset(); + data.recurring = [cost({ id: "phone", anchoring: "lastCharge", startDate: "2026-01-01" })]; + data.purchases = [confirmed("phone", "2026-09-01", "p1")]; + return data; +} + +// A `calendar` cost with September's slot confirmed, so October is the next +// unconfirmed slot and reaches "coming up". +function calendarDatasetSettledThisMonth(): Dataset { + const data = baseDataset(); + // Starts IN September so there is no earlier unconfirmed slot to trip X3 + // (an outstanding earlier occurrence would suppress the coming-up row + // entirely — see the X3 tests above). + data.recurring = [cost({ id: "rent", startDate: "2026-09" })]; // anchoring: "calendar" by default + data.purchases = [confirmed("rent", "2026-09", "p1")]; + return data; +} + // Gap 1 (spec §5): the band offers confirmation on the next pending // occurrence, not only ones landing in the displayed month. describe("expectedGroups (Coming up)", () => { @@ -359,3 +381,33 @@ describe("expectedGroups — coming-up confirmations never break the fold (forme expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow(); }); }); + +// Task 6: this month's row keeps its one-click Confirm for both anchorings — +// for a lastCharge cost that records "paid on time" at the due date. A +// coming-up row is different: only a calendar cost can be paid early in a way +// a button can express (its grid doesn't move); confirming a future ANCHORED +// charge IS the refresh, which needs a real date and possibly a different +// amount — the purchase dialog's job, not a button here. +describe("anchored rows", () => { + test("this month's anchored row is confirmable at its own date", () => { + expect(recordedDateFor("pending", "2026-09-04", "lastCharge")).toBeNull(); + }); + + test("a coming-up row on a CALENDAR cost still records today", () => { + expect(recordedDateFor("comingUp", "2026-09-04", "calendar")).toBe("2026-09-04"); + }); + + test("an anchored coming-up row is not confirmable at all", () => { + const groups = expectedGroups(anchoredDatasetDueNextMonth(), "2026-09", "2026-09-04"); + const row = groups.comingUp.find((o) => o.recurringId === "phone"); + expect(row).toBeDefined(); + expect(row!.confirmable).toBe(false); + }); + + test("a calendar coming-up row IS confirmable", () => { + const groups = expectedGroups(calendarDatasetSettledThisMonth(), "2026-09", "2026-09-04"); + const row = groups.comingUp.find((o) => o.recurringId === "rent"); + expect(row).toBeDefined(); + expect(row!.confirmable).toBe(true); + }); +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index 2feeafd..aafccc4 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -3,13 +3,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDataset } from "../hooks/useDataset.ts"; import { useMutate } from "../hooks/useMutate.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { today } from "../../store/index.ts"; import { confirmOccurrence } from "../../store/actions.ts"; import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts"; import { addMonths, compareMonths } from "../../domain/months.ts"; import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts"; import { Section } from "./Section.tsx"; -import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts"; +import type { Anchoring, Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts"; /** * `typed` is `undefined` when the field has never been edited — that always @@ -36,19 +37,37 @@ export function resolveExpectedAmount( export type ExpectedRowGroup = "pending" | "comingUp"; /** - * What a Confirm click should record, given which group the row is in. A - * this-month row confirms at the slot's own date — `null`, `confirmOccurrence`'s - * default — and a coming-up row confirms at `today` instead, because it is - * being paid early on purpose (see `ExpectedBand`'s doc comment). + * What a Confirm click should record. A this-month row confirms at the + * occurrence's own date (`null`, `confirmOccurrence`'s default) whatever the + * anchoring — for an anchored cost that means "paid on time", which re-anchors + * from the due date. * - * This one branch (previously inlined as `row(occurrence, null)` versus - * `row(occurrence, today)`) is the whole of the phone-bill behaviour, and - * nothing else in the suite would catch the two being swapped — a DOM test - * isn't needed for that, a direct one is. Exported for `ExpectedBand.test.ts` - * (X2). + * A coming-up row is being paid EARLY, and only a calendar cost can express + * that: its grid does not move, so settling a future slot today is simply + * paying ahead. An anchored cost has no slot to settle early — recording it + * today IS the refresh, which belongs in the purchase dialog where the owner + * sets the real date. So this returns null for it and the row carries no + * Confirm at all; see `confirmable` below. + * + * Exported for `ExpectedBand.test.ts` (X2). */ -export function recordedDateFor(group: ExpectedRowGroup, today: IsoDate): IsoDate | null { - return group === "comingUp" ? today : null; +export function recordedDateFor( + group: ExpectedRowGroup, + today: IsoDate, + anchoring: Anchoring, +): IsoDate | null { + if (group !== "comingUp") return null; + return anchoring === "calendar" ? today : null; +} + +/** + * A "coming up" row. `Occurrence` is a domain type and must not gain a UI + * flag, so the band carries its own row shape for the one thing it needs to + * decide rendering: whether this row gets a Confirm button at all. + */ +export interface ComingUpRow extends Occurrence { + /** False for an anchored (`lastCharge`) cost: see `recordedDateFor`. */ + confirmable: boolean; } export interface ExpectedGroups { @@ -61,7 +80,7 @@ export interface ExpectedGroups { * strictly after `monthId`, out to `horizonMonths` ahead. Never a list of * everything the future holds. */ - comingUp: Occurrence[]; + comingUp: ComingUpRow[]; } /** @@ -75,24 +94,25 @@ export interface ExpectedGroups { * enough to catch an annual `everyNMonths` cost — and is a band-local concern. * `foldBalances`'s own `upToMonth` bound is untouched. * - * `today` is what a "Coming up" row would record if confirmed (see `row` in - * `ExpectedBand` below) — passed in rather than read from a clock, because - * this function is exercised directly by tests with no DOM. + * `today` is what a "Coming up" row would record if confirmed, when it is + * confirmable at all (see `recordedDateFor`) — passed in rather than read + * from a clock, because this function is exercised directly by tests with no + * DOM. * - * Two rules keep a "coming up" row from ever being offered when confirming it - * cannot go well (X1, X3): + * One rule keeps a "coming up" row from ever being offered when confirming it + * cannot go well (X3): a cost with an unresolved occurrence in `monthId` or + * any earlier FOLDABLE month (same bound `overdue` uses) is skipped entirely. + * Settle the earlier bill first — offering a later one while an earlier sits + * outstanding would let confirming the later one leave the earlier projection + * standing, double-counting the bill. * - * - A cost with an unresolved occurrence in `monthId` or any earlier - * FOLDABLE month (same bound `overdue` uses) is skipped entirely. Settle - * the earlier bill first — offering a later one while an earlier sits - * outstanding would let confirming the later one leave the earlier - * projection standing, double-counting the bill. - * - Otherwise, the row is offered only if recording it at `today` would - * leave `occurrencesOf`'s walk able to advance past that slot - * (`wouldAdvancePast`). A bill confirmed today for a slot far enough in - * the future can rebase the series backwards, which is exactly what - * `occurrencesOf` throws on — so a row that would do that must never be - * offered in the first place, not offered-and-disabled. + * Every "coming up" row also carries `confirmable`, true only for a + * `calendar` cost. Its grid never moves, so settling a future slot today is + * simply paying ahead. A `lastCharge` cost has no grid to settle early + * against — recording a charge today for it IS the refresh that rebases the + * series, and that belongs in the purchase dialog where a real date (and + * possibly a different amount) gets set, not in a one-click button here. See + * `recordedDateFor`. * * Exported for `ExpectedBand.test.ts`. */ @@ -110,7 +130,7 @@ export function expectedGroups( // Costs with an outstanding slot at or before `monthId` — X3: none of // these may get a "coming up" row until that slot is settled. const unresolved = new Set(pending.map((o) => o.recurringId)); - const comingUp: Occurrence[] = []; + const comingUp: ComingUpRow[] = []; for (const month of [...byMonth.keys()].sort(compareMonths)) { const cmp = compareMonths(month, monthId); @@ -143,9 +163,11 @@ export function expectedGroups( if (seen.has(occurrence.recurringId)) continue; seen.add(occurrence.recurringId); if (unresolved.has(occurrence.recurringId)) continue; // X3 + // Should never happen: occurrencesByMonth only emits IDs of costs that + // still exist in this dataset. Defensive, not a real branch to explain. const cost = dataset.recurring.find((c) => c.id === occurrence.recurringId); - if (!cost) continue; // X1 - comingUp.push(occurrence); + if (!cost) continue; + comingUp.push({ ...occurrence, confirmable: cost.anchoring === "calendar" }); } } @@ -162,15 +184,21 @@ export function expectedGroups( * cap is hit on the 12th when the slot sat on the 30th — and confirming the * pending slot with the real date is exactly how the series rebases. * - * The same is true one month further out: "Coming up" offers the next - * pending occurrence even when it lands after the displayed month, because - * confirming it EARLY — dated today rather than the future slot — is what - * rebases a `lastCharge` series. A this-month row still confirms at the - * slot's own date; only a coming-up row records today instead. + * The same is true one month further out for a `calendar` cost only: "Coming + * up" offers its next pending occurrence even when it lands after the + * displayed month, because confirming it EARLY — dated today rather than the + * future slot — is simply paying ahead on a grid that does not move. + * + * A `lastCharge` coming-up row is different: it has no grid to pay ahead of, + * so confirming it today would BE the refresh, and a refresh needs a real + * date (and possibly a different amount) that only the purchase dialog can + * supply. Such a row is rendered informational, with no Confirm at all — see + * `recordedDateFor` and `ComingUpRow.confirmable`. */ export function ExpectedBand({ monthId }: { monthId: MonthId }) { const dataset = useDataset(); const { mutate } = useMutate(); + const fmt = useMoneyFormat(); const [amounts, setAmounts] = useState>({}); const { pending, overdue, comingUp } = useMemo( @@ -184,8 +212,10 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { dataset.recurring.find((c) => c.id === recurringId)?.name ?? "Unknown"; /** - * One row, shared by "Expected" and "Coming up" — they differ only in the - * subtitle and in what date confirming records, per `recordedDateFor`. + * One row, shared by "Expected" and a confirmable "Coming up" entry — they + * differ only in the subtitle and in what date confirming records, per + * `recordedDateFor`. A non-confirmable coming-up row never reaches this + * function; see `informationalRow` below. */ function row(occurrence: Occurrence, recordedDate: IsoDate | null) { const key = `${occurrence.recurringId}:${occurrence.date}`; @@ -263,6 +293,31 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { ); } + /** + * A coming-up row for an anchored (`lastCharge`) cost: no Confirm, because + * confirming it today would BE the refresh — see `recordedDateFor`. Date + * and amount are plain text; the muted line names what to do instead. + */ + function informationalRow(occurrence: Occurrence) { + const key = `${occurrence.recurringId}:${occurrence.date}`; + return ( +
  • +
    +
    {nameOf(occurrence.recurringId)}
    +
    {occurrence.date}
    +
    +
    + + {fmt.money(occurrence.amount.amount, occurrence.amount.currency)} + + + Add a purchase and tag it to this cost to record a charge early. + +
    +
  • + ); + } + return (
    {pending.length > 0 && (
      - {pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))} + {/* Anchoring never matters here: `recordedDateFor` returns null for + the "pending" group unconditionally, whatever anchoring is + passed. */} + {pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today, "calendar")))}
    )} @@ -280,7 +338,11 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { Coming up
      - {comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))} + {comingUp.map((occurrence) => + occurrence.confirmable + ? row(occurrence, recordedDateFor("comingUp", today, "calendar")) + : informationalRow(occurrence), + )}
    )}