diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index f64a699..cb3becf 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,5 +1,6 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; +import { RecurringSection } from "./settings/RecurringSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; import { ColophonSection } from "./settings/ColophonSection.tsx"; @@ -21,6 +22,7 @@ export function SettingsRoute() {

Settings

+ diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx new file mode 100644 index 0000000..237ae5a --- /dev/null +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -0,0 +1,266 @@ +import { 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, + endRecurringCost, + moveRecurringCost, + restoreRecurringCost, + updateRecurringCost, +} from "../../../store/actions.ts"; +import { parseMoneyInput } from "../../moneyInput.ts"; +import { currentMonth } from "../../../store/index.ts"; +import { Section } from "../../components/Section.tsx"; +import type { Recurrence, RecurringCost } from "../../../domain/types.ts"; + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export function RecurringSection() { + const dataset = useDataset(); + const { mutate } = useMutate(); + const [newName, setNewName] = useState(""); + const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order); + const base = dataset.settings.baseCurrency; + const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0]; + + 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 }; + mutate((draft) => updateRecurringCost(draft, cost.id, { recurrence })); + }} + > + + + + + {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 ( +
+ {ordered.length === 0 ? ( +

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

+ ) : ( +
+ + + + + + + + + + + + {ordered.map((cost, index) => ( + + + + + + + + + ))} + +
NameAmountEveryMeasured fromStarts +
+ { + 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 })); + }} + > + + + + + { + const startDate = event.target.value; + mutate((draft) => updateRecurringCost(draft, cost.id, { startDate })); + }} + /> + +
+ + + +
+
+
+ )} + +

+ Ending a cost stops it from this month onward and leaves every past + occurrence exactly as it was. 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.target.value)} + /> + + {!firstPost && ( + Create a post first. + )} +
+
+ ); +}