diff --git a/.claude/commands/frontend-ui-ux.md b/.claude/commands/frontend-ui-ux.md new file mode 100644 index 000000000..5d031d01b --- /dev/null +++ b/.claude/commands/frontend-ui-ux.md @@ -0,0 +1,86 @@ +--- +description: Designer-turned-developer who crafts stunning UI/UX even without design mockups +argument-hint: [component-or-page-description] +--- + +# Role: Designer-Turned-Developer + +You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces. + +**Mission**: Create visually stunning, emotionally engaging interfaces users fall in love with. Obsess over pixel-perfect details, smooth animations, and intuitive interactions while maintaining code quality. + +--- + +# Work Principles + +1. **Complete what's asked** — Execute the exact task. No scope creep. Work until it works. Never mark work complete without proper verification. +2. **Leave it better** — Ensure the project is in a working state after your changes. +3. **Study before acting** — Examine existing patterns, conventions, and commit history (git log) before implementing. Understand why code is structured the way it is. +4. **Blend seamlessly** — Match existing code patterns. Your code should look like the team wrote it. +5. **Be transparent** — Announce each step. Explain reasoning. Report both successes and failures. + +--- + +# Design Process + +Before coding, commit to a **BOLD aesthetic direction**: + +1. **Purpose**: What problem does this solve? Who uses it? +2. **Tone**: Pick an extreme—brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian +3. **Constraints**: Technical requirements (framework, performance, accessibility) +4. **Differentiation**: What's the ONE thing someone will remember? + +**Key**: Choose a clear direction and execute with precision. Intentionality > intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, Angular, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +--- + +# Aesthetic Guidelines + +## Typography +Choose distinctive fonts. **Avoid**: Arial, Inter, Roboto, system fonts, Space Grotesk. Pair a characterful display font with a refined body font. + +## Color +Commit to a cohesive palette. Use CSS variables. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. **Avoid**: purple gradients on white (AI slop). + +## Motion +Focus on high-impact moments. One well-orchestrated page load with staggered reveals (animation-delay) > scattered micro-interactions. Use scroll-triggering and hover states that surprise. Prioritize CSS-only. Use Motion library for React when available. + +## Spatial Composition +Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. + +## Visual Details +Create atmosphere and depth—gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, grain overlays. Never default to solid colors. + +--- + +# Anti-Patterns (NEVER) + +- Generic fonts (Inter, Roboto, Arial, system fonts, Space Grotesk) +- Cliched color schemes (purple gradients on white) +- Predictable layouts and component patterns +- Cookie-cutter design lacking context-specific character +- Converging on common choices across generations + +--- + +# Execution + +Match implementation complexity to aesthetic vision: +- **Maximalist** → Elaborate code with extensive animations and effects +- **Minimalist** → Restraint, precision, careful spacing and typography + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back. + +--- + +# Task + +$ARGUMENTS + +If no specific task is provided, ask what UI/UX component or page the user would like to create. diff --git a/.claude/plans/reflective-wiggling-dragonfly.md b/.claude/plans/reflective-wiggling-dragonfly.md new file mode 100644 index 000000000..57101a28c --- /dev/null +++ b/.claude/plans/reflective-wiggling-dragonfly.md @@ -0,0 +1,394 @@ +# Plan: Coverage Protection Dashboard by Person + +## Summary +Create a visual dashboard showing coverage targets vs actual coverage per person, with gap analysis and protection score. **Phase 1 focuses on UI with empty state** (0% coverage until policies are integrated). + +--- + +## ASCII Design: Coverage Dashboard (Empty State - Phase 1) + +After the wizard is completed, show a **Coverage Dashboard** view. Initially shows 0% coverage since no policies are linked yet: + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ My Coverage [Edit Targets] [⟳] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ │ +│ │ │ 👤 John Tan [$120K/yr]│ │ PROTECTION SCORE │ │ │ +│ │ │ [Change Person ▼] │ │ │ │ │ +│ │ └─────────────────────────────────┘ │ ╭─────────╮ │ │ │ +│ │ │ ╱ ╲ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ 0% │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ ╲ ╱ │ │ │ +│ │ │ ╰─────────╯ │ │ │ +│ │ │ │ │ │ +│ │ │ Add policies to see your │ │ │ +│ │ │ protection score │ │ │ +│ │ └───────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ COVERAGE TARGETS │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 🏥 Hospitalization ⚠ No Policy │ │ +│ │ Target: Ward A + Rider │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 🛡️ Life / TPD ⚠ No Policy │ │ +│ │ Target: $1.2M (10× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 💊 Critical Illness ⚠ No Policy │ │ +│ │ Target: $600K (5× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ ⚡ Personal Accident ⚠ No Policy │ │ +│ │ Target: $360K (3× income) │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ PREMIUM BUDGET │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Budget: $12,000/year (10% of income) Current: $0/year │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │ │ +│ │ $12,000 remaining │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ + Add Your First Policy to Track Coverage │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Circular Gauge Component Detail + +The protection score gauge uses an SVG circle with stroke-dasharray for the progress arc: + +``` + ╭─────────────────╮ + ╱ ╲ + ╱ ┌───────────┐ ╲ + │ │ 67% │ │ + │ │ │ │ + │ │ ◉ Good │ │ ← Status text below percentage + │ └───────────┘ │ + ╲ ╭───────────╮ ╱ + ╲ ╱ ╲ ╱ + ╰─────────────────╯ + + ↑ Colored arc shows progress + (emerald for good, amber for partial, red for gap) + +Score Thresholds: + - 80-100%: "Well Protected" (emerald) + - 50-79%: "Partial Coverage" (amber) + - 0-49%: "Needs Attention" (red) +``` + +**Gauge Implementation (SVG):** +``` +┌─────────────────────────────────────┐ +│ │ +│ ╱‾‾‾‾‾‾‾‾‾‾‾╲ │ ← Background circle (gray) +│ │ ╱‾‾‾╲ │ │ +│ │ │ 67% │ │ │ ← Progress arc overlay (colored) +│ │ ╲___╱ │ │ +│ ╲___________╱ │ ← Stroke-dasharray controls fill +│ │ +│ 2 gaps to address │ ← Summary text +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## Coverage Progress Bar Component Detail + +Each category has its own progress bar with contextual information: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ 🛡️ Life / TPD ⚠ Gap │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ Target: $1.2M • Current: $800K • Gap: $400K │ +│ │ +│ ████████████████████████████████░░░░░░░░░░░░░░░░░░░░ 67% │ +│ ↑ filled portion (emerald) ↑ empty portion (gray) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Status Badge Colors: + - "✓ Covered" → emerald badge + - "⚠ Gap" → amber badge + - "⚠ No Policy" → gray/muted badge +``` + +**Hospitalization (Binary - no progress bar needed):** +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ 🏥 Hospitalization ✓ Covered │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ Ward A + Rider ← Just shows the coverage type │ +│ │ +│ ████████████████████████████████████████████████████████ 100% │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## With Policies (Future State - Phase 2) + +Once policies are linked, the dashboard shows actual coverage: + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ My Coverage [Edit Targets] [⟳] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │ │ +│ │ │ 👤 John Tan [$120K/yr]│ │ PROTECTION SCORE │ │ │ +│ │ │ [Change Person ▼] │ │ │ │ │ +│ │ └─────────────────────────────────┘ │ ╭─────────╮ │ │ │ +│ │ │ ╱ ██████████╲ │ │ │ +│ │ │ │ ██████████ │ │ │ │ +│ │ │ │ 67% │ │ │ │ +│ │ │ │ ██████████ │ │ │ │ +│ │ │ ╲ █████████ ╱ │ │ │ +│ │ │ ╰─────────╯ │ │ │ +│ │ │ │ │ │ +│ │ │ ⚠ Partial Coverage │ │ │ +│ │ │ 2 gaps need attention │ │ │ +│ │ └───────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ COVERAGE BY CATEGORY │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 🏥 Hospitalization ✓ Covered │ │ +│ │ Ward A + Rider │ │ +│ │ ████████████████████████████████████████████████████████████ 100% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 🛡️ Life / TPD ⚠ Gap │ │ +│ │ Target: $1.2M • Current: $800K • Gap: $400K │ │ +│ │ ███████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░ 67% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ 💊 Critical Illness ⚠ Gap │ │ +│ │ Target: $600K • Current: $200K • Gap: $400K │ │ +│ │ ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 33% │ │ +│ │ │ │ +│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ +│ │ │ │ +│ │ ⚡ Personal Accident ✓ Covered │ │ +│ │ Target: $360K • Current: $500K • Excess: +$140K │ │ +│ │ ████████████████████████████████████████████████████████████ 100%+ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ PREMIUM SUMMARY │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Monthly: $450 Annual: $5,400 Budget: $12,000 (10%) │ │ +│ │ ███████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 45% │ │ +│ │ Using 45% of budget • $6,600 remaining │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────���──┐ │ +│ │ QUICK ACTIONS │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ + Add Policy │ │ 📊 View Gaps │ │ ⚙ Edit Targets │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Layout Options + +**Option A: Side-by-side (Current plan)** +``` +┌──────────────────────────────────┬────────────────────────────────────────────┐ +│ PERSON SELECTOR │ PROTECTION SCORE │ +│ 👤 John Tan │ ╭─────╮ │ +│ $120K/year │ │ 67% │ │ +│ │ ╰─────╯ │ +└──────────────────────────────────┴────────────────────────────────────────────┘ +``` + +**Option B: Stacked (Mobile-friendly)** +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 👤 John Tan $120K/year │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ ╭─────────╮ │ +│ │ 67% │ │ +│ ╰─────────╯ │ +│ Partial Coverage │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Flow + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Guidelines │ │ Policies │ │ Coverage │ +│ Store │ │ (Phase 2) │ │ Dashboard │ +│ │ │ │ │ │ +│ • selectedPerson│────▶│ • Query by │────▶│ • Target vs │ +│ • annualIncome │ │ personId │ │ Actual │ +│ • multipliers │ │ • Sum coverage │ │ • Gap calc │ +│ • targets │ │ by category │ │ • Score % │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + +Phase 1: Guidelines Store → Dashboard (0% coverage, targets only) +Phase 2: + Policies → Dashboard (actual coverage shown) +``` + +--- + +## Protection Score Calculation + +``` +score = weightedAverage( + hospitalization: hasValidISP ? 100 : 0, weight: 0.25 + life_tpd: min(100, (current / target) * 100), weight: 0.35 + critical_illness: min(100, (current / target) * 100), weight: 0.25 + personal_accident: min(100, (current / target) * 100), weight: 0.15 +) +``` + +--- + +## Component Structure + +``` +CoverageDashboard +├── DashboardHeader +│ ├── Title "My Coverage" +│ ├── Edit Targets button +│ └── Reset button +├── PersonScoreSection (flex row) +│ ├── PersonCard +│ │ ├── Person name + color dot +│ │ ├── Annual income display +│ │ └── Change Person dropdown +│ └── ProtectionScoreGauge +│ ├── SVG circular progress +│ ├── Percentage text +│ └── Status message +├── CoverageCategoryList +│ └── CoverageCategoryRow × 4 +│ ├── Category icon + label +│ ├── Status badge (Covered/Gap/No Policy) +│ ├── Target/Current/Gap amounts +│ └── Progress bar +├── PremiumBudgetSection +│ ├── Budget info (monthly/annual) +│ ├── Progress bar +│ └── Remaining amount +└── QuickActionsSection + └── Add Policy button (prominent CTA) +``` + +--- + +## Files to Modify + +### 1. `frontend/src/types/insurance.ts` ✅ DONE +- Added `CoverageCategoryStatus` interface +- Added `CoverageStatus` interface +- Added `calculateProtectionScore()` function +- Added `buildCoverageStatus()` function + +### 2. `frontend/src/components/insurance/CoverageDashboard.tsx` (NEW) +Main dashboard component containing: +- `ProtectionScoreGauge` - SVG circular gauge +- `CoverageCategoryRow` - Individual category with progress bar +- `PremiumBudgetBar` - Budget utilization display +- `CoverageDashboard` - Main orchestrating component + +### 3. `frontend/src/components/insurance/tabs/GuidelinesTab.tsx` +- Replace `ConfiguredGuidelinesView` with import of `CoverageDashboard` + +--- + +## Implementation Notes + +**SVG Circular Gauge:** +- Use `stroke-dasharray` and `stroke-dashoffset` for progress +- Circle with radius ~45, stroke-width ~10 +- Background circle in gray, progress arc in color +- Animate with CSS transition on stroke-dashoffset + +**Progress Bars:** +- Simple div with width percentage +- Background: `bg-white/[0.1]` +- Fill: `bg-emerald-500` (covered), `bg-amber-500` (partial), `bg-slate-600` (empty) + +**Empty State:** +- All progress at 0% +- Protection score shows 0% +- Prominent CTA to add first policy +- Targets still visible so user knows what they're working toward + +--- + +## Verification + +1. Complete wizard → See coverage dashboard (empty state) +2. Dashboard shows targets with 0% +3. Circular gauge displays 0% with "Add policies" message +4. Person selector changes income and targets +5. TypeScript: `cd frontend && npx tsc --noEmit` + +--- + +## Questions for Review + +1. **Layout**: Side-by-side (Option A) or stacked (Option B) for person+score section? +2. **Empty state CTA**: Single prominent button or include "View Gaps" as secondary action? +3. **Category icons**: Use emojis (🏥🛡️💊⚡) or Lucide icons? diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..1f195064c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "enabledPlugins": { + "ralph-wiggum@claude-plugins-official": true + }, + "permissions": { + "allow": [ + "Bash(/Users/jitcorn/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/*)" + ] + } +} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 412cef9e6..99b80baf7 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -2,9 +2,9 @@ name: Claude Code on: issue_comment: - types: [created] + types: [created, edited] pull_request_review_comment: - types: [created] + types: [created, edited] issues: types: [opened, assigned] pull_request_review: @@ -19,11 +19,11 @@ jobs: (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write id-token: write - actions: read # Required for Claude to read CI results on PRs + actions: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -32,19 +32,9 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@v1.0.43 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.gitignore b/.gitignore index 58e79b846..3139a15c7 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ dist/ build/ .next/ .gocache/ +storybook-static/ # Logs *.log diff --git a/backend/cmd/server/handlers/persons_v2.go b/backend/cmd/server/handlers/persons_v2.go index 583b2a62b..393b51d4c 100644 --- a/backend/cmd/server/handlers/persons_v2.go +++ b/backend/cmd/server/handlers/persons_v2.go @@ -17,6 +17,7 @@ type personV2CreateInput struct { Gender string `json:"gender"` // Required: 'male' or 'female' for CPF LIFE calculations ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' (PR year is computed from prGrantDate) PRGrantDate *string `json:"prGrantDate"` // Required if residencyStatus='pr', format: "2006-01-02" + Relationship string `json:"relationship"` // 'self', 'spouse', 'child', 'parent', 'sibling', 'other' } // personV2UpdateInput is the JSON-friendly input struct for updating a person. @@ -28,6 +29,7 @@ type personV2UpdateInput struct { Gender string `json:"gender"` // Optional: 'male' or 'female' ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' PRGrantDate *string `json:"prGrantDate"` // Required if residencyStatus='pr', format: "2006-01-02" + Relationship string `json:"relationship"` // Optional: 'self', 'spouse', 'child', 'parent', 'sibling', 'other' } // PersonV2Handler serves person v2 endpoints. @@ -146,6 +148,7 @@ func (h *PersonV2Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { Gender: input.Gender, ResidencyStatus: residencyStatus, PRGrantDate: prGrantDate, + Relationship: input.Relationship, } if input.DisplayColor != "" { person.DisplayColor = &input.DisplayColor @@ -274,6 +277,7 @@ func (h *PersonV2Handler) HandleUpdate(w http.ResponseWriter, r *http.Request, i Gender: gender, ResidencyStatus: input.ResidencyStatus, PRGrantDate: prGrantDate, + Relationship: input.Relationship, } if input.DisplayColor != "" { person.DisplayColor = &input.DisplayColor diff --git a/backend/internal/financial/repository/timeline.go b/backend/internal/financial/repository/timeline.go index e2dd2e9c6..987053d20 100644 --- a/backend/internal/financial/repository/timeline.go +++ b/backend/internal/financial/repository/timeline.go @@ -27,6 +27,7 @@ type UserSettings struct { GroupItemsByCategory bool `json:"groupItemsByCategory"` // Whether to group financial items by category in UI ChartPictureInPicture bool `json:"chartPictureInPicture"` // Whether to show mini chart when scrolled out of view DashboardLayout string `json:"dashboardLayout"` // Dashboard layout: stacked, chart-left, or chart-right + OnboardingCompleted bool `json:"onboardingCompleted"` // Whether the onboarding wizard has been completed or dismissed UpdatedAt time.Time `json:"updatedAt,omitempty"` } @@ -257,11 +258,12 @@ func (s *Store) GetUserSettings(ctx context.Context, userID string) (UserSetting COALESCE(group_items_by_category, true), COALESCE(chart_picture_in_picture, false), COALESCE(dashboard_layout, 'stacked'), + COALESCE(onboarding_completed, false), updated_at FROM user_settings WHERE user_id = $1`, userID) var settings UserSettings - if err := row.Scan(&settings.ID, &settings.UserID, &settings.StartingAge, &settings.TerminalAge, &settings.YearDisplayFormat, &settings.TimeResolution, &settings.CompoundingFrequency, &settings.AutoExecuteTools, &settings.GroupItemsByCategory, &settings.ChartPictureInPicture, &settings.DashboardLayout, &settings.UpdatedAt); err != nil { + if err := row.Scan(&settings.ID, &settings.UserID, &settings.StartingAge, &settings.TerminalAge, &settings.YearDisplayFormat, &settings.TimeResolution, &settings.CompoundingFrequency, &settings.AutoExecuteTools, &settings.GroupItemsByCategory, &settings.ChartPictureInPicture, &settings.DashboardLayout, &settings.OnboardingCompleted, &settings.UpdatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return DefaultUserSettings, nil } @@ -273,8 +275,8 @@ func (s *Store) GetUserSettings(ctx context.Context, userID string) (UserSetting // UpsertUserSettings inserts or updates user settings. func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings UserSettings) (UserSettings, error) { row := s.db.QueryRowContext(ctx, ` - INSERT INTO user_settings (user_id, starting_age, terminal_age, year_display_format, time_resolution, compounding_frequency, auto_execute_tools, group_items_by_category, chart_picture_in_picture, dashboard_layout) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO user_settings (user_id, starting_age, terminal_age, year_display_format, time_resolution, compounding_frequency, auto_execute_tools, group_items_by_category, chart_picture_in_picture, dashboard_layout, onboarding_completed) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (user_id) DO UPDATE SET starting_age = EXCLUDED.starting_age, terminal_age = EXCLUDED.terminal_age, @@ -285,6 +287,7 @@ func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings group_items_by_category = EXCLUDED.group_items_by_category, chart_picture_in_picture = EXCLUDED.chart_picture_in_picture, dashboard_layout = EXCLUDED.dashboard_layout, + onboarding_completed = EXCLUDED.onboarding_completed, updated_at = NOW() RETURNING id, user_id, starting_age, terminal_age, year_display_format, COALESCE(time_resolution, 'yearly'), @@ -293,10 +296,11 @@ func (s *Store) UpsertUserSettings(ctx context.Context, userID string, settings COALESCE(group_items_by_category, true), COALESCE(chart_picture_in_picture, false), COALESCE(dashboard_layout, 'stacked'), + COALESCE(onboarding_completed, false), updated_at`, - userID, settings.StartingAge, settings.TerminalAge, settings.YearDisplayFormat, settings.TimeResolution, settings.CompoundingFrequency, settings.AutoExecuteTools, settings.GroupItemsByCategory, settings.ChartPictureInPicture, settings.DashboardLayout) + userID, settings.StartingAge, settings.TerminalAge, settings.YearDisplayFormat, settings.TimeResolution, settings.CompoundingFrequency, settings.AutoExecuteTools, settings.GroupItemsByCategory, settings.ChartPictureInPicture, settings.DashboardLayout, settings.OnboardingCompleted) var updated UserSettings - if err := row.Scan(&updated.ID, &updated.UserID, &updated.StartingAge, &updated.TerminalAge, &updated.YearDisplayFormat, &updated.TimeResolution, &updated.CompoundingFrequency, &updated.AutoExecuteTools, &updated.GroupItemsByCategory, &updated.ChartPictureInPicture, &updated.DashboardLayout, &updated.UpdatedAt); err != nil { + if err := row.Scan(&updated.ID, &updated.UserID, &updated.StartingAge, &updated.TerminalAge, &updated.YearDisplayFormat, &updated.TimeResolution, &updated.CompoundingFrequency, &updated.AutoExecuteTools, &updated.GroupItemsByCategory, &updated.ChartPictureInPicture, &updated.DashboardLayout, &updated.OnboardingCompleted, &updated.UpdatedAt); err != nil { return UserSettings{}, err } return updated, nil diff --git a/backend/internal/financial_v2/repository/person.go b/backend/internal/financial_v2/repository/person.go index b89b73e01..401eab4af 100644 --- a/backend/internal/financial_v2/repository/person.go +++ b/backend/internal/financial_v2/repository/person.go @@ -12,7 +12,7 @@ func (s *Store) ListPersons(ctx context.Context, userID string) ([]Person, error query := ` SELECT id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at + relationship, created_at, updated_at FROM persons WHERE user_id = $1 ORDER BY created_at ASC` @@ -31,7 +31,7 @@ func (s *Store) ListPersons(ctx context.Context, userID string) ([]Person, error if err := rows.Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, fmt.Errorf("failed to scan person: %w", err) } @@ -51,7 +51,7 @@ func (s *Store) ListPersonsWithStats(ctx context.Context, userID string) ([]Pers SELECT p.id, p.user_id, p.name, p.display_color, p.is_included, p.date_of_birth, p.gender, p.residency_status, p.pr_grant_date, - p.created_at, p.updated_at, + p.relationship, p.created_at, p.updated_at, COALESCE(income_counts.count, 0) as income_count, COALESCE(cpf_counts.count, 0) as cpf_count FROM persons p @@ -84,7 +84,7 @@ func (s *Store) ListPersonsWithStats(ctx context.Context, userID string) ([]Pers if err := rows.Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, &p.IncomeCount, &p.CPFCount, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, &p.IncomeCount, &p.CPFCount, ); err != nil { return nil, fmt.Errorf("failed to scan person with stats: %w", err) } @@ -103,7 +103,7 @@ func (s *Store) GetPerson(ctx context.Context, userID, id string) (*Person, erro query := ` SELECT id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at + relationship, created_at, updated_at FROM persons WHERE user_id = $1 AND id = $2` @@ -113,7 +113,7 @@ func (s *Store) GetPerson(ctx context.Context, userID, id string) (*Person, erro err := s.pool.QueryRow(ctx, query, userID, id).Scan( &p.ID, &p.UserID, &p.Name, &p.DisplayColor, &p.IsIncluded, &p.DateOfBirth, &p.Gender, &p.ResidencyStatus, &p.PRGrantDate, - &p.CreatedAt, &p.UpdatedAt, + &p.Relationship, &p.CreatedAt, &p.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound @@ -130,11 +130,11 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per query := ` INSERT INTO persons (user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at) - VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, NOW(), NOW()) + relationship, created_at, updated_at) + VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9, NOW(), NOW()) RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` displayColor := "" if p.DisplayColor != nil { @@ -153,7 +153,13 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per gender = "male" } - args := []any{userID, p.Name, displayColor, true, p.DateOfBirth, gender, residencyStatus, p.PRGrantDate} + // Default relationship to 'self' if not provided + relationship := p.Relationship + if relationship == "" { + relationship = "self" + } + + args := []any{userID, p.Name, displayColor, true, p.DateOfBirth, gender, residencyStatus, p.PRGrantDate, relationship} logQuery(query, args) @@ -161,7 +167,7 @@ func (s *Store) CreatePerson(ctx context.Context, userID string, p Person) (*Per err := s.pool.QueryRow(ctx, query, args...).Scan( &created.ID, &created.UserID, &created.Name, &created.DisplayColor, &created.IsIncluded, &created.DateOfBirth, &created.Gender, &created.ResidencyStatus, &created.PRGrantDate, - &created.CreatedAt, &created.UpdatedAt, + &created.Relationship, &created.CreatedAt, &created.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to create person: %w", err) @@ -181,11 +187,12 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( gender = COALESCE(NULLIF($7, ''), gender), residency_status = COALESCE(NULLIF($8, ''), residency_status), pr_grant_date = $9, + relationship = COALESCE(NULLIF($10, ''), relationship), updated_at = NOW() WHERE user_id = $1 AND id = $2 RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` displayColor := "" if p.DisplayColor != nil { @@ -198,7 +205,7 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( dateOfBirth = p.DateOfBirth } - args := []any{userID, id, p.Name, displayColor, p.IsIncluded, dateOfBirth, p.Gender, p.ResidencyStatus, p.PRGrantDate} + args := []any{userID, id, p.Name, displayColor, p.IsIncluded, dateOfBirth, p.Gender, p.ResidencyStatus, p.PRGrantDate, p.Relationship} logQuery(query, args) @@ -206,7 +213,7 @@ func (s *Store) UpdatePerson(ctx context.Context, userID, id string, p Person) ( err := s.pool.QueryRow(ctx, query, args...).Scan( &updated.ID, &updated.UserID, &updated.Name, &updated.DisplayColor, &updated.IsIncluded, &updated.DateOfBirth, &updated.Gender, &updated.ResidencyStatus, &updated.PRGrantDate, - &updated.CreatedAt, &updated.UpdatedAt, + &updated.Relationship, &updated.CreatedAt, &updated.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound @@ -245,7 +252,7 @@ func (s *Store) TogglePersonIncluded(ctx context.Context, userID, id string) (*P WHERE user_id = $1 AND id = $2 RETURNING id, user_id, name, display_color, is_included, date_of_birth, gender, residency_status, pr_grant_date, - created_at, updated_at` + relationship, created_at, updated_at` logQuery(query, []any{userID, id}) @@ -253,7 +260,7 @@ func (s *Store) TogglePersonIncluded(ctx context.Context, userID, id string) (*P err := s.pool.QueryRow(ctx, query, userID, id).Scan( &updated.ID, &updated.UserID, &updated.Name, &updated.DisplayColor, &updated.IsIncluded, &updated.DateOfBirth, &updated.Gender, &updated.ResidencyStatus, &updated.PRGrantDate, - &updated.CreatedAt, &updated.UpdatedAt, + &updated.Relationship, &updated.CreatedAt, &updated.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, ErrNotFound diff --git a/backend/internal/financial_v2/repository/store.go b/backend/internal/financial_v2/repository/store.go index 1d053499e..46a4cbd47 100644 --- a/backend/internal/financial_v2/repository/store.go +++ b/backend/internal/financial_v2/repository/store.go @@ -257,6 +257,7 @@ type Person struct { Gender string `json:"gender"` // 'male' or 'female' - required for CPF LIFE calculations ResidencyStatus string `json:"residencyStatus"` // 'citizen' or 'pr' (PR year computed from prGrantDate) PRGrantDate *time.Time `json:"prGrantDate,omitempty"` + Relationship string `json:"relationship"` // 'self', 'spouse', 'child', 'parent', 'sibling', 'other' CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` // Stats populated by GetPersonsWithStats diff --git a/backend/migrations/202602040001_add_person_relationship.down.sql b/backend/migrations/202602040001_add_person_relationship.down.sql new file mode 100644 index 000000000..8435468e7 --- /dev/null +++ b/backend/migrations/202602040001_add_person_relationship.down.sql @@ -0,0 +1 @@ +ALTER TABLE persons DROP COLUMN IF EXISTS relationship; diff --git a/backend/migrations/202602040001_add_person_relationship.up.sql b/backend/migrations/202602040001_add_person_relationship.up.sql new file mode 100644 index 000000000..35f76d86d --- /dev/null +++ b/backend/migrations/202602040001_add_person_relationship.up.sql @@ -0,0 +1,8 @@ +-- Add relationship column to persons table for household role identification +-- Used by insurance coverage guidelines to determine spouse vs dependent vs self + +ALTER TABLE persons +ADD COLUMN relationship VARCHAR(20) DEFAULT 'self' NOT NULL +CHECK (relationship IN ('self', 'spouse', 'child', 'parent', 'sibling', 'other')); + +COMMENT ON COLUMN persons.relationship IS 'Relationship to primary household member (self/spouse/child/parent/sibling/other)'; diff --git a/backend/migrations/202602040002_add_onboarding_completed.down.sql b/backend/migrations/202602040002_add_onboarding_completed.down.sql new file mode 100644 index 000000000..914a22945 --- /dev/null +++ b/backend/migrations/202602040002_add_onboarding_completed.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings +DROP COLUMN IF EXISTS onboarding_completed; diff --git a/backend/migrations/202602040002_add_onboarding_completed.up.sql b/backend/migrations/202602040002_add_onboarding_completed.up.sql new file mode 100644 index 000000000..6c382f396 --- /dev/null +++ b/backend/migrations/202602040002_add_onboarding_completed.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings +ADD COLUMN onboarding_completed boolean DEFAULT false NOT NULL; diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md new file mode 100644 index 000000000..4be6e4e40 --- /dev/null +++ b/conductor/product-guidelines.md @@ -0,0 +1,32 @@ +# Product Guidelines + +## Prose Style + +The language used throughout the application and in all communications should be clear, concise, and professional. Avoid jargon where possible, or explain it clearly if necessary. The tone should be informative and helpful, instilling confidence in the user regarding their financial decisions. + +## Brand Messaging + +Our core message revolves around empowering users with intelligent financial planning. Key themes to emphasize include: + +- **Intelligence:** Leveraging AI and LLMs to provide smart, data-driven insights. +- **Control:** Users maintain full control over their financial actions with transparent review and approval processes. +- **Transparency:** Clear explanations of AI-generated suggestions and their potential impact. +- **Ease of Use:** A seamless, intuitive chat-based interface that simplifies complex financial management. +- **Security:** Reassurance about the protection of sensitive financial data. + +## Visual Identity + +The visual design should convey modernity, trustworthiness, and approachability. + +- **Clean Interface:** A minimalist and uncluttered design to reduce cognitive load. +- **Intuitive Layout:** Easy navigation and clear presentation of information. +- **Consistent Branding:** Use a consistent color palette, typography, and iconography across all platforms and communications to build brand recognition. + +## Tone of Voice + +The application's tone should be: + +- **Helpful:** Guiding users through their financial journey. +- **Reassuring:** Providing a sense of security and confidence in their financial planning. +- **Objective:** Presenting information and options impartially. +- **Engaging:** Maintaining an interactive and responsive dialogue. diff --git a/conductor/product.md b/conductor/product.md new file mode 100644 index 000000000..890eb8e70 --- /dev/null +++ b/conductor/product.md @@ -0,0 +1,26 @@ +# Initial Concept + +A modern financial planning chat application with LLM-native tool calling architecture. + +## Product Vision + +To provide an intuitive and intelligent platform for financial planning, leveraging large language models to assist users in managing their assets, liabilities, and financial scenarios through an interactive chat interface. The system aims to simplify complex financial tasks and offer actionable insights with user approval. + +## Key Features + +- **LLM-Native Tool Calling:** Integrates advanced language models to interpret user requests and invoke appropriate financial tools dynamically. +- **Manual Action Review:** Ensures user control and transparency by requiring explicit approval for AI-generated financial actions before execution. +- **Session Management:** Maintains persistent conversation context and state across user sessions, allowing for a seamless and continuous financial planning experience. +- **Financial Tools:** Provides a suite of tools for users to create, update, and manage various financial elements, including assets, liabilities, and property scenarios. +- **API Versioning:** Implements a versioned REST API to ensure backward compatibility and future-proof the system's extensibility. + +## Target Audience + +Individuals and households seeking an interactive and intelligent solution to manage their personal finances, plan for the future, and understand their financial standing with the assistance of AI. + +## High-Level Goals + +- Enhance user engagement in financial planning through conversational AI. +- Increase accuracy and efficiency in managing personal financial data. +- Provide clear, actionable, and user-approved financial advice and actions. +- Ensure the system is scalable, secure, and easily maintainable. diff --git a/conductor/setup_state.json b/conductor/setup_state.json new file mode 100644 index 000000000..24df52004 --- /dev/null +++ b/conductor/setup_state.json @@ -0,0 +1 @@ +{"last_successful_step": "2.3_tech_stack"} \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md new file mode 100644 index 000000000..ed2fab127 --- /dev/null +++ b/conductor/tech-stack.md @@ -0,0 +1,50 @@ +# Technology Stack + +## Overview + +The project leverages a modern, distributed architecture designed for scalability, performance, and maintainability. + +## Backend + +- **Language:** Go (Golang) + - Chosen for its performance characteristics, concurrency model, and strong type safety, making it ideal for building robust API services. +- **Framework:** Standard Go libraries and potentially specialized HTTP routers (e.g., Gin, Echo, not explicitly stated but common for REST APIs in Go). +- **LLM Integration:** + - OpenAI GPT-4: For primary large language model capabilities and tool-calling. + - Anthropic Claude: Supported as an alternative or supplementary LLM provider. +- **Database Driver:** Likely `database/sql` with a PostgreSQL driver (e.g., `lib/pq` or `pgx`). + +## Frontend + +- **Framework/Library:** React 18 + - A declarative, component-based JavaScript library for building user interfaces, chosen for its efficiency and widespread adoption. +- **Language:** TypeScript + - Provides static typing to JavaScript, enhancing code quality, maintainability, and developer experience. +- **Styling:** Not explicitly stated, but common choices include CSS-in-JS, Tailwind CSS, or component libraries like Material UI. +- **Build Tool:** Likely Webpack or Vite (inferred from React 18 context). + +## Database + +- **Type:** Relational Database +- **Specific:** PostgreSQL + - A powerful, open-source object-relational database system known for its reliability, feature robustness, and performance. +- **ORM/Query Builder:** Potentially `GORM`, `sqlc`, or direct `database/sql` usage (not explicitly stated). + +## Development and Operations (DevOps) + +- **Containerization:** Docker & Docker Compose + - For consistent development environments and streamlined deployment. +- **Build Automation:** Makefile + - Used for managing common development tasks like dependency installation, building, testing, and running services. +- **Version Control:** Git + - Standard distributed version control system. + +## Testing + +- **Backend:** Go's built-in testing framework (`testing` package). +- **Frontend:** Likely React Testing Library, Jest, or similar. + +## API + +- **Style:** REST APIs +- **Documentation:** Swagger UI (inferred from `README.md` mentioning `/swagger/index.html`). diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..21d5a6631 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,37 @@ +import type { StorybookConfig } from '@storybook/react-webpack5' +import path from 'path' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: [ + '@storybook/addon-webpack5-compiler-swc', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-themes', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: (prop) => + prop.parent ? !/node_modules/.test(prop.parent.fileName) : true, + }, + }, + webpackFinal: async (webpackConfig) => { + // Path alias: @ -> src/ + if (webpackConfig.resolve) { + webpackConfig.resolve.alias = { + ...webpackConfig.resolve.alias, + '@': path.resolve(__dirname, '../src'), + } + } + + return webpackConfig + }, +} + +export default config diff --git a/frontend/.storybook/preview-head.html b/frontend/.storybook/preview-head.html new file mode 100644 index 000000000..349012710 --- /dev/null +++ b/frontend/.storybook/preview-head.html @@ -0,0 +1,14 @@ + + + + + diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..a9a414464 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,38 @@ +import type { Preview } from '@storybook/react' +import { withThemeByClassName } from '@storybook/addon-themes' + +// Import the same CSS files the app uses so all design tokens are available +import '../src/styles/globals.css' +import '../src/index.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: 'centered', + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: 'hsl(240 10% 3.9%)' }, + { name: 'light', value: 'hsl(0 0% 100%)' }, + { name: 'monet', value: '#FAF8F5' }, + ], + }, + }, + decorators: [ + withThemeByClassName({ + themes: { + light: '', + dark: 'dark', + monet: 'monet', + }, + defaultTheme: 'dark', + }), + ], +} + +export default preview diff --git a/frontend/e2e/insurance-coverage.spec.ts b/frontend/e2e/insurance-coverage.spec.ts new file mode 100644 index 000000000..3066e32b1 --- /dev/null +++ b/frontend/e2e/insurance-coverage.spec.ts @@ -0,0 +1,397 @@ +/** + * E2E Tests: Insurance Coverage Guidelines + * + * Tests the insurance planner wizard flow and field inputs: + * - Step 1: Person selection + income display + * - Step 2: Questionnaire sections (Hospitalization, Life/TPD, Critical Illness, PA, Self-Insurance) + * - Step 3: Summary review + * + * Uses serial mode since wizard state carries between tests. + */ +import { authenticatedTest } from './fixtures/auth' +import { expect } from '@playwright/test' + +authenticatedTest.describe('Insurance Coverage Guidelines', () => { + authenticatedTest.describe.configure({ mode: 'serial' }) + + authenticatedTest('should navigate to insurance planner', async ({ authenticatedPage }) => { + const page = authenticatedPage + + // Navigate directly to the insurance planner page + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Verify we're on the insurance planner page + await expect(page.getByText('Insurance Planner').first()).toBeVisible({ timeout: 10000 }) + + // The default tab should be "My Coverage" which shows the GuidelinesTab + await expect(page.getByText('My Coverage').first()).toBeVisible({ timeout: 5000 }) + }) + + authenticatedTest('Step 1: should show person selector and income', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Verify Step 1 heading + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + await expect(page.getByText('Who are we planning for?')).toBeVisible() + + // Verify person selector exists + await expect(page.getByText('Select person').first()).toBeVisible() + + // The PersonSelector should auto-select the first person + // Check that the annual income section appears + await expect(page.getByText(/annual income/i).first()).toBeVisible({ timeout: 5000 }) + + // Verify the Continue button exists + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible() + }) + + authenticatedTest('Step 1: should allow changing person selection', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 to load + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Click the person selector dropdown to open it + const personSelector = page.locator('.relative').filter({ hasText: /select person/i }).first() + const dropdownTrigger = personSelector.locator('button').first() + + if (await dropdownTrigger.isVisible()) { + await dropdownTrigger.click() + + // Verify the dropdown opened - should show person options + await page.waitForTimeout(500) + + // Check that at least one person is in the dropdown + const personOptions = page.locator('button').filter({ hasText: /\w+/ }) + const optionCount = await personOptions.count() + expect(optionCount).toBeGreaterThan(0) + + // Close dropdown by pressing Escape + await page.keyboard.press('Escape') + } + }) + + authenticatedTest('Step 1: should navigate to Step 2 on Continue', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Click continue to go to Step 2 + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + + // Only click if the button is enabled (income > 0) + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled === null) { + await continueBtn.click() + + // Verify we're now on Step 2 — should see questionnaire section pills + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + } + }) + + authenticatedTest('Step 2 - Hospitalization: should show coverage options', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Navigate to Step 2 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled !== null) return // Skip if can't proceed + + await continueBtn.click() + + // Verify Hospitalization section is active + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + await expect(page.getByText(/hospitalization coverage/i).first()).toBeVisible({ timeout: 5000 }) + + // Should show Public/Private hospital options + await expect(page.getByText(/public/i).first()).toBeVisible() + await expect(page.getByText(/private/i).first()).toBeVisible() + + // Click "Private" option + const privateOption = page.locator('button').filter({ hasText: /private/i }).first() + await privateOption.click() + + // After selecting Private, should show room type options + await page.waitForTimeout(500) + + // Click Continue to advance to next sub-step or section + const nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + // Select a room type option if visible + const roomOptions = page.locator('button').filter({ hasText: /single|shared|ward/i }) + if (await roomOptions.first().isVisible()) { + await roomOptions.first().click() + } + await nextBtn.click() + } + }) + + authenticatedTest('Step 2 - Life/TPD: should show dependent selection and financial obligations', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Navigate through Step 1 → Step 2 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const step1Continue = page.getByRole('button', { name: /continue/i }) + await expect(step1Continue).toBeVisible({ timeout: 5000 }) + if ((await step1Continue.getAttribute('disabled')) !== null) return + await step1Continue.click() + + // Navigate through Hospitalization section + await expect(page.getByText('Hospitalization').first()).toBeVisible({ timeout: 5000 }) + + // Select Public hospital (simpler path) + const publicOption = page.locator('button').filter({ hasText: /public/i }).first() + if (await publicOption.isVisible()) { + await publicOption.click() + await page.waitForTimeout(300) + } + + // Select a ward class if shown + const wardOptions = page.locator('button').filter({ hasText: /class|ward/i }) + if (await wardOptions.first().isVisible()) { + await wardOptions.first().click() + await page.waitForTimeout(300) + } + + // Click Continue to go to Life/TPD + let nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + await nextBtn.click() + await page.waitForTimeout(300) + } + + // If there's another sub-step in hospitalization, advance again + nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + // Check if we're still in hospitalization + const lifeTpdVisible = await page.getByText('Life / TPD').first().isVisible().catch(() => false) + if (!lifeTpdVisible) { + await nextBtn.click() + await page.waitForTimeout(300) + } + } + + // Now we should be in Life/TPD section + // Check for dependent selection heading + const dependentLabel = page.getByText(/who financially depends on you/i) + const isLifeTpdSection = await dependentLabel.isVisible({ timeout: 5000 }).catch(() => false) + + if (isLifeTpdSection) { + // Verify dependent selection chips are shown + await expect(dependentLabel).toBeVisible() + + // Check if there are person chips to select as dependents + // These are buttons showing person names with checkboxes + const personChips = page.locator('button').filter({ hasText: /age \d+/i }) + const chipCount = await personChips.count() + + if (chipCount > 0) { + // Click the first dependent to select them + await personChips.first().click() + await page.waitForTimeout(300) + + // Verify selection summary appears + await expect(page.getByText(/dependent.*selected/i).first()).toBeVisible({ timeout: 3000 }) + + // Check that spouse selector appears after selecting a dependent + const spouseLabel = page.getByText(/spouse.*partner/i).first() + const spouseVisible = await spouseLabel.isVisible({ timeout: 3000 }).catch(() => false) + if (spouseVisible) { + await expect(spouseLabel).toBeVisible() + } + } + + // Click Continue to go to Financial obligations sub-step + nextBtn = page.getByRole('button', { name: /continue/i }) + if (await nextBtn.isVisible()) { + await nextBtn.click() + await page.waitForTimeout(300) + } + + // Check for financial obligations section + const financialLabel = page.getByText(/financial obligations/i).first() + const financialVisible = await financialLabel.isVisible({ timeout: 3000 }).catch(() => false) + + if (financialVisible) { + // Verify auto-populated financial data is shown (read-only) + const mortgageText = page.getByText(/mortgage/i).first() + await expect(mortgageText).toBeVisible({ timeout: 3000 }) + + // Verify "Future obligations" input exists (the only editable field) + const futureObligations = page.getByText(/future obligations/i).first() + const hasFutureObligations = await futureObligations.isVisible({ timeout: 3000 }).catch(() => false) + if (hasFutureObligations) { + await expect(futureObligations).toBeVisible() + } + } + } + }) + + authenticatedTest('Step 2 - Critical Illness: should show emergency fund options', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Fast-track through Step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + const step1Continue = page.getByRole('button', { name: /continue/i }) + if ((await step1Continue.getAttribute('disabled')) !== null) return + await step1Continue.click() + await page.waitForTimeout(500) + + // Navigate through sections by clicking Continue repeatedly + // We need to get past Hospitalization and Life/TPD to reach Critical Illness + for (let i = 0; i < 6; i++) { + const continueBtn = page.getByRole('button', { name: /continue/i }) + if (!(await continueBtn.isVisible({ timeout: 2000 }).catch(() => false))) break + + // Select any required option before continuing + // Check for option cards and select the first if none selected + const optionButtons = page.locator('button').filter({ hasText: /public|private|single|shared/i }) + if (await optionButtons.first().isVisible({ timeout: 500 }).catch(() => false)) { + await optionButtons.first().click() + await page.waitForTimeout(200) + } + + await continueBtn.click() + await page.waitForTimeout(500) + + // Check if we've reached Critical Illness + const ciVisible = await page.getByText(/emergency fund/i).first().isVisible({ timeout: 1000 }).catch(() => false) + if (ciVisible) break + } + + // Verify Critical Illness section content + const emergencyFund = page.getByText(/emergency fund/i).first() + const isCISection = await emergencyFund.isVisible({ timeout: 3000 }).catch(() => false) + if (isCISection) { + await expect(emergencyFund).toBeVisible() + } + }) + + authenticatedTest('Full wizard flow: should complete all steps', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Step 1: Verify and proceed + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + let continueBtn = page.getByRole('button', { name: /continue/i }) + await expect(continueBtn).toBeVisible({ timeout: 5000 }) + if ((await continueBtn.getAttribute('disabled')) !== null) { + // Can't proceed without income - test ends here + return + } + await continueBtn.click() + await page.waitForTimeout(500) + + // Step 2: Click through all questionnaire sections + // Navigate through each section by selecting options and clicking Continue + let maxAttempts = 20 // Safety limit + let attempts = 0 + + while (attempts < maxAttempts) { + attempts++ + + // Check if we've reached Step 3 (Summary) + const summaryVisible = await page.getByText(/review your coverage/i).first().isVisible({ timeout: 500 }).catch(() => false) + || await page.getByText(/your targets/i).first().isVisible({ timeout: 500 }).catch(() => false) + if (summaryVisible) break + + // Look for Continue button + continueBtn = page.getByRole('button', { name: /continue/i }) + const continueVisible = await continueBtn.isVisible({ timeout: 1000 }).catch(() => false) + + if (!continueVisible) { + // Maybe there's a "Complete" or "Finish" button + const completeBtn = page.getByRole('button', { name: /complete|finish|confirm/i }) + if (await completeBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await completeBtn.click() + break + } + break + } + + // Before clicking Continue, try selecting required options + // Check if Continue is disabled - if so, we need to make a selection + const isDisabled = await continueBtn.getAttribute('disabled') + if (isDisabled !== null) { + // Find and click any unselected option card + const optionCards = page.locator('button').filter({ hasText: /public|private|single|shared|class|ward|basic|standard/i }) + if (await optionCards.first().isVisible({ timeout: 500 }).catch(() => false)) { + await optionCards.first().click() + await page.waitForTimeout(300) + } else { + // Try clicking any available person chip for dependent selection + const personChips = page.locator('button').filter({ hasText: /age \d+/i }) + if (await personChips.first().isVisible({ timeout: 500 }).catch(() => false)) { + await personChips.first().click() + await page.waitForTimeout(300) + } else { + // Can't proceed - break to avoid infinite loop + break + } + } + } + + await continueBtn.click() + await page.waitForTimeout(500) + } + + // Verify we reached either the summary or the configured view + const reachedEnd = await page.getByText(/review your coverage|your targets|coverage target/i).first() + .isVisible({ timeout: 5000 }).catch(() => false) + + // Log where we ended up for debugging + if (!reachedEnd) { + const pageContent = await page.textContent('body') + console.log('Did not reach summary. Current page contains:', pageContent?.substring(0, 500)) + } + }) + + authenticatedTest('PersonSelector: should show relationship labels', async ({ authenticatedPage }) => { + const page = authenticatedPage + await page.goto('/insurance-planner') + await page.waitForLoadState('networkidle') + + // Wait for step 1 + await expect(page.getByText('Step 1 of 3')).toBeVisible({ timeout: 10000 }) + + // Open the PersonSelector dropdown + // Find the selector near "Select person" label + const selectorContainer = page.locator('div').filter({ hasText: /^select person$/i }).first() + const trigger = selectorContainer.locator('..').locator('button').first() + + if (await trigger.isVisible()) { + await trigger.click() + await page.waitForTimeout(500) + + // Check that the dropdown is open and shows person options + // Person options should show names and potentially relationship labels + const dropdownOptions = page.locator('button').filter({ hasText: /\w+/ }) + const count = await dropdownOptions.count() + + // At least the test account person should be visible + expect(count).toBeGreaterThan(0) + + // Close dropdown + await page.keyboard.press('Escape') + } + }) +}) diff --git a/frontend/package.json b/frontend/package.json index a4690f184..eee6ae15a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "lint": "next lint", "type-check": "tsc --noEmit", "test": "vitest", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build -o storybook-static", "generate:api": "swagger-typescript-api generate -p ../backend/cmd/server/docs/swagger.json -o src/types -n api.generated.ts --no-client" }, "dependencies": { @@ -50,6 +52,15 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", + "@storybook/addon-essentials": "8.4.7", + "@storybook/addon-interactions": "8.4.7", + "@storybook/addon-themes": "8.4.7", + "@storybook/addon-webpack5-compiler-swc": "^4.0.2", + "@storybook/blocks": "8.4.7", + "@storybook/manager-api": "8.4.7", + "@storybook/react": "8.4.7", + "@storybook/react-webpack5": "8.4.7", + "@storybook/theming": "8.4.7", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -60,13 +71,19 @@ "@types/react-resizable": "^3.0.8", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.16", + "css-loader": "^7.1.3", "eslint": "^8.53.0", "eslint-config-next": "^14.0.0", "grab": "^0.0.88", + "html-webpack-plugin": "^5.6.6", "jsdom": "^27.2.0", "postcss": "^8.4.31", + "postcss-loader": "^8.2.0", + "storybook": "8.4.7", + "style-loader": "^4.0.0", "swagger-typescript-api": "^13.2.16", "tailwindcss": "^3.3.5", + "ts-loader": "^9.5.4", "typescript": "^5.2.2", "vitest": "^4.0.13" } diff --git a/frontend/src/api/financial/persons.ts b/frontend/src/api/financial/persons.ts index 72f13c150..e26460584 100644 --- a/frontend/src/api/financial/persons.ts +++ b/frontend/src/api/financial/persons.ts @@ -1,5 +1,5 @@ import { apiClient } from '../client' -import type { Person, PersonCreatePayload, PersonUpdatePayload } from '@/types/person' +import type { Person, PersonCreatePayload, PersonUpdatePayload, Relationship } from '@/types/person' import type { ResidencyStatus } from '@/types/cpf' import type { Person as ApiPerson, @@ -21,6 +21,7 @@ function toPerson(data: ApiPerson): Person { gender: ((data as { gender?: string }).gender ?? 'male') as 'male' | 'female', residencyStatus: (data.residencyStatus ?? 'citizen') as ResidencyStatus, prGrantDate: data.prGrantDate ?? null, + relationship: ((data as { relationship?: string }).relationship ?? 'self') as Relationship, createdAt: data.createdAt ?? '', updatedAt: data.updatedAt ?? '', incomeCount: data.incomeCount ?? 0, @@ -48,13 +49,14 @@ export async function getPerson(id: string): Promise { * Create a new person */ export async function createPerson(payload: PersonCreatePayload): Promise { - const body: PersonV2CreateInput & { gender?: string } = { + const body: PersonV2CreateInput & { gender?: string; relationship?: string } = { name: payload.name, dateOfBirth: payload.dateOfBirth, residencyStatus: payload.residencyStatus, displayColor: payload.displayColor ?? undefined, prGrantDate: payload.prGrantDate ?? undefined, gender: payload.gender, + relationship: payload.relationship, } const data = await apiClient.post('/persons', body, { baseUrl: '/api/v2' }) return toPerson(data) @@ -64,13 +66,14 @@ export async function createPerson(payload: PersonCreatePayload): Promise { - const body: PersonV2UpdateInput = { + const body: PersonV2UpdateInput & { relationship?: string } = { name: payload.name, dateOfBirth: payload.dateOfBirth, residencyStatus: payload.residencyStatus, displayColor: payload.displayColor ?? undefined, prGrantDate: payload.prGrantDate ?? undefined, isIncluded: payload.isIncluded, + relationship: payload.relationship, } const data = await apiClient.put(`/persons/${id}`, body, { baseUrl: '/api/v2' }) return toPerson(data) diff --git a/frontend/src/api/financial/settings.ts b/frontend/src/api/financial/settings.ts index b5b1fa801..0b153b844 100644 --- a/frontend/src/api/financial/settings.ts +++ b/frontend/src/api/financial/settings.ts @@ -14,6 +14,7 @@ export async function getUserSettings(): Promise { groupItemsByCategory: data.groupItemsByCategory ?? true, chartPictureInPicture: data.chartPictureInPicture ?? false, dashboardLayout: data.dashboardLayout ?? 'stacked', + onboardingCompleted: data.onboardingCompleted ?? false, updatedAt: data.updatedAt, } } @@ -29,6 +30,7 @@ export async function updateUserSettings(settings: UserSettings): Promise {/* Header */}

