Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .agents/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,14 @@
[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).
[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.
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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)",
Comment on lines +5 to +9
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configuration name says "Launch" but the debugger request is actually an "attach" to an existing Chrome instance. Rename the config to "Attach" (or switch to a true launch config) to avoid confusing users when selecting debug targets.

Copilot uses AI. Check for mistakes.
"webRoot": "${workspaceFolder}/apps/pwa",
"sourceMaps": true,
"sourceMapPathOverrides": {
"/src/*": "${webRoot}/src/*",
"/@fs/*": "/*",
"/*": "${workspaceFolder}/*"
}
},
{
"name": "Tithe API: Launch (tsx)",
"type": "node",
Expand Down
11 changes: 11 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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\"",
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This task hard-codes macOS-only tooling (open -na "Google Chrome", /tmp/...) and relies on POSIX shell syntax, so it will fail on Windows/Linux or when VS Code uses a non-bash shell. Consider using VS Code task OS overrides (windows/linux/osx) with equivalent commands (or a cross-platform opener like npx open) and setting an explicit options.shell if needed.

Suggested change
"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\"",
"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}\" && google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-tithe \"$URL\"",
"options": {
"shell": {
"executable": "/bin/bash",
"args": [
"-c"
]
}
},
"osx": {
"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\""
},
"windows": {
"options": {
"shell": {
"executable": "C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command"
]
}
},
"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\"; Start-Process \"chrome\" \"--remote-debugging-port=9222 --user-data-dir=$env:TEMP\\chrome-debug-tithe $Url\""
},

Copilot uses AI. Check for mistakes.
"problemMatcher": []
}
]
}
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,24 @@ 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/<feature>`; 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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
184 changes: 29 additions & 155 deletions apps/pwa/src/features/categories/CategoriesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@ import AddIcon from '@mui/icons-material/Add';
import { Alert, Box, CircularProgress, Fab, Stack } from '@mui/material';
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,
Expand All @@ -21,18 +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<string | null>(null);
const [draftsById, setDraftsById] = useState<Record<string, CategoryEditDraft>>({});
const [rulesOpenCategoryId, setRulesOpenCategoryId] = useState<string | null>(null);
const [rowErrorById, setRowErrorById] = useState<Record<string, string | null>>({});
const [rulesErrorByExpenseCategoryId, setRulesErrorByExpenseCategoryId] = useState<
Record<string, string | null>
>({});

const categoriesQuery = useCategoriesListQuery();
const rulesQuery = useReimbursementCategoryRulesQuery();
Expand All @@ -44,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<string, ReimbursementCategoryRule[]>();
for (const rule of rules) {
Expand All @@ -69,111 +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 beginEdit = (category: Category) => {
setEditingCategoryId(category.id);
setDraftsById((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));
setDraftsById((prev) => {
const next = { ...prev };
delete next[categoryId];
return next;
});
setRowErrorById((prev) => ({ ...prev, [categoryId]: null }));
};

const setDraft = (categoryId: string, patch: Partial<CategoryEditDraft>) => {
setDraftsById((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 = draftsById[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 (
Expand All @@ -197,8 +83,8 @@ export const CategoriesScreen = () => {
<CategoriesListCard
categories={categories}
rulesByExpenseCategoryId={rulesByExpenseCategoryId}
onOpenAutoMatchRules={setRulesOpenCategoryId}
onEditCategory={beginEdit}
onOpenAutoMatchRules={rulesDialog.openForCategory}
onEditCategory={editDialog.beginEdit}
/>
</Stack>

Expand All @@ -220,43 +106,31 @@ export const CategoriesScreen = () => {
<AddCategoryDialog open={addOpen} onClose={() => setAddOpen(false)} />

<AutoMatchRepaymentCategoriesDialog
open={Boolean(rulesEditingCategory)}
expenseCategory={rulesEditingCategory}
open={rulesDialog.open}
expenseCategory={rulesDialog.expenseCategory}
inboundCategories={inboundCategories}
linkedInboundIds={rulesEditingLinkedInboundIds}
errorMessage={
rulesEditingCategory
? (rulesErrorByExpenseCategoryId[rulesEditingCategory.id] ?? null)
: null
}
linkedInboundIds={rulesDialog.linkedInboundIds}
errorMessage={rulesDialog.errorMessage}
isBusy={createRule.isPending || deleteRule.isPending}
onClose={() => setRulesOpenCategoryId(null)}
onClose={rulesDialog.close}
onToggleRule={(inboundCategoryId, enabled) => {
if (!rulesEditingCategory) return;
void handleToggleRule(rulesEditingCategory.id, inboundCategoryId, enabled);
void rulesDialog.toggleRule(inboundCategoryId, enabled);
}}
/>

<EditCategoryDialog
open={Boolean(editingCategory && editingDraft)}
category={editingCategory}
draft={editingDraft}
open={editDialog.open}
category={editDialog.category}
draft={editDialog.draft}
iconOptions={CATEGORY_ICON_OPTIONS}
iconComponents={CATEGORY_ICON_COMPONENTS}
errorMessage={editingCategory ? (rowErrorById[editingCategory.id] ?? null) : null}
errorMessage={editDialog.errorMessage}
isSubmitting={updateCategory.isPending}
onClose={() => {
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}
/>
</Box>
);
Expand Down
Loading