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:
1 parent
7a755f6406
commit
61e3773733
2 files changed
+158
-44
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user