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 }));
+ }}
+ >
+ days
+ weeks
+ months
+
+ {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) => (
+
+ {day}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {ordered.length === 0 ? (
+
+ No recurring costs yet. Add rent, a subscription, or a phone bill below.
+
+ ) : (
+
+
+
+
+ Name
+ Amount
+ Every
+ Measured from
+ Starts
+
+
+
+
+ {ordered.map((cost, index) => (
+
+
+ {
+ 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 }));
+ }}
+ >
+ the calendar
+ the last charge
+
+
+
+ {
+ const startDate = event.target.value;
+ mutate((draft) => updateRecurringCost(draft, cost.id, { startDate }));
+ }}
+ />
+
+
+
+ mutate((draft) => moveRecurringCost(draft, cost.id, -1))}
+ >
+ up
+
+ mutate((draft) => moveRecurringCost(draft, cost.id, 1))}
+ >
+ down
+
+
+ mutate((draft) =>
+ cost.archived
+ ? restoreRecurringCost(draft, cost.id)
+ : endRecurringCost(draft, cost.id, currentMonth),
+ )
+ }
+ >
+ {cost.archived ? "restart" : "end"}
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ 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)}
+ />
+ {
+ const name = newName.trim();
+ mutate((draft) =>
+ addRecurringCost(draft, {
+ name,
+ archived: false,
+ amount: { amount: 0, currency: base },
+ startDate: currentMonth,
+ recurrence: { kind: "everyNMonths", n: 1 },
+ anchoring: "calendar",
+ splitMode: "percent",
+ splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
+ }),
+ );
+ setNewName("");
+ }}
+ >
+ Add recurring cost
+
+ {!firstPost && (
+ Create a post first.
+ )}
+
+
+ );
+}