feat(recurring): offer confirmation on next occurrence beyond the month

Gap 1 (spec §5): ExpectedBand only listed this month's occurrences, so a
lastCharge bill whose pending slot had rolled into next month (the phone-bill
flagship case) was not confirmable without navigating there first.

Extend the walk 12 months past the displayed month (occurrencesByMonth's own
upToMonth bound; foldBalances is untouched) and surface a "Coming up" group:
at most one row per recurring cost, its next unconfirmed occurrence. That row
confirms EARLY, so it records today rather than the future slot date — which
is exactly what rebases a lastCharge series — and shows both dates so the
owner can see what will be written. `today` is exported from src/store/index.ts
next to `currentMonth`, keeping the clock out of src/domain/.

expectedGroups() is the pure, exported core (pending/overdue/comingUp);
ExpectedBand renders it and still returns null when all three are empty.
This commit is contained in:
Lucas Winther
2026-09-04 08:46:45 +02:00
parent 042bc8597d
commit 9fe950bc7d
3 changed files with 320 additions and 94 deletions
+10
View File
@@ -2,10 +2,20 @@ import { monthOf } from "../domain/months.ts";
import { readDataset, writeDataset } from "./db.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { BAKED_FX_RATES } from "./bakedRates.ts";
import type { IsoDate } from "../domain/types.ts";
/** The current month, read at module load. The UI owns the clock; the domain does not. */
export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
/**
* Today, day-granular, read at module load. The UI owns the clock; the domain
* does not. `ExpectedBand`'s "Coming up" group needs the actual day — a
* coming-up occurrence is confirmed EARLY, so the purchase it writes must be
* dated today rather than the future slot, which is exactly what rebases a
* `lastCharge` series.
*/
export const today: IsoDate = new Date().toISOString().slice(0, 10);
export const store = createSnapshotStore(
{ read: readDataset, write: writeDataset },
currentMonth,
+132 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test";
import { resolveExpectedAmount } from "./ExpectedBand.tsx";
import { expectedGroups, resolveExpectedAmount } from "./ExpectedBand.tsx";
import { createSeedDataset } from "../../domain/seed.ts";
import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts";
const DKK_USD = [
{ code: "DKK", symbol: "kr" },
@@ -35,3 +37,132 @@ describe("resolveExpectedAmount", () => {
expect(resolveExpectedAmount("thirty", DKK_USD, "DKK").invalid).toBe(true);
});
});
function baseDataset(): Dataset {
const data = createSeedDataset("2026-02");
data.posts[0]!.id = "housing";
return data;
}
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
return {
id: "r1",
name: "Rent",
order: 0,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
...overrides,
};
}
function confirmed(recurringId: string, occurrenceDate: string, id: string): Purchase {
return {
id,
date: occurrenceDate,
description: "x",
total: { amount: 8000, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
schedule: null,
source: { recurringId, occurrenceDate },
};
}
// 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)", () => {
test("a dataset with no recurring costs shows nothing — the component must return null", () => {
const data = baseDataset();
const groups = expectedGroups(data, "2026-02");
expect(groups.pending).toEqual([]);
expect(groups.overdue).toBe(0);
expect(groups.comingUp).toEqual([]);
});
test("a lastCharge bill's next occurrence next month is offered as 'coming up', not hidden until you navigate", () => {
// The flagship scenario: displayed month is February, the pending slot
// sits in March because rent-like accounting isn't in play here — a
// monthly cost's only occurrence for a month IS next month once this
// month's is confirmed.
const data = baseDataset();
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done
const groups = expectedGroups(data, "2026-02");
expect(groups.pending).toEqual([]);
expect(groups.comingUp.length).toBe(1);
expect(groups.comingUp[0]!.recurringId).toBe("phone");
expect(groups.comingUp[0]!.date).toBe("2026-03");
});
test("at most one row per cost, even when several future occurrences fall within the horizon", () => {
// A weekly cost produces many unconfirmed occurrences across the next
// several months. Only the soonest one past the displayed month may
// appear — never a list of everything the future holds.
const data = baseDataset();
data.recurring = [
cost({
id: "gym",
startDate: "2026-02-02",
recurrence: { kind: "everyNDays", n: 7 },
}),
];
const groups = expectedGroups(data, "2026-02");
// Four Monday-ish occurrences fall in February itself.
expect(groups.pending.length).toBe(4);
// Many more unconfirmed occurrences exist in March, April, ... within the
// horizon — but comingUp holds exactly one.
const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym");
expect(forThisCost.length).toBe(1);
expect(forThisCost[0]!.date).toBe("2026-03-02"); // the soonest one past February
});
test("the horizon is bounded: a cost whose next occurrence lands beyond it does not appear", () => {
const data = baseDataset();
// Interval bigger than the default horizon: confirmed this month, the
// next occurrence is 15 months out — past the 12-month default.
data.recurring = [
cost({
id: "insurance",
startDate: "2026-02",
recurrence: { kind: "everyNMonths", n: 15 },
}),
];
data.purchases = [confirmed("insurance", "2026-02", "p1")];
expect(expectedGroups(data, "2026-02").comingUp).toEqual([]); // default (12) horizon
// Raising the horizon far enough reveals the same occurrence, proving the
// emptiness above is the bound, not a bug elsewhere.
const wider = expectedGroups(data, "2026-02", 16).comingUp;
expect(wider.length).toBe(1);
expect(wider[0]!.date).toBe("2027-05");
});
test("a confirmed future occurrence never appears in comingUp", () => {
const data = baseDataset();
// Ends right after March, so April onward is never generated at all —
// isolating the point of the test (a confirmed slot is excluded) from an
// unrelated, genuinely-unconfirmed one the walk would otherwise reach.
data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })];
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
const groups = expectedGroups(data, "2026-02");
expect(groups.pending).toEqual([]);
expect(groups.comingUp).toEqual([]);
});
test("overdue counts only earlier, foldable months and is unaffected by extending the walk for comingUp", () => {
const data = baseDataset();
data.settings.foldStartMonth = "2026-01";
data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed
const groups = expectedGroups(data, "2026-02");
expect(groups.overdue).toBe(1);
});
});
+178 -93
View File
@@ -3,12 +3,13 @@ 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 { today } from "../../store/index.ts";
import { confirmOccurrence } from "../../store/actions.ts";
import { occurrencesByMonth } from "../../domain/occurrences.ts";
import { compareMonths } from "../../domain/months.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, MonthId } from "../../domain/types.ts";
import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts";
/**
* `typed` is `undefined` when the field has never been edited — that always
@@ -32,6 +33,75 @@ export function resolveExpectedAmount(
return { parsed, invalid: parsed === null };
}
export interface ExpectedGroups {
/** This month's unconfirmed occurrences. */
pending: Occurrence[];
/** Count of unconfirmed occurrences from earlier, foldable months. */
overdue: number;
/**
* At most ONE row per recurring cost: its next unconfirmed occurrence
* strictly after `monthId`, out to `horizonMonths` ahead. Never a list of
* everything the future holds.
*/
comingUp: Occurrence[];
}
/**
* Everything the band has to show, in three groups. §5 offers confirmation on
* "the next pending occurrence, not only on ones whose date has passed" — the
* flagship case is a `lastCharge` bill whose pending slot sits next month, and
* `occurrencesByMonth` only walks TO the month it is given, so this walks past
* the displayed month on purpose.
*
* The walk stays bounded — `horizonMonths` ahead, twelve by default, which is
* enough to catch an annual `everyNMonths` cost — and is a band-local concern.
* `foldBalances`'s own `upToMonth` bound is untouched.
*
* Exported for `ExpectedBand.test.ts`.
*/
export function expectedGroups(
dataset: Dataset,
monthId: MonthId,
horizonMonths = 12,
): ExpectedGroups {
const byMonth = occurrencesByMonth(dataset, addMonths(monthId, horizonMonths));
const pending = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy);
let overdue = 0;
const seen = new Set<string>();
const comingUp: Occurrence[] = [];
for (const month of [...byMonth.keys()].sort(compareMonths)) {
const cmp = compareMonths(month, monthId);
if (cmp === 0) continue; // already `pending`, above.
const occurrences = byMonth.get(month)!;
if (cmp < 0) {
// Everything unconfirmed in an EARLIER month, but not before
// foldStartMonth: foldBalances never folds those, so an occurrence out
// there never held the projected balance down in the first place, and
// counting it here would make the sentence below claim a gap that isn't
// in the number the owner is looking at.
if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue;
overdue += occurrences.filter((o) => !o.confirmedBy).length;
continue;
}
// A future month within the horizon. Months are visited in ascending
// order, and a single cost's own occurrences are already emitted in date
// order, so the first unconfirmed one seen for a given recurringId is
// that cost's soonest pending occurrence beyond the displayed month.
for (const occurrence of occurrences) {
if (occurrence.confirmedBy) continue;
if (seen.has(occurrence.recurringId)) continue;
seen.add(occurrence.recurringId);
comingUp.push(occurrence);
}
}
return { pending, overdue, comingUp };
}
/**
* The month's expected charges, and the one click that turns each into a real
* purchase.
@@ -41,36 +111,110 @@ export function resolveExpectedAmount(
* bill anchored to its last charge can charge early — a phone plan whose data
* 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.
*/
export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const dataset = useDataset();
const { mutate } = useMutate();
const [amounts, setAmounts] = useState<Record<string, string>>({});
const { pending, overdue } = useMemo(() => {
const byMonth = occurrencesByMonth(dataset, monthId);
const thisMonth = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy);
const { pending, overdue, comingUp } = useMemo(
() => expectedGroups(dataset, monthId),
[dataset, monthId],
);
// Everything unconfirmed in an EARLIER month, but not before
// foldStartMonth: foldBalances never folds those, so an occurrence out
// there never held the projected balance down in the first place, and
// counting it here would make the sentence below claim a gap that isn't
// in the number the owner is looking at.
let earlier = 0;
for (const [month, occurrences] of byMonth) {
if (compareMonths(month, monthId) >= 0) continue;
if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue;
earlier += occurrences.filter((o) => !o.confirmedBy).length;
}
return { pending: thisMonth, overdue: earlier };
}, [dataset, monthId]);
if (pending.length === 0 && overdue === 0) return null;
if (pending.length === 0 && overdue === 0 && comingUp.length === 0) return null;
const nameOf = (recurringId: string) =>
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. `recordedDate` is `null`
* for a this-month row (confirms at the slot's own date, `confirmOccurrence`'s
* default) and `today` for a coming-up row (confirms early, on purpose).
*/
function row(occurrence: Occurrence, recordedDate: IsoDate | null) {
const key = `${occurrence.recurringId}:${occurrence.date}`;
const typed = amounts[key];
const { parsed, invalid } = resolveExpectedAmount(
typed,
dataset.currencies,
occurrence.amount.currency,
);
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">
{recordedDate ? (
<>
Due {occurrence.date} — confirming now records it {recordedDate}
</>
) : (
occurrence.date
)}
</div>
</div>
<div className="flex items-center gap-2">
<div>
<Input
className="h-8 w-28"
// text, not number: a number input discards the "$" before
// parseMoneyInput can read it.
type="text"
aria-label={`Amount for ${nameOf(occurrence.recurringId)} on ${occurrence.date}`}
aria-invalid={invalid}
value={typed ?? String(occurrence.amount.amount)}
onChange={(event) => {
const next = event.target.value;
setAmounts((current) => ({ ...current, [key]: next }));
}}
/>
{invalid && (
<div className="mt-1 text-xs text-destructive">
Doesn't look like an amount — Confirm is disabled.
</div>
)}
</div>
<span className="text-xs text-budget-ink-muted">{occurrence.amount.currency}</span>
<Button
size="sm"
variant="outline"
disabled={invalid}
onClick={() => {
// Captured before mutate, per AGENTS.md: mutate defers behind
// the write queue and React resets the input first, so reading
// in the callback commits stale text. `invalid` already guards
// this (and disables the button), so reaching here means
// `parsed` is either a real amount or the field was never
// touched.
if (invalid) return;
mutate((draft) =>
confirmOccurrence(draft, occurrence.recurringId, occurrence.date, {
date: recordedDate ?? undefined,
amount: parsed ?? undefined,
}),
);
setAmounts((current) => {
const next = { ...current };
delete next[key];
return next;
});
}}
>
Confirm
</Button>
</div>
</li>
);
}
return (
<Section
title="Expected"
@@ -78,80 +222,21 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
>
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => {
const key = `${occurrence.recurringId}:${occurrence.date}`;
const typed = amounts[key];
const { parsed, invalid } = resolveExpectedAmount(
typed,
dataset.currencies,
occurrence.amount.currency,
);
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">
<div>
<Input
className="h-8 w-28"
// text, not number: a number input discards the "$" before
// parseMoneyInput can read it.
type="text"
aria-label={`Amount for ${nameOf(occurrence.recurringId)} on ${occurrence.date}`}
aria-invalid={invalid}
value={typed ?? String(occurrence.amount.amount)}
onChange={(event) => {
const next = event.target.value;
setAmounts((current) => ({ ...current, [key]: next }));
}}
/>
{invalid && (
<div className="mt-1 text-xs text-destructive">
Doesn't look like an amount — Confirm is disabled.
</div>
)}
</div>
<span className="text-xs text-budget-ink-muted">
{occurrence.amount.currency}
</span>
<Button
size="sm"
variant="outline"
disabled={invalid}
onClick={() => {
// Captured before mutate, per AGENTS.md: mutate defers
// behind the write queue and React resets the input
// first, so reading in the callback commits stale text.
// `invalid` already guards this (and disables the
// button), so reaching here means `parsed` is either a
// real amount or the field was never touched.
if (invalid) return;
mutate((draft) =>
confirmOccurrence(draft, occurrence.recurringId, occurrence.date, {
amount: parsed ?? undefined,
}),
);
setAmounts((current) => {
const next = { ...current };
delete next[key];
return next;
});
}}
>
Confirm
</Button>
</div>
</li>
);
})}
{pending.map((occurrence) => row(occurrence, null))}
</ul>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<p className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</p>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) => row(occurrence, today))}
</ul>
</div>
)}
{overdue > 0 && (
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never