From b0324cb3e2b28ff8668fd3a9b49c7f3003636e41 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:41:43 +0200 Subject: [PATCH] feat(month): confirm expected charges in one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repeated act, so it is a row and a button rather than a dialog — entry speed is a product principle and a modal per bill would defeat it. The amount is editable inline because paying a different figure is common; anything more unusual is edited afterwards like any other purchase. Confirmation is offered on a pending occurrence whose date has not arrived yet, deliberately. A bill anchored to its last charge can charge EARLY, and confirming the pending slot with the real date is how the series rebases. The overdue count is not decoration. An occurrence nobody ever confirms holds the projected balance down forever, which is the honest reading of an unreconciled commitment but is invisible without it. Returns null when nothing is expected, so a dataset with no recurring costs sees no change to the month view at all. --- src/ui/components/ExpectedBand.tsx | 125 +++++++++++++++++++++++++++++ src/ui/routes/MonthRoute.tsx | 3 + 2 files changed, 128 insertions(+) create mode 100644 src/ui/components/ExpectedBand.tsx diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx new file mode 100644 index 0000000..a930a9f --- /dev/null +++ b/src/ui/components/ExpectedBand.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +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 { confirmOccurrence } from "../../store/actions.ts"; +import { occurrencesByMonth } from "../../domain/occurrences.ts"; +import { compareMonths } from "../../domain/months.ts"; +import { parseMoneyInput } from "../moneyInput.ts"; +import { Section } from "./Section.tsx"; +import type { MonthId } from "../../domain/types.ts"; + +/** + * The month's expected charges, and the one click that turns each into a real + * purchase. + * + * Confirmation is offered on any unconfirmed occurrence in this month, + * INCLUDING one whose date has not arrived yet. That is not an oversight: a + * 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. + */ +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); + + // Everything unconfirmed in an EARLIER month. An occurrence nobody ever + // confirms holds the projected balance down forever, which is the honest + // reading of an unreconciled commitment — but it is silent unless counted. + let earlier = 0; + for (const [month, occurrences] of byMonth) { + if (compareMonths(month, monthId) >= 0) continue; + earlier += occurrences.filter((o) => !o.confirmedBy).length; + } + + return { pending: thisMonth, overdue: earlier }; + }, [dataset, monthId]); + + if (pending.length === 0 && overdue === 0) return null; + + const nameOf = (recurringId: string) => + dataset.recurring.find((c) => c.id === recurringId)?.name ?? "Unknown"; + + return ( +
+ {pending.length > 0 && ( +
    + {pending.map((occurrence) => { + const key = `${occurrence.recurringId}:${occurrence.date}`; + const typed = amounts[key]; + return ( +
  • +
    +
    {nameOf(occurrence.recurringId)}
    +
    {occurrence.date}
    +
    +
    + { + const next = event.target.value; + setAmounts((current) => ({ ...current, [key]: next })); + }} + /> + + {occurrence.amount.currency} + + +
    +
  • + ); + })} +
+ )} + + {overdue > 0 && ( +

+ {overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never + been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected + balance stays lower than the real one by that amount. +

+ )} +
+ ); +} diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 61a66b9..d62d41b 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -15,6 +15,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { PostTable, PostTableLegend } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; +import { ExpectedBand } from "../components/ExpectedBand.tsx"; import { Meter } from "../components/Meter.tsx"; import { Section, Stat } from "../components/Section.tsx"; import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts"; @@ -124,6 +125,8 @@ export function MonthRoute() { + +
}