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 && ( + + )} + + {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() { + +
}