import { Fragment, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; import { addRecurringCost, addRecurringCostWithPost, endRecurringCost, restoreRecurringCost, setRecurringCostEndedFrom, updateRecurringCost, } from "../../../store/actions.ts"; import { parseMoneyInput } from "../../moneyInput.ts"; import { currentMonth } from "../../../store/index.ts"; import { Section } from "../../components/Section.tsx"; import { RecurringSplitEditor, type RecurringSplitSelection } from "../../components/RecurringSplitEditor.tsx"; import { monthOf } from "../../../domain/months.ts"; import { toDayOrdinal } from "../../../domain/days.ts"; import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts"; const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; /** "YYYY-MM" is 7 characters; "YYYY-MM-DD" is 10. Same idiom PurchaseDialog uses. */ export function isDayGranular(date: IsoDate): boolean { return date.length > 7; } /** * True only for a string this cost's recurrence kind could actually walk: * `everyNMonths` tolerates either granularity (the walk normalises through * `monthOf`), the other two require a day-granular date because they reach * `addDays` directly. Reuses the domain's own parsers rather than a second, * possibly-diverging regex. * * A day-SHAPED value is routed through `toDayOrdinal` even under * `everyNMonths`: `monthOf` only extracts the month and never checks the day, * so without this a calendar-impossible date like "2026-09-31" (September has * 30 days) would pass here and could still be stored, only to throw three * months later out of the fold. * * Exported for `RecurringSection.test.ts` — this is the one thing standing * between the "Starts" field and C2 (an empty or malformed value bricking * every route the fold touches), so it is tested directly rather than only * through a DOM interaction. */ export function isValidStartDate(value: string, kind: Recurrence["kind"]): boolean { try { if (kind === "everyNMonths" && !isDayGranular(value)) monthOf(value); else toDayOrdinal(value); return true; } catch { return false; } } /** * Validates the "Ends" column. Unlike `startDate`, `endedFrom` carries NO * granularity rule (§10 of the design) — it is only ever compared * lexicographically via `isBefore` in `domain/occurrences.ts` and never * reaches `addDays`, so a month-only cancellation is exactly as valid as a * day-granular one regardless of the cost's recurrence kind. It still has to * be a real calendar date: "2026-02-30" is refused the same way an * impossible `startDate` already is. * * An empty string is valid — it means "clear the cancellation" — and is what * lets the field double as the un-end control. * * Exported for `RecurringSection.test.ts`. */ export function isValidEndedFrom(value: string): boolean { if (value === "") return true; try { if (isDayGranular(value)) toDayOrdinal(value); else monthOf(value); return true; } catch { return false; } } /** * What the "Starts" value becomes when the owner switches to a day-granular * unit: a month-only value expands to the first of that month, in the SAME * mutate call that changes the kind (C1) — the walk must never see the old * pairing even for one render. `everyNMonths` and an already day-granular * value pass through untouched. * * Exported for `RecurringSection.test.ts`. */ export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoDate): IsoDate { if (kind === "everyNMonths" || isDayGranular(startDate)) return startDate; return `${startDate}-01`; } export function RecurringSection() { const dataset = useDataset(); const { mutate, error } = useMutate(); const [newName, setNewName] = useState(""); const [newAmount, setNewAmount] = useState("0"); const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("existing"); const [newPostName, setNewPostName] = useState(""); const [folderId, setFolderId] = useState(null); const [newSelection, setNewSelection] = useState({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); const [editingId, setEditingId] = useState(null); const [editSelection, setEditSelection] = useState(null); const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order); const base = dataset.settings.baseCurrency; const activePosts = dataset.posts.filter(post => !post.archived).sort((a, b) => a.order - b.order); const parsedNewAmount = parseMoneyInput(newAmount, dataset.currencies, base); const validNewSplits = newSelection.splits.length > 0 && newSelection.splits.every(split => activePosts.some(post => post.id === split.postId)); function amountCell(cost: RecurringCost) { return ( { // Captured before mutate: mutate defers behind the write queue and // React resets the DOM value first, so reading event.target.value // inside the callback would commit the previous keystroke. const typed = event.target.value; // (text, currency options, fallback) — the same three arguments // PurchaseDialog passes. The options list is what lets "30$" resolve // to USD; the fallback is used when no symbol or code is typed. const parsed = parseMoneyInput(typed, dataset.currencies, cost.amount.currency); if (!parsed) return; mutate((draft) => updateRecurringCost(draft, cost.id, { amount: parsed })); }} /> ); } function intervalCell(cost: RecurringCost) { return (
{ // Captured BEFORE mutate: mutate defers behind the write queue and // React resets the DOM value first, so reading it inside the // callback commits the previous keystroke. const n = Number(event.target.value); if (!Number.isInteger(n) || n < 1) return; mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence: { ...cost.recurrence, n } }), ); }} /> { const kind = event.target.value as Recurrence["kind"]; const recurrence: Recurrence = kind === "everyNWeeks" ? { kind, n: cost.recurrence.n, weekday: 1 } : { kind, n: cost.recurrence.n }; // Switching to a day-granular kind against a month-only // startDate is exactly how C1 bricked the app: the walk hands // "2026-09" to addDays and throws. Expand it to the first of // that month IN THIS SAME mutate call, so the pairing the store // now validates never has a chance to go wrong. const startDate = normalizedStartDateFor(kind, cost.startDate); mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence, startDate }), ); }} > {cost.recurrence.kind === "everyNWeeks" && ( { const weekday = Number(event.target.value); mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence: { kind: "everyNWeeks", n: cost.recurrence.n, weekday }, }), ); }} > {WEEKDAYS.map((day, index) => ( ))} )}
); } return (

Move and reorder bills in Budget folders

{error &&

Could not save: {error}

} {ordered.length === 0 ? (

No recurring costs yet. Add rent, a subscription, or a phone bill below.

) : (
{ordered.map((cost) => ( {editingId === cost.id && editSelection && ( )} ))}
Name Amount Every Measured from Starts Ends
{ const name = event.target.value; mutate((draft) => updateRecurringCost(draft, cost.id, { name })); }} /> {amountCell(cost)} {intervalCell(cost)} { const anchoring = event.target.value as RecurringCost["anchoring"]; mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring })); }} > {/* Uncontrolled: committing only happens on blur, and only when the text parses as an IsoDate of the granularity this cost's recurrence kind requires. Keyed on the stored value so an external change (kind flip auto-expanding it, or a rejected edit) is reflected rather than left showing stale typed text. */} { const typed = event.target.value; if (!isValidStartDate(typed, cost.recurrence.kind)) { event.target.value = cost.startDate; return; } mutate((draft) => updateRecurringCost(draft, cost.id, { startDate: typed }), ); }} /> {/* Same idiom as "Starts": uncontrolled, committed on blur, keyed on the stored value so an external change (the end/restart button, or a rejected edit) shows up rather than leaving stale typed text. Empty means "not ended" and is itself a valid value — clearing it here does the same thing as the "restart" button. */} { const typed = event.target.value.trim(); if (!isValidEndedFrom(typed)) { event.target.value = cost.endedFrom ?? ""; return; } mutate((draft) => setRecurringCostEndedFrom( draft, cost.id, typed === "" ? undefined : typed, ), ); }} />
)}

Ending a cost — from the "end" button, or by typing a date into "Ends" directly — stops it from that date onward and leaves every past occurrence exactly as it was; clearing the date restarts it. Changing the amount only moves occurrences that have not been confirmed yet — what you already paid is recorded on the purchases themselves, so a price rise never rewrites history.

setNewName(event.currentTarget.value)} /> setNewAmount(event.currentTarget.value)} /> setFolderId(event.target.value || null)}> {dataset.folders.map(folder => )} setDestinationKind(event.target.value as "existing" | "dedicated")}> {destinationKind === "dedicated" ? ( setNewPostName(event.target.value)} /> ) : ( )}
); }