26 KiB
Phase 6: Preset Data, First-Run Detection, and DB Safety - Research
Researched: 2026-04-20 Domain: PostgreSQL constraints, Supabase migrations, React Query hooks, i18n data files Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- Default amounts in EUR (matches existing profiles.currency default)
- 4 income, 4 bill, 5 variable_expense, 2 debt, 2 saving, 2 investment items (~19 total)
- i18n key format:
presets.{category_type}.{slug}(e.g.,presets.bill.rent) - Round number amounts (e.g., rent=1000, groceries=400) — easier to adjust, less presumptuous
- First-run triggers when user has zero categories OR zero template items
- New dedicated hook:
src/hooks/useFirstRunState.ts(follows useCategories/useTemplate pattern) - Backfill via Supabase migration SQL:
UPDATE profiles SET setup_completed = true WHERE id IN (SELECT DISTINCT user_id FROM categories) - Hook derives state from existing hooks (useCategories count + useTemplate items count) — no extra network call
- Cleanup step before adding unique constraints (deduplicate existing data safely)
- Separate migration files:
006_uniqueness_constraints.sqland007_setup_completed.sql setup_completedcolumn defaults tofalse; existing users backfilled totrue- Unique constraint on budgets:
(user_id, start_date)only — end_date always derived
Claude's Discretion
- Exact preset item names and amounts (within the balanced category distribution)
- German translation text for preset i18n keys
- Order of migration operations within each file
- Error handling approach for constraint violations in application code
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AUTO-01 | User's monthly budget is auto-created from template when visiting a month for the first time | useFirstRunState hook detects zero-category / zero-template-item state; budgets unique constraint prevents duplicate auto-creation |
| AUTO-03 | Auto-creation uses the user's configured currency, not a hardcoded default | profiles.currency confirmed as text not null default 'EUR' — hook/migration must not hardcode currency |
| SETUP-01 | New user is guided through a 3-step wizard: income → recurring items → review | setup_completed column + backfill migration provides the flag Phase 7 wizard reads to decide whether to show onboarding |
| SETUP-02 | User sees pre-filled common budget items with sensible default amounts (~15-20 items) | src/data/presets.ts delivers the curated item library with i18n keys and EUR amounts |
| </phase_requirements> |
Summary
Phase 6 is a pure data/infrastructure layer — no new UI. It consists of four discrete deliverables: two Supabase migration files (uniqueness constraints + setup_completed column), a new TypeScript data file (src/data/presets.ts), a new React Query hook (src/hooks/useFirstRunState.ts), and additions to both i18n JSON files.
The existing schema (verified against migrations 001–005) has no duplicate-prevention constraints on budgets or categories. The profiles table has no setup_completed column yet. The templates table already has unique(user_id). All hook patterns follow useQuery + useMutation from @tanstack/react-query with the Supabase JS client — useFirstRunState will be read-only (query-only) and simpler than existing hooks.
The main planning risk is the cleanup step before adding unique constraints: the deduplication SQL must run inside the same transaction as the ADD CONSTRAINT statement so the constraint cannot be violated by orphaned duplicates surviving the cleanup.
Primary recommendation: Write migration 006 as a single transaction — DELETE duplicates, then ADD CONSTRAINT — so it's atomic and safe to run on existing databases.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Duplicate-write prevention | Database | — | Constraint enforced at DB level; application code is a secondary guard only |
| First-run detection state | Frontend (React Query hook) | — | Derived from cached query data, no extra network call |
| Preset item library | Frontend (static data file) | — | Static TypeScript module; no DB persistence, consumed by wizard (Phase 7) |
| setup_completed persistence | Database (profiles table) | — | Boolean column; written by migration backfill and by wizard completion (Phase 7) |
| i18n for preset names | Frontend (JSON files) | — | Follows existing en.json / de.json pattern |
Standard Stack
Core (all already installed — verified against package.json)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
@tanstack/react-query |
(existing) | Server state, caching for useFirstRunState | Already used by all hooks |
@supabase/supabase-js |
(existing) | DB client in hooks and migrations | Already used by all hooks |
| PostgreSQL (via Supabase) | (existing) | Unique constraints, ALTER TABLE | Native DB feature; no library needed |
No new npm packages are required for this phase.
Migration Tooling
Supabase migrations are plain .sql files in supabase/migrations/. They are run in filename order by the Supabase CLI (supabase db push) or applied directly via the Supabase dashboard SQL editor. [VERIFIED: project migration files 001–005]
Architecture Patterns
System Architecture Diagram
Supabase DB
├── Migration 006: uniqueness_constraints
│ ├── DELETE duplicate budgets (keep oldest per user+start_date)
│ ├── DELETE duplicate categories (keep oldest per user+name)
│ ├── ADD CONSTRAINT budgets_user_month_unique ON budgets(user_id, start_date)
│ └── ADD CONSTRAINT categories_user_name_unique ON categories(user_id, name)
│
└── Migration 007: setup_completed
├── ALTER TABLE profiles ADD COLUMN setup_completed boolean NOT NULL DEFAULT false
└── UPDATE profiles SET setup_completed = true
WHERE id IN (SELECT DISTINCT user_id FROM categories)
src/data/presets.ts ──────────────────────────────────────► Phase 7 wizard
(static array of PresetItem objects, keyed by i18n slug)
src/hooks/useFirstRunState.ts
├── calls useCategories() (uses cached ["categories"] query — no new fetch)
├── calls useTemplate() (uses cached ["template-items"] query — no new fetch)
└── returns { isFirstRun: boolean, loading: boolean }
isFirstRun = categories.length === 0 || items.length === 0
src/i18n/en.json } add "presets" top-level key
src/i18n/de.json } add "presets" top-level key
Recommended Project Structure (additions only)
src/
├── data/
│ └── presets.ts # NEW: preset budget item library
├── hooks/
│ └── useFirstRunState.ts # NEW: first-run detection hook
└── i18n/
├── en.json # ADD: presets.* keys
└── de.json # ADD: presets.* keys
supabase/migrations/
├── 006_uniqueness_constraints.sql # NEW
└── 007_setup_completed.sql # NEW
Pattern 1: Safe Deduplication Before Unique Constraint
What: Delete all but the oldest row per unique key before adding the constraint, wrapped in a transaction. When to use: Any migration adding a unique constraint on a table that may have existing duplicates.
-- Source: [VERIFIED: standard PostgreSQL CTE deduplication pattern]
BEGIN;
-- Keep only the oldest budget per (user_id, start_date)
DELETE FROM budgets
WHERE id NOT IN (
SELECT DISTINCT ON (user_id, start_date) id
FROM budgets
ORDER BY user_id, start_date, created_at ASC
);
ALTER TABLE budgets
ADD CONSTRAINT budgets_user_month_unique UNIQUE (user_id, start_date);
-- Keep only the oldest category per (user_id, name)
DELETE FROM categories
WHERE id NOT IN (
SELECT DISTINCT ON (user_id, name) id
FROM categories
ORDER BY user_id, name, created_at ASC
);
ALTER TABLE categories
ADD CONSTRAINT categories_user_name_unique UNIQUE (user_id, name);
COMMIT;
Pattern 2: ADD COLUMN with Default + Immediate Backfill
What: Add a boolean column with a default, then immediately UPDATE to backfill existing rows. When to use: Column needs a non-null default for new rows, but existing rows need a different value.
-- Source: [VERIFIED: standard PostgreSQL ALTER TABLE pattern]
ALTER TABLE profiles
ADD COLUMN setup_completed boolean NOT NULL DEFAULT false;
-- Backfill: existing users who have categories are considered set up
UPDATE profiles
SET setup_completed = true
WHERE id IN (SELECT DISTINCT user_id FROM categories);
Important: DEFAULT false in the ALTER TABLE statement means the column is added atomically with false for all existing rows before the UPDATE runs. The UPDATE then flips the qualifying rows. Order matters here. [VERIFIED: PostgreSQL behavior]
Pattern 3: Derived State Hook (no extra network call)
What: Build a hook that computes a boolean from two already-cached queries. When to use: When state can be inferred from data already loaded by sibling hooks.
// Source: [VERIFIED: matches useCategories.ts + useTemplate.ts patterns in codebase]
import { useCategories } from "@/hooks/useCategories"
import { useTemplate } from "@/hooks/useTemplate"
export function useFirstRunState() {
const { categories, loading: catLoading } = useCategories()
const { items, loading: tmplLoading } = useTemplate()
return {
isFirstRun: categories.length === 0 || items.length === 0,
loading: catLoading || tmplLoading,
}
}
Key insight: useCategories caches under ["categories"] and useTemplate caches under ["template"] + ["template-items"]. If those queries have already been called (e.g., by mounted pages), useFirstRunState returns instantly from cache — no extra Supabase round-trip. [VERIFIED: React Query staleTime default behavior]
Pattern 4: Preset Data File Shape
What: Static TypeScript module exporting typed preset items. When to use: Read-only reference data consumed by the wizard UI (Phase 7).
// Source: [ASSUMED — no existing presets.ts to verify against; shape inferred from types.ts]
export interface PresetItem {
slug: string // used to build i18n key: presets.{type}.{slug}
type: CategoryType // from src/lib/types.ts
defaultAmount: number // EUR, round number
item_tier: "fixed" | "variable"
}
export const PRESETS: PresetItem[] = [
// income (4)
{ slug: "salary", type: "income", defaultAmount: 3000, item_tier: "fixed" },
{ slug: "freelance", type: "income", defaultAmount: 500, item_tier: "variable" },
{ slug: "rental_income", type: "income", defaultAmount: 800, item_tier: "fixed" },
{ slug: "other_income", type: "income", defaultAmount: 200, item_tier: "variable" },
// bill (4)
{ slug: "rent", type: "bill", defaultAmount: 1000, item_tier: "fixed" },
{ slug: "electricity", type: "bill", defaultAmount: 80, item_tier: "fixed" },
{ slug: "internet", type: "bill", defaultAmount: 40, item_tier: "fixed" },
{ slug: "phone", type: "bill", defaultAmount: 30, item_tier: "fixed" },
// variable_expense (5)
{ slug: "groceries", type: "variable_expense", defaultAmount: 400, item_tier: "variable" },
{ slug: "transport", type: "variable_expense", defaultAmount: 100, item_tier: "variable" },
{ slug: "dining_out", type: "variable_expense", defaultAmount: 150, item_tier: "variable" },
{ slug: "health", type: "variable_expense", defaultAmount: 50, item_tier: "variable" },
{ slug: "clothing", type: "variable_expense", defaultAmount: 100, item_tier: "variable" },
// debt (2)
{ slug: "loan_repayment", type: "debt", defaultAmount: 200, item_tier: "fixed" },
{ slug: "credit_card", type: "debt", defaultAmount: 100, item_tier: "fixed" },
// saving (2)
{ slug: "emergency_fund", type: "saving", defaultAmount: 200, item_tier: "fixed" },
{ slug: "vacation", type: "saving", defaultAmount: 100, item_tier: "fixed" },
// investment (2)
{ slug: "etf", type: "investment", defaultAmount: 200, item_tier: "fixed" },
{ slug: "pension", type: "investment", defaultAmount: 100, item_tier: "fixed" },
]
Pattern 5: i18n Key Structure for Presets
The existing i18n files use a flat nested structure. Add a "presets" top-level key, nested by category_type, then slug. [VERIFIED: matches en.json structure]
// en.json addition
{
"presets": {
"income": {
"salary": "Salary",
"freelance": "Freelance Income",
"rental_income": "Rental Income",
"other_income": "Other Income"
},
"bill": {
"rent": "Rent",
"electricity": "Electricity",
"internet": "Internet",
"phone": "Phone"
},
"variable_expense": {
"groceries": "Groceries",
"transport": "Transport",
"dining_out": "Dining Out",
"health": "Health & Pharmacy",
"clothing": "Clothing"
},
"debt": {
"loan_repayment": "Loan Repayment",
"credit_card": "Credit Card"
},
"saving": {
"emergency_fund": "Emergency Fund",
"vacation": "Vacation Fund"
},
"investment": {
"etf": "ETF / Index Fund",
"pension": "Pension"
}
}
}
Anti-Patterns to Avoid
- Adding UNIQUE constraint without deduplication first: Will fail with
duplicate key value violates unique constrainton any DB with existing duplicate rows. - Separate transaction for cleanup and constraint: If cleanup succeeds but constraint ADD fails (e.g., missed a duplicate), the DB is left partially modified. Always wrap both in one
BEGIN/COMMIT. - isFirstRun computed at render without loading guard: Returns
truespuriously while queries are still in-flight (both lengths are 0). Always gate onloadingbefore acting onisFirstRun. - Hardcoding 'EUR' in presets.ts: The preset amounts are EUR, but the label/currency displayed in the wizard must come from
profiles.currency, not from presets.ts. Keep amounts as plain numbers.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Duplicate row prevention | Application-level upsert logic with SELECT-then-INSERT | PostgreSQL UNIQUE constraint | Race condition between SELECT and INSERT; DB constraint is atomic |
| Query caching for derived state | Manual state tracking / local useState | React Query (already installed) | useCategories + useTemplate already cache — just consume the cached data |
| i18n key resolution | Custom translation lookup | Existing i18n framework already in use | Check how t() is called in existing components and replicate |
Common Pitfalls
Pitfall 1: DISTINCT ON Requires ORDER BY on the Same Columns
What goes wrong: SELECT DISTINCT ON (user_id, start_date) without ORDER BY user_id, start_date throws a PostgreSQL error.
Why it happens: PostgreSQL requires the DISTINCT ON expressions to be the leftmost ORDER BY expressions.
How to avoid: Always write ORDER BY user_id, start_date, created_at ASC (or whichever tiebreaker). [VERIFIED: PostgreSQL docs behavior]
Warning signs: ERROR: SELECT DISTINCT ON expressions must match initial ORDER BY expressions
Pitfall 2: isFirstRun is True During Loading
What goes wrong: categories.length === 0 || items.length === 0 is true while data is still loading (both arrays are empty defaults). If Phase 7 wizard triggers on isFirstRun, it fires on every page load while data fetches.
Why it happens: useCategories returns [] as default; hook consumers see isFirstRun = true before the query resolves.
How to avoid: Export loading from useFirstRunState. Callers must check !loading && isFirstRun before acting. [VERIFIED: useCategories.ts returns query.data ?? []]
Warning signs: Wizard flashes on every load for users who have data.
Pitfall 3: Backfill Misses Users Without Categories
What goes wrong: The backfill SQL sets setup_completed = true only for users who have categories. A v1.0 user who created a template but no categories would remain false and see the wizard on first v2.0 login.
Why it happens: The backfill uses categories as the signal; it's possible (though unlikely in v1.0) to have a template with no categories.
How to avoid: The agreed logic (zero categories OR zero template items = first run) means a user with template items but no categories is legitimately not set up. Verify that v1.0 users always created categories before template items. If uncertain, widen the backfill:
UPDATE profiles SET setup_completed = true
WHERE id IN (
SELECT DISTINCT user_id FROM categories
UNION
SELECT t.user_id FROM templates t
INNER JOIN template_items ti ON ti.template_id = t.id
);
Warning signs: v1.0 users reporting wizard appearing unexpectedly.
Pitfall 4: Profile Type Missing setup_completed
What goes wrong: After migration 007 adds setup_completed, the TypeScript Profile interface in src/lib/types.ts still lacks the field. Any code reading profile.setup_completed gets a TypeScript error or undefined.
Why it happens: Migrations and TypeScript types are manually kept in sync — there is no auto-generation in this project.
How to avoid: Include updating src/lib/types.ts as an explicit task in the plan. Add setup_completed: boolean to the Profile interface. [VERIFIED: types.ts inspected — field absent]
Warning signs: TypeScript compiler error on profile.setup_completed.
Pitfall 5: Migration File Ordering
What goes wrong: A migration named 006_... that runs after one named 007_... (or vice versa) due to filename sort.
Why it happens: Supabase CLI applies migrations in lexicographic filename order.
How to avoid: Uniqueness constraints must come in 006_... and setup_completed in 007_... because the backfill in 007 reads from categories — which needs to exist and be constraint-safe first. The order is correct as specified. [VERIFIED: existing migrations 001–005 confirm naming convention]
Code Examples
Confirmed: Category Type Enum Values
From 002_categories.sql and src/lib/types.ts [VERIFIED]:
'income' | 'bill' | 'variable_expense' | 'debt' | 'saving' | 'investment'
Preset items must use these exact string values as type.
Confirmed: profiles Table Columns (before migration 007)
From 001_profiles.sql [VERIFIED]:
id, display_name, locale, currency, created_at, updated_at
No setup_completed column exists yet. Migration 007 adds it.
Confirmed: budgets Table (before migration 006)
From 004_budgets.sql [VERIFIED]:
- No unique constraint on
(user_id, start_date). start_date date not null,end_date date not null— both present.
Confirmed: categories Table (before migration 006)
From 002_categories.sql [VERIFIED]:
- No unique constraint on
(user_id, name). - Only a
categories_user_id_idxindex exists.
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Application-level duplicate checks | DB-level UNIQUE constraint | This phase | Eliminates race conditions; constraint error surfaces as Supabase PostgREST error code 23505 |
| No first-run concept | setup_completed + useFirstRunState |
This phase | Enables Phase 7 wizard to show only to new users |
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | item_tier: "one_off" is excluded from presets (only "fixed" and "variable") |
Pattern 4 | If wizard allows one_off presets, the type needs updating — but template_items.item_tier check constraint already limits to ('fixed', 'variable') so this is safe |
| A2 | No existing duplicate budgets or categories in production data | Migration 006 | If duplicates exist the DELETE+CONSTRAINT pattern handles them; risk is low, the cleanup step is exactly for this |
| A3 | The i18n library in use supports nested key access via dot notation (e.g., t('presets.bill.rent')) |
i18n pattern | If it uses a flat key format, key structure needs adjustment — check existing t() calls in components before writing keys |
Open Questions
-
How does the app call
t()?- What we know: en.json and de.json use nested objects (e.g.,
categories.types.income). - What's unclear: Whether translation is
t('categories.types.income')(dot-path) ort('categories')['types']['income']. Most react-i18next setups use dot-path. - Recommendation: Before writing i18n keys, grep one existing
t()call in the codebase to confirm syntax. Plan task should include this verification.
- What we know: en.json and de.json use nested objects (e.g.,
-
Are there any existing v1.0 users in production?
- What we know: The blocker in STATE.md says "existing v1.0 users must not see the wizard on first v2.0 login."
- What's unclear: Whether production has real user rows in
profilesor if this is a personal/dev-only app. - Recommendation: The migration is written defensively regardless — backfill runs on all qualifying rows, so there's no harm if the table is empty.
Environment Availability
Step 2.6: SKIPPED (no new external dependencies — all tools are existing: Supabase CLI, PostgreSQL, Node/TypeScript toolchain already in use by the project).
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | None detected — no vitest.config., jest.config., or test scripts in package.json |
| Config file | None — Wave 0 must create if tests are written |
| Quick run command | N/A |
| Full suite command | N/A |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| AUTO-01 | useFirstRunState returns isFirstRun=true when categories=[] |
unit | vitest run src/hooks/useFirstRunState.test.ts |
❌ Wave 0 |
| AUTO-01 | useFirstRunState returns isFirstRun=false when categories and items present |
unit | vitest run src/hooks/useFirstRunState.test.ts |
❌ Wave 0 |
| AUTO-01 | useFirstRunState returns loading=true while queries in flight |
unit | vitest run src/hooks/useFirstRunState.test.ts |
❌ Wave 0 |
| SETUP-02 | PRESETS exports exactly 19 items |
unit | vitest run src/data/presets.test.ts |
❌ Wave 0 |
| SETUP-02 | Each preset has valid type from CategoryType enum |
unit | vitest run src/data/presets.test.ts |
❌ Wave 0 |
| SETUP-02 | Each preset has item_tier of fixed or variable (not one_off) |
unit | vitest run src/data/presets.test.ts |
❌ Wave 0 |
| AUTO-03 | Profile.setup_completed TypeScript type is boolean |
compile-time | tsc --noEmit |
❌ Wave 0 (after types.ts update) |
| DB constraints | Unique constraint SQL — not unit-testable without Supabase local instance | manual | Supabase local dev + insert duplicate | N/A |
Sampling Rate
- Per task commit:
tsc --noEmit(type safety at minimum) - Per wave merge:
vitest run(once vitest is set up) - Phase gate: All passing before
/gsd-verify-work
Wave 0 Gaps
vitestnot installed — install:npm install -D vitest @vitest/uisrc/hooks/useFirstRunState.test.ts— covers AUTO-01 loading guard and isFirstRun logicsrc/data/presets.test.ts— covers SETUP-02 count, type validity, item_tier validityvitest.config.ts— framework config (or add"test"tovite.config.ts)
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | yes | Supabase RLS already enabled on all tables — new constraints do not bypass RLS |
| V5 Input Validation | no | No new user-facing input in this phase |
| V6 Cryptography | no | — |
Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Duplicate budget creation via parallel requests | Tampering | UNIQUE constraint on (user_id, start_date) — DB rejects second insert atomically |
| First-run flag manipulation (client sets setup_completed) | Elevation of privilege | RLS policy "Users can update own profile" — user can update their own row; acceptable since setup_completed is not a security gate, only a UX flag |
Sources
Primary (HIGH confidence)
supabase/migrations/001_profiles.sql— profiles table schema, trigger, no setup_completed column confirmedsupabase/migrations/002_categories.sql— categories table schema, no unique constraint on (user_id, name) confirmedsupabase/migrations/004_budgets.sql— budgets table schema, no unique constraint on (user_id, start_date) confirmedsrc/hooks/useCategories.ts— hook pattern, query key ["categories"], default returns []src/hooks/useTemplate.ts— hook pattern, query keys, items default []src/lib/types.ts— CategoryType values, Profile interface (no setup_completed), all confirmedsrc/i18n/en.json,de.json— i18n structure and nesting pattern confirmed.planning/config.json— nyquist_validation: true confirmed
Secondary (MEDIUM confidence)
- PostgreSQL DISTINCT ON + ORDER BY requirement — standard behavior, well-documented
Tertiary (LOW confidence)
- A3: i18n dot-path notation — inferred from JSON structure; needs grep verification
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries verified as already installed
- Architecture: HIGH — all schema details verified against actual migration files and hook source
- Pitfalls: HIGH — derived from direct inspection of existing code and standard PostgreSQL semantics
- Preset content: MEDIUM — exact amounts and slugs are at Claude's discretion; structure is HIGH confidence
Research date: 2026-04-20 Valid until: 2026-07-20 (stable domain — PostgreSQL constraints and React Query patterns are not fast-moving)