From 9fe950bc7d68d93fcfa181f79286d65f187ff4e4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 08:46:45 +0200 Subject: [PATCH] feat(recurring): offer confirmation on next occurrence beyond the month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/store/index.ts | 10 + src/ui/components/ExpectedBand.test.ts | 133 +++++++++++- src/ui/components/ExpectedBand.tsx | 271 ++++++++++++++++--------- 3 files changed, 320 insertions(+), 94 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index dd98131..0ac5dda 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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, diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts index 890e32c..d56cec5 100644 --- a/src/ui/components/ExpectedBand.test.ts +++ b/src/ui/components/ExpectedBand.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index 085ee29..348e960 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -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(); + 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>({}); - 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 ( +
  • +
    +
    {nameOf(occurrence.recurringId)}
    +
    + {recordedDate ? ( + <> + Due {occurrence.date} — confirming now records it {recordedDate} + + ) : ( + occurrence.date + )} +
    +
    +
    +
    + { + const next = event.target.value; + setAmounts((current) => ({ ...current, [key]: next })); + }} + /> + {invalid && ( +
    + Doesn't look like an amount — Confirm is disabled. +
    + )} +
    + {occurrence.amount.currency} + +
    +
  • + ); + } + return (
    {pending.length > 0 && (
      - {pending.map((occurrence) => { - const key = `${occurrence.recurringId}:${occurrence.date}`; - const typed = amounts[key]; - const { parsed, invalid } = resolveExpectedAmount( - typed, - dataset.currencies, - occurrence.amount.currency, - ); - return ( -
    • -
      -
      {nameOf(occurrence.recurringId)}
      -
      {occurrence.date}
      -
      -
      -
      - { - const next = event.target.value; - setAmounts((current) => ({ ...current, [key]: next })); - }} - /> - {invalid && ( -
      - Doesn't look like an amount — Confirm is disabled. -
      - )} -
      - - {occurrence.amount.currency} - - -
      -
    • - ); - })} + {pending.map((occurrence) => row(occurrence, null))}
    )} + {comingUp.length > 0 && ( +
    0 ? "mt-4" : ""}> +

    + Coming up +

    +
      + {comingUp.map((occurrence) => row(occurrence, today))} +
    +
    + )} + {overdue > 0 && (

    {overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never