Files

26 KiB
Raw Permalink Blame History

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.sql and 007_setup_completed.sql
  • setup_completed column defaults to false; existing users backfilled to true
  • 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 001005) 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 001005]


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
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 constraint on 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 true spuriously while queries are still in-flight (both lengths are 0). Always gate on loading before acting on isFirstRun.
  • 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 001005 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_idx index 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

  1. 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) or t('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.
  2. 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 profiles or 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

  • vitest not installed — install: npm install -D vitest @vitest/ui
  • src/hooks/useFirstRunState.test.ts — covers AUTO-01 loading guard and isFirstRun logic
  • src/data/presets.test.ts — covers SETUP-02 count, type validity, item_tier validity
  • vitest.config.ts — framework config (or add "test" to vite.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 confirmed
  • supabase/migrations/002_categories.sql — categories table schema, no unique constraint on (user_id, name) confirmed
  • supabase/migrations/004_budgets.sql — budgets table schema, no unique constraint on (user_id, start_date) confirmed
  • src/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 confirmed
  • src/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)