From 72471914ba89f122c638a2b8fede2d51b68df25a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:30:22 +0200 Subject: [PATCH] feat(settings): define recurring costs Follows PostsSection: same table, same xs ghost row actions, same add row. Definition is the rare act, so it lives here rather than in the month view. Placed after Posts because a cost's splits reference posts, and before the data section so the destructive import controls stay last. 'end' sets endedFrom to the current month and archives in one action, which is what the owner means by cancelling a subscription. Past occurrences are untouched, so no historical figure moves. --- src/ui/routes/SettingsRoute.tsx | 2 + src/ui/routes/settings/RecurringSection.tsx | 266 ++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/ui/routes/settings/RecurringSection.tsx 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. + )} +
+
+ ); +}