docs(06): create phase 6 plan — preset data, first-run detection, and DB safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:45:50 +02:00
parent 662390fc78
commit 9963926971
4 changed files with 814 additions and 4 deletions

View File

@@ -0,0 +1,256 @@
---
phase: 06-preset-data-first-run-detection-and-db-safety
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- supabase/migrations/006_uniqueness_constraints.sql
- supabase/migrations/007_setup_completed.sql
- src/lib/types.ts
autonomous: true
requirements:
- AUTO-01
- AUTO-03
- SETUP-01
must_haves:
truths:
- "Migration 006 adds a unique constraint on budgets(user_id, start_date) with safe deduplication"
- "Migration 006 adds a unique constraint on categories(user_id, name) with safe deduplication"
- "Migration 007 adds setup_completed boolean NOT NULL DEFAULT false to profiles"
- "Migration 007 backfills setup_completed = true for all users with existing categories"
- "Profile TypeScript interface includes setup_completed: boolean"
artifacts:
- path: "supabase/migrations/006_uniqueness_constraints.sql"
provides: "Atomic deduplication + unique constraint DDL for budgets and categories"
- path: "supabase/migrations/007_setup_completed.sql"
provides: "ALTER TABLE profiles ADD COLUMN setup_completed + backfill UPDATE"
- path: "src/lib/types.ts"
provides: "Updated Profile interface with setup_completed field"
contains: "setup_completed: boolean"
key_links:
- from: "supabase/migrations/007_setup_completed.sql"
to: "profiles table"
via: "ALTER TABLE profiles ADD COLUMN"
pattern: "setup_completed boolean NOT NULL DEFAULT false"
- from: "src/lib/types.ts"
to: "Profile interface"
via: "TypeScript field"
pattern: "setup_completed: boolean"
---
<objective>
Write two Supabase migration files and update the Profile TypeScript type.
Migration 006 makes duplicate budget/category creation impossible at the DB level by first deduplicating any existing rows then adding UNIQUE constraints. Migration 007 adds the `setup_completed` boolean column to `profiles` and backfills existing users who have categories to `true`. The TypeScript `Profile` interface in `src/lib/types.ts` is updated to include `setup_completed: boolean`.
Purpose: The DB layer must enforce uniqueness atomically (prevents race conditions) and must know which users have already completed setup (prevents existing v1.0 users from seeing the wizard on first v2.0 login).
Output: 2 `.sql` migration files + 1 updated TypeScript type file.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-CONTEXT.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md
<interfaces>
<!-- Existing migration chain: 001-005. New files must be named 006_... and 007_... for correct lexicographic ordering. -->
From supabase/migrations/001_profiles.sql — profiles table columns:
id, display_name, locale, currency, created_at, updated_at
(NO setup_completed yet)
From supabase/migrations/002_categories.sql — categories table:
id, user_id, name, type, icon, sort_order, created_at, updated_at
Existing index: categories_user_id_idx
(NO unique constraint on (user_id, name) yet)
From supabase/migrations/004_budgets.sql — budgets table:
id, user_id, start_date, end_date, currency, carryover_amount, created_at, updated_at
Existing index: budgets_user_id_idx
(NO unique constraint on (user_id, start_date) yet)
From src/lib/types.ts — Profile interface (before this plan):
export interface Profile {
id: string
display_name: string | null
locale: string
currency: string
created_at: string
updated_at: string
}
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Write migration 006 — uniqueness constraints with safe deduplication</name>
<files>supabase/migrations/006_uniqueness_constraints.sql</files>
<read_first>
- supabase/migrations/002_categories.sql
- supabase/migrations/004_budgets.sql
- .planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md (Pattern 1: Safe Deduplication)
</read_first>
<action>
Create `supabase/migrations/006_uniqueness_constraints.sql` with this exact content — a single transaction that deduplicates then constrains:
```sql
-- Migration 006: Add uniqueness constraints to budgets and categories
-- Safe deduplication runs first inside the transaction before each constraint.
BEGIN;
-- Deduplicate budgets: keep the oldest row 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);
-- Deduplicate categories: keep the oldest row 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;
```
Key: DISTINCT ON requires ORDER BY on the same leading columns — already satisfied above. Wrapping both operations in a single transaction means if constraint ADD fails, cleanup rolls back too.
</action>
<verify>
<automated>grep -c "ADD CONSTRAINT" supabase/migrations/006_uniqueness_constraints.sql && grep -c "BEGIN" supabase/migrations/006_uniqueness_constraints.sql && grep -c "COMMIT" supabase/migrations/006_uniqueness_constraints.sql</automated>
</verify>
<done>File exists. Contains exactly 2 ADD CONSTRAINT statements, 1 BEGIN, 1 COMMIT. Both constraints named: budgets_user_month_unique and categories_user_name_unique.</done>
</task>
<task type="auto">
<name>Task 2: Write migration 007 — setup_completed column + backfill</name>
<files>supabase/migrations/007_setup_completed.sql</files>
<read_first>
- supabase/migrations/001_profiles.sql
- .planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md (Pattern 2: ADD COLUMN with Default + Backfill, Pitfall 3)
</read_first>
<action>
Create `supabase/migrations/007_setup_completed.sql` with this exact content:
```sql
-- Migration 007: Add setup_completed to profiles
-- New signups default to false (not set up).
-- Existing users who have any categories are backfilled to true (already set up).
-- Wider backfill also includes users with template items to protect against
-- edge case where user created template items but skipped category creation.
ALTER TABLE profiles
ADD COLUMN setup_completed boolean NOT NULL DEFAULT false;
-- Backfill: users with categories OR template items are considered set up
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
);
```
Note: The wider backfill UNION (categories OR template items) matches Pitfall 3 guidance from RESEARCH.md — protects v1.0 users who may have template items but no categories.
</action>
<verify>
<automated>grep -c "ADD COLUMN setup_completed" supabase/migrations/007_setup_completed.sql && grep -c "UPDATE profiles" supabase/migrations/007_setup_completed.sql</automated>
</verify>
<done>File exists. Contains ADD COLUMN statement with `boolean NOT NULL DEFAULT false` and UPDATE statement with UNION backfill covering both categories and template_items.</done>
</task>
<task type="auto">
<name>Task 3: Update Profile TypeScript interface to include setup_completed</name>
<files>src/lib/types.ts</files>
<read_first>
- src/lib/types.ts
</read_first>
<action>
In `src/lib/types.ts`, add `setup_completed: boolean` to the `Profile` interface. The field goes after `currency` and before `created_at`:
```typescript
export interface Profile {
id: string
display_name: string | null
locale: string
currency: string
setup_completed: boolean // <-- ADD THIS LINE
created_at: string
updated_at: string
}
```
No other changes to types.ts.
</action>
<verify>
<automated>grep -n "setup_completed: boolean" src/lib/types.ts</automated>
</verify>
<done>`src/lib/types.ts` contains `setup_completed: boolean` inside the Profile interface. `tsc --noEmit` passes with no new errors.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Migration SQL → Supabase DB | DDL runs with superuser privileges via Supabase CLI — only run locally or in controlled CI |
| Client → profiles RLS | Client can UPDATE own profile row including setup_completed — acceptable since it's a UX flag, not a security gate |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | Tampering | budgets table — duplicate row creation via concurrent requests | mitigate | UNIQUE constraint on (user_id, start_date) enforced at DB level; second INSERT returns PostgREST error 23505 |
| T-06-02 | Tampering | categories table — duplicate name creation | mitigate | UNIQUE constraint on (user_id, name) enforced at DB level; second INSERT returns PostgREST error 23505 |
| T-06-03 | Elevation of privilege | profiles.setup_completed — client sets own flag to false to force wizard re-display | accept | setup_completed is a UX routing flag only; no data is gated behind it; RLS allows own-row update |
</threat_model>
<verification>
After all 3 tasks complete:
```bash
# Confirm both migration files exist
ls supabase/migrations/006_uniqueness_constraints.sql supabase/migrations/007_setup_completed.sql
# Confirm TypeScript type updated
grep "setup_completed: boolean" src/lib/types.ts
# Confirm TypeScript compiles cleanly
npx tsc --noEmit
```
</verification>
<success_criteria>
- `supabase/migrations/006_uniqueness_constraints.sql` exists, contains BEGIN/COMMIT, two DISTINCT ON dedup DELETEs, two ADD CONSTRAINT statements
- `supabase/migrations/007_setup_completed.sql` exists, adds column with `boolean NOT NULL DEFAULT false`, backfills with UNION covering categories and template_items
- `src/lib/types.ts` Profile interface has `setup_completed: boolean`
- `tsc --noEmit` passes with no errors
</success_criteria>
<output>
After completion, create `.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,283 @@
---
phase: 06-preset-data-first-run-detection-and-db-safety
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/data/presets.ts
- src/i18n/en.json
- src/i18n/de.json
autonomous: true
requirements:
- SETUP-02
must_haves:
truths:
- "src/data/presets.ts exports a PRESETS array of exactly 19 typed items"
- "PRESETS covers all 6 category types with the agreed distribution (4+4+5+2+2+2)"
- "Every preset item has a slug, type (valid CategoryType), defaultAmount (round number EUR), and item_tier (fixed or variable)"
- "en.json has a top-level presets key with all 19 slugs translated to English"
- "de.json has a top-level presets key with all 19 slugs translated to German"
artifacts:
- path: "src/data/presets.ts"
provides: "PresetItem interface and PRESETS array"
exports: ["PresetItem", "PRESETS"]
- path: "src/i18n/en.json"
provides: "English preset translations under presets.* key"
contains: "presets"
- path: "src/i18n/de.json"
provides: "German preset translations under presets.* key"
contains: "presets"
key_links:
- from: "src/data/presets.ts"
to: "src/lib/types.ts"
via: "import CategoryType"
pattern: "import.*CategoryType.*from.*types"
- from: "src/i18n/en.json"
to: "presets.{type}.{slug}"
via: "react-i18next t() dot-path"
pattern: "\"presets\":"
---
<objective>
Create the static preset budget item library and its i18n translations.
`src/data/presets.ts` exports `PresetItem` interface and `PRESETS` array with 19 items across 6 category types (4 income, 4 bill, 5 variable_expense, 2 debt, 2 saving, 2 investment). Both `src/i18n/en.json` and `src/i18n/de.json` get a new top-level `"presets"` key containing all 19 English/German display names.
Purpose: This is the curated item library the Phase 7 wizard shows to new users for one-click budget template setup. All amounts are plain EUR numbers — the wizard reads currency from `profiles.currency`, not from this file.
Output: `src/data/presets.ts`, updated `en.json`, updated `de.json`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-CONTEXT.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md
<interfaces>
From src/lib/types.ts:
```typescript
export type CategoryType =
| "income"
| "bill"
| "variable_expense"
| "debt"
| "saving"
| "investment"
export type ItemTier = "fixed" | "variable" | "one_off"
```
From src/i18n/en.json — existing top-level structure (add "presets" alongside these):
"app", "nav", "auth", "categories", "template", "budget", "settings", "common"
i18n library: react-i18next — uses t('dot.path.key') syntax confirmed.
NOTE: Do NOT hardcode currency symbols in preset display names or amounts.
Amounts are plain numbers (EUR value). The wizard (Phase 7) will format them.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create src/data/presets.ts — 19-item preset library</name>
<files>src/data/presets.ts</files>
<read_first>
- src/lib/types.ts
- .planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md (Pattern 4: Preset Data File Shape)
</read_first>
<action>
Create `src/data/presets.ts`. The file must NOT import from Supabase or React — it is a pure static data module.
```typescript
import type { CategoryType } from "@/lib/types"
export interface PresetItem {
slug: string
type: CategoryType
defaultAmount: number // EUR, round number — do NOT suffix with currency symbol
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" },
]
```
The `item_tier` for all items must be either `"fixed"` or `"variable"` — never `"one_off"` (which is excluded from template_items by the DB check constraint).
</action>
<verify>
<automated>grep -c "slug:" src/data/presets.ts</automated>
</verify>
<done>File exists. Contains exactly 19 objects (grep -c "slug:" returns 19). All `type` values are valid CategoryType strings. No `item_tier: "one_off"` present. `tsc --noEmit` passes.</done>
</task>
<task type="auto">
<name>Task 2: Add preset translations to en.json and de.json</name>
<files>src/i18n/en.json, src/i18n/de.json</files>
<read_first>
- src/i18n/en.json
- src/i18n/de.json
</read_first>
<action>
Add a top-level `"presets"` key to both i18n files. The key structure is `presets.{category_type}.{slug}` — matching the `type` and `slug` fields in PRESETS.
For `src/i18n/en.json`, add after the last existing top-level key:
```json
"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"
}
}
```
For `src/i18n/de.json`, add the same structure with German translations:
```json
"presets": {
"income": {
"salary": "Gehalt",
"freelance": "Freelance-Einkommen",
"rental_income": "Mieteinnahmen",
"other_income": "Sonstiges Einkommen"
},
"bill": {
"rent": "Miete",
"electricity": "Strom",
"internet": "Internet",
"phone": "Telefon"
},
"variable_expense": {
"groceries": "Lebensmittel",
"transport": "Transport",
"dining_out": "Auswärts essen",
"health": "Gesundheit & Apotheke",
"clothing": "Kleidung"
},
"debt": {
"loan_repayment": "Kreditrückzahlung",
"credit_card": "Kreditkarte"
},
"saving": {
"emergency_fund": "Notfallfonds",
"vacation": "Urlaubskasse"
},
"investment": {
"etf": "ETF / Indexfonds",
"pension": "Altersvorsorge"
}
}
```
Both files must remain valid JSON after the edit. Add the `"presets"` key as the last entry in each JSON object (before the closing `}`), preceded by a comma on the previous last key.
</action>
<verify>
<automated>node -e "JSON.parse(require('fs').readFileSync('src/i18n/en.json','utf8')); JSON.parse(require('fs').readFileSync('src/i18n/de.json','utf8')); console.log('JSON valid')" && grep -c '"slug_key"' src/i18n/en.json || grep -c '"salary"' src/i18n/en.json</automated>
</verify>
<done>Both JSON files are valid (node JSON.parse succeeds). `en.json` and `de.json` each contain a top-level `"presets"` key. `grep '"salary"' src/i18n/en.json` returns 1 match. `grep '"Gehalt"' src/i18n/de.json` returns 1 match.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| presets.ts → wizard UI | Static read-only data — no user input, no network calls |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-04 | Information Disclosure | presets.ts amounts | accept | Amounts are generic EUR defaults, not user-specific data; public knowledge |
| T-06-05 | Tampering | i18n JSON malformed after edit | mitigate | Verify both JSON files parse cleanly after edit (`node -e "JSON.parse(...)"`) before committing |
</threat_model>
<verification>
```bash
# Confirm presets file exists with 19 items
grep -c "slug:" src/data/presets.ts
# Confirm no one_off tier
grep "one_off" src/data/presets.ts || echo "no one_off found (correct)"
# Confirm i18n JSON files are valid
node -e "JSON.parse(require('fs').readFileSync('src/i18n/en.json','utf8')); console.log('en.json valid')"
node -e "JSON.parse(require('fs').readFileSync('src/i18n/de.json','utf8')); console.log('de.json valid')"
# Confirm presets key present in both
grep '"presets"' src/i18n/en.json src/i18n/de.json
# TypeScript clean
npx tsc --noEmit
```
</verification>
<success_criteria>
- `src/data/presets.ts` exports `PresetItem` interface and `PRESETS` array with exactly 19 items
- Distribution: 4 income, 4 bill, 5 variable_expense, 2 debt, 2 saving, 2 investment
- All `item_tier` values are `"fixed"` or `"variable"` only
- `en.json` and `de.json` both contain a valid `"presets"` nested object with all 19 slugs translated
- Both JSON files remain valid (parseable) after edits
- `tsc --noEmit` passes
</success_criteria>
<output>
After completion, create `.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,255 @@
---
phase: 06-preset-data-first-run-detection-and-db-safety
plan: 03
type: execute
wave: 2
depends_on:
- "06-01"
- "06-02"
files_modified:
- src/hooks/useFirstRunState.ts
autonomous: false
requirements:
- AUTO-01
- AUTO-03
- SETUP-01
- SETUP-02
must_haves:
truths:
- "useFirstRunState hook returns isFirstRun=true when categories array is empty"
- "useFirstRunState hook returns isFirstRun=true when template items array is empty"
- "useFirstRunState hook returns isFirstRun=false when user has both categories and template items"
- "useFirstRunState hook returns loading=true while either underlying query is in flight"
- "DB schema push completes without errors — uniqueness constraints and setup_completed column live in Supabase"
artifacts:
- path: "src/hooks/useFirstRunState.ts"
provides: "Derived first-run state from cached useCategories + useTemplate queries"
exports: ["useFirstRunState"]
key_links:
- from: "src/hooks/useFirstRunState.ts"
to: "src/hooks/useCategories.ts"
via: "useCategories() call — reads from cache key ['categories']"
pattern: "useCategories"
- from: "src/hooks/useFirstRunState.ts"
to: "src/hooks/useTemplate.ts"
via: "useTemplate() call — reads from cache keys ['template', 'template-items']"
pattern: "useTemplate"
---
<objective>
Write the `useFirstRunState` hook and push all migrations to the database.
`useFirstRunState` derives first-run state from existing React Query caches — no extra network call. It returns `{ isFirstRun: boolean, loading: boolean }` where `isFirstRun` is `true` when either categories or template items count is zero, and `loading` is `true` while either underlying query is still fetching.
The DB schema push applies migrations 006 and 007, making uniqueness constraints and the `setup_completed` column live. This is a `[BLOCKING]` step — all verification depends on the schema being applied.
Purpose: Phase 7 wizard reads `useFirstRunState().isFirstRun` to decide whether to redirect new users to setup. The DB constraints prevent the data corruption that would require rollback work in Phase 8.
Output: `src/hooks/useFirstRunState.ts` + DB schema pushed + human verification checkpoint.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-CONTEXT.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-01-SUMMARY.md
@.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-02-SUMMARY.md
<interfaces>
From src/hooks/useCategories.ts:
```typescript
export function useCategories() {
// queryKey: ["categories"]
return {
categories: query.data ?? [], // Category[]
loading: query.isLoading,
create, update, remove,
}
}
```
From src/hooks/useTemplate.ts:
```typescript
export function useTemplate() {
// queryKeys: ["template"], ["template-items"]
return {
template: templateQuery.data ?? null,
items: itemsQuery.data ?? [], // TemplateItem[]
loading: templateQuery.isLoading || itemsQuery.isLoading,
updateName, createItem, updateItem, deleteItem, reorderItems,
}
}
```
From src/lib/types.ts (after Plan 01):
```typescript
export interface Profile {
id: string
display_name: string | null
locale: string
currency: string
setup_completed: boolean // added by Plan 01
created_at: string
updated_at: string
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create src/hooks/useFirstRunState.ts</name>
<files>src/hooks/useFirstRunState.ts</files>
<read_first>
- src/hooks/useCategories.ts
- src/hooks/useTemplate.ts
- .planning/phases/06-preset-data-first-run-detection-and-db-safety/06-RESEARCH.md (Pattern 3: Derived State Hook, Pitfall 2)
</read_first>
<action>
Create `src/hooks/useFirstRunState.ts` with the following exact implementation. Do not add mutations, do not import supabase directly — this hook is read-only and derives state from already-cached queries.
```typescript
import { useCategories } from "@/hooks/useCategories"
import { useTemplate } from "@/hooks/useTemplate"
/**
* Derives first-run state from cached category and template queries.
* No additional network calls are made — data is read from React Query cache.
*
* isFirstRun is true when:
* - categories.length === 0 (user has not created any categories), OR
* - items.length === 0 (user has no template items)
*
* IMPORTANT: Always check `loading` before acting on `isFirstRun`.
* While queries are in flight, both arrays default to [] which would
* cause isFirstRun to be true spuriously.
*
* Usage:
* const { isFirstRun, loading } = useFirstRunState()
* if (!loading && isFirstRun) { redirect to /setup }
*/
export function useFirstRunState() {
const { categories, loading: catLoading } = useCategories()
const { items, loading: tmplLoading } = useTemplate()
return {
isFirstRun: categories.length === 0 || items.length === 0,
loading: catLoading || tmplLoading,
}
}
```
The `loading` guard is critical — see Pitfall 2 in RESEARCH.md. Callers in Phase 7 MUST check `!loading && isFirstRun` before redirecting.
</action>
<verify>
<automated>grep -n "isFirstRun" src/hooks/useFirstRunState.ts && grep -n "loading" src/hooks/useFirstRunState.ts && npx tsc --noEmit</automated>
</verify>
<done>File exists. Exports `useFirstRunState` function. Returns `{ isFirstRun: boolean, loading: boolean }`. `tsc --noEmit` passes with no errors.</done>
</task>
<task type="auto" id="db-push-blocking">
<name>Task 2: [BLOCKING] Push database schema (migrations 006 + 007)</name>
<files>supabase/migrations/006_uniqueness_constraints.sql, supabase/migrations/007_setup_completed.sql</files>
<read_first>
- supabase/migrations/006_uniqueness_constraints.sql
- supabase/migrations/007_setup_completed.sql
</read_first>
<action>
Run the Supabase schema push to apply migrations 006 and 007 to the database. This command requires the Supabase CLI and an active project link.
```bash
supabase db push
```
If the push prompts interactively and cannot be suppressed, set the access token first:
```bash
SUPABASE_ACCESS_TOKEN=$(cat ~/.supabase/access-token 2>/dev/null || echo "SET_TOKEN_HERE") supabase db push
```
Expected outcome: Both migrations apply in order (006 then 007). The command exits 0.
If the command fails with a duplicate constraint error (constraint already exists), the migrations may have been partially applied. In that case, check `supabase db diff` to see what's pending and resolve manually.
If `supabase db push` requires interactive confirmation that cannot be bypassed, flag this task with a `checkpoint:human-action` and have the user run it manually from their terminal.
</action>
<verify>
<automated>supabase db push --dry-run 2>&1 | grep -E "No migrations|already applied|006|007" || echo "Check supabase CLI output above"</automated>
</verify>
<done>Both migrations applied. `supabase db push` exits 0 (or reports migrations already applied). No error output containing "ERROR" or "FATAL".</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify DB constraints and setup_completed column are live</name>
<what-built>
Migration 006 added UNIQUE constraints on budgets(user_id, start_date) and categories(user_id, name).
Migration 007 added setup_completed boolean column to profiles and backfilled existing users.
useFirstRunState hook created at src/hooks/useFirstRunState.ts.
</what-built>
<how-to-verify>
1. Open the Supabase dashboard for this project.
2. Go to Table Editor > profiles — confirm the `setup_completed` column exists with boolean type.
3. Go to Database > Tables > budgets — confirm constraint `budgets_user_month_unique` exists on (user_id, start_date).
4. Go to Database > Tables > categories — confirm constraint `categories_user_name_unique` exists on (user_id, name).
5. If you have an existing v1.0 user row in profiles, confirm setup_completed = true for that row.
6. Run `npx tsc --noEmit` in the terminal — confirm zero TypeScript errors.
</how-to-verify>
<resume-signal>Type "approved" if all constraints are live and tsc passes, or describe any issues found.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| useFirstRunState → React Query cache | Reads cached data from existing hooks — no new trust boundary introduced |
| supabase db push → Supabase project | CLI command with project-level credentials — run only in trusted environment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-06 | Spoofing | useFirstRunState — isFirstRun=true while loading | mitigate | loading flag exported; callers must check `!loading && isFirstRun` before acting (documented in hook JSDoc) |
| T-06-07 | Denial of Service | supabase db push failing mid-migration | accept | Transaction wrapping in 006 ensures atomicity; 007 is idempotent (ADD COLUMN IF NOT EXISTS can be added if needed) |
</threat_model>
<verification>
```bash
# Hook exports correctly
grep "export function useFirstRunState" src/hooks/useFirstRunState.ts
# Hook uses both existing hooks
grep "useCategories\|useTemplate" src/hooks/useFirstRunState.ts
# TypeScript clean across all new files
npx tsc --noEmit
# Migration files in place
ls supabase/migrations/006_uniqueness_constraints.sql supabase/migrations/007_setup_completed.sql
# All 4 requirement IDs traceable across plans:
# AUTO-01: useFirstRunState (plan 03) + budgets constraint (plan 01)
# AUTO-03: setup_completed not hardcoding currency (plan 01, 02, 03 — amounts plain numbers)
# SETUP-01: setup_completed column + backfill (plan 01) + useFirstRunState (plan 03)
# SETUP-02: PRESETS array 19 items (plan 02)
```
</verification>
<success_criteria>
- `src/hooks/useFirstRunState.ts` exists and exports `useFirstRunState()` returning `{ isFirstRun: boolean, loading: boolean }`
- `isFirstRun` is derived from `categories.length === 0 || items.length === 0` — no direct Supabase calls
- DB push completes: `budgets_user_month_unique` and `categories_user_name_unique` constraints live in Supabase
- `profiles` table has `setup_completed boolean NOT NULL DEFAULT false` column
- All existing users with categories (or template items) have `setup_completed = true`
- `tsc --noEmit` passes with zero errors across all modified files
</success_criteria>
<output>
After completion, create `.planning/phases/06-preset-data-first-run-detection-and-db-safety/06-03-SUMMARY.md`
</output>