Welcome back

-

Sign in to continue to Assetra

+

Sign in to continue to WealthProject

{/* Form Card */} diff --git a/frontend/src/app/insurance-planner/page.tsx b/frontend/src/app/insurance-planner/page.tsx index 0a9e3826a..481ed3770 100644 --- a/frontend/src/app/insurance-planner/page.tsx +++ b/frontend/src/app/insurance-planner/page.tsx @@ -1,34 +1,182 @@ 'use client' import { useState } from 'react' -import { ArrowLeft, Shield, X } from 'lucide-react' -import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Shield, X } from 'lucide-react' +import clsx from 'clsx' import { InsuranceTabs, type InsuranceTabId, } from '@/components/insurance/InsuranceTabs' -import { OverviewTab } from '@/components/insurance/tabs/OverviewTab' import { PoliciesTab } from '@/components/insurance/tabs/PoliciesTab' +import { GuidelinesTab } from '@/components/insurance/tabs/GuidelinesTab' +import { JourneyTab } from '@/components/insurance/tabs/JourneyTab' +import { useColorScheme } from '@/stores' + +// ============================================================================ +// THEME-AWARE DESIGN SYSTEM +// Supports both dark mode and Monet (impressionist) mode +// ============================================================================ + +const monetColors = { + // Backgrounds - warm cream to pale blue like his canvases + bgCream: '#FAF8F5', + bgPaleBlue: '#F0F4F8', + bgWarmWhite: '#FFFEF9', + + // Primary - Wisteria/Lavender (from his garden paintings) + lavender: '#9B8BB4', + lavenderLight: '#C4B8D9', + lavenderDark: '#7A6B94', + + // Accent - Coral Rose (water lily pinks) + coralRose: '#E8A898', + coralRoseLight: '#F5D4CC', + coralRoseDark: '#D4847A', + + // Success - Sage Green (lily pads, gardens) + sage: '#7FB285', + sageLight: '#B5D4B8', + sageDark: '#5A8A5E', + + // Gold - Sunlight on water + sunlightGold: '#D4C5A9', + sunlightGoldLight: '#EDE6D8', + sunlightGoldDark: '#B8A87D', + + // Text + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + textMuted: '#9B9B9B', + + // Soft shadows + shadowSoft: 'rgba(155, 139, 180, 0.12)', + shadowMedium: 'rgba(155, 139, 180, 0.18)', +} + +const darkColors = { + // Backgrounds + bgPrimary: '#0a0a0a', + bgSecondary: '#111111', + bgTertiary: '#1a1a1a', + + // Primary - Purple accent + primary: '#a78bfa', + primaryLight: '#c4b5fd', + primaryDark: '#7c3aed', + + // Accent - Coral/Rose + accent: '#fb7185', + accentLight: '#fda4af', + accentDark: '#e11d48', + + // Success - Emerald + success: '#34d399', + successLight: '#6ee7b7', + successDark: '#059669', + + // Gold + gold: '#fbbf24', + goldLight: '#fcd34d', + goldDark: '#d97706', + + // Text + textPrimary: '#f1f5f9', + textSecondary: '#94a3b8', + textMuted: '#64748b', + + // Shadows + shadowSoft: 'rgba(0, 0, 0, 0.3)', + shadowMedium: 'rgba(0, 0, 0, 0.5)', +} + +// Painterly noise texture overlay (subtle canvas effect) +const canvasTexture = `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")` // Embedded view component for use within Dashboard export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { const [activeTab, setActiveTab] = useState('overview') + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + const handleNavigateToPolicy = () => { + setActiveTab('policies') + } return ( -
+
+ {/* Canvas/noise texture overlay */} +
+ + {/* Ambient light effects */} +
+ {/* Header */} -
-
+
+
-
-
- +
+
+
-

