431 lines
20 KiB
TypeScript
431 lines
20 KiB
TypeScript
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<string | null>(null);
|
|
const [newSelection, setNewSelection] = useState<RecurringSplitSelection>({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] });
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editSelection, setEditSelection] = useState<RecurringSplitSelection | null>(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 (
|
|
<Input
|
|
className="h-8 w-28"
|
|
// text, not number: a number input strips the "$" before
|
|
// parseMoneyInput ever sees it, so "30$" would lose its currency.
|
|
type="text"
|
|
aria-label={`Amount for ${cost.name}`}
|
|
defaultValue={`${cost.amount.amount} ${cost.amount.currency}`}
|
|
onBlur={(event) => {
|
|
// 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 (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
className="h-8 w-20"
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
aria-label={`Interval for ${cost.name}`}
|
|
value={cost.recurrence.n}
|
|
onChange={(event) => {
|
|
// 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 } }),
|
|
);
|
|
}}
|
|
/>
|
|
<NativeSelect
|
|
className="h-8 w-auto text-xs md:text-xs"
|
|
aria-label={`Unit for ${cost.name}`}
|
|
value={cost.recurrence.kind}
|
|
onChange={(event) => {
|
|
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 }),
|
|
);
|
|
}}
|
|
>
|
|
<option value="everyNDays">days</option>
|
|
<option value="everyNWeeks">weeks</option>
|
|
<option value="everyNMonths">months</option>
|
|
</NativeSelect>
|
|
{cost.recurrence.kind === "everyNWeeks" && (
|
|
<NativeSelect
|
|
className="h-8 w-auto text-xs md:text-xs"
|
|
aria-label={`Weekday for ${cost.name}`}
|
|
value={cost.recurrence.weekday}
|
|
onChange={(event) => {
|
|
const weekday = Number(event.target.value);
|
|
mutate((draft) =>
|
|
updateRecurringCost(draft, cost.id, {
|
|
recurrence: { kind: "everyNWeeks", n: cost.recurrence.n, weekday },
|
|
}),
|
|
);
|
|
}}
|
|
>
|
|
{WEEKDAYS.map((day, index) => (
|
|
<option key={day} value={index}>
|
|
{day}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Section
|
|
title="Recurring costs"
|
|
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
|
|
>
|
|
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder bills in Budget folders</a></p>
|
|
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
|
|
{ordered.length === 0 ? (
|
|
<p className="text-sm text-budget-ink-muted">
|
|
No recurring costs yet. Add rent, a subscription, or a phone bill below.
|
|
</p>
|
|
) : (
|
|
<div className="-mr-2 overflow-x-auto pr-2">
|
|
<table className="w-full min-w-[46rem] text-sm">
|
|
<thead className="text-left">
|
|
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
|
<th className="py-2 font-medium">Name</th>
|
|
<th className="py-2 font-medium">Amount</th>
|
|
<th className="py-2 font-medium">Every</th>
|
|
<th className="py-2 font-medium">Measured from</th>
|
|
<th className="py-2 font-medium">Starts</th>
|
|
<th className="py-2 font-medium">Ends</th>
|
|
<th className="py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ordered.map((cost) => (<Fragment key={cost.id}>
|
|
<tr
|
|
data-bill-id={cost.id}
|
|
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
|
|
>
|
|
<td className="py-2">
|
|
<Input
|
|
className="h-8 w-40"
|
|
value={cost.name}
|
|
onChange={(event) => {
|
|
const name = event.target.value;
|
|
mutate((draft) => updateRecurringCost(draft, cost.id, { name }));
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-2">{amountCell(cost)}</td>
|
|
<td className="py-2">{intervalCell(cost)}</td>
|
|
<td className="py-2">
|
|
<NativeSelect
|
|
className="h-8 w-auto text-xs md:text-xs"
|
|
aria-label={`Anchoring for ${cost.name}`}
|
|
value={cost.anchoring}
|
|
onChange={(event) => {
|
|
const anchoring = event.target.value as RecurringCost["anchoring"];
|
|
mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring }));
|
|
}}
|
|
>
|
|
<option value="calendar">the calendar</option>
|
|
<option value="lastCharge">the last charge</option>
|
|
</NativeSelect>
|
|
</td>
|
|
<td className="py-2">
|
|
{/* 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. */}
|
|
<Input
|
|
key={`${cost.id}-${cost.startDate}`}
|
|
className="h-8 w-32"
|
|
type="text"
|
|
aria-label={`Start date for ${cost.name}`}
|
|
defaultValue={cost.startDate}
|
|
onBlur={(event) => {
|
|
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 }),
|
|
);
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-2">
|
|
{/* 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. */}
|
|
<Input
|
|
key={`${cost.id}-ends-${cost.endedFrom ?? ""}`}
|
|
className="h-8 w-32"
|
|
type="text"
|
|
placeholder="—"
|
|
aria-label={`Ends date for ${cost.name}`}
|
|
defaultValue={cost.endedFrom ?? ""}
|
|
onBlur={(event) => {
|
|
const typed = event.target.value.trim();
|
|
if (!isValidEndedFrom(typed)) {
|
|
event.target.value = cost.endedFrom ?? "";
|
|
return;
|
|
}
|
|
mutate((draft) =>
|
|
setRecurringCostEndedFrom(
|
|
draft,
|
|
cost.id,
|
|
typed === "" ? undefined : typed,
|
|
),
|
|
);
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="py-2">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button id={`bill-${cost.id}`} size="xs" variant="ghost" onClick={() => {
|
|
setEditingId(cost.id);
|
|
setEditSelection({ splitMode: cost.splitMode, splits: cost.splits.map(split => ({ ...split })) });
|
|
}}>Edit</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
className="-mr-2"
|
|
onClick={() =>
|
|
mutate((draft) =>
|
|
cost.archived
|
|
? restoreRecurringCost(draft, cost.id)
|
|
: endRecurringCost(draft, cost.id, currentMonth),
|
|
)
|
|
}
|
|
>
|
|
{cost.archived ? "restart" : "end"}
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{editingId === cost.id && editSelection && (
|
|
<tr key={`${cost.id}-editor`}><td colSpan={7} className="py-3">
|
|
<div className="space-y-3 rounded-md border border-budget-rule p-3">
|
|
<RecurringSplitEditor {...editSelection} posts={activePosts} onChange={setEditSelection} />
|
|
<div className="flex gap-2">
|
|
<Button size="sm" disabled={!editSelection.splits.every(split => activePosts.some(post => post.id === split.postId))} onClick={() => {
|
|
const selected = { splitMode: editSelection.splitMode, splits: editSelection.splits.map(split => ({ ...split })) };
|
|
void mutate(draft => updateRecurringCost(draft, cost.id, selected)).then(saved => { if (saved) setEditingId(null); });
|
|
}}>Save destinations</Button>
|
|
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Cancel</Button>
|
|
</div>
|
|
</div>
|
|
</td></tr>
|
|
)}
|
|
</Fragment>))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
|
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.
|
|
</p>
|
|
|
|
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
|
|
<Input
|
|
className="w-48"
|
|
placeholder="New cost name"
|
|
value={newName}
|
|
onInput={(event) => setNewName(event.currentTarget.value)}
|
|
/>
|
|
<Input className="w-28" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
|
|
<NativeSelect aria-label="Bill folder" value={folderId ?? ""} onChange={event => setFolderId(event.target.value || null)}>
|
|
<option value="">Unfiled</option>
|
|
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
|
</NativeSelect>
|
|
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
|
|
<option value="existing">Use existing post</option>
|
|
<option value="dedicated">Create dedicated post</option>
|
|
</NativeSelect>
|
|
{destinationKind === "dedicated" ? (
|
|
<Input aria-label="Dedicated post name" placeholder="New post name" value={newPostName} onChange={event => setNewPostName(event.target.value)} />
|
|
) : (
|
|
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
disabled={newName.trim() === "" || !parsedNewAmount || (destinationKind === "dedicated" ? !newPostName.trim() : !validNewSplits)}
|
|
onClick={() => {
|
|
const name = newName.trim();
|
|
const amount = parsedNewAmount;
|
|
if (!amount) return;
|
|
const selectedFolder = folderId;
|
|
const selectedKind = destinationKind;
|
|
const dedicatedName = newPostName.trim();
|
|
const selectedSplits = newSelection.splits.map(split => ({ ...split }));
|
|
const selectedMode = newSelection.splitMode;
|
|
void mutate((draft) => {
|
|
const input = {
|
|
name,
|
|
folderId: selectedFolder,
|
|
archived: false,
|
|
amount,
|
|
startDate: currentMonth,
|
|
recurrence: { kind: "everyNMonths" as const, n: 1 },
|
|
anchoring: "calendar" as const,
|
|
};
|
|
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
|
|
else addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
|
|
},
|
|
).then((saved) => {
|
|
if (saved) { setNewName(""); setNewPostName(""); setNewAmount("0"); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
|
|
});
|
|
}}
|
|
>
|
|
Add recurring cost
|
|
</Button>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|