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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user