From d8636a8fa081a0da2d1f2019e7adaec9f431e44c Mon Sep 17 00:00:00 2001 From: faizur_almas Date: Tue, 27 Oct 2026 11:53:19 +0600 Subject: [PATCH] Fix the Biome errors that fail CI on main `biome ci .` has failed on main since #3 with 47 errors. This makes it pass (5 warnings remain, as before). - Biome's safe fixes: formatting and import order across 18 files. - biome.json parses Tailwind's CSS directives (`@theme` was a parse error). - The receipts list's filter now recomputes when receipts change (receipts was missing from its useMemo list: a stale list after loading). - The connect page's destination is a stable callback listed in its effect. - role="group" containers are fieldsets (receipt filters, the heatmap's day grid, the theme picker), with min-w-0 so they still shrink and scroll. - The heatmap's outer box is a labelled section; aria-label is dropped from plain divs, and the less/more scale is hidden from screen readers (each day's button already says its amount). - A few rules are suppressed with the reason next to them: autofocus on the new-space name, the backdrop click and drop zone (Escape, the close button and the file button cover the keyboard), index keys for the server's issue list and for rows whose inputs are controlled, and the app shell's refresh-on-navigation dependency. - A test no longer reads .body from a possibly undefined value. Co-Authored-By: Claude Opus 5.5 --- app/budgets/page.tsx | 80 ++- app/connect/page.tsx | 132 +++- app/globals.css | 4 +- app/receipts/[id]/page.tsx | 551 +++++++++++---- app/receipts/page.tsx | 115 +++- app/scan/page.tsx | 86 ++- app/spaces/new/page.tsx | 88 ++- app/spaces/page.tsx | 57 +- biome.json | 5 + src/components/app-shell.tsx | 152 ++++- src/components/budget-arc.tsx | 87 ++- src/components/daily-spend-heatmap.tsx | 140 ++-- src/components/dashboard.tsx | 88 ++- src/components/demo-context.tsx | 174 +++-- src/components/month-nav.tsx | 40 +- src/components/profile-menu.tsx | 360 +++++++--- src/components/receipt-upload-dialog.tsx | 809 ++++++++++++++++++----- src/lib/demo-data.ts | 117 +++- test/receipt-upload-dialog.test.tsx | 32 +- tsconfig.json | 15 +- vitest.config.ts | 2 +- 21 files changed, 2443 insertions(+), 691 deletions(-) diff --git a/app/budgets/page.tsx b/app/budgets/page.tsx index 220513c..45fd47b 100644 --- a/app/budgets/page.tsx +++ b/app/budgets/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { useDemo } from "@/src/components/demo-context"; -import { money } from "@/src/lib/demo-data"; import { currencySymbol, monthLabel } from "@/src/format"; +import { money } from "@/src/lib/demo-data"; export default function BudgetsPage() { const { activeSpace, categories, currency, source, month, saveBudgets } = useDemo(); @@ -12,25 +12,36 @@ export default function BudgetsPage() { const [error, setError] = useState(""); useEffect(() => { - setLimits(Object.fromEntries(categories.map((category) => [category.key, category.budget ? String(category.budget) : ""]))); + setLimits( + Object.fromEntries( + categories.map((category) => [category.key, category.budget ? String(category.budget) : ""]), + ), + ); }, [categories]); return (
-

{activeSpace} · {monthLabel(month)}

+

+ {activeSpace} · {monthLabel(month)} +

Monthly budgets

Set a spending limit for each category in {activeSpace}.

-
{ - event.preventDefault(); - setError(""); - const budgets = Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, Number(value) || 0])); - void saveBudgets(budgets) - .then(() => setSaved(true)) - .catch((cause: Error) => setError(cause.message)); - }} className="rounded-2xl border border-line bg-card p-5 sm:p-7"> + { + event.preventDefault(); + setError(""); + const budgets = Object.fromEntries( + Object.entries(limits).map(([key, value]) => [key, Number(value) || 0]), + ); + void saveBudgets(budgets) + .then(() => setSaved(true)) + .catch((cause: Error) => setError(cause.message)); + }} + className="rounded-2xl border border-line bg-card p-5 sm:p-7" + >

Category limits

@@ -43,13 +54,18 @@ export default function BudgetsPage() { const limit = Number(limits[category.key] || 0); const spentPercent = limit ? Math.round((category.amount / limit) * 100) : 0; return ( -
+
- {currencySymbol(currency)} + + {currencySymbol(currency)} + { setSaved(false); setLimits({ ...limits, [category.key]: event.target.value }); }} + onChange={(event) => { + setSaved(false); + setLimits({ ...limits, [category.key]: event.target.value }); + }} className="w-full rounded-lg border border-line bg-paper py-2 pl-8 pr-3 text-right tabular-nums outline-none focus:border-ledger" />
Spent {money(category.amount, currency)} - = 80 ? "font-semibold text-amber" : ""}>{limit ? `${spentPercent}% used` : "No limit"} + = 80 ? "font-semibold text-amber" : ""}> + {limit ? `${spentPercent}% used` : "No limit"} +
- {limit ?
-
= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`} style={{ width: `${Math.min(spentPercent, 100)}%` }} /> -
: null} + {limit ? ( +
+
= 100 ? "bg-stamp" : spentPercent >= 80 ? "bg-amber" : "bg-ledger"}`} + style={{ width: `${Math.min(spentPercent, 100)}%` }} + /> +
+ ) : null}
); })}
-

{error || (saved ? (source === "connected" ? "Budgets saved." : "Budgets saved in this preview.") : source === "connected" ? "Changes will update this space." : "Changes are local to this preview.")}

- +

+ {error || + (saved + ? source === "connected" + ? "Budgets saved." + : "Budgets saved in this preview." + : source === "connected" + ? "Changes will update this space." + : "Changes are local to this preview.")} +

