From 9634bb171b14e08ac491783f78a6193f3f31c3ae Mon Sep 17 00:00:00 2001 From: Serg Dort Date: Wed, 4 Mar 2026 22:51:08 +0000 Subject: [PATCH 1/3] Add debug --- .vscode/launch.json | 14 ++++++++++++++ .vscode/tasks.json | 11 +++++++++++ 2 files changed, 25 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 5c1b1a2..02c76c8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,20 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Tithe PWA: Launch (Chrome)", + "type": "pwa-chrome", + "request": "attach", + "port": 9222, + "preLaunchTask": "Tithe PWA: Open Chrome (.env URL)", + "webRoot": "${workspaceFolder}/apps/pwa", + "sourceMaps": true, + "sourceMapPathOverrides": { + "/src/*": "${webRoot}/src/*", + "/@fs/*": "/*", + "/*": "${workspaceFolder}/*" + } + }, { "name": "Tithe API: Launch (tsx)", "type": "node", diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a0d3955 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Tithe PWA: Open Chrome (.env URL)", + "type": "shell", + "command": "PORT=$(node -e \"const fs=require('node:fs'); const env=fs.existsSync('.env')?fs.readFileSync('.env','utf8'):''; const read=(k)=>{const m=env.match(new RegExp('^'+k+'=(.*)$','m')); return m?m[1].trim().replace(/^['\\\"]|['\\\"]$/g,''):'';}; const p=read('PWA_PORT')||read('VITE_PWA_PORT')||'5173'; process.stdout.write(p);\") && URL=\"http://localhost:${PORT}\" && open -na \"Google Chrome\" --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-tithe \"$URL\"", + "problemMatcher": [] + } + ] +} From efe8cb8ae274a7ac15c1cf2f4d95570ecf93ce10 Mon Sep 17 00:00:00 2001 From: Serg Dort Date: Wed, 4 Mar 2026 22:52:01 +0000 Subject: [PATCH 2/3] fix(pwa): make category edits update list immediately --- .agents/notes.md | 10 +++++++ AGENTS.md | 3 ++ README.md | 3 ++ .../features/categories/CategoriesScreen.tsx | 21 +++++++++---- .../hooks/useCategoriesMutations.ts | 30 +++++++++++++++++-- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/.agents/notes.md b/.agents/notes.md index 1ec10f3..a8f9bc0 100644 --- a/.agents/notes.md +++ b/.agents/notes.md @@ -39,3 +39,13 @@ [0] The global `tithe` shim under `~/Library/pnpm/` is just a launcher script; after `pnpm link --global ./apps/cli` it executes the workspace path (`.../apps/cli/dist/index.js`), so `tithe web` always uses the current local checkout via relative workspace-root resolution. [0] For daemon metadata files, resolve path by checking existing state/pid files across candidates (`~/.tithe`, workspace fallback) before choosing writable location, and pass the chosen directory to child/supervisor processes to keep `status`/`stop` targeting consistent across privilege contexts. [0] When using `gh pr create --body` through shell commands, avoid unescaped backticks in the argument string because zsh command substitution can execute unintended commands; prefer `--body-file` for multiline markdown. +[1] For PWA category edits, patch TanStack Query `['categories']` cache on mutation success; immediate refetch can overwrite fresh UI with stale response, so invalidate with `refetchType: 'none'` (or defer refetch) instead of refetching right away. +[0] In React-controlled edit dialogs, saving immediately after an input selection can read stale state; keep a ref-synced latest draft and read from it in submit handlers for reliable writes (observed with category icon updates). +[0] VS Code frontend debugging for this repo needs explicit `pwa-chrome` launch config plus a background task for `pnpm --filter @tithe/pwa dev`; only API node configs are not enough for browser breakpoints. +[0] VS Code `tasks.json` custom `problemMatcher.pattern` must define `file` and `message`; for background startup detection without diagnostics, use a never-matching regex with captured `file`/`message` plus `background.endsPattern`. +[0] Hardcoding PWA debug URL (`http://localhost:5173`) breaks when `PWA_PORT` changes in `.env`; prefer a Node launch config with `serverReadyAction` patterning Vite `Local:` output and `debugWithChrome`. +[0] With VS Code `serverReadyAction` + `debugWithChrome` for Vite, add explicit `webRoot` and `sourceMapPathOverrides` (`/src/*` and `/@fs/*`) on the launch config to avoid unbound TSX breakpoints in `apps/pwa/src`. +[0] VS Code `serverReadyAction` launches are more reliable with `type: node-terminal` + `command` for Vite than `type: node` with `runtimeExecutable/runtimeArgs`, especially when expecting auto-open Chrome from terminal log patterns. +[0] For reliable PWA breakpoints + Chrome auto-open in this repo, use `pwa-chrome` attach (`:9222`) with prelaunch tasks: background Vite dev server plus `open -na "Google Chrome" --args --remote-debugging-port=9222` targeting URL derived from workspace `.env` `PWA_PORT`. +[0] If the user runs dev stack manually, keep VS Code debug setup minimal: prelaunch task should only open Chrome using `.env` `PWA_PORT`, then `pwa-chrome` attach to `:9222`; avoid auto-managing Vite tasks in debugger config. +[0] When editing hooks with repeated `invalidateQueries` blocks, apply_patch can match the wrong mutation; re-open the file and verify the intended function was changed (here: update mutation, not create mutation). diff --git a/AGENTS.md b/AGENTS.md index 500685b..450cf95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,6 +159,9 @@ Failure: - PWA Home pending commitments support a quick `Mark paid` action that creates a linked actual transaction (`source='commitment'`) and updates the ledger. - PWA Expenses page now surfaces semantic/reimbursement chips (`Internal transfer`, `External transfer`, `Pending`, `Reimbursable`, `Partial`, `Settled`, `Written off`) and basic reimbursement actions (`Link repayment`, `Mark written off`, `Reopen`). - PWA Categories page uses a floating `+` action to open `Add Category`, and category add/edit dialogs can capture expense-category reimbursement settings/defaults while reimbursement auto-match rule management also runs in a dialog. +- PWA Categories edit saves update the cached `categories` query immediately so the list reflects changes without a manual page refresh. +- PWA Categories edit save reads the latest in-dialog draft state (including icon changes) to avoid stale writes when saving immediately after selecting a value. +- PWA Categories edit mutation marks `categories` as stale without immediate refetch after cache write to avoid stale-response overwrites of freshly edited rows. - PWA short-form list-page dialogs (for example Expenses/Categories add/edit flows) should follow the Expenses pattern: MUI `Dialog` with `fullWidth` and no mobile `fullScreen`. - Ledger v2 development rollout requires a fresh local DB reset (no backfill); reset `DB_PATH` (default `~/.tithe/tithe.db`) before running v2 migrations/commands. - PWA large pages should use thin route entrypoints in `apps/pwa/src/pages` and feature-scoped UI/data modules under `apps/pwa/src/features/`; shared domain-neutral helpers belong in `apps/pwa/src/lib`. diff --git a/README.md b/README.md index ed5cd8a..ca64dae 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,9 @@ Current status in this implementation: - PWA Home includes a single `Add Transaction` flow for manual `income`, `expense`, and `transfer` entries (transfer entries require direction and support semantic subtype `internal` / `external`). - Reimbursable expense categories in Home manual entry can capture `Track reimbursement` plus `My share`. - PWA Categories page uses a floating `+` button for `Add Category`, and opens dialogs for category add/edit (including expense-category reimbursement settings/defaults) and reimbursement auto-match rule management (link expense categories to income/transfer categories). +- PWA Categories edit saves update the cached category list immediately, so changes appear without refreshing the page. +- PWA Categories edit save uses the latest dialog draft values (including icon selection) to prevent stale writes on fast save clicks. +- PWA Categories edit marks category data stale without immediate refetch after cache update to prevent stale server responses from overwriting freshly edited rows. - PWA Home pending commitments support `Mark paid`, which creates a linked actual transaction (`source=commitment`) and updates the monthly ledger. - Home dashboard cards load independently: a ledger/Monzo/commitments fetch error is shown in that card without blocking the entire Home screen. - `Connect` opens the Monzo OAuth flow in a separate window/tab (opened immediately on click to avoid popup blocking after async API calls). diff --git a/apps/pwa/src/features/categories/CategoriesScreen.tsx b/apps/pwa/src/features/categories/CategoriesScreen.tsx index bb102de..71b8a85 100644 --- a/apps/pwa/src/features/categories/CategoriesScreen.tsx +++ b/apps/pwa/src/features/categories/CategoriesScreen.tsx @@ -1,6 +1,6 @@ import AddIcon from '@mui/icons-material/Add'; import { Alert, Box, CircularProgress, Fab, Stack } from '@mui/material'; -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import type { Category, ReimbursementCategoryRule } from '../../types.js'; import { CategoriesListCard } from './components/CategoriesListCard.js'; @@ -28,6 +28,7 @@ export const CategoriesScreen = () => { const [addOpen, setAddOpen] = useState(false); const [editingCategoryId, setEditingCategoryId] = useState(null); const [draftsById, setDraftsById] = useState>({}); + const draftsByIdRef = useRef>({}); const [rulesOpenCategoryId, setRulesOpenCategoryId] = useState(null); const [rowErrorById, setRowErrorById] = useState>({}); const [rulesErrorByExpenseCategoryId, setRulesErrorByExpenseCategoryId] = useState< @@ -78,9 +79,19 @@ export const CategoriesScreen = () => { ), ); + const setDrafts = ( + updater: (prev: Record) => Record, + ): void => { + setDraftsById((prev) => { + const next = updater(prev); + draftsByIdRef.current = next; + return next; + }); + }; + const beginEdit = (category: Category) => { setEditingCategoryId(category.id); - setDraftsById((prev) => ({ + setDrafts((prev) => ({ ...prev, [category.id]: prev[category.id] ?? buildDraftFromCategory(category), })); @@ -89,7 +100,7 @@ export const CategoriesScreen = () => { const cancelEdit = (categoryId: string) => { setEditingCategoryId((prev) => (prev === categoryId ? null : prev)); - setDraftsById((prev) => { + setDrafts((prev) => { const next = { ...prev }; delete next[categoryId]; return next; @@ -98,7 +109,7 @@ export const CategoriesScreen = () => { }; const setDraft = (categoryId: string, patch: Partial) => { - setDraftsById((prev) => ({ + setDrafts((prev) => ({ ...prev, [categoryId]: { ...(prev[categoryId] ?? { @@ -115,7 +126,7 @@ export const CategoriesScreen = () => { }; const handleSaveCategory = async (category: Category) => { - const draft = draftsById[category.id] ?? buildDraftFromCategory(category); + const draft = draftsByIdRef.current[category.id] ?? buildDraftFromCategory(category); setRowErrorById((prev) => ({ ...prev, [category.id]: null })); try { diff --git a/apps/pwa/src/features/categories/hooks/useCategoriesMutations.ts b/apps/pwa/src/features/categories/hooks/useCategoriesMutations.ts index 7c7749f..a06e625 100644 --- a/apps/pwa/src/features/categories/hooks/useCategoriesMutations.ts +++ b/apps/pwa/src/features/categories/hooks/useCategoriesMutations.ts @@ -1,11 +1,23 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../../../api.js'; +import type { Category } from '../../../types.js'; import { categoriesQueryKeys } from '../queries.js'; type CreateCategoryInput = Parameters[0]; type UpdateCategoryPatch = Parameters[1]; +const updateCategoriesCacheEntry = (categories: Category[], updated: Category): Category[] => { + const index = categories.findIndex((item) => item.id === updated.id); + if (index === -1) { + return categories; + } + + const next = [...categories]; + next[index] = updated; + return next; +}; + export const useCreateCategoryMutation = () => { const queryClient = useQueryClient(); @@ -23,8 +35,22 @@ export const useUpdateCategoryMutation = () => { return useMutation({ mutationFn: (input: { id: string; patch: UpdateCategoryPatch }) => api.categories.update(input.id, input.patch), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.categories() }); + onSuccess: async (updatedCategory) => { + queryClient.setQueryData( + categoriesQueryKeys.categories(), + (currentCategories) => { + if (!currentCategories) { + return currentCategories; + } + + return updateCategoriesCacheEntry(currentCategories, updatedCategory); + }, + ); + + await queryClient.invalidateQueries({ + queryKey: categoriesQueryKeys.categories(), + refetchType: 'none', + }); }, }); }; From de717664311a3f1b330dc0cbdadb68f9415db499 Mon Sep 17 00:00:00 2001 From: Serg Dort Date: Wed, 4 Mar 2026 23:13:38 +0000 Subject: [PATCH 3/3] refactor(pwa): split categories screen state into focused hooks --- .agents/notes.md | 1 + AGENTS.md | 10 + .../features/categories/CategoriesScreen.tsx | 197 +++--------------- .../hooks/useAutoMatchRulesDialog.ts | 97 +++++++++ .../categories/hooks/useCategoryEditDialog.ts | 136 ++++++++++++ 5 files changed, 274 insertions(+), 167 deletions(-) create mode 100644 apps/pwa/src/features/categories/hooks/useAutoMatchRulesDialog.ts create mode 100644 apps/pwa/src/features/categories/hooks/useCategoryEditDialog.ts diff --git a/.agents/notes.md b/.agents/notes.md index a8f9bc0..3e2ef28 100644 --- a/.agents/notes.md +++ b/.agents/notes.md @@ -49,3 +49,4 @@ [0] For reliable PWA breakpoints + Chrome auto-open in this repo, use `pwa-chrome` attach (`:9222`) with prelaunch tasks: background Vite dev server plus `open -na "Google Chrome" --args --remote-debugging-port=9222` targeting URL derived from workspace `.env` `PWA_PORT`. [0] If the user runs dev stack manually, keep VS Code debug setup minimal: prelaunch task should only open Chrome using `.env` `PWA_PORT`, then `pwa-chrome` attach to `:9222`; avoid auto-managing Vite tasks in debugger config. [0] When editing hooks with repeated `invalidateQueries` blocks, apply_patch can match the wrong mutation; re-open the file and verify the intended function was changed (here: update mutation, not create mutation). +[0] For large PWA screens mixing query + dialog + form state, extract dialog-specific state machines into dedicated feature hooks (`useCategoryEditDialog`, `useAutoMatchRulesDialog`) and keep the screen as a composition layer. diff --git a/AGENTS.md b/AGENTS.md index 450cf95..d1b59dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,16 @@ Failure: - PWA large pages should use thin route entrypoints in `apps/pwa/src/pages` and feature-scoped UI/data modules under `apps/pwa/src/features/`; shared domain-neutral helpers belong in `apps/pwa/src/lib`. - PWA Home dashboard widgets (ledger, Monzo, commitments) should manage loading/error states independently to avoid page-wide blocking when one widget fails. +### PWA state management notes + +- Treat TanStack Query as the source of truth for server state; avoid copying query results into component state except for transient UI drafts. +- Keep route/screen components as composition layers and extract workflow-specific state into focused hooks (for example edit dialog state vs auto-match rules state). +- Prefer one focused state object per active dialog/workflow instead of parallel id-indexed maps when only one entity can be edited at a time. +- Keep query cache writes/invalidation policy inside mutation hooks so UI components consume a stable API (`mutateAsync`, `isPending`) without cache plumbing details. +- For optimistic cache writes, avoid immediate active refetches that can overwrite fresh UI with stale responses; mark stale and refetch intentionally. +- Keep derived view data (`Map` indexes, filtered lists, linked id sets) pure and memoized from query data. +- When save handlers can race with rapid input events, read from a ref-synced latest draft snapshot (or a form library with synchronous submit state) to avoid stale payloads. + ### API dev runtime notes - `@tithe/api` dev script runs via `node --import tsx src/index.ts` (no file watch) to avoid tsx IPC socket failures in restricted environments. diff --git a/apps/pwa/src/features/categories/CategoriesScreen.tsx b/apps/pwa/src/features/categories/CategoriesScreen.tsx index 71b8a85..ac33248 100644 --- a/apps/pwa/src/features/categories/CategoriesScreen.tsx +++ b/apps/pwa/src/features/categories/CategoriesScreen.tsx @@ -1,17 +1,14 @@ import AddIcon from '@mui/icons-material/Add'; import { Alert, Box, CircularProgress, Fab, Stack } from '@mui/material'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; -import type { Category, ReimbursementCategoryRule } from '../../types.js'; +import type { ReimbursementCategoryRule } from '../../types.js'; import { CategoriesListCard } from './components/CategoriesListCard.js'; -import { - CATEGORY_ICON_COMPONENTS, - CATEGORY_ICON_OPTIONS, - DEFAULT_CATEGORY_COLOR, -} from './constants.js'; +import { CATEGORY_ICON_COMPONENTS, CATEGORY_ICON_OPTIONS } from './constants.js'; import { AddCategoryDialog } from './dialogs/AddCategoryDialog.js'; import { AutoMatchRepaymentCategoriesDialog } from './dialogs/AutoMatchRepaymentCategoriesDialog.js'; import { EditCategoryDialog } from './dialogs/EditCategoryDialog.js'; +import { useAutoMatchRulesDialog } from './hooks/useAutoMatchRulesDialog.js'; import { useCreateReimbursementCategoryRuleMutation, useDeleteReimbursementCategoryRuleMutation, @@ -21,19 +18,10 @@ import { useCategoriesListQuery, useReimbursementCategoryRulesQuery, } from './hooks/useCategoriesQueries.js'; -import type { CategoryEditDraft } from './types.js'; -import { buildDraftFromCategory, getErrorMessage, parseNullableNonNegativeInt } from './utils.js'; +import { useCategoryEditDialog } from './hooks/useCategoryEditDialog.js'; export const CategoriesScreen = () => { const [addOpen, setAddOpen] = useState(false); - const [editingCategoryId, setEditingCategoryId] = useState(null); - const [draftsById, setDraftsById] = useState>({}); - const draftsByIdRef = useRef>({}); - const [rulesOpenCategoryId, setRulesOpenCategoryId] = useState(null); - const [rowErrorById, setRowErrorById] = useState>({}); - const [rulesErrorByExpenseCategoryId, setRulesErrorByExpenseCategoryId] = useState< - Record - >({}); const categoriesQuery = useCategoriesListQuery(); const rulesQuery = useReimbursementCategoryRulesQuery(); @@ -45,15 +33,6 @@ export const CategoriesScreen = () => { const categories = categoriesQuery.data ?? []; const rules = rulesQuery.data ?? []; - const editingCategory = editingCategoryId - ? (categories.find((category) => category.id === editingCategoryId) ?? null) - : null; - const rulesEditingCategory = rulesOpenCategoryId - ? (categories.find( - (category) => category.id === rulesOpenCategoryId && category.kind === 'expense', - ) ?? null) - : null; - const rulesByExpenseCategoryId = useMemo(() => { const map = new Map(); for (const rule of rules) { @@ -70,121 +49,17 @@ export const CategoriesScreen = () => { [categories], ); - const editingDraft = editingCategory - ? (draftsById[editingCategory.id] ?? buildDraftFromCategory(editingCategory)) - : null; - const rulesEditingLinkedInboundIds = new Set( - (rulesEditingCategory ? (rulesByExpenseCategoryId.get(rulesEditingCategory.id) ?? []) : []).map( - (rule) => rule.inboundCategoryId, - ), - ); - - const setDrafts = ( - updater: (prev: Record) => Record, - ): void => { - setDraftsById((prev) => { - const next = updater(prev); - draftsByIdRef.current = next; - return next; - }); - }; - - const beginEdit = (category: Category) => { - setEditingCategoryId(category.id); - setDrafts((prev) => ({ - ...prev, - [category.id]: prev[category.id] ?? buildDraftFromCategory(category), - })); - setRowErrorById((prev) => ({ ...prev, [category.id]: null })); - }; - - const cancelEdit = (categoryId: string) => { - setEditingCategoryId((prev) => (prev === categoryId ? null : prev)); - setDrafts((prev) => { - const next = { ...prev }; - delete next[categoryId]; - return next; - }); - setRowErrorById((prev) => ({ ...prev, [categoryId]: null })); - }; - - const setDraft = (categoryId: string, patch: Partial) => { - setDrafts((prev) => ({ - ...prev, - [categoryId]: { - ...(prev[categoryId] ?? { - name: '', - icon: 'savings', - color: DEFAULT_CATEGORY_COLOR, - reimbursementMode: 'none', - defaultCounterpartyType: null, - defaultRecoveryWindowDaysText: '', - }), - ...patch, - }, - })); - }; - - const handleSaveCategory = async (category: Category) => { - const draft = draftsByIdRef.current[category.id] ?? buildDraftFromCategory(category); - setRowErrorById((prev) => ({ ...prev, [category.id]: null })); - - try { - const patch: { - name?: string; - icon?: string; - color?: string; - reimbursementMode?: 'none' | 'optional' | 'always'; - defaultCounterpartyType?: 'self' | 'partner' | 'team' | 'other' | null; - defaultRecoveryWindowDays?: number | null; - } = { - name: draft.name.trim(), - icon: draft.icon, - color: draft.color, - }; + const editDialog = useCategoryEditDialog({ + categories, + saveCategory: (input) => updateCategory.mutateAsync(input), + }); - if (category.kind === 'expense') { - patch.reimbursementMode = draft.reimbursementMode; - patch.defaultCounterpartyType = draft.defaultCounterpartyType; - patch.defaultRecoveryWindowDays = parseNullableNonNegativeInt( - draft.defaultRecoveryWindowDaysText, - ); - } - - await updateCategory.mutateAsync({ id: category.id, patch }); - setEditingCategoryId((prev) => (prev === category.id ? null : prev)); - } catch (error) { - setRowErrorById((prev) => ({ - ...prev, - [category.id]: getErrorMessage(error, 'Failed to update category.'), - })); - } - }; - - const handleToggleRule = async ( - expenseCategoryId: string, - inboundCategoryId: string, - enabled: boolean, - ) => { - setRulesErrorByExpenseCategoryId((prev) => ({ ...prev, [expenseCategoryId]: null })); - - try { - const existing = (rulesByExpenseCategoryId.get(expenseCategoryId) ?? []).find( - (rule) => rule.inboundCategoryId === inboundCategoryId, - ); - - if (enabled) { - await createRule.mutateAsync({ expenseCategoryId, inboundCategoryId }); - } else if (existing) { - await deleteRule.mutateAsync(existing.id); - } - } catch (error) { - setRulesErrorByExpenseCategoryId((prev) => ({ - ...prev, - [expenseCategoryId]: getErrorMessage(error, 'Failed to update auto-match rule.'), - })); - } - }; + const rulesDialog = useAutoMatchRulesDialog({ + categories, + rulesByExpenseCategoryId, + createRule: (input) => createRule.mutateAsync(input), + deleteRule: (id) => deleteRule.mutateAsync(id), + }); if (categoriesQuery.isLoading || rulesQuery.isLoading) { return ( @@ -208,8 +83,8 @@ export const CategoriesScreen = () => { @@ -231,43 +106,31 @@ export const CategoriesScreen = () => { setAddOpen(false)} /> setRulesOpenCategoryId(null)} + onClose={rulesDialog.close} onToggleRule={(inboundCategoryId, enabled) => { - if (!rulesEditingCategory) return; - void handleToggleRule(rulesEditingCategory.id, inboundCategoryId, enabled); + void rulesDialog.toggleRule(inboundCategoryId, enabled); }} /> { - if (!editingCategory) return; - cancelEdit(editingCategory.id); - }} + onClose={editDialog.closeEdit} onSave={() => { - if (!editingCategory) return; - void handleSaveCategory(editingCategory); - }} - onChangeDraft={(patch) => { - if (!editingCategory) return; - setDraft(editingCategory.id, patch); + void editDialog.save(); }} + onChangeDraft={editDialog.changeDraft} /> ); diff --git a/apps/pwa/src/features/categories/hooks/useAutoMatchRulesDialog.ts b/apps/pwa/src/features/categories/hooks/useAutoMatchRulesDialog.ts new file mode 100644 index 0000000..5e4220f --- /dev/null +++ b/apps/pwa/src/features/categories/hooks/useAutoMatchRulesDialog.ts @@ -0,0 +1,97 @@ +import { useMemo, useState } from 'react'; + +import type { Category, ReimbursementCategoryRule } from '../../../types.js'; +import { getErrorMessage } from '../utils.js'; + +interface UseAutoMatchRulesDialogInput { + categories: Category[]; + rulesByExpenseCategoryId: Map; + createRule: (input: { expenseCategoryId: string; inboundCategoryId: string }) => Promise; + deleteRule: (id: string) => Promise; +} + +interface UseAutoMatchRulesDialogOutput { + open: boolean; + expenseCategory: Category | null; + errorMessage: string | null; + linkedInboundIds: Set; + openForCategory: (categoryId: string) => void; + close: () => void; + toggleRule: (inboundCategoryId: string, enabled: boolean) => Promise; +} + +export const useAutoMatchRulesDialog = ({ + categories, + rulesByExpenseCategoryId, + createRule, + deleteRule, +}: UseAutoMatchRulesDialogInput): UseAutoMatchRulesDialogOutput => { + const [openExpenseCategoryId, setOpenExpenseCategoryId] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const expenseCategory = useMemo(() => { + if (!openExpenseCategoryId) { + return null; + } + return ( + categories.find( + (category) => category.id === openExpenseCategoryId && category.kind === 'expense', + ) ?? null + ); + }, [categories, openExpenseCategoryId]); + + const linkedInboundIds = useMemo( + () => + new Set( + (expenseCategory ? (rulesByExpenseCategoryId.get(expenseCategory.id) ?? []) : []).map( + (rule) => rule.inboundCategoryId, + ), + ), + [expenseCategory, rulesByExpenseCategoryId], + ); + + const openForCategory = (categoryId: string): void => { + setOpenExpenseCategoryId(categoryId); + setErrorMessage(null); + }; + + const close = (): void => { + setOpenExpenseCategoryId(null); + setErrorMessage(null); + }; + + const toggleRule = async (inboundCategoryId: string, enabled: boolean): Promise => { + if (!expenseCategory) { + return; + } + + setErrorMessage(null); + + try { + const existing = (rulesByExpenseCategoryId.get(expenseCategory.id) ?? []).find( + (rule) => rule.inboundCategoryId === inboundCategoryId, + ); + + if (enabled && !existing) { + await createRule({ + expenseCategoryId: expenseCategory.id, + inboundCategoryId, + }); + } else if (!enabled && existing) { + await deleteRule(existing.id); + } + } catch (error) { + setErrorMessage(getErrorMessage(error, 'Failed to update auto-match rule.')); + } + }; + + return { + open: Boolean(expenseCategory), + expenseCategory, + errorMessage, + linkedInboundIds, + openForCategory, + close, + toggleRule, + }; +}; diff --git a/apps/pwa/src/features/categories/hooks/useCategoryEditDialog.ts b/apps/pwa/src/features/categories/hooks/useCategoryEditDialog.ts new file mode 100644 index 0000000..1b8dcdd --- /dev/null +++ b/apps/pwa/src/features/categories/hooks/useCategoryEditDialog.ts @@ -0,0 +1,136 @@ +import { useMemo, useRef, useState } from 'react'; + +import type { Category } from '../../../types.js'; +import type { CategoryEditDraft } from '../types.js'; +import { buildDraftFromCategory, getErrorMessage, parseNullableNonNegativeInt } from '../utils.js'; + +type CategoryUpdatePatch = { + name?: string; + icon?: string; + color?: string; + reimbursementMode?: 'none' | 'optional' | 'always'; + defaultCounterpartyType?: 'self' | 'partner' | 'team' | 'other' | null; + defaultRecoveryWindowDays?: number | null; +}; + +interface CategoryEditingState { + categoryId: string; + draft: CategoryEditDraft; +} + +interface UseCategoryEditDialogInput { + categories: Category[]; + saveCategory: (input: { id: string; patch: CategoryUpdatePatch }) => Promise; +} + +interface UseCategoryEditDialogOutput { + open: boolean; + category: Category | null; + draft: CategoryEditDraft | null; + errorMessage: string | null; + beginEdit: (category: Category) => void; + closeEdit: () => void; + changeDraft: (patch: Partial) => void; + save: () => Promise; +} + +export const useCategoryEditDialog = ({ + categories, + saveCategory, +}: UseCategoryEditDialogInput): UseCategoryEditDialogOutput => { + const [editing, setEditing] = useState(null); + const editingRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(null); + + const setEditingState = ( + updater: (prev: CategoryEditingState | null) => CategoryEditingState | null, + ): void => { + setEditing((prev) => { + const next = updater(prev); + editingRef.current = next; + return next; + }); + }; + + const category = useMemo(() => { + if (!editing) { + return null; + } + return categories.find((item) => item.id === editing.categoryId) ?? null; + }, [categories, editing]); + + const beginEdit = (nextCategory: Category): void => { + setErrorMessage(null); + setEditingState(() => ({ + categoryId: nextCategory.id, + draft: buildDraftFromCategory(nextCategory), + })); + }; + + const closeEdit = (): void => { + setEditingState(() => null); + setErrorMessage(null); + }; + + const changeDraft = (patch: Partial): void => { + setEditingState((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + draft: { + ...prev.draft, + ...patch, + }, + }; + }); + }; + + const save = async (): Promise => { + const current = editingRef.current; + if (!current) { + return; + } + + const currentCategory = categories.find((item) => item.id === current.categoryId); + if (!currentCategory) { + return; + } + + setErrorMessage(null); + + try { + const patch: CategoryUpdatePatch = { + name: current.draft.name.trim(), + icon: current.draft.icon, + color: current.draft.color, + }; + + if (currentCategory.kind === 'expense') { + patch.reimbursementMode = current.draft.reimbursementMode; + patch.defaultCounterpartyType = current.draft.defaultCounterpartyType; + patch.defaultRecoveryWindowDays = parseNullableNonNegativeInt( + current.draft.defaultRecoveryWindowDaysText, + ); + } + + await saveCategory({ id: current.categoryId, patch }); + closeEdit(); + } catch (error) { + setErrorMessage(getErrorMessage(error, 'Failed to update category.')); + } + }; + + return { + open: Boolean(category && editing), + category, + draft: editing?.draft ?? null, + errorMessage, + beginEdit, + closeEdit, + changeDraft, + save, + }; +};