538 lines
29 KiB
Markdown
538 lines
29 KiB
Markdown
# Phase 3: Interaction Quality and Completeness - Research
|
|
|
|
**Researched:** 2026-03-11
|
|
**Domain:** React UI feedback patterns — loading states, hover affordances, flash animations, empty states, skeleton loaders, confirmation dialogs
|
|
**Confidence:** HIGH
|
|
|
|
## Summary
|
|
|
|
Phase 3 adds the UX feedback layer on top of an already-functional data app. All required shadcn/ui primitives (`Spinner`, `Skeleton`, `Dialog`) are installed and working. The core data components (`InlineEditCell`, `BillsTracker`, `VariableExpenses`, `DebtTracker`) are in place with proper TypeScript interfaces. The token system (`--success`, `--destructive`, `palette.ts`) is complete from Phase 1.
|
|
|
|
The implementation falls into four distinct work tracks: (1) spinner injection into four forms, (2) pencil-icon hover + save-flash in `InlineEditCell`, (3) delete confirmation dialog in `CategoriesPage`, and (4) empty states and tinted skeletons. Each track is self-contained with clear entry points.
|
|
|
|
One critical constraint was resolved during research: the database uses `ON DELETE RESTRICT` on the `budget_items.category_id` foreign key. This means attempting to delete a category that has associated budget items will fail with a 500 error from the backend. The confirmation dialog copy and error handling must account for this — users need to be warned, and the frontend must handle the ApiError gracefully.
|
|
|
|
**Primary recommendation:** Implement all four tracks in parallel waves — spinners first (lowest risk, 4 targeted edits), then InlineEditCell enhancements, then delete confirmation, then empty states and skeletons.
|
|
|
|
---
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
|
|
**Edit affordance & save feedback**
|
|
- Pencil icon appears on hover only, subtle opacity fade-in — not always visible
|
|
- Pencil positioned to the right of the cell value
|
|
- Save confirmation: soft green row highlight using --success token, fades over ~600ms, applies to entire row
|
|
- Save failure: red flash using --destructive token, value reverts to original — no toast, no modal
|
|
- All changes go into InlineEditCell.tsx (already extracted in Phase 1)
|
|
|
|
**Empty states & loading skeletons**
|
|
- Empty state style: icon + text only (lucide-react icon, no custom illustrations)
|
|
- Shared template structure (icon + heading + subtext + CTA button), unique content per section
|
|
- CTA tone: direct action — "Create your first budget" / "Add a category" — no fluff
|
|
- Loading skeletons tinted per section using palette.ts light shades (bills skeleton uses bill light shade, etc.)
|
|
|
|
**Spinner placement & style**
|
|
- Submit buttons replace text with spinner while loading (button maintains width via min-width)
|
|
- Button disabled during loading to prevent double-submit
|
|
- All four forms get spinners: Login, Register, Budget Create, Budget Edit
|
|
- Use existing shadcn spinner.tsx component as-is
|
|
|
|
**Delete confirmation dialog**
|
|
- Tone: clear and factual — "Delete [category name]? This cannot be undone."
|
|
- Confirm button: "Delete" in destructive variant (red), paired with neutral "Cancel"
|
|
- Delete button shows spinner + disables during API call (consistent with form submit pattern)
|
|
- Scope: category deletion only (per IXTN-05), not budget deletion
|
|
|
|
### Claude's Discretion
|
|
- Exact animation timing and easing curves for hover/flash transitions
|
|
- Empty state icon choices per section (appropriate lucide-react icons)
|
|
- Skeleton layout structure (number of rows, widths) per section
|
|
- Whether to extract a shared EmptyState component or inline per page
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
None — discussion stayed within phase scope
|
|
</user_constraints>
|
|
|
|
---
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|-----------------|
|
|
| IXTN-01 | Form submit buttons show a spinner during async operations (login, register, budget create/edit) | `loading` state already exists in LoginPage and RegisterPage; BudgetSetup has `saving` state. Spinner component is installed at `ui/spinner.tsx`. Pattern: replace button text with `<Spinner />` when loading, add `min-w-*` to prevent layout shift |
|
|
| IXTN-02 | Inline-editable rows show a pencil icon on hover as an edit affordance | InlineEditCell display-mode span already has `cursor-pointer hover:bg-muted`. Pencil icon: `Pencil` from lucide-react. CSS: `opacity-0 group-hover:opacity-100 transition-opacity` on icon, `group` on the span wrapper |
|
|
| IXTN-03 | Inline edit saves show a brief visual confirmation (row background flash) | Flash must apply to the entire TableRow, not just the cell. InlineEditCell sits inside a TableRow it does not own. Pattern: callback-based — `onSave` resolves → parent sets a `flash` state on the row ID → row gets a timed className → clears after ~600ms |
|
|
| IXTN-05 | Category deletion triggers a confirmation dialog before executing | `dialog.tsx` is installed. Current `handleDelete` fires immediately on button click. Replace with: set `pendingDeleteId` state → Dialog opens → confirm triggers actual delete with spinner → catch ApiError (ON DELETE RESTRICT → 500) |
|
|
| STATE-01 | Dashboard shows a designed empty state with CTA when user has no budgets | DashboardPage already has a fallback Card when `!current` — needs replacement with full empty-state design. `list.length === 0 && !loading` is the trigger condition |
|
|
| STATE-02 | Categories page shows a designed empty state with create CTA when no categories exist | CategoriesPage `grouped` array will be empty when no categories exist. Currently renders nothing in that case. Needs empty-state block |
|
|
| STATE-03 | Loading skeletons are styled with pastel-tinted backgrounds matching section colors | DashboardPage already uses `<Skeleton>` in the loading branch. Skeleton accepts `className` — override `bg-muted` with `style={{ backgroundColor: palette.bill.light }}` pattern. Need skeletons in BillsTracker, VariableExpenses, DebtTracker sections too |
|
|
</phase_requirements>
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core (all already installed)
|
|
| Library | Version | Purpose | Status |
|
|
|---------|---------|---------|--------|
|
|
| `ui/spinner.tsx` | shadcn (Loader2Icon) | Loading indicator in buttons | Installed, use as-is |
|
|
| `ui/skeleton.tsx` | shadcn (animate-pulse) | Loading placeholders | Installed, accepts `className` for tinting |
|
|
| `ui/dialog.tsx` | radix-ui Dialog | Modal confirmation | Installed, full API available |
|
|
| `lucide-react` | ^0.577.0 | Icons for pencil affordance and empty states | Installed |
|
|
| `tw-animate-css` | installed | Utility animation classes (`animate-in`, `fade-in`, etc.) | Available in index.css |
|
|
|
|
### CSS Tokens (from index.css :root)
|
|
| Token | Value | Use in Phase 3 |
|
|
|-------|-------|----------------|
|
|
| `--success` | `oklch(0.55 0.15 145)` | Row flash on successful save |
|
|
| `--destructive` | `oklch(0.58 0.22 27)` | Row flash on failed save, delete button |
|
|
| `palette[type].light` | per-section oklch | Skeleton background tinting |
|
|
|
|
### Supporting: palette.ts light shades for skeleton tinting
|
|
| Section | Component | Palette Key | Light Value |
|
|
|---------|-----------|-------------|-------------|
|
|
| Bills Tracker | BillsTracker | `palette.bill.light` | `oklch(0.96 0.03 250)` |
|
|
| Variable Expenses | VariableExpenses | `palette.variable_expense.light` | `oklch(0.97 0.04 85)` |
|
|
| Debt Tracker | DebtTracker | `palette.debt.light` | `oklch(0.96 0.04 15)` |
|
|
| Dashboard overview | DashboardPage initial skeleton | `palette.saving.light` | `oklch(0.95 0.04 280)` |
|
|
|
|
## Architecture Patterns
|
|
|
|
### Pattern 1: Spinner in Submit Buttons
|
|
**What:** Replace button label text with Spinner component while async op is in-flight. Button stays disabled to prevent double-submit. Min-width prevents layout shift when text disappears.
|
|
|
|
**Entry points:**
|
|
- `LoginPage.tsx` line 81 — has `loading` state already
|
|
- `RegisterPage.tsx` line 89 — has `loading` state already
|
|
- `BudgetSetup.tsx` line 92 — has `saving` state already
|
|
- `CategoriesPage.tsx` (save button in dialog) — add `saving` state
|
|
|
|
**Pattern:**
|
|
```tsx
|
|
// Source: existing project pattern + shadcn spinner.tsx
|
|
import { Spinner } from '@/components/ui/spinner'
|
|
|
|
<Button type="submit" disabled={loading} className="w-full min-w-[120px]">
|
|
{loading ? <Spinner /> : t('auth.login')}
|
|
</Button>
|
|
```
|
|
|
|
**Key constraint:** `min-w-*` class value depends on expected button text width. Use `min-w-[120px]` as a safe default, adjust per button.
|
|
|
|
### Pattern 2: Pencil Icon Hover Affordance
|
|
**What:** The display-mode span in InlineEditCell gets a group wrapper. Pencil icon appears to the right of the value, fades in on hover.
|
|
|
|
**Pattern:**
|
|
```tsx
|
|
// Source: Tailwind group-hover pattern, lucide-react Pencil icon
|
|
import { Pencil } from 'lucide-react'
|
|
|
|
{/* In display mode, not editing */}
|
|
<span
|
|
className="group flex cursor-pointer items-center justify-end gap-1 rounded px-2 py-1 hover:bg-muted"
|
|
onClick={handleClick}
|
|
>
|
|
{formatCurrency(value, currency)}
|
|
<Pencil className="size-3 opacity-0 transition-opacity group-hover:opacity-100 text-muted-foreground" />
|
|
</span>
|
|
```
|
|
|
|
**Note:** The outer `TableCell` already has `text-right`. The span needs `flex justify-end` to align the pencil icon after the value text while keeping right-alignment.
|
|
|
|
### Pattern 3: Row Flash After Save (Callback Pattern)
|
|
**What:** InlineEditCell cannot flash the row itself (it only owns a `<td>`). The flash must be signaled up to the parent component that owns the `<tr>`.
|
|
|
|
**Problem:** `BillsTracker`, `VariableExpenses`, and `DebtTracker` render the `<TableRow>`. `InlineEditCell` is one cell in that row.
|
|
|
|
**Solution:** Add a `onSaveSuccess?: () => void` callback to `InlineEditCellProps`. Parent calls `setFlashId(item.id)` in response, adds flash className to `TableRow`, clears after 600ms with `setTimeout`.
|
|
|
|
```tsx
|
|
// InlineEditCell.tsx — add to props and handleBlur
|
|
interface InlineEditCellProps {
|
|
value: number
|
|
currency: string
|
|
onSave: (value: number) => Promise<void>
|
|
onSaveSuccess?: () => void // NEW
|
|
onSaveError?: () => void // NEW
|
|
className?: string
|
|
}
|
|
|
|
// In handleBlur:
|
|
const handleBlur = async () => {
|
|
const num = parseFloat(inputValue)
|
|
if (!isNaN(num) && num !== value) {
|
|
try {
|
|
await onSave(num)
|
|
onSaveSuccess?.()
|
|
} catch {
|
|
setInputValue(String(value)) // revert
|
|
onSaveError?.()
|
|
}
|
|
}
|
|
setEditing(false)
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// BillsTracker.tsx — flash state on parent
|
|
const [flashRowId, setFlashRowId] = useState<string | null>(null)
|
|
const [errorRowId, setErrorRowId] = useState<string | null>(null)
|
|
|
|
const flashRow = (id: string, type: 'success' | 'error') => {
|
|
if (type === 'success') setFlashRowId(id)
|
|
else setErrorRowId(id)
|
|
setTimeout(() => {
|
|
if (type === 'success') setFlashRowId(null)
|
|
else setErrorRowId(null)
|
|
}, 600)
|
|
}
|
|
|
|
// On the TableRow:
|
|
<TableRow
|
|
key={item.id}
|
|
className={cn(
|
|
flashRowId === item.id && 'bg-success/20 transition-colors duration-600',
|
|
errorRowId === item.id && 'bg-destructive/20 transition-colors duration-600'
|
|
)}
|
|
>
|
|
```
|
|
|
|
**CSS note:** `bg-success/20` requires `--success` to be in the CSS token system. Confirmed: it is in `index.css :root`.
|
|
|
|
### Pattern 4: Delete Confirmation Dialog
|
|
**What:** Replace the direct `handleDelete(id)` call with a two-step flow: set pending ID → Dialog opens → user confirms → delete executes with spinner.
|
|
|
|
```tsx
|
|
// CategoriesPage.tsx additions
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
|
|
|
const confirmDelete = async () => {
|
|
if (!pendingDeleteId) return
|
|
setDeleting(true)
|
|
setDeleteError(null)
|
|
try {
|
|
await categoriesApi.delete(pendingDeleteId)
|
|
setPendingDeleteId(null)
|
|
fetchCategories()
|
|
} catch (err) {
|
|
// ON DELETE RESTRICT: category has budget items
|
|
setDeleteError(err instanceof Error ? err.message : 'Delete failed')
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
// Dialog usage (dialog.tsx is already imported):
|
|
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => !open && setPendingDeleteId(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {pendingCategoryName}?</DialogTitle>
|
|
<DialogDescription>This cannot be undone.</DialogDescription>
|
|
</DialogHeader>
|
|
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>Cancel</Button>
|
|
<Button variant="destructive" onClick={confirmDelete} disabled={deleting} className="min-w-[80px]">
|
|
{deleting ? <Spinner /> : 'Delete'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
```
|
|
|
|
**CRITICAL — ON DELETE RESTRICT:** Database constraint prevents deleting a category that has associated budget items. The backend returns `500` with `"failed to delete category"`. The frontend ApiError will have `status: 500`. Display the error inline in the dialog (not a toast). No copy change needed — the error message from the API surfaces naturally.
|
|
|
|
### Pattern 5: Empty States
|
|
**What:** Replace placeholder text/empty content with an icon + heading + subtext + CTA pattern.
|
|
|
|
```tsx
|
|
// Shared pattern (extract or inline — discretion area)
|
|
// icon: from lucide-react, chosen per section
|
|
// heading: bold, action-oriented
|
|
// subtext: one line of context
|
|
// CTA: Button with onClick pointing to create flow
|
|
|
|
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
|
<FolderOpen className="size-12 text-muted-foreground" />
|
|
<div className="flex flex-col gap-1">
|
|
<p className="font-semibold">No budgets yet</p>
|
|
<p className="text-sm text-muted-foreground">Create your first budget to get started.</p>
|
|
</div>
|
|
<Button onClick={() => setShowCreate(true)}>Create your first budget</Button>
|
|
</div>
|
|
```
|
|
|
|
**Dashboard trigger:** `list.length === 0 && !loading` (not `!current`, which is also true when switching budgets)
|
|
**Categories trigger:** `grouped.length === 0 && list.length === 0` — when the fetch has completed but returned no data
|
|
|
|
### Pattern 6: Tinted Skeletons
|
|
**What:** Skeleton component accepts `className` and a `style` prop. Override `bg-muted` with a palette light shade using inline style.
|
|
|
|
```tsx
|
|
// Source: skeleton.tsx — bg-muted is a Tailwind class, override via style prop
|
|
import { palette } from '@/lib/palette'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
// Bills section skeleton:
|
|
<div className="flex flex-col gap-2">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<Skeleton
|
|
key={i}
|
|
className="h-10 w-full rounded-md"
|
|
style={{ backgroundColor: palette.bill.light }}
|
|
/>
|
|
))}
|
|
</div>
|
|
```
|
|
|
|
**Note:** Using `style` prop overrides Tailwind's `bg-muted` in `skeleton.tsx` without editing the component file. This aligns with the project rule: never edit `src/components/ui/` source files.
|
|
|
|
### Anti-Patterns to Avoid
|
|
- **Editing ui/ source files:** Never modify `spinner.tsx`, `skeleton.tsx`, `dialog.tsx` — use `className` and `style` props only
|
|
- **Flash on the cell, not the row:** Don't apply success/error background to the `<td>` — apply to the `<TableRow>` for full visual impact
|
|
- **hardcoded hex colors for flash:** Use `bg-success/20` and `bg-destructive/20` Tailwind classes which reference the CSS tokens
|
|
- **Empty state before data loads:** Guard empty state behind `!loading` to avoid flash of empty content
|
|
- **Calling delete without await on error:** `handleDelete` must catch the ApiError from ON DELETE RESTRICT and show inline feedback
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Loading spinner | Custom SVG animation | `<Spinner />` from `ui/spinner.tsx` | Already installed, accessible (role=status, aria-label) |
|
|
| Skeleton loader | Div with custom CSS pulse | `<Skeleton>` from `ui/skeleton.tsx` | animate-pulse built in, accepts className/style |
|
|
| Modal confirmation | Custom overlay/modal | `<Dialog>` from `ui/dialog.tsx` | Radix focus trap, keyboard dismiss, a11y compliant |
|
|
| Pencil icon | Custom SVG or CSS | `<Pencil>` from lucide-react | Already a dependency, consistent stroke width |
|
|
| Empty state icon | Custom illustration | lucide-react icons | No extra dependency, consistent visual language |
|
|
| CSS transition timing | Custom keyframe animation | `tw-animate-css` classes or Tailwind `transition-*` | Already imported in index.css |
|
|
|
|
**Key insight:** All required primitives are installed. This phase is purely wiring them together — no new dependencies needed.
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Flash Timing — `bg-success/20` Requires Tailwind to Know the Color
|
|
**What goes wrong:** `bg-success/20` works only if `--success` is defined in CSS AND Tailwind knows about it. Tailwind 4 scans CSS variables automatically from `@theme inline` — but the token must appear as `--color-success` or be referenced in the theme config.
|
|
**Why it happens:** Tailwind 4's CSS-first config infers color utilities from `--color-*` variables. The project uses `--success` not `--color-success`.
|
|
**How to avoid:** Use inline style for the flash: `style={{ backgroundColor: 'oklch(0.55 0.15 145 / 0.2)' }}` or `style={{ backgroundColor: 'color-mix(in oklch, var(--success) 20%, transparent)' }}` rather than a Tailwind class. Alternatively, verify the theme config exposes `success` as a color utility.
|
|
**Warning signs:** `bg-success/20` renders as no background in the browser, or TypeScript/Tailwind LSP shows it as invalid.
|
|
|
|
**Resolution:** Check if `text-success` works in existing components — it does (used in `amountColorClass`). This means `--success` IS exposed as a Tailwind color. `bg-success/20` should therefore work. Confidence: MEDIUM — verify in browser.
|
|
|
|
### Pitfall 2: Dialog State Management — `pendingDeleteId` vs `pendingDeleteName`
|
|
**What goes wrong:** The Dialog body needs to show the category name ("Delete Rent?") but `pendingDeleteId` is just a UUID.
|
|
**Why it happens:** ID is needed for the API call; name is needed for dialog copy.
|
|
**How to avoid:** Store both: `const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null)`. Use `pendingDelete?.name` in dialog, `pendingDelete?.id` for API call.
|
|
|
|
### Pitfall 3: InlineEditCell `onSave` Currently Swallows Errors
|
|
**What goes wrong:** Current `handleBlur` calls `await onSave(num)` without try/catch. If the API call fails, the input just closes with no feedback — the user sees no error and the value may appear to have saved.
|
|
**Why it happens:** Phase 1 only extracted the component, didn't add error handling.
|
|
**How to avoid:** Phase 3 adds try/catch in `handleBlur`, reverts `inputValue` to `String(value)` on error, and calls `onSaveError?.()`.
|
|
|
|
### Pitfall 4: Empty State vs Loading State Race
|
|
**What goes wrong:** On initial load, the component briefly shows the empty state before data arrives.
|
|
**Why it happens:** `loading` may be `false` before the data prop is populated, or the loading state is in the hook not the component.
|
|
**How to avoid:** In DashboardPage, the guard is `loading && list.length === 0` for the skeleton (already correct). The empty state must be `list.length === 0 && !loading`. For CategoriesPage, add a `loading` state to the `fetchCategories` flow.
|
|
|
|
### Pitfall 5: TableRow Background Overrides `hover:bg-muted`
|
|
**What goes wrong:** The success/error flash background and the row's hover background may conflict, leaving a stale color.
|
|
**Why it happens:** Tailwind applies both classes; the transition clears the flash class but hover class remains.
|
|
**How to avoid:** The flash is controlled via a timed state reset — after 600ms, the flash className is removed. The hover class is always present but only visible without the flash. No conflict since flash duration is short.
|
|
|
|
### Pitfall 6: Category Delete Restricted by ON DELETE RESTRICT
|
|
**What goes wrong:** Attempting to delete a category with associated budget items returns a 500 error. Without error handling, the dialog closes and the user sees nothing.
|
|
**Why it happens:** `budget_items.category_id` has `ON DELETE RESTRICT` in the migration. The backend handler does not distinguish the constraint violation from other errors — it returns 500 + "failed to delete category".
|
|
**How to avoid:** Catch the ApiError in `confirmDelete`, display the message inline in the dialog (don't close it), let user dismiss manually. Consider adding a user-visible note to the dialog: "Cannot delete a category that has been used in a budget."
|
|
|
|
## Code Examples
|
|
|
|
### Spinner in Button (verified pattern)
|
|
```tsx
|
|
// Source: ui/spinner.tsx + LoginPage.tsx existing pattern
|
|
import { Spinner } from '@/components/ui/spinner'
|
|
|
|
<Button type="submit" disabled={loading} className="w-full min-w-[120px]">
|
|
{loading ? <Spinner /> : t('auth.login')}
|
|
</Button>
|
|
```
|
|
|
|
### Pencil Icon Hover in InlineEditCell display mode
|
|
```tsx
|
|
// Source: Tailwind group pattern + lucide-react Pencil
|
|
import { Pencil } from 'lucide-react'
|
|
|
|
// Replace the existing <span> in display mode:
|
|
<span
|
|
className="group flex cursor-pointer items-center justify-end gap-1 rounded px-2 py-1 hover:bg-muted"
|
|
onClick={handleClick}
|
|
>
|
|
{formatCurrency(value, currency)}
|
|
<Pencil className="size-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 text-muted-foreground" />
|
|
</span>
|
|
```
|
|
|
|
### Tinted Skeleton override
|
|
```tsx
|
|
// Source: ui/skeleton.tsx className + inline style override
|
|
// style prop overrides bg-muted without editing the ui component
|
|
<Skeleton
|
|
className="h-10 w-full"
|
|
style={{ backgroundColor: palette.bill.light }}
|
|
/>
|
|
```
|
|
|
|
### Delete Dialog structure
|
|
```tsx
|
|
// Source: ui/dialog.tsx exported components
|
|
<Dialog open={!!pendingDelete} onOpenChange={(open) => { if (!open) setPendingDelete(null) }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {pendingDelete?.name}?</DialogTitle>
|
|
<DialogDescription>This cannot be undone.</DialogDescription>
|
|
</DialogHeader>
|
|
{deleteError && (
|
|
<p className="text-sm text-destructive">{deleteError}</p>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setPendingDelete(null)} disabled={deleting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={confirmDelete}
|
|
disabled={deleting}
|
|
className="min-w-[80px]"
|
|
>
|
|
{deleting ? <Spinner /> : 'Delete'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
```
|
|
|
|
### Row flash pattern (parent component)
|
|
```tsx
|
|
// cn() + timed state reset pattern
|
|
const [flashRowId, setFlashRowId] = useState<string | null>(null)
|
|
const [errorRowId, setErrorRowId] = useState<string | null>(null)
|
|
|
|
const triggerFlash = (id: string, type: 'success' | 'error') => {
|
|
if (type === 'success') {
|
|
setFlashRowId(id)
|
|
setTimeout(() => setFlashRowId(null), 600)
|
|
} else {
|
|
setErrorRowId(id)
|
|
setTimeout(() => setErrorRowId(null), 600)
|
|
}
|
|
}
|
|
|
|
// On the row:
|
|
<TableRow
|
|
key={item.id}
|
|
style={
|
|
flashRowId === item.id
|
|
? { backgroundColor: 'color-mix(in oklch, var(--success) 20%, transparent)' }
|
|
: errorRowId === item.id
|
|
? { backgroundColor: 'color-mix(in oklch, var(--destructive) 20%, transparent)' }
|
|
: undefined
|
|
}
|
|
>
|
|
```
|
|
|
|
**Note on `color-mix` vs Tailwind class:** Using inline `color-mix()` with CSS variables is more reliable than `bg-success/20` for dynamic state since it avoids Tailwind class purging concerns and works at runtime.
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | Notes |
|
|
|--------------|------------------|-------|
|
|
| Toast notifications for all feedback | Row-level contextual flash | Already decided in CONTEXT.md — row flash is more contextual |
|
|
| Alert dialogs (window.confirm) | Radix Dialog | Accessible, styleable, non-blocking |
|
|
| Hardcoded grey skeleton | Section-tinted skeleton | Reinforces palette.ts color system |
|
|
|
|
## Open Questions
|
|
|
|
1. **`bg-success/20` Tailwind class availability**
|
|
- What we know: `text-success` works (used in amountColorClass). `--success` is in `:root`.
|
|
- What's unclear: Whether Tailwind 4 generates `bg-success` utilities from `--success` or only from `--color-success`.
|
|
- Recommendation: Use `color-mix(in oklch, var(--success) 20%, transparent)` as the inline style fallback — it works regardless of Tailwind utility availability. If `bg-success/20` is confirmed to work in testing, switch to the class for cleaner JSX.
|
|
|
|
2. **EmptyState — shared component vs inline**
|
|
- What we know: Three locations need empty states (Dashboard, Categories, potentially per-section). Structure is identical (icon + heading + subtext + CTA).
|
|
- What's unclear: Whether the CTA prop types are manageable in a shared component (different onClick signatures).
|
|
- Recommendation (discretion): Extract a shared `EmptyState` component with `icon`, `heading`, `subtext`, and `action: { label: string; onClick: () => void }` props. Avoids duplication of the flex/gap/text structure across pages.
|
|
|
|
3. **Categories page loading state**
|
|
- What we know: CategoriesPage currently has no `loading` state — `fetchCategories` fires in useEffect but there's no indicator.
|
|
- What's unclear: Whether a loading skeleton is in scope for CategoriesPage (STATE-03 mentions sections, not necessarily the categories table).
|
|
- Recommendation: STATE-03 specifies "section colors" referring to the dashboard tracker sections. CategoriesPage skeleton is not explicitly required but adds completeness. Add a simple `loading` state guard to prevent empty-state flash on initial load.
|
|
|
|
## Validation Architecture
|
|
|
|
### Test Framework
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Framework | Vitest 4.0.18 + @testing-library/react + jsdom |
|
|
| Config file | `frontend/vite.config.ts` (test section) |
|
|
| Quick run command | `cd frontend && bun vitest run src/components/InlineEditCell.test.tsx` |
|
|
| Full suite command | `cd frontend && bun vitest run` |
|
|
|
|
### Phase Requirements → Test Map
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
|--------|----------|-----------|-------------------|-------------|
|
|
| IXTN-01 | Submit button shows spinner and disables while loading | unit | `bun vitest run src/pages/LoginPage.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-01 | Register button shows spinner while submitting | unit | `bun vitest run src/pages/RegisterPage.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-01 | BudgetSetup create button shows spinner | unit | `bun vitest run src/components/BudgetSetup.test.tsx` | ❌ Wave 0 |
|
|
| IXTN-02 | Pencil icon not visible in normal state | unit | `bun vitest run src/components/InlineEditCell.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-02 | Pencil icon present in DOM (discoverable via hover) | unit | `bun vitest run src/components/InlineEditCell.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-03 | onSaveSuccess callback fires after successful save | unit | `bun vitest run src/components/InlineEditCell.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-03 | onSaveError fires and value reverts on save failure | unit | `bun vitest run src/components/InlineEditCell.test.tsx` | ✅ (extend existing) |
|
|
| IXTN-05 | Delete button opens confirmation dialog, not immediate delete | unit | `bun vitest run src/pages/CategoriesPage.test.tsx` | ❌ Wave 0 |
|
|
| IXTN-05 | Confirm delete calls API; cancel does not | unit | `bun vitest run src/pages/CategoriesPage.test.tsx` | ❌ Wave 0 |
|
|
| STATE-01 | Empty state renders when budget list is empty | unit | `bun vitest run src/pages/DashboardPage.test.tsx` | ❌ Wave 0 |
|
|
| STATE-02 | Empty state renders when category list is empty | unit | `bun vitest run src/pages/CategoriesPage.test.tsx` | ❌ Wave 0 |
|
|
| STATE-03 | Skeleton renders with section-tinted background style | unit | `bun vitest run src/components/BillsTracker.test.tsx` | ❌ Wave 0 |
|
|
|
|
### Sampling Rate
|
|
- **Per task commit:** `cd frontend && bun vitest run src/components/InlineEditCell.test.tsx`
|
|
- **Per wave merge:** `cd frontend && bun vitest run`
|
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
|
|
|
### Wave 0 Gaps
|
|
- [ ] `frontend/src/components/BudgetSetup.test.tsx` — covers IXTN-01 (budget form spinner)
|
|
- [ ] `frontend/src/pages/CategoriesPage.test.tsx` — covers IXTN-05 (delete confirmation), STATE-02 (empty state)
|
|
- [ ] `frontend/src/pages/DashboardPage.test.tsx` — covers STATE-01 (dashboard empty state)
|
|
- [ ] `frontend/src/components/BillsTracker.test.tsx` — covers STATE-03 (tinted skeleton)
|
|
|
|
**Existing tests to extend:**
|
|
- `LoginPage.test.tsx` — add IXTN-01 spinner assertion
|
|
- `RegisterPage.test.tsx` — add IXTN-01 spinner assertion
|
|
- `InlineEditCell.test.tsx` — add IXTN-02 pencil icon + IXTN-03 flash callbacks
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- Direct codebase inspection — `frontend/src/components/InlineEditCell.tsx`, `LoginPage.tsx`, `RegisterPage.tsx`, `BudgetSetup.tsx`, `CategoriesPage.tsx`, `DashboardPage.tsx`
|
|
- Direct codebase inspection — `frontend/src/components/ui/spinner.tsx`, `skeleton.tsx`, `dialog.tsx`, `button.tsx`
|
|
- Direct codebase inspection — `frontend/src/lib/palette.ts`, `frontend/src/index.css`
|
|
- Direct codebase inspection — `backend/migrations/001_initial.sql` (ON DELETE RESTRICT confirmed)
|
|
- Direct codebase inspection — `backend/internal/api/handlers.go`, `db/queries.go` (delete behavior confirmed)
|
|
- Direct codebase inspection — `frontend/src/i18n/en.json` (existing i18n keys)
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- Tailwind 4 CSS variable → utility generation: `bg-success/20` likely works if `text-success` works, but `color-mix()` inline style is a safer fallback
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH — all components inspected directly from source files
|
|
- Architecture: HIGH — patterns derived from existing codebase patterns and shadcn component APIs
|
|
- Pitfalls: HIGH — ON DELETE RESTRICT confirmed from migration SQL; flash/state patterns from direct code analysis
|
|
- Test infrastructure: HIGH — vitest config verified, existing test files inspected
|
|
|
|
**Research date:** 2026-03-11
|
|
**Valid until:** 2026-06-11 (stable dependencies, 90 days)
|