+
diff --git a/app/connect/page.tsx b/app/connect/page.tsx index 13aeaa9..c45d70e 100644 --- a/app/connect/page.tsx +++ b/app/connect/page.tsx @@ -1,9 +1,17 @@ "use client"; -import { useEffect, useState, type FormEvent } from "react"; -import { api, DEMO_ACCOUNT_EMAIL, isDemoSession, oidcConfig, saveTokens, session, startDemoSession } from "@/src/api/client"; -import type { ClientConfig } from "@/src/api/types"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { + api, + DEMO_ACCOUNT_EMAIL, + isDemoSession, + oidcConfig, + saveTokens, + session, + startDemoSession, +} from "@/src/api/client"; import type { DevToken } from "@/src/api/generated"; +import type { ClientConfig } from "@/src/api/types"; import * as oidc from "@/src/oidc"; export default function ConnectPage() { @@ -12,27 +20,29 @@ export default function ConnectPage() { const [error, setError] = useState(""); const [ready, setReady] = useState(false); - const destination = () => { + const destination = useCallback(() => { const requested = new URLSearchParams(window.location.search).get("returnTo"); return requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/"; - }; + }, []); useEffect(() => { - void api("/v1/config").then(async (clientConfig) => { - session.config = clientConfig; - setConfig(clientConfig); - const oidcSettings = oidcConfig(); - if (oidcSettings) { - const tokens = await oidc.finish(oidcSettings); - if (tokens) saveTokens(tokens); - } - if (session.token || isDemoSession()) window.location.assign(destination()); - setReady(true); - }).catch(() => { - setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`); - setReady(true); - }); - }, []); + void api("/v1/config") + .then(async (clientConfig) => { + session.config = clientConfig; + setConfig(clientConfig); + const oidcSettings = oidcConfig(); + if (oidcSettings) { + const tokens = await oidc.finish(oidcSettings); + if (tokens) saveTokens(tokens); + } + if (session.token || isDemoSession()) window.location.assign(destination()); + setReady(true); + }) + .catch(() => { + setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`); + setReady(true); + }); + }, [destination]); const signInDev = async (event: FormEvent) => { event.preventDefault(); @@ -52,7 +62,7 @@ export default function ConnectPage() { body: JSON.stringify({ email: email.trim() }), }); if (!response.ok) throw new Error("Couldn't sign in. Check the email address."); - const token = await response.json() as DevToken; + const token = (await response.json()) as DevToken; saveTokens({ access_token: token.access_token, expires_in: 12 * 3600 }); window.location.assign(destination()); } catch (cause) { @@ -62,15 +72,23 @@ export default function ConnectPage() { const login = (prompt?: string) => { const settings = oidcConfig(); - if (settings) void oidc.login(settings, { prompt }).catch(() => setError("Couldn't reach the sign-in page. Try again.")); + if (settings) + void oidc + .login(settings, { prompt }) + .catch(() => setError("Couldn't reach the sign-in page. Try again.")); }; return (
-
); diff --git a/app/globals.css b/app/globals.css index e2c8264..e3b0bac 100644 --- a/app/globals.css +++ b/app/globals.css @@ -79,9 +79,7 @@ body { } body { - background: - radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%), - var(--paper); + background: radial-gradient(1000px 520px at 78% -12%, rgb(30 78 66 / 16%), transparent 64%), var(--paper); color: var(--ink); font-family: var(--font-hind); -webkit-font-smoothing: antialiased; diff --git a/app/receipts/[id]/page.tsx b/app/receipts/[id]/page.tsx index 3c2647f..682a3eb 100644 --- a/app/receipts/[id]/page.tsx +++ b/app/receipts/[id]/page.tsx @@ -6,8 +6,8 @@ import { useEffect, useMemo, useState } from "react"; import { api, request } from "@/src/api/client"; import type { DocSchema, DocumentOut, FieldSpec, Issue } from "@/src/api/types"; import { useDemo } from "@/src/components/demo-context"; -import { dateLabel } from "@/src/lib/demo-data"; import { nice, parseAmount, title, toDMY, toISO } from "@/src/format"; +import { dateLabel } from "@/src/lib/demo-data"; type FieldKind = NonNullable; type FormData = Record; @@ -35,14 +35,22 @@ function hasContent(value: unknown): boolean { function issueText(issue: Issue) { switch (issue.rule) { - case "empty": return "Nothing was read automatically. Type the main details."; - case "missing": return `The ${nice(issue.detail).replace(/\[\d+\]/, "")} is missing.`; - case "date_parse": return "The date isn't readable. Use DD/MM/YYYY."; - case "total_positive": return "The total should be more than zero."; - case "items_sum": return `The items don't add up: ${issue.detail.replace("!=", "but")}.`; - case "total_math": return `Subtotal plus VAT doesn't match the total (${issue.detail.replace("!=", "≠")}).`; - case "line_math": return `A line's quantity × price doesn't match its amount (${issue.detail}).`; - default: return issue.detail; + case "empty": + return "Nothing was read automatically. Type the main details."; + case "missing": + return `The ${nice(issue.detail).replace(/\[\d+\]/, "")} is missing.`; + case "date_parse": + return "The date isn't readable. Use DD/MM/YYYY."; + case "total_positive": + return "The total should be more than zero."; + case "items_sum": + return `The items don't add up: ${issue.detail.replace("!=", "but")}.`; + case "total_math": + return `Subtotal plus VAT doesn't match the total (${issue.detail.replace("!=", "≠")}).`; + case "line_math": + return `A line's quantity × price doesn't match its amount (${issue.detail}).`; + default: + return issue.detail; } } @@ -70,21 +78,22 @@ export default function ReviewReceiptPage() { useEffect(() => { if (source !== "connected") return; let cancelled = false; - void Promise.all([ - api(`/v1/documents/${params.id}`), - api("/v1/categories"), - ]).then(async ([loaded, availableCategories]) => { - const loadedSchema = await api(`/v1/schemas/${loaded.doc_type}`); - if (cancelled) return; - setDocument(loaded); - setSchema(loadedSchema); - setFormData(loaded.data ?? {}); - setCategory(loaded.category ?? availableCategories[0] ?? ""); - setAllCategories(availableCategories); - }).catch((cause: Error) => { - if (!cancelled) setError(cause.message); - }); - return () => { cancelled = true; }; + void Promise.all([api(`/v1/documents/${params.id}`), api("/v1/categories")]) + .then(async ([loaded, availableCategories]) => { + const loadedSchema = await api(`/v1/schemas/${loaded.doc_type}`); + if (cancelled) return; + setDocument(loaded); + setSchema(loadedSchema); + setFormData(loaded.data ?? {}); + setCategory(loaded.category ?? availableCategories[0] ?? ""); + setAllCategories(availableCategories); + }) + .catch((cause: Error) => { + if (!cancelled) setError(cause.message); + }); + return () => { + cancelled = true; + }; }, [source, params.id]); useEffect(() => { @@ -117,13 +126,17 @@ export default function ReviewReceiptPage() { useEffect(() => { if (source !== "connected") return; let objectUrl: string | null = null; - void request(`/v1/documents/${params.id}/file`).then(async (response) => { - if (!response.ok) return; - const blob = await response.blob(); - objectUrl = URL.createObjectURL(blob); - setPhoto({ url: objectUrl, pdf: blob.type === "application/pdf" }); - }).catch(() => {}); - return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }; + void request(`/v1/documents/${params.id}/file`) + .then(async (response) => { + if (!response.ok) return; + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + setPhoto({ url: objectUrl, pdf: blob.type === "application/pdf" }); + }) + .catch(() => {}); + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; }, [source, params.id]); const fields = useMemo(() => Object.entries(schema?.properties ?? {}), [schema]); @@ -132,19 +145,35 @@ export default function ReviewReceiptPage() { if (!receipt) return; setSampleMerchant(receipt.merchant); setSampleAmount(String(receipt.amount)); - setCategory(categories.find((item) => item.name === receipt.category)?.key ?? receipt.category.toLowerCase().replaceAll(" ", "_")); + setCategory( + categories.find((item) => item.name === receipt.category)?.key ?? + receipt.category.toLowerCase().replaceAll(" ", "_"), + ); }, [receipt, categories]); - const collectData = () => Object.fromEntries(fields.map(([name, spec]) => { - const kind = spec["x-kind"] ?? "text"; - if (kind !== "rows") return [name, readField(kind, formData[name] == null ? "" : String(formData[name]))]; - const columns = Object.entries(spec.items?.properties ?? {}); - const rows = Array.isArray(formData[name]) ? formData[name] as FormData[] : []; - return [name, rows.map((row) => Object.fromEntries(columns.map(([column, columnSpec]) => { - const columnKind = columnSpec["x-kind"] ?? "text"; - return [column, readField(columnKind, row[column] == null ? "" : String(row[column]))]; - }))).filter((row) => Object.values(row).some((value) => value != null))]; - })); + const collectData = () => + Object.fromEntries( + fields.map(([name, spec]) => { + const kind = spec["x-kind"] ?? "text"; + if (kind !== "rows") + return [name, readField(kind, formData[name] == null ? "" : String(formData[name]))]; + const columns = Object.entries(spec.items?.properties ?? {}); + const rows = Array.isArray(formData[name]) ? (formData[name] as FormData[]) : []; + return [ + name, + rows + .map((row) => + Object.fromEntries( + columns.map(([column, columnSpec]) => { + const columnKind = columnSpec["x-kind"] ?? "text"; + return [column, readField(columnKind, row[column] == null ? "" : String(row[column]))]; + }), + ), + ) + .filter((row) => Object.values(row).some((value) => value != null)), + ]; + }), + ); const canConfirm = hasContent(collectData()); const save = async (confirmed = false) => { @@ -185,26 +214,55 @@ export default function ReviewReceiptPage() { } }; - if (!receipt && source === "sample" && loading) return

Loading receipt…

; + if (!receipt && source === "sample" && loading) + return ( +

+ Loading receipt… +

+ ); if (!receipt && source === "sample") { return (

Receipt not found

This receipt is not part of the sample set.

- Back to receipts + + Back to receipts +
); } - if (source === "connected" && (!document || !schema)) return

{error || "Loading receipt details…"}

; + if (source === "connected" && (!document || !schema)) + return ( +

+ {error || "Loading receipt details…"} +

+ ); if (source === "connected" && document?.status === "processing") { return (
-
+
-

Reading receipt

@@ -212,10 +270,24 @@ export default function ReviewReceiptPage() {
-
    +
    1. 01 - + Receipt uploaded
    2. @@ -230,115 +302,364 @@ export default function ReviewReceiptPage() {
    -

    This page will show the extracted fields when they are ready to review.

    - Back to receipts +

    + This page will show the extracted fields when they are ready to review. +

    + + Back to receipts +
); } - const status = document?.status === "ready" ? "Ready" : document?.status === "needs_review" ? "Needs review" : document?.status === "processing" ? "Processing" : document?.status === "failed" ? "Unreadable" : receipt?.status ?? "Processing"; - const statusNote = document?.status === "ready" ? "Checked and counted in your spending." : document?.status === "needs_review" ? document.error ?? "Please check these details before confirming." : document?.status === "processing" ? "Still reading this receipt…" : document?.status === "failed" ? document.error ?? "This file couldn't be read." : "Review and edit the receipt fields."; - const merchantLabel = document?.doc_type === "invoice" ? "Supplier" : document?.doc_type === "bank_statement" ? "Bank" : "Merchant"; + const status = + document?.status === "ready" + ? "Ready" + : document?.status === "needs_review" + ? "Needs review" + : document?.status === "processing" + ? "Processing" + : document?.status === "failed" + ? "Unreadable" + : (receipt?.status ?? "Processing"); + const statusNote = + document?.status === "ready" + ? "Checked and counted in your spending." + : document?.status === "needs_review" + ? (document.error ?? "Please check these details before confirming.") + : document?.status === "processing" + ? "Still reading this receipt…" + : document?.status === "failed" + ? (document.error ?? "This file couldn't be read.") + : "Review and edit the receipt fields."; + const merchantLabel = + document?.doc_type === "invoice" + ? "Supplier" + : document?.doc_type === "bank_statement" + ? "Bank" + : "Merchant"; const amountLabel = document?.doc_type === "bank_statement" ? "Closing balance" : "Total amount"; return (
- Back to receipts + + Back to receipts +
-

{dateLabel(document?.date ?? receipt?.date ?? document?.created_at.slice(0, 10) ?? "")} · {params.id}

-

{document?.merchant || receipt?.merchant || document?.filename || "Receipt"}

+

+ {dateLabel(document?.date ?? receipt?.date ?? document?.created_at.slice(0, 10) ?? "")} ·{" "} + {params.id} +

+

+ {document?.merchant || receipt?.merchant || document?.filename || "Receipt"} +

{statusNote}

- {status} + + {status} +
- {document?.possible_duplicate_of ?
-

This looks like a receipt you already added.

- View the other one -
: null} + {document?.possible_duplicate_of ? ( +
+

This looks like a receipt you already added.

+ + View the other one + +
+ ) : null}
- {photo?.pdf ? Open receipt PDF : photo ? ( + {photo?.pdf ? ( + + Open receipt PDF + + ) : photo ? ( // eslint-disable-next-line @next/next/no-img-element - Uploaded receipt - ) :
-
- + Uploaded receipt + ) : ( +
+
+ +
+

Receipt image preview

+

+ {source === "connected" + ? "The image is not available." + : "A real receipt image appears here when an account is connected."} +

-

Receipt image preview

-

{source === "connected" ? "The image is not available." : "A real receipt image appears here when an account is connected."}

-
} + )}
-
{ event.preventDefault(); void save(); }} className="rounded-2xl border border-line bg-card p-5 sm:p-6"> + { + event.preventDefault(); + void save(); + }} + className="rounded-2xl border border-line bg-card p-5 sm:p-6" + >

Purchase details

Review and edit the details read from your receipt.

- {document?.issues.length && document.status !== "ready" ?
-

Doesn’t add up yet

-
    {document.issues.map((issue, index) =>
  • {issueText(issue)}
  • )}
-
: null} + {document?.issues.length && document.status !== "ready" ? ( +
+

Doesn’t add up yet

+
    + {document.issues.map((issue, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server's list, shown as is and never reordered +
  • {issueText(issue)}
  • + ))} +
+
+ ) : null}
- - { + setCategory(event.target.value); + setSaved(false); + }} + className={inputClass()} + > + {allCategories.map((item) => ( + + ))}
{fields.map(([name, spec]) => { const kind = spec["x-kind"] ?? "text"; if (kind === "rows") { const columns = Object.entries(spec.items?.properties ?? {}); - const rows = Array.isArray(formData[name]) ? formData[name] as FormData[] : []; + const rows = Array.isArray(formData[name]) ? (formData[name] as FormData[]) : []; const updateRow = (rowIndex: number, column: string, value: string) => { - const nextRows = rows.map((row, index) => index === rowIndex ? { ...row, [column]: value } : row); + const nextRows = rows.map((row, index) => + index === rowIndex ? { ...row, [column]: value } : row, + ); setFormData((current) => ({ ...current, [name]: nextRows })); setSaved(false); }; - return
-

{title(name)}

- {rows.length ?
- - {columns.map(([column]) => )} - {rows.map((row, rowIndex) => - {columns.map(([column, columnSpec]) => { - const columnKind = columnSpec["x-kind"] ?? "text"; - return ; - })} - - )} -
{title(column)}Actions
updateRow(rowIndex, column, event.target.value)} className="w-full min-w-24 rounded-md border border-line bg-paper px-2 py-1.5 outline-none focus:border-ledger" />
-
:

No {nice(name)} added.

} - -
; + return ( +
+

{title(name)}

+ {rows.length ? ( +
+ + + + {columns.map(([column]) => ( + + ))} + + + + + {rows.map((row, rowIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows have no ID; their inputs are controlled by the row data, so a removed row's DOM shows the right values + + {columns.map(([column, columnSpec]) => { + const columnKind = columnSpec["x-kind"] ?? "text"; + return ( + + ); + })} + + + ))} + +
+ {title(column)} + + Actions +
+ updateRow(rowIndex, column, event.target.value)} + className="w-full min-w-24 rounded-md border border-line bg-paper px-2 py-1.5 outline-none focus:border-ledger" + /> + + +
+
+ ) : ( +

No {nice(name)} added.

+ )} + +
+ ); } const value = formData[name]; - return
- - { setFormData((current) => ({ ...current, [name]: event.target.value })); setSaved(false); }} className={inputClass()} /> -
; + return ( +
+ + { + setFormData((current) => ({ ...current, [name]: event.target.value })); + setSaved(false); + }} + className={inputClass()} + /> +
+ ); })} - {source === "sample" ? <> -
- - { setSampleMerchant(event.target.value); setSaved(false); }} className={inputClass()} /> -
-
- - { setSampleAmount(event.target.value); setSaved(false); }} className={inputClass()} /> -
- : null} + {source === "sample" ? ( + <> +
+ + { + setSampleMerchant(event.target.value); + setSaved(false); + }} + className={inputClass()} + /> +
+
+ + { + setSampleAmount(event.target.value); + setSaved(false); + }} + className={inputClass()} + /> +
+ + ) : null}
-

{error || (saved ? source === "connected" ? document?.status === "ready" ? "Receipt confirmed and saved." : document?.issues.length ? "Saved. Some numbers still need a check." : "Saved. Confirm the details when they look right." : "Preview saved for this screen." : source === "connected" ? "Check the extracted details, then confirm them." : "Sample mode: values are shown for preview." )}

+

+ {error || + (saved + ? source === "connected" + ? document?.status === "ready" + ? "Receipt confirmed and saved." + : document?.issues.length + ? "Saved. Some numbers still need a check." + : "Saved. Confirm the details when they look right." + : "Preview saved for this screen." + : source === "connected" + ? "Check the extracted details, then confirm them." + : "Sample mode: values are shown for preview.")} +

- {source === "connected" ? : null} - - {document?.status === "needs_review" ? : null} + {source === "connected" ? ( + + ) : null} + + {document?.status === "needs_review" ? ( + + ) : null}
diff --git a/app/receipts/page.tsx b/app/receipts/page.tsx index 517dc04..300dcf7 100644 --- a/app/receipts/page.tsx +++ b/app/receipts/page.tsx @@ -4,21 +4,28 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { request } from "@/src/api/client"; import { useDemo } from "@/src/components/demo-context"; -import { dateLabel, money } from "@/src/lib/demo-data"; -import { monthLabel } from "@/src/format"; import { MonthNav } from "@/src/components/month-nav"; +import { monthLabel } from "@/src/format"; +import { dateLabel, money } from "@/src/lib/demo-data"; export default function ReceiptsPage() { - const { activeSpace, activeSpaceId, receipts, categories, currency, month, source, downloadCsv } = useDemo(); + const { activeSpace, activeSpaceId, receipts, categories, currency, month, source, downloadCsv } = + useDemo(); const [filter, setFilter] = useState("All receipts"); const [query, setQuery] = useState(""); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(""); - const shown = useMemo(() => receipts.filter((receipt) => { - const matchesFilter = filter === "All receipts" || (filter === "Needs review" ? receipt.status === filter : receipt.category === filter); - const searchText = `${receipt.merchant} ${receipt.category}`.toLowerCase(); - return matchesFilter && searchText.includes(query.trim().toLowerCase()); - }), [filter, query]); + const shown = useMemo( + () => + receipts.filter((receipt) => { + const matchesFilter = + filter === "All receipts" || + (filter === "Needs review" ? receipt.status === filter : receipt.category === filter); + const searchText = `${receipt.merchant} ${receipt.category}`.toLowerCase(); + return matchesFilter && searchText.includes(query.trim().toLowerCase()); + }), + [receipts, filter, query], + ); const filters = ["All receipts", "Needs review", ...new Set(categories.map((category) => category.name))]; useEffect(() => { const status = new URLSearchParams(window.location.search).get("status"); @@ -51,50 +58,106 @@ export default function ReceiptsPage() {
-

{activeSpace} · {monthLabel(month)}

+

+ {activeSpace} · {monthLabel(month)} +

Receipts

Browse and review your purchases.

- - + +
-
+
{filters.map((name) => ( - ))} -
+
-

{shown.length} {shown.length === 1 ? "receipt" : "receipts"}

+

+ {shown.length} {shown.length === 1 ? "receipt" : "receipts"} +

Newest first - - {source === "connected" ? : null} + + {source === "connected" ? ( + + ) : null}
- {exportError ?

{exportError}

: null} + {exportError ? ( +

+ {exportError} +

+ ) : null} {shown.length ? (
{shown.map((receipt) => ( - + {receipt.merchant} - {receipt.category} · {dateLabel(receipt.date)} + + {receipt.category} · {dateLabel(receipt.date)} + {dateLabel(receipt.date)} - {money(receipt.amount, currency)} - {receipt.status} + + {money(receipt.amount, currency)} + + + {receipt.status} + ))} diff --git a/app/scan/page.tsx b/app/scan/page.tsx index 7b493b2..e5c4a67 100644 --- a/app/scan/page.tsx +++ b/app/scan/page.tsx @@ -9,7 +9,13 @@ type UploadStatus = { id: number; name: string; status: string; failed?: boolean function deviceLabel(): string { const ua = navigator.userAgent; const os = /iPhone|iPad/.test(ua) ? "iPhone" : /Android/.test(ua) ? "Android phone" : "Phone"; - const browser = /CriOS|Chrome/.test(ua) ? "Chrome" : /Safari/.test(ua) ? "Safari" : /Firefox/.test(ua) ? "Firefox" : "browser"; + const browser = /CriOS|Chrome/.test(ua) + ? "Chrome" + : /Safari/.test(ua) + ? "Safari" + : /Firefox/.test(ua) + ? "Firefox" + : "browser"; return `${browser} on ${os}`; } @@ -40,10 +46,12 @@ export default function ScanPage() { setMessage("This code is no longer valid. Show a new one and scan again."); return; } - const claim = await response.json() as PairClaim; + const claim = (await response.json()) as PairClaim; if (cancelled) return; setMessage(`Allow this phone on “${claim.shower || "your computer"}”…`); - events = new EventSource(`/v1/pair/offers/${claim.offer_id}/events?key=${encodeURIComponent(claim.claimer_key)}`); + events = new EventSource( + `/v1/pair/offers/${claim.offer_id}/events?key=${encodeURIComponent(claim.claimer_key)}`, + ); events.addEventListener("approved", async () => { events?.close(); const collected = await fetch(`/v1/pair/offers/${claim.offer_id}/collect`, { @@ -54,17 +62,26 @@ export default function ScanPage() { setMessage("Couldn't connect. Scan a new code."); return; } - setGrant(await collected.json() as PairGrant); + setGrant((await collected.json()) as PairGrant); setMessage("Connected. Photos go straight to ScribbleScan."); }); - events.addEventListener("denied", () => { setMessage("Not allowed."); events?.close(); }); - events.addEventListener("expired", () => { setMessage("Timed out. Scan again."); events?.close(); }); + events.addEventListener("denied", () => { + setMessage("Not allowed."); + events?.close(); + }); + events.addEventListener("expired", () => { + setMessage("Timed out. Scan again."); + events?.close(); + }); } catch { if (!cancelled) setMessage("Couldn't connect. Check your connection and scan a new code."); } }; void connect(); - return () => { cancelled = true; events?.close(); }; + return () => { + cancelled = true; + events?.close(); + }; }, []); const sendFile = async (file: File, id: number) => { @@ -73,17 +90,21 @@ export default function ScanPage() { let status = 0; for (const wait of RETRY_WAITS_MS) { if (wait) { - setUploads((current) => current.map((item) => item.id === id ? { ...item, status: "Retrying…" } : item)); + setUploads((current) => + current.map((item) => (item.id === id ? { ...item, status: "Retrying…" } : item)), + ); await new Promise((resolve) => setTimeout(resolve, wait)); } const body = new FormData(); body.append("file", file); try { - status = (await fetch(`/v1/spaces/${grant.space_id}/documents`, { - method: "POST", - body, - headers: { Authorization: `Bearer ${grant.token}`, "Idempotency-Key": key }, - })).status; + status = ( + await fetch(`/v1/spaces/${grant.space_id}/documents`, { + method: "POST", + body, + headers: { Authorization: `Bearer ${grant.token}`, "Idempotency-Key": key }, + }) + ).status; } catch { status = 0; continue; @@ -91,7 +112,13 @@ export default function ScanPage() { if (status < 500) break; } const ok = status >= 200 && status < 300; - setUploads((current) => current.map((item) => item.id === id ? { ...item, status: ok ? "Sent" : status === 401 ? "Disconnected" : "Failed", failed: !ok } : item)); + setUploads((current) => + current.map((item) => + item.id === id + ? { ...item, status: ok ? "Sent" : status === 401 ? "Disconnected" : "Failed", failed: !ok } + : item, + ), + ); }; const onFiles = (files: FileList | null) => { @@ -107,13 +134,36 @@ export default function ScanPage() { return (

ScribbleScan camera

-

{message}

+

+ {message} +

{grant ? ( <> - onFiles(event.currentTarget.files)} /> - + onFiles(event.currentTarget.files)} + /> +
    - {uploads.map((upload) =>
  • {upload.name}{upload.status}
  • )} + {uploads.map((upload) => ( +
  • + {upload.name} + {upload.status} +
  • + ))}
) : null} diff --git a/app/spaces/new/page.tsx b/app/spaces/new/page.tsx index 9a5d0bb..76292f5 100644 --- a/app/spaces/new/page.tsx +++ b/app/spaces/new/page.tsx @@ -15,36 +15,80 @@ export default function NewSpacePage() { return (
- Back to spaces + + Back to spaces +

Create a space

Give a set of spending its own name.

-
{ - event.preventDefault(); - const cleanName = name.trim(); - if (!cleanName) { setError("Enter a name for this space."); return; } - setSaving(true); - try { - await addSpace(cleanName, kind); - router.push("/spaces"); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Couldn't create the space."); - setSaving(false); - } - }} className="rounded-2xl border border-line bg-card p-5 sm:p-7"> - - { setName(event.target.value); setError(""); }} placeholder="For example, Family or Shop" className="mt-2 w-full rounded-lg border border-line bg-paper px-3.5 py-3 outline-none focus:border-ledger" /> - - { + setName(event.target.value); + setError(""); + }} + placeholder="For example, Family or Shop" + className="mt-2 w-full rounded-lg border border-line bg-paper px-3.5 py-3 outline-none focus:border-ledger" + /> + + -

{error}

+

+ {error} +

- - Cancel + + + Cancel +
-

In sample mode, this space exists until you reload the page.

+

+ In sample mode, this space exists until you reload the page. +

); diff --git a/app/spaces/page.tsx b/app/spaces/page.tsx index 9713453..c77f090 100644 --- a/app/spaces/page.tsx +++ b/app/spaces/page.tsx @@ -13,25 +13,68 @@ export default function SpacesPage() {

Spaces

Choose a space for home, a shop, or another set of spending.

- Create a space + + Create a space +
{spaces.map((space) => ( -
+
- +
- {space.name === activeSpace ? Current space : null} + {space.name === activeSpace ? ( + + Current space + + ) : null}

{space.name}

-

{space.kind === "personal" ? "Personal spending" : "Business spending and budgets"}

- {space.name !== activeSpace ? : null} +

+ {space.kind === "personal" ? "Personal spending" : "Business spending and budgets"} +

+ {space.name !== activeSpace ? ( + + ) : null}
))}
- {source === "sample" ?

Sample mode: spaces are temporary and will reset when you reload.

: null} + {source === "sample" ? ( +

+ Sample mode: spaces are temporary and will reset when you reload. +

+ ) : null}
); } diff --git a/biome.json b/biome.json index b0d897a..bfdcade 100644 --- a/biome.json +++ b/biome.json @@ -22,5 +22,10 @@ "organizeImports": "on" } } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } } diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 21eabfc..300a84c 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -2,15 +2,15 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; -import { DemoProvider, useDemo } from "@/src/components/demo-context"; +import { useCallback, useEffect, useState } from "react"; import { api, clearTokens, DEMO_ACCOUNT_EMAIL, isDemoSession, oidcConfig, session } from "@/src/api/client"; import type { NotificationsOut } from "@/src/api/types"; +import { DemoProvider, useDemo } from "@/src/components/demo-context"; +import { type AccountNotification, ProfileMenu } from "@/src/components/profile-menu"; +import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; import * as oidc from "@/src/oidc"; import { store } from "@/src/store"; -import { ProfileMenu, type AccountNotification } from "@/src/components/profile-menu"; -import { ReceiptUploadDialog } from "@/src/components/receipt-upload-dialog"; type NavItem = { href?: string; label: string; icon: "spending" | "receipts" | "add" | "budgets" | "spaces" }; @@ -32,7 +32,16 @@ function NavIcon({ name }: { name: NavItem["icon"] }) { }; return ( - {item.label}; - return ( - item.href - ? {content} - : + const content = ( + <> + + {item.label} + + ); + return item.href ? ( + + {content} + + ) : ( + ); })} @@ -101,9 +130,23 @@ export function AppShell({ children }: { children: ReactNode }) { }; }, [router]); - if (isPublicRoute) return {children}; - if (!checked || !authorized) return
Taking you to sign in…
; - return {children}; + if (isPublicRoute) + return ( + + {children} + + ); + if (!checked || !authorized) + return ( +
+ Taking you to sign in… +
+ ); + return ( + + {children} + + ); } function AppFrame({ children }: { children: ReactNode }) { @@ -114,6 +157,7 @@ function AppFrame({ children }: { children: ReactNode }) { const openUpload = useCallback(() => setUploadOpen(true), []); const closeUpload = useCallback(() => setUploadOpen(false), []); + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname refreshes notifications on every page change useEffect(() => { if (source !== "connected") { setServerNotifications([]); @@ -167,43 +211,99 @@ function AppFrame({ children }: { children: ReactNode }) { else window.location.assign("/"); }; if (loading && pathname !== "/connect" && pathname !== "/scan") { - return
Loading your account…
; + return ( +
+ Loading your account… +
+ ); } const notifications: AccountNotification[] = source === "connected" ? serverNotifications : []; return (
- S - - selectSpace(event.target.value)} + className="max-w-[44vw] appearance-none rounded-full border border-line bg-card px-3 py-2 pr-8 text-sm font-semibold text-ink outline-none focus:border-ledger sm:max-w-[58vw] sm:px-4" + > + {spaces.map((space) => ( + + ))} - {source === "connected" ? "Connected" : "Sample data"} + + {source === "connected" ? "Connected" : "Sample data"} +
- - void markServerNotificationsRead() : undefined} onDownloadCsv={downloadCsv} onSignOut={() => void signOut()} /> + void markServerNotificationsRead() : undefined + } + onDownloadCsv={downloadCsv} + onSignOut={() => void signOut()} + />
- {source === "sample" ?
Preview data. Live account data is unavailable.
: null} + {source === "sample" ? ( +
+ Preview data. Live account data is + unavailable. +
+ ) : null} {children}
diff --git a/src/components/budget-arc.tsx b/src/components/budget-arc.tsx index 4774912..62aa028 100644 --- a/src/components/budget-arc.tsx +++ b/src/components/budget-arc.tsx @@ -11,31 +11,86 @@ export function BudgetArc({ categories, currency }: { categories: Category[]; cu

Category budget use

- {featured.length} categories + + {featured.length} categories +
- `${category.name}: ${category.budget ? Math.round(category.amount / category.budget * 100) : 0}% of budget`).join(", ")}> + + `${category.name}: ${category.budget ? Math.round((category.amount / category.budget) * 100) : 0}% of budget`, + ) + .join(", ")} + > {featured.map((category, index) => { const radius = 135 - index * 23; - const progress = category.budget ? Math.min(100, Math.max(0, category.amount / category.budget * 100)) : 0; + const progress = category.budget + ? Math.min(100, Math.max(0, (category.amount / category.budget) * 100)) + : 0; const arc = `M ${160 - radius} 160 A ${radius} ${radius} 0 0 1 ${160 + radius} 160`; - return - - - ; + return ( + + + + + ); })} - {overall}% - overall + + {overall}% + + + overall +
{featured.map((category) => { - const percent = category.budget ? Math.round(category.amount / category.budget * 100) : 0; - return
-
- - {category.name} + const percent = category.budget ? Math.round((category.amount / category.budget) * 100) : 0; + return ( +
+
+ + {category.name} +
+

+ {money(category.amount, currency)}{" "} + · {percent}% +

-

{money(category.amount, currency)} · {percent}%

-
; + ); })}
diff --git a/src/components/daily-spend-heatmap.tsx b/src/components/daily-spend-heatmap.tsx index 19bf56d..83be5a2 100644 --- a/src/components/daily-spend-heatmap.tsx +++ b/src/components/daily-spend-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { money, type Category, type Receipt } from "@/src/lib/demo-data"; +import { type Category, money, type Receipt } from "@/src/lib/demo-data"; const intensityClasses = [ "bg-[var(--track)] text-quiet", @@ -13,7 +13,17 @@ const intensityClasses = [ type DailyCategory = { name: string; amount: number; color: string }; -export function DailySpendHeatmap({ receipts, categories, currency, month }: { receipts: Receipt[]; categories: Category[]; currency: string; month: string }) { +export function DailySpendHeatmap({ + receipts, + categories, + currency, + month, +}: { + receipts: Receipt[]; + categories: Category[]; + currency: string; + month: string; +}) { const [selectedDate, setSelectedDate] = useState(null); const [year, monthNumber] = month.split("-").map(Number); const monthIndex = monthNumber - 1; @@ -36,10 +46,15 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r day.categories.set(key, category); totals.set(receipt.date, day); } - return new Map([...totals].map(([date, day]) => [date, { - total: day.total, - categories: [...day.categories.values()].sort((a, b) => a.name.localeCompare(b.name)), - }])); + return new Map( + [...totals].map(([date, day]) => [ + date, + { + total: day.total, + categories: [...day.categories.values()].sort((a, b) => a.name.localeCompare(b.name)), + }, + ]), + ); }, [categories, month, receipts]); const maxAmount = Math.max(0, ...[...spendingByDay.values()].map((day) => day.total)); @@ -47,33 +62,42 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r for (const day of spendingByDay.values()) { for (const category of day.categories) monthCategories.set(category.name.toLowerCase(), category); } - const latestSpendDate = [...spendingByDay.entries()] - .filter(([, day]) => day.total > 0) - .map(([date]) => date) - .sort() - .at(-1) ?? null; + const latestSpendDate = + [...spendingByDay.entries()] + .filter(([, day]) => day.total > 0) + .map(([date]) => date) + .sort() + .at(-1) ?? null; const activeDate = selectedDate && spendingByDay.has(selectedDate) ? selectedDate : latestSpendDate; - const activeAmount = activeDate ? spendingByDay.get(activeDate)?.total ?? 0 : 0; + const activeAmount = activeDate ? (spendingByDay.get(activeDate)?.total ?? 0) : 0; const activeLabel = activeDate - ? new Date(year, monthIndex, Number(activeDate.slice(-2))).toLocaleDateString("en-GB", { day: "numeric", month: "short" }) + ? new Date(year, monthIndex, Number(activeDate.slice(-2))).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + }) : null; return ( -
+

Daily spending

-

{activeLabel ? `${activeLabel} · ${money(activeAmount, currency)}` : "No receipt spending recorded"}

+

+ {activeLabel + ? `${activeLabel} · ${money(activeAmount, currency)}` + : "No receipt spending recorded"} +

-
+
-
{Array.from({ length: daysInMonth }, (_, index) => { @@ -81,14 +105,28 @@ export function DailySpendHeatmap({ receipts, categories, currency, month }: { r const isoDate = `${month}-${String(day).padStart(2, "0")}`; const dailySpend = spendingByDay.get(isoDate); const amount = dailySpend?.total ?? 0; - const categoryDetails = dailySpend?.categories.map((category) => `${category.name}: ${money(category.amount, currency)}`).join(", "); - const level = amount > 0 && maxAmount > 0 - ? amount >= maxAmount * 0.75 ? 4 : amount >= maxAmount * 0.5 ? 3 : amount >= maxAmount * 0.25 ? 2 : 1 - : 0; - const label = new Date(year, monthIndex, day).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }); - const spendDetail = amount > 0 - ? `${money(amount, currency)} spent${categoryDetails ? `. Categories: ${categoryDetails}` : ""}` - : "no receipt spending"; + const categoryDetails = dailySpend?.categories + .map((category) => `${category.name}: ${money(category.amount, currency)}`) + .join(", "); + const level = + amount > 0 && maxAmount > 0 + ? amount >= maxAmount * 0.75 + ? 4 + : amount >= maxAmount * 0.5 + ? 3 + : amount >= maxAmount * 0.25 + ? 2 + : 1 + : 0; + const label = new Date(year, monthIndex, day).toLocaleDateString("en-GB", { + weekday: "long", + day: "numeric", + month: "long", + }); + const spendDetail = + amount > 0 + ? `${money(amount, currency)} spent${categoryDetails ? `. Categories: ${categoryDetails}` : ""}` + : "no receipt spending"; return ( ); })} -
- {monthCategories.size ?
- {[...monthCategories.values()].sort((a, b) => a.name.localeCompare(b.name)).map((category) => ( - - - ))} -
: null} -
+ + {monthCategories.size ? ( +
+ {[...monthCategories.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((category) => ( + + + ))} +
+ ) : null} +
); } diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index b607e55..7e53b1f 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -1,29 +1,55 @@ "use client"; import Link from "next/link"; -import { dateLabel, money } from "@/src/lib/demo-data"; -import { useDemo } from "@/src/components/demo-context"; -import { MonthNav } from "@/src/components/month-nav"; import { BudgetArc } from "@/src/components/budget-arc"; import { DailySpendHeatmap } from "@/src/components/daily-spend-heatmap"; +import { useDemo } from "@/src/components/demo-context"; +import { MonthNav } from "@/src/components/month-nav"; +import { dateLabel, money } from "@/src/lib/demo-data"; -function Metric({ label, value, note, accent = false }: { label: string; value: string; note: string; accent?: boolean }) { +function Metric({ + label, + value, + note, + accent = false, +}: { + label: string; + value: string; + note: string; + accent?: boolean; +}) { return (

{label}

-

{value}

+

+ {value} +

{note}

); } export function Dashboard() { - const { activeSpace, categories, receipts, spentTotal, budgetTotal, documentCount, lastMonthTotal, changePct, currency, month } = useDemo(); + const { + activeSpace, + categories, + receipts, + spentTotal, + budgetTotal, + documentCount, + lastMonthTotal, + changePct, + currency, + month, + } = useDemo(); const percent = budgetTotal ? Math.round((spentTotal / budgetTotal) * 100) : 0; const remaining = Math.max(0, budgetTotal - spentTotal); - const comparison = changePct == null - ? `${documentCount} receipts recorded this month` - : `${Math.abs(changePct)}% ${changePct > 0 ? "more" : "less"} than last month (${money(lastMonthTotal, currency)})`; + const comparison = + changePct == null + ? `${documentCount} receipts recorded this month` + : `${Math.abs(changePct)}% ${changePct > 0 ? "more" : "less"} than last month (${money(lastMonthTotal, currency)})`; return (
@@ -36,11 +62,25 @@ export function Dashboard() {
- - + +
- +
@@ -51,18 +91,32 @@ export function Dashboard() {

Recent receipts

Latest additions to {activeSpace}.

- See all + + See all +
{receipts.slice(0, 8).map((receipt) => ( - + {receipt.merchant} - {receipt.category} · {dateLabel(receipt.date)} + + {receipt.category} · {dateLabel(receipt.date)} + - {money(receipt.amount, currency)} - {receipt.status} + + {money(receipt.amount, currency)} + + + {receipt.status} + ))} diff --git a/src/components/demo-context.tsx b/src/components/demo-context.tsx index f83cd22..d15575b 100644 --- a/src/components/demo-context.tsx +++ b/src/components/demo-context.tsx @@ -1,11 +1,18 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { api, isDemoSession, session } from "@/src/api/client"; import type { ClientConfig, DocumentOut, Expenses, SpaceOut, Summary } from "@/src/api/types"; import { thisMonth, title } from "@/src/format"; +import { + type Category, + type Receipt, + budgetTotal as sampleBudget, + categories as sampleCategories, + receipts as sampleReceipts, + spentTotal as sampleSpent, +} from "@/src/lib/demo-data"; import { store } from "@/src/store"; -import { categories as sampleCategories, receipts as sampleReceipts, spentTotal as sampleSpent, budgetTotal as sampleBudget, type Category, type Receipt } from "@/src/lib/demo-data"; type Space = Pick; type DataSource = "sample" | "connected"; @@ -35,14 +42,26 @@ type AppData = { upload: (spaceId: string, file: File) => Promise; }; -const colorByCategory: Record = Object.fromEntries(sampleCategories.map((category) => [category.name.toLowerCase(), category.color])); +const colorByCategory: Record = Object.fromEntries( + sampleCategories.map((category) => [category.name.toLowerCase(), category.color]), +); const sampleSpaces: Space[] = [ { id: "sample-personal", name: "Personal", kind: "personal", currency: "BDT" }, { id: "sample-shop", name: "Demo shop", kind: "business", currency: "BDT" }, ]; -type DataState = Omit; +type DataState = Omit< + AppData, + | "selectSpace" + | "setMonth" + | "addSpace" + | "saveBudgets" + | "updateReceipt" + | "downloadCsv" + | "refresh" + | "upload" +>; const initialData: DataState = { source: "sample" as DataSource, @@ -144,41 +163,73 @@ export function DemoProvider({ children }: { children: ReactNode }) { void loadLive(); }, [loadLive]); - const selectSpace = useCallback((name: string) => { - setData((current) => ({ ...current, activeSpace: name })); - if (data.source === "connected") void loadLive(name, data.month); - }, [data.month, data.source, loadLive]); + const selectSpace = useCallback( + (name: string) => { + setData((current) => ({ ...current, activeSpace: name })); + if (data.source === "connected") void loadLive(name, data.month); + }, + [data.month, data.source, loadLive], + ); - const setMonth = useCallback((month: string) => { - setData((current) => ({ ...current, month })); - if (data.source === "connected") void loadLive(data.activeSpace, month); - }, [data.activeSpace, data.source, loadLive]); + const setMonth = useCallback( + (month: string) => { + setData((current) => ({ ...current, month })); + if (data.source === "connected") void loadLive(data.activeSpace, month); + }, + [data.activeSpace, data.source, loadLive], + ); - const addSpace = useCallback(async (name: string, kind: "personal" | "business" = "business") => { - if (data.source === "connected") { - await api("/v1/spaces", { method: "POST", body: { name, kind, currency: data.currency } }); - await loadLive(name, data.month); - return; - } - const space: Space = { id: `sample-${crypto.randomUUID()}`, name, kind, currency: "BDT" }; - setData((current) => ({ ...current, spaces: [...current.spaces, space], activeSpace: name, activeSpaceId: space.id })); - }, [data.currency, data.month, data.source, loadLive]); - - const saveBudgets = useCallback(async (budgets: Record) => { - if (data.source === "connected") { - await api(`/v1/spaces/${data.activeSpaceId}/budgets`, { method: "PUT", body: { budgets } }); - await loadLive(data.activeSpace, data.month); - return; - } - setData((current) => { - const categories = current.categories.map((category) => ({ ...category, budget: budgets[category.key] ?? 0 })); - return { ...current, categories, budgetTotal: categories.reduce((sum, category) => sum + category.budget, 0) }; - }); - }, [data.activeSpace, data.activeSpaceId, data.month, data.source, loadLive]); - - const updateReceipt = useCallback((id: string, updates: Partial>) => { - setData((current) => ({ ...current, receipts: current.receipts.map((receipt) => receipt.id === id ? { ...receipt, ...updates } : receipt) })); - }, []); + const addSpace = useCallback( + async (name: string, kind: "personal" | "business" = "business") => { + if (data.source === "connected") { + await api("/v1/spaces", { method: "POST", body: { name, kind, currency: data.currency } }); + await loadLive(name, data.month); + return; + } + const space: Space = { id: `sample-${crypto.randomUUID()}`, name, kind, currency: "BDT" }; + setData((current) => ({ + ...current, + spaces: [...current.spaces, space], + activeSpace: name, + activeSpaceId: space.id, + })); + }, + [data.currency, data.month, data.source, loadLive], + ); + + const saveBudgets = useCallback( + async (budgets: Record) => { + if (data.source === "connected") { + await api(`/v1/spaces/${data.activeSpaceId}/budgets`, { method: "PUT", body: { budgets } }); + await loadLive(data.activeSpace, data.month); + return; + } + setData((current) => { + const categories = current.categories.map((category) => ({ + ...category, + budget: budgets[category.key] ?? 0, + })); + return { + ...current, + categories, + budgetTotal: categories.reduce((sum, category) => sum + category.budget, 0), + }; + }); + }, + [data.activeSpace, data.activeSpaceId, data.month, data.source, loadLive], + ); + + const updateReceipt = useCallback( + (id: string, updates: Partial>) => { + setData((current) => ({ + ...current, + receipts: current.receipts.map((receipt) => + receipt.id === id ? { ...receipt, ...updates } : receipt, + ), + })); + }, + [], + ); const upload = useCallback(async (spaceId: string, file: File) => { const { uploadFile } = await import("@/src/api/client"); @@ -188,13 +239,25 @@ export function DemoProvider({ children }: { children: ReactNode }) { const downloadCsv = useCallback(() => { const rows: (string | number)[][] = [ ["Date", "Merchant", "Category", "Amount", "Status"], - ...data.receipts.map((receipt) => [receipt.date, receipt.merchant, receipt.category, receipt.amount, receipt.status]), + ...data.receipts.map((receipt) => [ + receipt.date, + receipt.merchant, + receipt.category, + receipt.amount, + receipt.status, + ]), ]; - const csv = rows.map((row) => row.map((value) => { - const raw = String(value); - const safe = /^[=+@\t\r]/.test(raw) ? `'${raw}` : raw; - return `"${safe.replaceAll('"', '""')}"`; - }).join(",")).join("\r\n"); + const csv = rows + .map((row) => + row + .map((value) => { + const raw = String(value); + const safe = /^[=+@\t\r]/.test(raw) ? `'${raw}` : raw; + return `"${safe.replaceAll('"', '""')}"`; + }) + .join(","), + ) + .join("\r\n"); const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -206,17 +269,20 @@ export function DemoProvider({ children }: { children: ReactNode }) { window.setTimeout(() => URL.revokeObjectURL(url), 2000); }, [data.activeSpace, data.month, data.receipts]); - const value = useMemo(() => ({ - ...data, - selectSpace, - setMonth, - addSpace, - saveBudgets, - updateReceipt, - downloadCsv, - refresh: () => loadLive(data.activeSpace, data.month), - upload, - }), [data, selectSpace, setMonth, addSpace, saveBudgets, updateReceipt, downloadCsv, loadLive, upload]); + const value = useMemo( + () => ({ + ...data, + selectSpace, + setMonth, + addSpace, + saveBudgets, + updateReceipt, + downloadCsv, + refresh: () => loadLive(data.activeSpace, data.month), + upload, + }), + [data, selectSpace, setMonth, addSpace, saveBudgets, updateReceipt, downloadCsv, loadLive, upload], + ); return {children}; } diff --git a/src/components/month-nav.tsx b/src/components/month-nav.tsx index 7346b4e..dd5976f 100644 --- a/src/components/month-nav.tsx +++ b/src/components/month-nav.tsx @@ -1,19 +1,49 @@ "use client"; -import { monthLabel, shiftMonth, thisMonth } from "@/src/format"; import { useDemo } from "@/src/components/demo-context"; +import { monthLabel, shiftMonth, thisMonth } from "@/src/format"; export function MonthNav() { const { month, source, setMonth } = useDemo(); const move = (amount: number) => setMonth(shiftMonth(month, amount)); return (
- {monthLabel(month)} -
); diff --git a/src/components/profile-menu.tsx b/src/components/profile-menu.tsx index af2beb3..b8982a4 100644 --- a/src/components/profile-menu.tsx +++ b/src/components/profile-menu.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; import { store } from "@/src/store"; type Theme = "dark" | "light"; @@ -18,24 +18,99 @@ type ProfileMenuProps = { onSignOut: () => void; }; -export type AccountNotification = { id: string; title: string; detail: string; href: string; unread?: boolean }; +export type AccountNotification = { + id: string; + title: string; + detail: string; + href: string; + unread?: boolean; +}; -function Icon({ name }: { name: "user" | "settings" | "theme" | "subscription" | "notification" | "csv" | "back" | "logout" }) { +function Icon({ + name, +}: { + name: "user" | "settings" | "theme" | "subscription" | "notification" | "csv" | "back" | "logout"; +}) { const paths = { - user: <>, - settings: <>, - theme: <>, - subscription: <>, - notification: <>, - csv: <>, - back: <>, - logout: <>, + user: ( + <> + + + + ), + settings: ( + <> + + + + ), + theme: ( + <> + + + + ), + subscription: ( + <> + + + + ), + notification: ( + <> + + + + ), + csv: ( + <> + + + + ), + back: ( + <> + + + + ), + logout: ( + <> + + + + ), }; - return ; + return ( + + ); } -export function ProfileMenu({ email, preview, space, currency, notifications, onNotificationsOpened, onDownloadCsv, onSignOut }: ProfileMenuProps) { +export function ProfileMenu({ + email, + preview, + space, + currency, + notifications, + onNotificationsOpened, + onDownloadCsv, + onSignOut, +}: ProfileMenuProps) { const root = useRef(null); const trigger = useRef(null); const [open, setOpen] = useState(false); @@ -60,7 +135,9 @@ export function ProfileMenu({ email, preview, space, currency, notifications, on useEffect(() => { try { const stored = JSON.parse(store.get("ss.seen-notifications") ?? "[]") as unknown; - setSeenNotificationIds(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === "string") : []); + setSeenNotificationIds( + Array.isArray(stored) ? stored.filter((id): id is string => typeof id === "string") : [], + ); } catch { setSeenNotificationIds([]); } @@ -114,11 +191,18 @@ export function ProfileMenu({ email, preview, space, currency, notifications, on }; const showPanel = (nextPanel: Panel) => setPanel(nextPanel); - const heading = panel === "settings" ? "Settings" : panel === "appearance" ? "Change theme" : "Subscription"; + const heading = + panel === "settings" ? "Settings" : panel === "appearance" ? "Change theme" : "Subscription"; return (
- + - {unseenCount > 0 ? : null} + {unseenCount > 0 ? ( + + ) : null} {open ? ( -