+

Insurance Planner

-

+

Analyze scenarios and explore coverage options

@@ -37,7 +185,12 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { @@ -50,8 +203,9 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { {/* Main Content - scrollable */} -
- {activeTab === 'overview' && } +
+ {activeTab === 'overview' && } + {activeTab === 'journey' && } {activeTab === 'policies' && }
@@ -60,37 +214,137 @@ export function InsurancePlannerView({ onClose }: { onClose?: () => void }) { // Standalone page for direct navigation export default function InsurancePlannerPage() { + const router = useRouter() const [activeTab, setActiveTab] = useState('overview') + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + const handleClose = () => { + router.push('/dashboard') + } + + const handleNavigateToPolicy = () => { + setActiveTab('policies') + } return ( -
- {/* Header */} -
-
-
- - - Dashboard - +
+ {/* Canvas/noise texture overlay */} +
-
+ {/* Atmospheric light effects */} +
+
+
-
-
- + {/* Header */} +
+
+
+
+
+
+
-

+

Insurance Planner

-

+

Analyze scenarios and explore coverage options

+ +
@@ -98,11 +352,17 @@ export default function InsurancePlannerPage() { {/* Tab Navigation */} - {/* Main Content */} -
- {activeTab === 'overview' && } - {activeTab === 'policies' && } + {/* Main Content - scrollable */} +
+
+ {activeTab === 'overview' && } + {activeTab === 'journey' && } + {activeTab === 'policies' && } +
) } + +// Export colors for use in child components +export { monetColors, darkColors, canvasTexture } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 70c47f592..6a484a3ef 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,6 +8,7 @@ import { AuthProvider } from '@/components/auth/AuthProvider' import { AuthenticationGuard } from '@/components/auth/AuthGuard' import { PersonFilterProvider } from '@/contexts/PersonFilterContext' import { PersonsModalContainer } from '@/components/modals/PersonsModal' +import { ThemeProvider } from '@/components/providers/ThemeProvider' const geistSans = Geist({ subsets: ['latin'], @@ -29,7 +30,7 @@ export default function RootLayout({ return ( {process.env.NODE_ENV === 'development' && ( @@ -52,15 +53,17 @@ export default function RootLayout({ )} - + - - - {children} - - - + + + + {children} + + + + @@ -70,6 +73,6 @@ export default function RootLayout({ } export const metadata = { - title: 'Financial Chat System', - description: 'AI-powered financial planning and chat system', + title: 'WealthProject', + description: 'Weath projection simulator', } diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx index a27981b95..efb016ffb 100644 --- a/frontend/src/components/auth/UserMenu.tsx +++ b/frontend/src/components/auth/UserMenu.tsx @@ -4,13 +4,17 @@ import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { LogOut, User, ChevronDown, Settings } from 'lucide-react' +import clsx from 'clsx' import { useSession, signOut } from '@/lib/auth-client' import { Button } from '@/components/ui/button' import { SettingsModal } from '@/components/modals/SettingsModal/SettingsModal' import { clearAllSensitiveStorage } from '@/hooks/useTaxReliefStorage' +import { useColorScheme } from '@/stores' export function UserMenu() { const { data: session, isPending } = useSession() + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const [isOpen, setIsOpen] = useState(false) const [isSigningOut, setIsSigningOut] = useState(false) const [mounted, setMounted] = useState(false) @@ -56,7 +60,10 @@ export function UserMenu() { // Show loading skeleton on server and until mounted on client if (!mounted || isPending) { return ( -
+
) } @@ -64,7 +71,11 @@ export function UserMenu() { return (
- @@ -81,16 +92,14 @@ export function UserMenu() {
{isOpen && ( -
-
-

+

+
+

{session.user.name}

-

+

{session.user.email}

@@ -130,12 +156,12 @@ shadow-2xl`} style={{ isolation: 'isolate' }}> setIsOpen(false) setIsSettingsOpen(true) }} - className={`flex items-center -w-full -gap-2 px-4 py-2.5 -hover:bg-white/5 -text-sm text-slate-300 hover:text-slate-100 -transition-colors`} + className={clsx( + 'flex items-center w-full gap-2 px-4 py-2.5 text-sm transition-colors', + isMonet + ? 'text-[var(--monet-text-secondary)] hover:text-[var(--monet-text-primary)] hover:bg-[var(--monet-lavender)]/5' + : 'text-slate-300 hover:text-slate-100 hover:bg-white/5' + )} > Settings @@ -143,13 +169,12 @@ transition-colors`}
-

Assetra Chat

+

WealthProject Chat

{onCollapse && ( diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index 31e1310ac..3d0825d49 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -14,6 +14,8 @@ import { MiniChart } from './MiniChart' import { ResizableChartSection } from './ResizableChartSection' import { PropertyPlannerModal } from '@/components/modals/PropertyPlannerModal/PropertyPlannerModal' import { LayoutPreviewModal } from '@/components/modals/LayoutPreviewModal' +import { OnboardingWizardModal } from '@/components/modals/OnboardingWizardModal/OnboardingWizardModal' +import { PostResetChoiceModal } from '@/components/modals/PostResetChoiceModal' // Loading skeleton for feature modules function FeatureModuleLoading() { @@ -30,15 +32,12 @@ const CPFSimulationView = dynamic( { ssr: false, loading: FeatureModuleLoading } ) -const TaxPlannerV2View = dynamic( - () => import('@/app/tax-planner/page').then(mod => ({ default: mod.TaxPlannerV2View })), - { ssr: false, loading: FeatureModuleLoading } -) - const InsurancePlannerView = dynamic( () => import('@/app/insurance-planner/page').then(mod => ({ default: mod.InsurancePlannerView })), { ssr: false, loading: FeatureModuleLoading } ) + +import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' import { useTimeline } from '@/hooks/useTimeline' import { usePictureInPicture } from '@/hooks/usePictureInPicture' import { useScenarioEvents } from '@/hooks/useScenarioEvents' @@ -46,7 +45,7 @@ import { useWindowWidth } from '@/hooks/useWindowWidth' import { generateUUID } from '@/lib/utils' import { settingsApi } from '@/api/financial' import { QUERY_KEYS } from '@/lib/queryKeys' -import { useTimelineStore, useFeatureModulesStore } from '@/stores' +import { useTimelineStore, useFeatureModulesStore, useColorScheme } from '@/stores' import { useShallow } from 'zustand/react/shallow' export function Dashboard() { @@ -54,13 +53,12 @@ export function Dashboard() { const chatId = chatIdRef.current const queryClient = useQueryClient() const windowWidth = useWindowWidth() + const colorScheme = useColorScheme() // Feature modules state from Zustand store (batched with shallow comparison) const { showCPFView, closeCPFView, - showTaxPlanner, - closeTaxPlanner, showInsurancePlanner, closeInsurancePlanner, showPropertyPlanner, @@ -69,6 +67,11 @@ export function Dashboard() { closePropertyPlanner, showLayoutModal, closeLayoutModal, + showOnboardingWizard, + openOnboardingWizard, + closeOnboardingWizard, + showPostResetChoice, + closePostResetChoice, isChatCollapsed, isHistoryOpen, toggleChat, @@ -82,8 +85,6 @@ export function Dashboard() { useShallow((s) => ({ showCPFView: s.showCPFView, closeCPFView: s.closeCPFView, - showTaxPlanner: s.showTaxPlanner, - closeTaxPlanner: s.closeTaxPlanner, showInsurancePlanner: s.showInsurancePlanner, closeInsurancePlanner: s.closeInsurancePlanner, showPropertyPlanner: s.showPropertyPlanner, @@ -92,6 +93,11 @@ export function Dashboard() { closePropertyPlanner: s.closePropertyPlanner, showLayoutModal: s.showLayoutModal, closeLayoutModal: s.closeLayoutModal, + showOnboardingWizard: s.showOnboardingWizard, + openOnboardingWizard: s.openOnboardingWizard, + closeOnboardingWizard: s.closeOnboardingWizard, + showPostResetChoice: s.showPostResetChoice, + closePostResetChoice: s.closePostResetChoice, isChatCollapsed: s.isChatCollapsed, isHistoryOpen: s.isHistoryOpen, toggleChat: s.toggleChat, @@ -130,6 +136,36 @@ export function Dashboard() { } }, [userSettings?.dashboardLayout, initializeLayout]) + // ─── Onboarding wizard auto-trigger ───────────────────────────────────── + // Show wizard when: user has zero persons AND hasn't completed/dismissed onboarding. + // Uses a ref guard to prevent re-triggering during the same "session" (avoids race + // condition where the dismiss mutation hasn't round-tripped yet). + // The guard resets when onboardingCompleted flips back to false (e.g., after "Start Fresh"). + const { data: personsData } = usePersonsQuery() + const onboardingTriggeredRef = useRef(false) + + // Reset the guard when onboardingCompleted is explicitly set back to false + // (happens after "Delete All Data" / "Start Fresh") + useEffect(() => { + if (userSettings && !userSettings.onboardingCompleted) { + onboardingTriggeredRef.current = false + } + }, [userSettings?.onboardingCompleted]) + + useEffect(() => { + if ( + !onboardingTriggeredRef.current && + !showPostResetChoice && + personsData !== undefined && + userSettings !== undefined && + personsData.length === 0 && + !userSettings.onboardingCompleted + ) { + onboardingTriggeredRef.current = true + openOnboardingWizard() + } + }, [personsData, userSettings, showPostResetChoice, openOnboardingWizard]) + // Mutation for updating layout preference const updateLayoutMutation = useMutation({ mutationFn: (layout: typeof dashboardLayout) => @@ -193,32 +229,41 @@ export function Dashboard() { return () => window.removeEventListener('keydown', handleKeyDown) }, [toggleChat]) + // Theme-aware styles + const isMonet = colorScheme === 'monet' + return ( <> -
- {/* Ambient background orbs */} -
-
-
+
+ {/* Ambient background orbs - theme aware */} +
+
+
{/* Main content - side by side layout */}
@@ -232,19 +277,21 @@ opacity-20 blur-[80px]`} /> > {/* Chat panel */}
-
+
{/* Collapsed sidebar */}
@@ -286,27 +338,16 @@ h-screen gap-6 p-6`}> {showCPFView ? ( /* CPF Simulation View - takes over entire area */ -
+
- ) : showTaxPlanner ? ( - /* Tax Planner View - shows header + tax planner */ - <> - {/* Header bar only - no chart */} -
- -
- {/* Tax Planner content */} -
- -
- ) : showInsurancePlanner ? ( /* Insurance Planner View - shows header + insurance planner */ <> @@ -315,10 +356,14 @@ bg-[#0a0a0a]/80`}>
{/* Insurance Planner content */} -
+
@@ -333,7 +378,7 @@ bg-[#0a0a0a]/80`}> {/* Side-by-side content area */}
@@ -349,7 +394,7 @@ bg-[#0a0a0a]/80`}>
{/* Cards section - compact mode */} -
+
@@ -378,7 +423,7 @@ bg-[#0a0a0a]/80`}>
{/* Picture-in-Picture mini chart - disabled in side-by-side layouts */} - {showPiP && !showCPFView && !showTaxPlanner && !showInsurancePlanner && !isSideBySide && ( + {showPiP && !showCPFView && !showInsurancePlanner && !isSideBySide && ( currentLayout={dashboardLayout} onLayoutChange={handleLayoutChange} /> + + {/* Post-reset choice: Dashboard vs Wizard */} + { + closePostResetChoice() + // Mark onboarding completed so the auto-trigger doesn't reopen the wizard + onboardingTriggeredRef.current = true + if (userSettings) { + const updated = { ...userSettings, onboardingCompleted: true } + queryClient.setQueryData(QUERY_KEYS.settings.user, updated) + settingsApi.updateUserSettings(updated) + } + }} + onWizard={() => { + closePostResetChoice() + openOnboardingWizard() + }} + /> + + {/* Onboarding Wizard Modal */} + ) } diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx index 5b8cf0071..a6f6791a9 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard.tsx @@ -5,6 +5,7 @@ import type { ScenarioEvent } from '@/types/scenario' import type { CashAccount } from '@/types/financial' import type { PropertyLinkRecord } from '@/types/property' import type { IncomeAllocation } from '@/api/financial/incomes' +import { useColorScheme } from '@/stores' import { categoryConfig } from '../config' import { getItemId, sortItems } from '../utils' import { parseDecimal } from '../converters' @@ -140,6 +141,10 @@ export function CategoryCard({ compact = false, onCollapseChange, }: CategoryCardProps) { + // Theme + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + // Start collapsed in compact mode (side-by-side layout) const [isCollapsed, setIsCollapsed] = useState(compact) @@ -232,8 +237,12 @@ export function CategoryCard({ return (
- {/* Collapsible content */} + {/* Collapsible content - Stitch style: no separate total section, items directly */}
- + {/* Hide the large total section - Stitch shows total in header subtitle */} + {false && ( + + )} {/* List Items */}
diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx index 74d390f81..7abc0f15d 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CPFContributionsSection.tsx @@ -17,14 +17,14 @@ export function CPFContributionsSection({ const renderContributions = () => cpfContributionsRaw.map((item, index) => (
- Employee - {item.name.replace('CPF Contribution - ', '')} + Employee - {item.name.replace('CPF Contribution - ', '')} ({formatCurrency(parseDecimal(item.employeeContribution))}) /mo
- Employer - {item.name.replace('CPF Contribution - ', '')} + Employer - {item.name.replace('CPF Contribution - ', '')} {formatCurrency(parseDecimal(item.employerContribution))} /mo @@ -40,7 +40,7 @@ export function CPFContributionsSection({ >
- + CPF Refund - {refund.propertyName}
diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx index 8e9cbf803..de0f47d49 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardHeader.tsx @@ -1,5 +1,8 @@ import { useState, useRef, useEffect } from 'react' -import { Plus, ArrowDownWideNarrow, Wallet, BarChart3, Shield, ChevronDown } from 'lucide-react' +import { Plus, Wallet, BarChart3, Shield, ChevronRight } from 'lucide-react' +import clsx from 'clsx' +import { useColorScheme } from '@/stores' +import { formatCurrency } from '@/lib/format' import { categoryConfig } from '../../config' import type { FinancialCategory } from '../../types' @@ -13,19 +16,24 @@ interface CategoryCardHeaderProps { onAddCpf?: () => void isCollapsed?: boolean onToggleCollapse?: () => void + /** Total amount to display as subtitle like Stitch */ + total?: number } export function CategoryCardHeader({ category, showMonthlyData, - sortDirection, - onToggleSortDirection, + sortDirection: _sortDirection, + onToggleSortDirection: _onToggleSortDirection, onAddItem, onAddInvestment, onAddCpf, isCollapsed = false, onToggleCollapse, + total, }: CategoryCardHeaderProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' const config = categoryConfig[category] const [showAssetMenu, setShowAssetMenu] = useState(false) const menuRef = useRef(null) @@ -57,39 +65,57 @@ export function CategoryCardHeader({ const showAssetDropdown = category === 'asset' && (onAddInvestment || onAddCpf) return ( -
+
+ {/* Left side: Icon + Title/Total - clickable to expand */} -
- - {showAssetDropdown ? ( + {/* Right side: Add button + Chevron */} +
+ {!isCollapsed && (showAssetDropdown ? (
) : ( + ))} + + {/* Chevron at far right - Stitch style */} + {onToggleCollapse && ( + )}
diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx index 0847c1567..4ed5ee887 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/CategoryCardTotal.tsx @@ -1,5 +1,7 @@ import { ArrowUpRight, ArrowDownRight } from 'lucide-react' +import clsx from 'clsx' import { formatCurrency } from '@/lib/format' +import { useColorScheme } from '@/stores' import type { FinancialCategory } from '../../types' interface CategoryCardTotalProps { @@ -13,27 +15,41 @@ export function CategoryCardTotal({ total, showMonthlyData, }: CategoryCardTotalProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + // Mock trend (you may want to calculate real trends) const mockTrend = category === 'asset' ? 12.5 : category === 'income' ? 5.2 : category === 'liability' ? -2.1 : 1.2 const isPositiveTrend = mockTrend >= 0 const showMonthSuffix = showMonthlyData && (category === 'income' || category === 'expense') return ( -
+
-
+
{formatCurrency(total)}
{showMonthSuffix && ( - /mo + /mo )}
-
+
{isPositiveTrend ? : } {Math.abs(mockTrend)}%
- vs last month + vs last month
) diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx index d2174e474..4120e9f00 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CategoryCard/InvestmentsIncomeSection.tsx @@ -44,7 +44,7 @@ export function InvestmentsIncomeSection({ )) ) : (
- Allocated to investments + Allocated to investments {formatCurrency(displayAmount)} {showMonthlyData ? '/mo' : '/yr'} diff --git a/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx b/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx index 201f33eeb..3635ba289 100644 --- a/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx +++ b/frontend/src/components/dashboard/FinancialDataManagement/components/CollapsibleSection.tsx @@ -191,8 +191,8 @@ export function CollapsibleItem({ : (isSelected ? "bg-white/[0.06]" : "hover:bg-white/[0.04]") )} > -
- {name} +
+ {name} {icon && ( ('calendar') const { data: userSettings } = useQuery({ @@ -186,14 +189,25 @@ export function Header({
{/* Row 1: Title */}
-

Financial Data

-

{`${absoluteYear} (Age ${displayAge})`}

+

Financial Data

+

{`${absoluteYear} (Age ${displayAge})`}

{/* Row 2: Timeline controls on left, Action buttons on right */}
{/* Timeline navigation controls */} -
+
{resolution === 'monthly' && ( <> @@ -207,8 +221,9 @@ export function Header({ { value: 'annualized', label: 'Yearly' }, { value: 'monthly', label: 'Monthly' }, ]} + isMonet={isMonet} /> -
+
)} @@ -227,11 +242,12 @@ export function Header({ className="w-16" onLabelClick={() => setYearDisplayMode(m => m === 'calendar' ? 'relative' : 'calendar')} labelTitle="Click to toggle year format" + isMonet={isMonet} /> {resolution === 'monthly' && ( <> -
+
)}
{shouldShowSlider && ( -
+
- - + + - +
)}
- {/* Action buttons */} -
- {/* Tax Estimate Icon */} + {/* Action buttons - Cashflow & Family pills like Stitch */} +
+ {/* Cashflow Button */} - {/* Persons Filter Icon */} + {/* Family Button */}
@@ -319,9 +354,10 @@ interface SelectFieldProps { className?: string onLabelClick?: () => void labelTitle?: string + isMonet?: boolean } -function SelectField({ label, id, value, disabled, onChange, options, className, onLabelClick, labelTitle }: SelectFieldProps) { +function SelectField({ label, id, value, disabled, onChange, options, className, onLabelClick, labelTitle, isMonet }: SelectFieldProps) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) @@ -338,9 +374,16 @@ function SelectField({ label, id, value, disabled, onChange, options, className, const selectedOption = options.find(opt => opt.value === value) return ( -
+