diff --git a/src/domain/types.ts b/src/domain/types.ts index 6a8e247..1f37e08 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -83,6 +83,8 @@ export interface Settings { baseCurrency: Currency; foldStartMonth: MonthId; schemaVersion: number; + /** Optional: no migration needed, existing datasets leave it undefined. */ + fxApiUrl?: string; } export interface Dataset { diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 5c9b587..e46f142 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -312,4 +312,31 @@ describe("settings", () => { expect(data.settings.baseCurrency).toBe("EUR"); expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined(); }); + + test("removeFxRate filters the currency out entirely, leaving no row", () => { + const data = draft(); + actions.setFxRate(data, { + currency: "EUR", + baseUnitsPerOne: 7.46, + updatedAt: "2026-09-01", + source: "manual", + }); + actions.setFxRate(data, { + currency: "USD", + baseUnitsPerOne: 6.9, + updatedAt: "2026-09-01", + source: "manual", + }); + + actions.removeFxRate(data, "EUR"); + + expect(data.fxRates.map((r) => r.currency)).toEqual(["USD"]); + expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined(); + }); + + test("removeFxRate on a currency with no row is a no-op", () => { + const data = draft(); + actions.removeFxRate(data, "EUR"); + expect(data.fxRates).toEqual([]); + }); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index 7c311d7..561fd7d 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -219,6 +219,18 @@ export function setFxRate(draft: Dataset, rate: FxRate): void { else draft.fxRates[index] = rate; } +/** + * Removes a currency's rate row entirely. A cleared rate MUST become an + * absent row, not a stored 0: toBase() throws MissingRateError for an absent + * row but silently converts to 0 for a stored zero rate, which would corrupt + * every purchase in that currency without any error surfacing. Callers (the + * currency settings input) must route a cleared/invalid rate here instead of + * through setFxRate with a 0. + */ +export function removeFxRate(draft: Dataset, currency: Currency): void { + draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency); +} + /** The base currency never has a rate row of its own. */ export function setBaseCurrency(draft: Dataset, currency: Currency): void { draft.settings.baseCurrency = currency; diff --git a/src/store/fxApi.test.ts b/src/store/fxApi.test.ts new file mode 100644 index 0000000..5cf17a4 --- /dev/null +++ b/src/store/fxApi.test.ts @@ -0,0 +1,40 @@ +import { test, expect, describe } from "bun:test"; +import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts"; + +describe("buildFxUrl", () => { + test("substitutes the base and target placeholders", () => { + expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe( + "https://api.frankfurter.app/latest?from=DKK&to=USD,EUR", + ); + }); +}); + +describe("parseFxResponse", () => { + test("inverts the rates into base units per one unit", () => { + // The API returns how many target units 1 base unit buys; we store the + // inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46. + const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK"); + expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2); + expect(rates.every((r) => r.source === "api")).toBe(true); + expect(rates.every((r) => r.updatedAt !== "")).toBe(true); + }); + + test("skips the base currency's own row if the API returns one", () => { + const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK"); + expect(rates.map((r) => r.currency)).toEqual(["EUR"]); + }); + + test("ignores unsupported currencies", () => { + const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK"); + expect(rates.map((r) => r.currency)).toEqual(["EUR"]); + }); + + test("rejects a response with no rates object", () => { + expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/); + expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/); + }); + + test("rejects a zero or negative rate rather than storing an infinity", () => { + expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/); + }); +}); diff --git a/src/store/fxApi.ts b/src/store/fxApi.ts new file mode 100644 index 0000000..3eda3e3 --- /dev/null +++ b/src/store/fxApi.ts @@ -0,0 +1,56 @@ +import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; + +/** Frankfurter needs no API key. The URL is user-editable in settings. */ +export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}"; + +export function buildFxUrl( + template: string, + base: Currency, + targets: Currency[], +): string { + return template + .replace("{base}", base) + .replace("{targets}", targets.join(",")); +} + +export function parseFxResponse(body: unknown, base: Currency): FxRate[] { + const rates = + typeof body === "object" && body !== null + ? (body as Record).rates + : undefined; + if (typeof rates !== "object" || rates === null) { + throw new Error("Exchange rate response has no `rates` object"); + } + + const updatedAt = new Date().toISOString(); + const out: FxRate[] = []; + + for (const [currency, value] of Object.entries(rates as Record)) { + if (currency === base) continue; + if (!CURRENCIES.includes(currency as Currency)) continue; + if (typeof value !== "number" || !(value > 0)) { + throw new Error(`Exchange rate for ${currency} is not a positive number`); + } + // The API gives target-per-base; we store base-per-target. + out.push({ + currency: currency as Currency, + baseUnitsPerOne: Number((1 / value).toFixed(6)), + updatedAt, + source: "api", + }); + } + return out; +} + +/** Fails soft at the call site: callers keep the cached rates on error. */ +export async function fetchFxRates( + template: string, + base: Currency, + targets: Currency[], +): Promise { + const response = await fetch(buildFxUrl(template, base, targets)); + if (!response.ok) { + throw new Error(`Rate service returned ${response.status}`); + } + return parseFxResponse(await response.json(), base); +} diff --git a/src/ui/download.ts b/src/ui/download.ts new file mode 100644 index 0000000..0d01eb9 --- /dev/null +++ b/src/ui/download.ts @@ -0,0 +1,12 @@ +/** Triggers a browser download. Works only in a real page, not in tests. */ +export function downloadBlob(filename: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Give the browser a turn to start the download before revoking. + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index e274140..e0cb091 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,3 +1,14 @@ +import { CurrencySection } from "./settings/CurrencySection.tsx"; +import { PostsSection } from "./settings/PostsSection.tsx"; +import { DataSection } from "./settings/DataSection.tsx"; + export function SettingsRoute() { - return

Settings

; + return ( +
+

Settings

+ + + +
+ ); } diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx new file mode 100644 index 0000000..88903d9 --- /dev/null +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { useMutate } from "../../hooks/useMutate.ts"; +import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts"; +import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts"; +import { CURRENCIES, type Currency } from "../../../domain/types.ts"; + +export function CurrencySection() { + const dataset = useDataset(); + const { mutate } = useMutate(); + const base = dataset.settings.baseCurrency; + const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL; + const [status, setStatus] = useState(null); + const targets = CURRENCIES.filter((c) => c !== base); + + async function refresh() { + setStatus("Fetching…"); + try { + const rates = await fetchFxRates(apiUrl, base, targets); + mutate((draft) => { + for (const rate of rates) setFxRate(draft, rate); + }); + setStatus(`Updated ${rates.length} rate(s).`); + } catch (error) { + // Fails soft: the cached rates are still in place, never cleared. + setStatus( + `Could not fetch rates (${ + error instanceof Error ? error.message : String(error) + }). Your saved rates are unchanged.`, + ); + } + } + + return ( +
+

Currency

+ +
+ + +
+ + + + + + + + + + + {targets.map((currency) => { + const rate = dataset.fxRates.find((r) => r.currency === currency); + return ( + + + + + + ); + })} + +
Currency1 unit = ? {base}Source
{currency} + { + // A cleared or non-positive field MUST remove the rate + // row rather than store 0: toBase() converts a stored 0 + // silently to a 0-value conversion, while an absent row + // correctly throws MissingRateError. Storing 0 here + // would turn a loud, correct error into wrong numbers + // presented as fact. + const parsed = Number(event.target.value); + mutate((draft) => { + if (Number.isFinite(parsed) && parsed > 0) { + setFxRate(draft, { + currency, + baseUnitsPerOne: parsed, + updatedAt: new Date().toISOString(), + source: "manual", + }); + } else { + removeFxRate(draft, currency); + } + }); + }} + /> + + {rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"} +
+ +
+
+ + + mutate((draft) => { + draft.settings.fxApiUrl = event.target.value; + }) + } + /> +

+ {"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app + works offline with the rates you type in above. +

+
+ + {status &&

{status}

} +
+
+ ); +} diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx new file mode 100644 index 0000000..91a1b25 --- /dev/null +++ b/src/ui/routes/settings/DataSection.tsx @@ -0,0 +1,117 @@ +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { downloadBlob } from "../../download.ts"; +import { store, currentMonth } from "../../../store/index.ts"; +import { + describeDataset, + exportDatasetJson, + exportFilename, + parseDatasetJson, +} from "../../../export/json.ts"; +import { buildOds, odsFilename } from "../../../export/ods.ts"; +import type { Dataset } from "../../../domain/types.ts"; + +export function DataSection() { + const dataset = useDataset(); + const fileInput = useRef(null); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + function exportJson() { + downloadBlob( + exportFilename(currentMonth), + new Blob([exportDatasetJson(dataset)], { type: "application/json" }), + ); + } + + function exportOds() { + downloadBlob( + odsFilename(currentMonth), + new Blob([buildOds(dataset) as BlobPart], { + type: "application/vnd.oasis.opendocument.spreadsheet", + }), + ); + } + + async function chooseFile(file: File) { + setError(null); + try { + setPending(parseDatasetJson(await file.text())); + } catch (cause) { + setPending(null); + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + async function confirmImport() { + if (!pending) return; + // Back up first: import replaces everything, and this is the user's only + // copy. Order matters — the backup must download before the destructive + // replace, never after. + exportJson(); + await store.replace(pending); + setPending(null); + } + + const counts = describeDataset(dataset); + + return ( +
+

Your data

+

+ Everything lives in this browser. Export regularly — it is both your + backup and how you move your budget to another device. +

+ +
+ + + + { + const file = event.target.files?.[0]; + if (file) void chooseFile(file); + event.target.value = ""; + }} + /> +
+ + {error &&

Could not import: {error}

} + + {pending && ( +
+

This replaces everything currently stored.

+

+ Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases. +
+ After import: {describeDataset(pending).posts} posts,{" "} + {describeDataset(pending).months} months,{" "} + {describeDataset(pending).purchases} purchases. +

+

+ A backup of your current data will download first. +

+
+ + +
+
+ )} +
+ ); +} diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx new file mode 100644 index 0000000..f575d02 --- /dev/null +++ b/src/ui/routes/settings/PostsSection.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { useMutate } from "../../hooks/useMutate.ts"; +import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts"; +import { CURRENCIES, type Currency, type Rule } from "../../../domain/types.ts"; + +export function PostsSection() { + const dataset = useDataset(); + const { mutate } = useMutate(); + const [newName, setNewName] = useState(""); + const ordered = [...dataset.posts].sort((a, b) => a.order - b.order); + const base = dataset.settings.baseCurrency; + + function ruleEditor(postId: string, rule: Rule) { + return ( +
+ + { + const value = Number(event.target.value) || 0; + mutate((draft) => + updatePost(draft, postId, { + standingRule: + rule.kind === "fixed" + ? { kind: "fixed", amount: { ...rule.amount, amount: value } } + : { kind: "percentOfIncome", percent: value }, + }), + ); + }} + /> + + {rule.kind === "fixed" ? rule.amount.currency : "%"} + +
+ ); + } + + return ( +
+

Posts

+

+ Standing rules apply to every month automatically. Any single month can + override its own allocation from the month view. Percentages may total + more than 100%. +

+ + + + + + + + + + + {ordered.map((post, index) => ( + + + + + + + ))} + +
NameDisplay currencyStanding rule +
+ + mutate((draft) => updatePost(draft, post.id, { name: event.target.value })) + } + /> + + + {ruleEditor(post.id, post.standingRule)} + + + +
+

+ Archiving hides a post from new purchases while keeping its history. Posts + are never deleted, because purchases reference them. +

+ +
+ setNewName(event.target.value)} + /> + +
+
+ ); +}