feat(month): an anchored cost's future row is informational

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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-05 23:08:10 +02:00
1 parent 7a755f6406
commit 61e3773733
2 files changed
+158 -44

No files matched your search

+55 -3
View File
@@ -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);
});
});
+103 -41
View File
@@ -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<string>(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<Record<string, string>>({});
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 (
<li key={key} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-2.5">
<div className="min-w-0">
<div className="truncate text-sm">{nameOf(occurrence.recurringId)}</div>
<div className="text-xs text-budget-ink-muted">{occurrence.date}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">
{fmt.money(occurrence.amount.amount, occurrence.amount.currency)}
</span>
<span className="text-xs text-budget-ink-muted">
Add a purchase and tag it to this cost to record a charge early.
</span>
</div>
</li>
);
}
return (
<Section
title="Expected"
@@ -270,7 +325,10 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
>
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{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")))}
</ul>
)}
@@ -280,7 +338,11 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
Coming up
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))}
{comingUp.map((occurrence) =>
occurrence.confirmable
? row(occurrence, recordedDateFor("comingUp", today, "calendar"))
: informationalRow(occurrence),
)}
</ul>
</div>
)}