diff --git a/CHANGELOG.md b/CHANGELOG.md index f496ee1..742b152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 2026-04-06 — MoneyMirror: E2E documentation pass, `CODEBASE-CONTEXT` refresh, verification + +**App:** `apps/money-mirror` + +**What:** Refreshed [`apps/money-mirror/CODEBASE-CONTEXT.md`](apps/money-mirror/CODEBASE-CONTEXT.md) for post–issue-010 hardening: idempotent schema upgrades (`src/lib/schema-upgrades.ts`, `scripts/apply-schema-upgrades.ts`), boot-time DDL (`src/lib/run-schema-upgrade-on-boot.ts`, `src/instrumentation.ts`), Postgres error helpers (`src/lib/pg-errors.ts`) and `SCHEMA_DRIFT` JSON responses, `WebVitalsReporter`, Playwright (`playwright.config.ts`, `e2e/`). README **Docs** section links [`docs/PERFORMANCE-REVIEW.md`](apps/money-mirror/docs/PERFORMANCE-REVIEW.md) and [`docs/SCHEMA-DRIFT.md`](apps/money-mirror/docs/SCHEMA-DRIFT.md). + +**Verification (local):** `npm run lint`, `npm run test` (81/81), `npx playwright install chromium`, `npm run test:e2e` (2/2, from `apps/money-mirror`); `npm run check:all` (repo root); `npm run db:upgrade` (success — Neon via `.env.local`). + +**Learnings / corrections:** (1) **`db:upgrade` script shape** — an initial version used top-level `await`, which breaks `tsx`/`esbuild` execution; wrapping the script in `main()` fixed `npm run db:upgrade` (see earlier 2026-04-06 changelog entry). (2) **Schema drift vs “app bug”** — Neon DBs created before Phase 3 columns can throw Postgres `42703` (undefined column); API routes return `code: SCHEMA_DRIFT` with `SCHEMA_UPGRADE_HINT` so operators run `npm run db:upgrade` or rely on auto DDL on server boot (unless `MONEYMIRROR_SKIP_AUTO_SCHEMA=1`). (3) **No PM-pasted mistake list in this session** — additional bullets can be appended to `project-state.md` Decisions Log under the same date if needed. + +--- + +## 2026-04-06 — MoneyMirror: `db:upgrade` + schema drift API hints + +**App:** `apps/money-mirror` + +**What:** Idempotent `npm run db:upgrade` (`scripts/apply-schema-upgrades.ts`, shared `applyIdempotentSchemaUpgrades` in `src/lib/schema-upgrades.ts`) adds `merchant_key` + partial index and statement label columns when missing. `GET /api/transactions` and `GET /api/insights/merchants` return `code: SCHEMA_DRIFT` with upgrade instructions on Postgres undefined-column errors; Transactions tab and `MerchantRollups` surface `detail` for that code. Dev dependency: `tsx`. + +**Follow-up:** `runAutoSchemaUpgradeOnBoot` (`src/lib/run-schema-upgrade-on-boot.ts`) runs the same DDL from `instrumentation.ts` on Node server start when `DATABASE_URL` is set (opt out with `MONEYMIRROR_SKIP_AUTO_SCHEMA=1`). UI shows a single PM-friendly paragraph for `SCHEMA_DRIFT` (no duplicated title + hint). **Docs:** `docs/SCHEMA-DRIFT.md` (RCA + local verification). + +**Fix:** `scripts/apply-schema-upgrades.ts` — wrap in `main()` (no top-level `await`) so `tsx`/`esbuild` can run `npm run db:upgrade`. + +--- + +## 2026-04-06 — MoneyMirror: perf hardening, E2E smoke, launch checklist (post–issue-010) + +**App:** `apps/money-mirror` + +**What:** `next/font` (Inter + Space Grotesk); `WebVitalsReporter` + optional `NEXT_PUBLIC_POSTHOG_*`; viewport `maximumScale: 5`; Playwright smoke (`e2e/`, `test:e2e`); lazy Gemini on Insights (`/api/dashboard` fast path); scope-keyed dashboard refetch; dev-only transaction 500 `detail`; `dev:loopback` + README note for `uv_interface_addresses` dev noise. Docs: `docs/PERFORMANCE-REVIEW.md`, `experiments/results/production-launch-checklist-010.md`. **Linear:** ops follow-up comment on **VIJ-37** (does not reopen pipeline). + +--- + ## 2026-04-05 — MoneyMirror Phase 3 T4 (VIJ-41): facts-grounded AI coaching **App:** `apps/money-mirror` diff --git a/apps/money-mirror/.gitignore b/apps/money-mirror/.gitignore index dd146b5..c4e74eb 100644 --- a/apps/money-mirror/.gitignore +++ b/apps/money-mirror/.gitignore @@ -42,3 +42,7 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin + +# Lighthouse (generated locally; see docs/PERFORMANCE-REVIEW.md) +/docs/lighthouse-report.report.html +/docs/lighthouse-report.report.json diff --git a/apps/money-mirror/CODEBASE-CONTEXT.md b/apps/money-mirror/CODEBASE-CONTEXT.md index e6d12d0..d0acb5d 100644 --- a/apps/money-mirror/CODEBASE-CONTEXT.md +++ b/apps/money-mirror/CODEBASE-CONTEXT.md @@ -1,6 +1,6 @@ # Codebase Context: MoneyMirror -Last updated: 2026-04-05 (Phase 3 learning; aggregate invariants) +Last updated: 2026-04-06 (post–issue-010 hardening: schema upgrades, SCHEMA_DRIFT, Web Vitals, E2E) ## What This App Does @@ -8,10 +8,11 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## Architecture Overview -- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Overview with perceived vs actual + categories, **Insights** tab for AI nudges + **top merchants** (`MerchantRollups`) + **Sources** drawer (`FactsDrawer`) for Layer A facts behind Gemini narratives, **Transactions** tab for paginated txn list + filters + optional `merchant_key` deep link, **Upload** tab). URL: optional `?statement_id=` (legacy single-statement), or unified scope `?date_from=&date_to=` + optional `statement_ids=`; optional `?tab=` for `overview` \| `insights` \| `transactions` \| `upload`; optional `merchant_key=` on dashboard URL (filters Transactions list). Dashboard shell is `DashboardClient.tsx` composed of `ScopeBar` (T2), `ResultsPanel`, `InsightsPanel`, `TransactionsPanel`, `UploadPanel`, `ParsingPanel`, `DashboardNav`, `StatementFilters` (hidden when unified URL), `DashboardBrandBar`. +- **Frontend**: Next.js 16 App Router (RSC by default, `"use client"` for interactive panels). `next/font` loads Inter + Space Grotesk (see `src/app/layout.tsx`). Optional client `WebVitalsReporter` sends Core Web Vitals to PostHog when `NEXT_PUBLIC_POSTHOG_KEY` / `NEXT_PUBLIC_POSTHOG_HOST` are set. Key pages: `/` (landing), `/onboarding` (5-question flow), `/score` (Money Health Score reveal), `/dashboard` (Overview with perceived vs actual + categories, **Insights** tab for AI nudges + **top merchants** (`MerchantRollups`) + **Sources** drawer (`FactsDrawer`) for Layer A facts behind Gemini narratives, **Transactions** tab for paginated txn list + filters + optional `merchant_key` deep link, **Upload** tab). URL: optional `?statement_id=` (legacy single-statement), or unified scope `?date_from=&date_to=` + optional `statement_ids=`; optional `?tab=` for `overview` \| `insights` \| `transactions` \| `upload`; optional `merchant_key=` on dashboard URL (filters Transactions list). Dashboard shell is `DashboardClient.tsx` composed of `ScopeBar` (T2), `ResultsPanel`, `InsightsPanel`, `TransactionsPanel`, `UploadPanel`, `ParsingPanel`, `DashboardNav`, `StatementFilters` (hidden when unified URL), `DashboardBrandBar`. - **Backend**: Next.js API routes under `src/app/api/`. Neon Auth for session auth, Neon Postgres for persistence, Gemini 2.5 Flash for PDF parse + categorization, Resend for weekly recap emails, PostHog for server-side telemetry. - **Database**: Neon Postgres. 4 tables: `profiles`, `statements`, `transactions`, `advisory_feed`. `profiles` persists monthly income and perceived spend; `statements` tracks `institution_name`, `statement_type`, optional credit-card due metadata, optional `nickname`, `account_purpose`, `card_network` for multi-account labelling. All monetary values are stored as `BIGINT` in paisa (₹ × 100) to avoid float precision errors. - **AI Integration**: Gemini 2.5 Flash via `@google/genai`. Used for: (1) PDF text → structured bank-account or credit-card statement JSON, (2) transaction category normalization, (3) **Insights** narrative rewrite only — input is Layer A facts JSON + advisory headlines; structured JSON output with `cited_fact_ids` validated against `coaching_facts` (see `src/lib/gemini-coaching-narrative.ts`, `src/lib/coaching-facts.ts`, `src/lib/coaching-enrich.ts`). The statement-parse route currently enforces a 25s timeout and returns JSON 504 on timeout. +- **Schema upgrades**: Idempotent DDL lives in `src/lib/schema-upgrades.ts` (`applyIdempotentSchemaUpgrades`) and is invoked by (1) `npm run db:upgrade` → `scripts/apply-schema-upgrades.ts`, (2) `runAutoSchemaUpgradeOnBoot` from `src/instrumentation.ts` on Node server start when `DATABASE_URL` is set (skip with `MONEYMIRROR_SKIP_AUTO_SCHEMA=1`). Keeps older Neon DBs aligned with `schema.sql` tail (e.g. `transactions.merchant_key`, statement label columns). - **Analytics**: PostHog (server-side only, `posthog-node`). Core events: `onboarding_completed`, `statement_parse_started/rate_limited/success/timeout/failed`, `weekly_recap_triggered/completed`, `weekly_recap_email_sent/failed`, plus Phase 3 `transactions_view_opened`, `transactions_filter_applied`, `scope_changed`, `merchant_rollup_clicked`, `coaching_narrative_completed/timeout/failed`, `coaching_facts_expanded` (see `README.md`). All calls fire-and-forget (`.catch(() => {})`) where not awaiting success paths. - **Error tracking**: Sentry via `@sentry/nextjs` (`sentry.server.config.ts`, `sentry.edge.config.ts`, `src/instrumentation.ts`, `src/instrumentation-client.ts`). Uses `NEXT_PUBLIC_SENTRY_DSN` plus org/project/auth token vars as in app `README.md` / `.env.local.example`. @@ -34,7 +35,7 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K | `src/lib/advisory-engine.ts` | Rule-based advisories (perception gap, subscriptions, food, no investment, debt ratio, high Other bucket, discretionary mix, avoidable estimate, CC minimum-due risk). | | `src/lib/coaching-facts.ts` | Layer A Zod schema + `buildLayerAFacts` from `DashboardData` (deterministic server math only). | | `src/lib/gemini-coaching-narrative.ts` | Gemini structured JSON narratives + `cited_fact_ids` validation. | -| `src/lib/coaching-enrich.ts` | `attachCoachingLayer` — merges AI narratives + PostHog `coaching_narrative_*` events. | +| `src/lib/coaching-enrich.ts` | `attachCoachingFactsOnly` for fast `GET /api/dashboard`; `attachCoachingLayer` for `GET /api/dashboard/advisories` — Gemini narratives + PostHog `coaching_narrative_*` events. | | `src/components/FactsDrawer.tsx` | Renders cited Layer A rows for an advisory (read-only). | | `src/lib/format-date.ts` | UTC-safe date labels for statement periods (no raw ISO in UI). | | `src/app/api/statements/route.ts` | Authenticated GET — list processed statements for picker + month filter. | @@ -43,6 +44,12 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K | `src/lib/pdf-parser.ts` | Extracts raw text from PDF buffer using `pdf-parse`. Uses `result.total` (not `result.pages?.length`) for page count — v2 API. | | `src/lib/posthog.ts` | Server-side PostHog singleton. Reads `POSTHOG_KEY` and `POSTHOG_HOST` (server-only, no `NEXT_PUBLIC_` prefix). | | `docs/COACHING-TONE.md` | AI narrative guardrails for advisory copy — defines consequence-first framing, banned phrases, and tone rules for all Gemini-generated coaching language. | +| `src/lib/schema-upgrades.ts` | Shared idempotent `ALTER` / `CREATE INDEX` for DBs missing Phase 2/3 columns; keep in sync with `schema.sql` tail. | +| `scripts/apply-schema-upgrades.ts` | CLI entry for `npm run db:upgrade` (uses `main()`, not top-level `await`, for `tsx` compatibility). | +| `src/lib/run-schema-upgrade-on-boot.ts` | Called from `instrumentation.ts` to apply the same DDL once per boot when `DATABASE_URL` is set. | +| `src/lib/pg-errors.ts` | `isUndefinedColumnError` (Postgres `42703` / message match); `SCHEMA_UPGRADE_HINT` for JSON error `detail` when routes detect schema drift. | +| `src/components/WebVitalsReporter.tsx` | Client-only `web_vital` reporting to PostHog when public PostHog keys are configured. | +| `playwright.config.ts` + `e2e/` | Smoke E2E: production build + static server on port 3333; hits `/` and `/login`. Run via `npm run test:e2e`. | ## Data Model @@ -53,22 +60,22 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K ## API Endpoints -| Method | Path | Auth | Purpose | -| ------ | ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| POST | `/api/statement/parse` | Neon session cookie | Upload PDF plus `statement_type` and optional `nickname`, `account_purpose`, `card_network` | -| GET | `/api/dashboard` | Neon session cookie | Rehydrate processed statement + advisories + `coaching_facts` + optional Gemini `narrative` fields; unified or legacy query params | -| GET | `/api/statements` | Neon session cookie | List all processed statements for the user | -| GET | `/api/transactions` | Neon session cookie | Paginated txns + filters; `statement_id` or `statement_ids` (comma); dates optional; `merchant_key` optional; ownership enforced | -| GET | `/api/insights/merchants` | Neon session cookie | Top merchants (debit rollups) for same scope as transactions | -| POST | `/api/insights/merchant-click` | Neon session cookie | `merchant_rollup_clicked` PostHog (bucketed key) | -| POST | `/api/transactions/view-opened` | Neon session cookie | `transactions_view_opened` telemetry | -| POST | `/api/transactions/backfill-merchant-keys` | Neon session cookie | Backfill `merchant_key` for current user’s rows | -| GET | `/api/dashboard/advisories` | Neon session cookie | Same scope as dashboard; returns `advisories` + `coaching_facts` | -| POST | `/api/dashboard/coaching-facts-expanded` | Neon session cookie | PostHog `coaching_facts_expanded` when user opens Sources | -| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | -| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | -| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | -| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/api/statement/parse` | Neon session cookie | Upload PDF plus `statement_type` and optional `nickname`, `account_purpose`, `card_network` | +| GET | `/api/dashboard` | Neon session cookie | Fast path: Layer A `coaching_facts`, rule-based advisories (no Gemini). Unified or legacy query params | +| GET | `/api/statements` | Neon session cookie | List all processed statements for the user | +| GET | `/api/transactions` | Neon session cookie | Paginated txns + filters; `statement_id` or `statement_ids` (comma); dates optional; `merchant_key` optional; ownership enforced. On undefined-column errors, may return **500** with `code: SCHEMA_DRIFT` and `detail` hinting `npm run db:upgrade` / boot DDL | +| GET | `/api/insights/merchants` | Neon session cookie | Top merchants (debit rollups) for same scope as transactions. Same **SCHEMA_DRIFT** behavior as transactions when columns are missing | +| POST | `/api/insights/merchant-click` | Neon session cookie | `merchant_rollup_clicked` PostHog (bucketed key) | +| POST | `/api/transactions/view-opened` | Neon session cookie | `transactions_view_opened` telemetry | +| POST | `/api/transactions/backfill-merchant-keys` | Neon session cookie | Backfill `merchant_key` for current user’s rows | +| GET | `/api/dashboard/advisories` | Neon session cookie | Gemini coaching narratives; returns `advisories` + `coaching_facts` | +| POST | `/api/dashboard/coaching-facts-expanded` | Neon session cookie | PostHog `coaching_facts_expanded` when user opens Sources | +| POST | `/api/onboarding/complete` | Neon session cookie | Save onboarding income, score, and perceived spend to profiles | +| GET | `/api/cron/weekly-recap` | `authorization: Bearer ` or local `x-cron-secret` | Scheduled master fan-out | +| POST | `/api/cron/weekly-recap/worker` | `x-cron-secret` header | Worker: send one recap email; returns 502 on failure | +| ALL | `/api/auth/[...path]` | — | Neon Auth passthrough | ## Things NOT to Change Without Reading First @@ -93,3 +100,4 @@ MoneyMirror is a mobile-first PWA AI financial coach for Gen Z Indians (₹20K - Weekly recap email only triggers if the user has at least one processed statement. New users without statements are silently skipped. - Share button (`navigator.share`) is hidden on desktop browsers — only rendered when Web Share API is available. - Run `npm test` in `apps/money-mirror` for current library and API test counts (includes merchant rollup reconciliation unit tests). +- Run `npm run test:e2e` for Playwright smoke (requires `npx playwright install chromium` first). See `docs/PERFORMANCE-REVIEW.md` for perf notes and `docs/SCHEMA-DRIFT.md` for schema drift RCA. diff --git a/apps/money-mirror/README.md b/apps/money-mirror/README.md index ece1224..466016d 100644 --- a/apps/money-mirror/README.md +++ b/apps/money-mirror/README.md @@ -51,22 +51,25 @@ cp .env.local.example .env.local Fill in these values: -| Variable | Required | Description | -| ------------------------- | -------- | ------------------------------------------------------------------- | -| `DATABASE_URL` | Yes | Neon Postgres connection string | -| `NEON_AUTH_BASE_URL` | Yes | Base URL for your Neon Auth project | -| `NEON_AUTH_COOKIE_SECRET` | No | Optional only if Neon explicitly gives one for your project/runtime | -| `GEMINI_API_KEY` | Yes | Google AI Studio API key | -| `RESEND_API_KEY` | Yes | Resend API key | -| `POSTHOG_KEY` | Yes | Server-side PostHog key | -| `POSTHOG_HOST` | Yes | PostHog host URL | -| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL used in recap links | -| `CRON_SECRET` | Yes | Shared secret for cron routes | -| `NEXT_PUBLIC_SENTRY_DSN` | No | Client Sentry DSN — optional locally if you skip browser reporting | -| `SENTRY_AUTH_TOKEN` | Yes\* | \*Required for production builds that upload source maps to Sentry | -| `SENTRY_ORG` | No | Optional locally — used by Sentry CLI / webpack plugin for releases | -| `SENTRY_PROJECT` | No | Optional locally — same as above | -| `CI` | No | Optional CI build flag | +| Variable | Required | Description | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | Yes | Neon Postgres connection string | +| `NEON_AUTH_BASE_URL` | Yes | Base URL for your Neon Auth project | +| `NEON_AUTH_COOKIE_SECRET` | No | Optional only if Neon explicitly gives one for your project/runtime | +| `GEMINI_API_KEY` | Yes | Google AI Studio API key | +| `RESEND_API_KEY` | Yes | Resend API key | +| `POSTHOG_KEY` | Yes | Server-side PostHog key | +| `POSTHOG_HOST` | Yes | PostHog host URL | +| `NEXT_PUBLIC_POSTHOG_KEY` | No | Same key as `POSTHOG_KEY` — enables client `web_vital` (CWV) events | +| `NEXT_PUBLIC_POSTHOG_HOST` | No | Defaults to `https://app.posthog.com` if unset | +| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL used in recap links | +| `CRON_SECRET` | Yes | Shared secret for cron routes | +| `NEXT_PUBLIC_SENTRY_DSN` | No | Client Sentry DSN — optional locally if you skip browser reporting | +| `SENTRY_AUTH_TOKEN` | Yes\* | \*Required for production builds that upload source maps to Sentry | +| `SENTRY_ORG` | No | Optional locally — used by Sentry CLI / webpack plugin for releases | +| `SENTRY_PROJECT` | No | Optional locally — same as above | +| `CI` | No | Optional CI build flag | +| `MONEYMIRROR_SKIP_AUTO_SCHEMA` | No | Set to `1` to skip automatic idempotent DDL on server boot (`instrumentation.ts`); default is to run when `DATABASE_URL` is set | ### 3. Create Neon project and enable Neon Auth @@ -81,6 +84,18 @@ Fill in these values: Run the full contents of [`schema.sql`](./schema.sql) against your Neon database. +**Already have a DB from before Phase 2 / Phase 3?** Your `transactions` table may be missing `merchant_key` (and related indexes), which breaks the Transactions tab and merchant insights. From this app directory, with `DATABASE_URL` in `.env.local`: + +```bash +npm run db:upgrade +``` + +That runs idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` statements (same as the tail of `schema.sql`). Safe to run multiple times. + +**Automatic upgrades on server start:** When the Node server boots (`next dev` / `next start` / Vercel), MoneyMirror runs the same idempotent DDL once if `DATABASE_URL` is set and `MONEYMIRROR_SKIP_AUTO_SCHEMA` is not `1`. After pulling new code, **restart the dev server** so this runs; you usually do not need a separate migration step for missing `merchant_key` on Neon. + +Full **root cause, verification steps, and opt-out** for schema drift: [`docs/SCHEMA-DRIFT.md`](./docs/SCHEMA-DRIFT.md). + Tables created: - `profiles` @@ -118,6 +133,19 @@ First-run failure looks like: - `Error: DATABASE_URL is required.` - `Error: NEON_AUTH_BASE_URL is required` +**Dev server: `uv_interface_addresses` / `getNetworkHosts` warning** — In some restricted environments Next may log an unhandled rejection while resolving the LAN URL; the app often still serves on `http://localhost:3000`. Use `npm run dev:loopback` to bind only to `127.0.0.1` and avoid that code path, or run the dev server outside a sandbox. + +## Testing + +| Command | What it runs | +| ------------------ | -------------------------------------------------------------------------- | +| `npm run test` | Vitest — API routes, libs, parsers | +| `npm run test:e2e` | Playwright — builds, serves on port **3333**, smoke-tests `/` and `/login` | + +First-time E2E setup: `npx playwright install chromium`. See [`docs/PERFORMANCE-REVIEW.md`](./docs/PERFORMANCE-REVIEW.md) for Lighthouse and performance notes. + +Optional: set `NEXT_PUBLIC_POSTHOG_KEY` (same project as `POSTHOG_KEY`) to send **Core Web Vitals** (`web_vital` events) from the browser. + ## API ### `POST /api/onboarding/complete` @@ -179,7 +207,7 @@ Returns all processed statements for the authenticated user, sorted by creation ### `GET /api/dashboard` -Returns the full dashboard state for the authenticated user (Overview aggregates + generated advisories + optional `coaching_facts` Layer A JSON + Gemini-enriched `narrative` / `cited_fact_ids` on each advisory when AI succeeds). +Returns the full dashboard state for the authenticated user (Overview aggregates + generated advisories + optional `coaching_facts` Layer A JSON). **Does not** call Gemini — responses are fast for Overview and Transactions. Rule-based advisory copy is returned immediately. **Auth**: Neon Auth session cookie required. @@ -190,7 +218,7 @@ Returns the full dashboard state for the authenticated user (Overview aggregates ### `GET /api/dashboard/advisories` -Same query parameters as `GET /api/dashboard`. Returns `{ advisories, coaching_facts }` (same shapes as the parent dashboard payload). +Same query parameters as `GET /api/dashboard`. Returns `{ advisories, coaching_facts }`. This endpoint runs the **Gemini coaching narrative** step (can take up to ~9s) and is what the **Insights** tab calls after the fast dashboard load. **Auth**: Neon Auth session cookie required. @@ -345,6 +373,8 @@ Intentional error route to verify Sentry server-side capture (`SentryExampleAPIE ## Docs - [`docs/COACHING-TONE.md`](./docs/COACHING-TONE.md) — Coaching language guide: consequence-first nudge patterns, Layer A facts-only numerics, Gemini narrative guardrails, tone constraints for advisory copy. +- [`docs/PERFORMANCE-REVIEW.md`](./docs/PERFORMANCE-REVIEW.md) — Performance review notes (fonts, lazy Insights path, Web Vitals, Lighthouse). +- [`docs/SCHEMA-DRIFT.md`](./docs/SCHEMA-DRIFT.md) — Schema drift RCA, `SCHEMA_DRIFT` API behavior, `db:upgrade` vs boot-time DDL. ## Current scope diff --git a/apps/money-mirror/docs/PERFORMANCE-REVIEW.md b/apps/money-mirror/docs/PERFORMANCE-REVIEW.md new file mode 100644 index 0000000..3f1eb15 --- /dev/null +++ b/apps/money-mirror/docs/PERFORMANCE-REVIEW.md @@ -0,0 +1,149 @@ +# MoneyMirror — Performance & quality review (living doc) + +_Last updated: 2026-04-06_ + +This document captures an architecture-style review: root causes, prioritized backlog (P0–P2), test ideas, and best-practice gaps. Update it when major changes ship (e.g. E2E added, CWV wired, font strategy changed). + +### Recently shipped (this iteration) + +- **`next/font`** — Inter + Space Grotesk self-hosted via `layout.tsx`; removed blocking Google Fonts `@import` from `globals.css`. +- **Core Web Vitals → PostHog** — `WebVitalsReporter` (`src/components/WebVitalsReporter.tsx`) sends `web_vital` events when `NEXT_PUBLIC_POSTHOG_KEY` is set; dev logs metrics to console when the key is unset. +- **Viewport zoom** — `maximumScale` raised to `5` for accessibility. +- **Playwright smoke** — `e2e/smoke.spec.ts` (landing + login); run via `npm run test:e2e` (build + `next start` on port **3333**). + +--- + +## Executive summary (RCA) + +| Symptom | Likely root causes | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Very slow first paint / interactions | Dev Turbopack compile cost; request waterfall (statements → dashboard → transactions); remote Neon latency per route; Insights path runs a second full DB + Gemini via `/api/dashboard/advisories`. (Google Fonts blocking CSS import **removed** — use `next/font`.) | +| “Failed to load transactions” + empty area | Designed error state when `/api/transactions` fails (e.g. 500). Scope UI comes from `/api/dashboard`; the list is a **separate** request — not a layout bug. | +| Slowness on tab changes | Largely mitigated: dashboard refetch is keyed by **scope** (tab-only URL changes should not refetch `/api/dashboard`). | + +--- + +## Prioritized backlog + +### P0 — Fix or verify first (reliability / trust) + +1. **Transactions API failures in production** — Ensure 500s are logged and alertable (Sentry). Dev-only `detail` on transaction errors helps locally; prod may need correlation IDs or safe error codes. +2. **Environment / DB** — Validate `DATABASE_URL` and Neon Auth in each environment; consider a small health check for deploy verification. +3. **Browser E2E (expand)** — Smoke tests cover `/` and `/login`. Next: authenticated flow (dashboard + transactions) when a test-safe auth strategy exists. + +### P1 — Performance & UX (next) + +4. **Duplicate work on Insights** — `/api/dashboard` then `/api/dashboard/advisories` runs `fetchDashboardData` twice + Gemini. Consider short-TTL cache keyed by user + scope, or a single route with `include_narratives`. +5. ~~**Font loading**~~ — Done: `next/font` (see Recently shipped). +6. ~~**Core Web Vitals**~~ — Done: `WebVitalsReporter` + optional PostHog (see Recently shipped). + +### P2 — Accessibility, responsive polish, hygiene + +7. ~~**Viewport**~~ — Done: `maximumScale: 5` (see Recently shipped). +8. **Responsive layout** — `.page-container` / dashboard shell are intentionally narrow (mobile-first). Clarify product intent for tablet/desktop width. +9. **Inline styles** — Gradual move to shared primitives / Tailwind for consistency and testability. +10. **Component tests** — Add tests for `useDashboardState` tab + scope behavior if E2E is delayed. + +--- + +## Suggested E2E cases (when Playwright exists) + +| # | Flow | Assert | +| --- | --------------------------------------- | --------------------------------------------------------------- | +| 1 | Login → `/dashboard` | Shell renders; no stuck skeleton. | +| 2 | Empty statements | Upload or empty state; no crash. | +| 3 | Transactions tab | Rows or “No transactions”; no red error if API healthy. | +| 4 | Tab: Overview → Transactions → Insights | No redundant `/api/dashboard` for same scope (network panel). | +| 5 | Insights | Narratives load after fast dashboard (if `GEMINI_API_KEY` set). | +| 6 | Filters | Debounced search; no runaway requests. | +| 7 | Mobile 375px | Nav usable; no horizontal overflow. | + +--- + +## Edge cases already partially handled + +- AbortController on transactions fetch. +- Debounced search in `TransactionsPanel`. +- Legacy vs unified scope — different API query shapes; test both. + +--- + +## Best-practice snapshot + +| Area | Notes | +| --------- | --------------------------------------- | +| API auth | `getSessionUser` on protected routes | +| Telemetry | Fire-and-forget patterns per repo rules | +| DB | Indexes in `schema.sql` | +| E2E / CWV | Gaps — prioritize P0/P1 | +| A11y | Review `maximumScale` | + +--- + +## Commands to run (local) + +Use these from **`apps/money-mirror`** (or via your monorepo root with the appropriate package filter). + +### 1. Quality gate (always) + +```bash +cd apps/money-mirror && npm run lint && npm run test +``` + +### 2. Production-like performance (vs `npm run dev`) + +```bash +cd apps/money-mirror && npm run build && npm run start +``` + +Then open `http://localhost:3000` (default Next port). Compare perceived latency to dev mode. + +### 3. Local dev (iterate on UI) + +```bash +cd apps/money-mirror && npm run dev +``` + +### 4. Lighthouse (optional — needs running app + Chrome) + +With **`npm run start`** or **`npm run dev`** already running on port 3000: + +```bash +npx lighthouse http://localhost:3000 --view --only-categories=performance,accessibility +``` + +**Headless HTML report** (no browser window — writes into `docs/`): + +```bash +# After: npm run build && npm run start -- -p 3002 +npx lighthouse http://localhost:3002 --only-categories=performance,accessibility \ + --output=html --output=json --output-path=./docs/lighthouse-report --quiet \ + --chrome-flags="--headless --no-sandbox --disable-gpu" +``` + +Open `docs/lighthouse-report.report.html` locally. Headless runs may log `NO_LCP` and leave the **performance** score empty; use `--view` on your machine for a full performance score. + +### 5. Playwright E2E (smoke) + +First time only (browser binaries): + +```bash +cd apps/money-mirror && npx playwright install chromium +``` + +Run (builds, starts production server on port **3333**, runs tests): + +```bash +cd apps/money-mirror && npm run test:e2e +``` + +Interactive UI mode: `npm run test:e2e:ui` + +--- + +## Related code + +- Dashboard API (fast path, no Gemini): `src/app/api/dashboard/route.ts` +- Lazy Gemini / Insights: `src/app/api/dashboard/advisories/route.ts`, `src/app/dashboard/useDashboardState.ts` +- Transactions: `src/app/api/transactions/route.ts`, `src/app/dashboard/TransactionsPanel.tsx` +- Styles / viewport: `src/app/globals.css`, `src/app/layout.tsx` diff --git a/apps/money-mirror/docs/SCHEMA-DRIFT.md b/apps/money-mirror/docs/SCHEMA-DRIFT.md new file mode 100644 index 0000000..34e6df6 --- /dev/null +++ b/apps/money-mirror/docs/SCHEMA-DRIFT.md @@ -0,0 +1,39 @@ +# Schema drift (e.g. `merchant_key`) — RCA, fix, and verification + +## Symptom + +- **Transactions** tab or **Insights → Top merchants** shows an error, often including Postgres text like `column t.merchant_key does not exist`, or API `code: SCHEMA_DRIFT`. +- Automated tests can still pass (they do not assert your live Neon DDL). + +## Root cause + +1. **Phase 3 code** always queries optional columns such as `transactions.merchant_key` (merchant rollups, filters). +2. **Older Neon databases** were created before those columns existed, or only the initial `CREATE TABLE` ran and later **`ALTER TABLE … ADD COLUMN`** lines were never applied. +3. **`CREATE TABLE IF NOT EXISTS` does not add new columns** to an existing table — so “I ran schema.sql once” is not enough if the file gained new columns later unless the **idempotent `ALTER`s** at the end of `schema.sql` were executed. + +Net: **application code and database schema were out of sync** (schema drift). + +## Fixes implemented in the repo + +| Mechanism | Purpose | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`schema.sql` (tail)** | Idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS` for `merchant_key` and statement label columns; partial index `idx_transactions_user_merchant`. | +| **`npm run db:upgrade`** | Runs `scripts/apply-schema-upgrades.ts` → `applyIdempotentSchemaUpgrades()` (same DDL as the tail of `schema.sql`) using `DATABASE_URL` from `.env.local`. | +| **Server boot (`instrumentation.ts`)** | On **Node** runtime, `runAutoSchemaUpgradeOnBoot()` runs the same DDL when the server starts (unless `MONEYMIRROR_SKIP_AUTO_SCHEMA=1` or `DATABASE_URL` is unset). | +| **API + UI** | On Postgres undefined-column errors, APIs return `code: SCHEMA_DRIFT` with a single user-facing `detail` paragraph; Transactions / Merchant rollups show that text without duplicating titles. | + +## What you should do locally (verification) + +1. **`cd apps/money-mirror`** +2. Ensure **`.env.local`** has the same **`DATABASE_URL`** the app uses. +3. **Restart the dev server** (`Ctrl+C`, then `npm run dev`) so instrumentation runs and auto-upgrade can apply. +4. In the terminal, confirm either: + - `[money-mirror] Database schema verified (idempotent upgrades applied if needed).`, or + - an error line starting with `[money-mirror] Automatic schema upgrade on server start failed` — if so, run **`npm run db:upgrade`** once, or paste the tail of **`schema.sql`** into the Neon SQL Editor. +5. Hard-refresh **Dashboard → Transactions** and **Insights** (Top merchants). + +Optional after columns exist: authenticated **`POST /api/transactions/backfill-merchant-keys`** to populate `merchant_key` for old rows (see README). + +## Opt out + +- **`MONEYMIRROR_SKIP_AUTO_SCHEMA=1`** — skip automatic DDL on server boot (e.g. restricted DB roles); use manual `npm run db:upgrade` or Neon SQL instead. diff --git a/apps/money-mirror/e2e/smoke.spec.ts b/apps/money-mirror/e2e/smoke.spec.ts new file mode 100644 index 0000000..84aa733 --- /dev/null +++ b/apps/money-mirror/e2e/smoke.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +test.describe('public pages', () => { + test('landing page loads with hero', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toContainText(/Money/i); + await expect(page.getByRole('heading', { level: 1 })).toContainText(/Mirror/i); + }); + + test('login page loads', async ({ page }) => { + await page.goto('/login'); + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + }); +}); diff --git a/apps/money-mirror/next.config.ts b/apps/money-mirror/next.config.ts index 544407d..1860d3c 100644 --- a/apps/money-mirror/next.config.ts +++ b/apps/money-mirror/next.config.ts @@ -1,8 +1,22 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import type { NextConfig } from 'next'; import { withSentryConfig } from '@sentry/nextjs'; +const appDir = path.dirname(fileURLToPath(import.meta.url)); + const nextConfig: NextConfig = { - serverExternalPackages: ['pdf-parse'], + // Monorepo: pin Turbopack root to this app so it does not pick the repo-root lockfile. + // Without resolveAlias, `@import "tailwindcss"` resolves from `apps/` and fails; alias to this app's node_modules. + turbopack: { + root: appDir, + resolveAlias: { + tailwindcss: path.join(appDir, 'node_modules/tailwindcss'), + }, + }, + // OTEL/Sentry use these hook packages; Turbopack must not bundle them as hashed externals or dev fails with + // "Cannot find module 'require-in-the-middle-…'". + serverExternalPackages: ['pdf-parse', 'require-in-the-middle', 'import-in-the-middle'], // Enable PWA headers headers: async () => [ { diff --git a/apps/money-mirror/package.json b/apps/money-mirror/package.json index 1486402..5ada77b 100644 --- a/apps/money-mirror/package.json +++ b/apps/money-mirror/package.json @@ -4,12 +4,16 @@ "private": true, "scripts": { "dev": "next dev", + "dev:loopback": "next dev -H 127.0.0.1", "build": "next build", "start": "next start", "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "npm run build && playwright test", + "test:e2e:ui": "playwright test --ui", + "db:upgrade": "tsx scripts/apply-schema-upgrades.ts" }, "dependencies": { "@google/genai": "^1.48.0", @@ -30,6 +34,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -42,6 +47,7 @@ "eslint-config-next": "16.2.2", "jsdom": "^29.0.1", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5", "vitest": "^4.1.2" } diff --git a/apps/money-mirror/playwright.config.ts b/apps/money-mirror/playwright.config.ts new file mode 100644 index 0000000..66846e9 --- /dev/null +++ b/apps/money-mirror/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + ...devices['Desktop Chrome'], + // Dedicated port so E2E does not fight `next dev` on 3000. + baseURL: 'http://127.0.0.1:3333', + trace: 'on-first-retry', + }, + webServer: { + // `next start` after `npm run build` (see `test:e2e` script) — fast and stable vs cold `next dev`. + command: 'npm run start -- -p 3333', + url: 'http://127.0.0.1:3333', + reuseExistingServer: !process.env.CI, + timeout: 90_000, + }, +}); diff --git a/apps/money-mirror/scripts/apply-schema-upgrades.ts b/apps/money-mirror/scripts/apply-schema-upgrades.ts new file mode 100644 index 0000000..65e9c9b --- /dev/null +++ b/apps/money-mirror/scripts/apply-schema-upgrades.ts @@ -0,0 +1,59 @@ +/** + * Applies idempotent DDL from `src/lib/schema-upgrades.ts` (matches tail of schema.sql). + * Run after pulling Phase 2+ / Phase 3 code if your Neon DB predates new columns. + * + * Usage: npm run db:upgrade + * Requires DATABASE_URL in the environment or in `.env.local` (loaded automatically). + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { neon } from '@neondatabase/serverless'; +import { applyIdempotentSchemaUpgrades } from '../src/lib/schema-upgrades'; + +function loadEnvLocal(): void { + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + const p = join(root, '.env.local'); + if (!existsSync(p)) { + return; + } + const raw = readFileSync(p, 'utf8'); + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = val; + } + } +} + +loadEnvLocal(); + +async function main(): Promise { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('DATABASE_URL is not set. Add it to .env.local or export it.'); + process.exit(1); + } + + const sql = neon(databaseUrl); + await applyIdempotentSchemaUpgrades(sql); + console.log('MoneyMirror schema upgrades applied successfully.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/money-mirror/src/app/api/dashboard/route.ts b/apps/money-mirror/src/app/api/dashboard/route.ts index 3591e73..ded0e7d 100644 --- a/apps/money-mirror/src/app/api/dashboard/route.ts +++ b/apps/money-mirror/src/app/api/dashboard/route.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; import { ensureProfile } from '@/lib/db'; -import { attachCoachingLayer } from '@/lib/coaching-enrich'; +import { attachCoachingFactsOnly } from '@/lib/coaching-enrich'; import { fetchDashboardData, type DashboardFetchInput } from '@/lib/dashboard'; import { parseDashboardScopeFromSearchParams } from '@/lib/scope'; @@ -36,7 +36,7 @@ export async function GET(req: NextRequest): Promise { return NextResponse.json({ error: 'Dashboard not found' }, { status: 404 }); } - const enriched = await attachCoachingLayer(user.id, dashboard); + const enriched = await attachCoachingFactsOnly(user.id, dashboard); return NextResponse.json(enriched); } catch (err) { Sentry.captureException(err); diff --git a/apps/money-mirror/src/app/api/insights/merchants/route.ts b/apps/money-mirror/src/app/api/insights/merchants/route.ts index 5e3fef6..6a00444 100644 --- a/apps/money-mirror/src/app/api/insights/merchants/route.ts +++ b/apps/money-mirror/src/app/api/insights/merchants/route.ts @@ -4,9 +4,11 @@ * Top merchants by debit spend for the authenticated user, scoped like GET /api/transactions. */ +import * as Sentry from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { getSessionUser } from '@/lib/auth/session'; import { ensureProfile, getDb } from '@/lib/db'; +import { isUndefinedColumnError, SCHEMA_UPGRADE_HINT } from '@/lib/pg-errors'; import { listMerchantRollups, MERCHANT_ROLLUPS_MAX_LIMIT, @@ -130,7 +132,18 @@ export async function GET(req: NextRequest): Promise { keyed_debit_paisa: keyed_debit_sum, }); } catch (e) { + Sentry.captureException(e); console.error('[insights/merchants] GET failed:', e); + if (isUndefinedColumnError(e)) { + return NextResponse.json( + { + error: "Can't load merchant insights", + code: 'SCHEMA_DRIFT', + detail: SCHEMA_UPGRADE_HINT, + }, + { status: 500 } + ); + } return NextResponse.json({ error: 'Failed to load merchant insights' }, { status: 500 }); } } diff --git a/apps/money-mirror/src/app/api/transactions/route.ts b/apps/money-mirror/src/app/api/transactions/route.ts index 7588457..3493945 100644 --- a/apps/money-mirror/src/app/api/transactions/route.ts +++ b/apps/money-mirror/src/app/api/transactions/route.ts @@ -16,6 +16,7 @@ import { listTransactions, type ListTransactionsParams, } from '@/lib/transactions-list'; +import { isUndefinedColumnError, SCHEMA_UPGRADE_HINT } from '@/lib/pg-errors'; import { parseStatementIdsParam } from '@/lib/scope'; const CATEGORIES = new Set(['needs', 'wants', 'investment', 'debt', 'other']); @@ -206,6 +207,16 @@ export async function GET(req: NextRequest): Promise { } catch (e) { Sentry.captureException(e); console.error('[transactions] GET failed:', e); - return NextResponse.json({ error: 'Failed to load transactions' }, { status: 500 }); + const payload: { error: string; detail?: string; code?: string } = { + error: 'Failed to load transactions', + }; + if (isUndefinedColumnError(e)) { + payload.error = "Can't load transactions"; + payload.code = 'SCHEMA_DRIFT'; + payload.detail = SCHEMA_UPGRADE_HINT; + } else if (process.env.NODE_ENV === 'development' && e instanceof Error && e.message) { + payload.detail = e.message; + } + return NextResponse.json(payload, { status: 500 }); } } diff --git a/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx b/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx index a1cf23c..ceb627d 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardBrandBar.tsx @@ -13,7 +13,7 @@ export function DashboardBrandBar() { fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent)', - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', letterSpacing: '-0.02em', }} > diff --git a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx index b9a717c..5910da0 100644 --- a/apps/money-mirror/src/app/dashboard/DashboardClient.tsx +++ b/apps/money-mirror/src/app/dashboard/DashboardClient.tsx @@ -24,6 +24,7 @@ export function DashboardClient() { error, setError, isLoadingDashboard, + isLoadingNarratives, isParsing, statementType, setStatementType, @@ -144,6 +145,7 @@ export function DashboardClient() { advisories={advisories} txnScope={txnScope} coachingFacts={coachingFacts} + isLoadingNarratives={isLoadingNarratives} /> )} diff --git a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx index b2b4888..f6a2ddb 100644 --- a/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/InsightsPanel.tsx @@ -10,9 +10,16 @@ interface InsightsPanelProps { advisories: Advisory[]; txnScope: TxnScope | null; coachingFacts: LayerAFacts | null; + /** True while Gemini coaching narratives load (after fast dashboard). */ + isLoadingNarratives?: boolean; } -export function InsightsPanel({ advisories, txnScope, coachingFacts }: InsightsPanelProps) { +export function InsightsPanel({ + advisories, + txnScope, + coachingFacts, + isLoadingNarratives = false, +}: InsightsPanelProps) { return (
Highlights + {isLoadingNarratives && ( +

+ Polishing insight copy… +

+ )}

@@ -176,7 +176,7 @@ export function ResultsPanel({ style={{ fontSize: '1.25rem', fontWeight: 800, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', color: 'var(--success)', }} > diff --git a/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx index 3b7c235..08f38ba 100644 --- a/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx +++ b/apps/money-mirror/src/app/dashboard/TransactionsPanel.tsx @@ -80,8 +80,18 @@ export function TransactionsPanel({ txnScope }: TransactionsPanelProps) { try { const resp = await fetch(`/api/transactions?${q.toString()}`, { signal: ac.signal }); if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? `Load failed (${resp.status})`); + const body = (await resp.json().catch(() => ({}))) as { + error?: string; + detail?: string; + code?: string; + }; + const base = body.error ?? `Load failed (${resp.status})`; + if (body.code === 'SCHEMA_DRIFT' && body.detail) { + throw new Error(body.detail); + } + const showDetail = process.env.NODE_ENV === 'development' && body.detail; + const msg = showDetail ? `${base}: ${body.detail}` : base; + throw new Error(msg); } const data = (await resp.json()) as { transactions: TxRow[]; total: number }; setTotal(data.total); diff --git a/apps/money-mirror/src/app/dashboard/useDashboardInitialLoadEffect.ts b/apps/money-mirror/src/app/dashboard/useDashboardInitialLoadEffect.ts new file mode 100644 index 0000000..e42f3f3 --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/useDashboardInitialLoadEffect.ts @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect, type MutableRefObject } from 'react'; +import type { StatementListItem } from '@/lib/statements-list'; + +type Router = { replace: (href: string, options?: { scroll?: boolean }) => void }; + +/** Loads dashboard when URL scope or statement list changes; normalizes legacy `statement_id`. */ +export function useDashboardInitialLoadEffect(opts: { + statements: StatementListItem[] | null; + statementIdFromUrl: string | null; + tabParam: string | null; + isUnifiedUrl: boolean; + dashboardScopeKey: string; + loadDashboard: (override?: URLSearchParams) => Promise; + router: Router; + dashboardLoadedForScopeRef: MutableRefObject; +}) { + const { + statements, + statementIdFromUrl, + tabParam, + isUnifiedUrl, + dashboardScopeKey, + loadDashboard, + router, + dashboardLoadedForScopeRef, + } = opts; + + useEffect(() => { + if (statements === null) { + return; + } + if (statements.length === 0) { + dashboardLoadedForScopeRef.current = 'empty'; + void loadDashboard(new URLSearchParams()); + return; + } + + if (isUnifiedUrl) { + if (dashboardLoadedForScopeRef.current === dashboardScopeKey) { + return; + } + dashboardLoadedForScopeRef.current = dashboardScopeKey; + void loadDashboard(); + return; + } + + const valid = + statementIdFromUrl && statements.some((s) => s.id === statementIdFromUrl) + ? statementIdFromUrl + : statements[0].id; + + if (valid !== statementIdFromUrl) { + const q = new URLSearchParams(); + q.set('statement_id', valid); + const t = tabParam; + if (t && (t === 'insights' || t === 'upload' || t === 'transactions')) { + q.set('tab', t); + } + router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + return; + } + + if (dashboardLoadedForScopeRef.current === dashboardScopeKey) { + return; + } + dashboardLoadedForScopeRef.current = dashboardScopeKey; + void loadDashboard(); + }, [ + statements, + statementIdFromUrl, + isUnifiedUrl, + loadDashboard, + router, + dashboardScopeKey, + tabParam, + dashboardLoadedForScopeRef, + ]); +} diff --git a/apps/money-mirror/src/app/dashboard/useDashboardState.ts b/apps/money-mirror/src/app/dashboard/useDashboardState.ts index fdc36f4..ca0110c 100644 --- a/apps/money-mirror/src/app/dashboard/useDashboardState.ts +++ b/apps/money-mirror/src/app/dashboard/useDashboardState.ts @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import type { Advisory } from '@/lib/advisory-engine'; import type { LayerAFacts } from '@/lib/coaching-facts'; import type { StatementType } from '@/lib/statements'; @@ -9,28 +9,38 @@ import type { StatementListItem } from '@/lib/statements-list'; import { monthKeyFromPeriodEnd } from '@/lib/format-date'; import { dashboardApiPathFromSearchParams } from '@/lib/scope'; import type { ApplyUnifiedPayload } from '@/components/ScopeBar'; -import type { UploadFormMeta } from './UploadPanel'; import type { DashboardResult } from './dashboard-result-types'; import { useDashboardScopeDerived } from './useDashboardScopeDerived'; +import { useDashboardUrlModel } from './useDashboardUrlModel'; +import { useDashboardInitialLoadEffect } from './useDashboardInitialLoadEffect'; +import { useStatementUploadHandler } from './useStatementUploadHandler'; export { tabFromSearchParams } from './dashboard-tab-params'; export function useDashboardState() { const router = useRouter(); - const searchParams = useSearchParams(); - const statementIdFromUrl = searchParams.get('statement_id'); - const isUnifiedUrl = Boolean(searchParams.get('date_from') && searchParams.get('date_to')); + const { + searchParams, + statementIdFromUrl, + tabParam, + isUnifiedUrl, + dashboardScopeKey, + dashboardApiPath, + } = useDashboardUrlModel(); const [result, setResult] = useState(null); const [advisories, setAdvisories] = useState([]); const [coachingFacts, setCoachingFacts] = useState(null); const [error, setError] = useState(null); const [isLoadingDashboard, setIsLoadingDashboard] = useState(true); + const [isLoadingNarratives, setIsLoadingNarratives] = useState(false); const [isParsing, setIsParsing] = useState(false); const [statementType, setStatementType] = useState('bank_account'); const [statements, setStatements] = useState(null); const [monthFilter, setMonthFilter] = useState('all'); const dashboardAbortRef = useRef(null); + const dashboardLoadedForScopeRef = useRef(null); + const narrativesFetchedForScopeRef = useRef(null); const { filteredStatements, effectiveSelectedId, txnScope } = useDashboardScopeDerived({ statements, @@ -50,7 +60,6 @@ export function useDashboardState() { const loadDashboard = useCallback( async (override?: URLSearchParams) => { - // Cancel any in-flight dashboard fetch (e.g. Gemini-enriched response from old scope) dashboardAbortRef.current?.abort(); const ac = new AbortController(); dashboardAbortRef.current = ac; @@ -59,7 +68,7 @@ export function useDashboardState() { setError(null); try { - const path = dashboardApiPathFromSearchParams(override ?? searchParams); + const path = override ? dashboardApiPathFromSearchParams(override) : dashboardApiPath; const resp = await fetch(`/api/dashboard${path}`, { signal: ac.signal }); if (resp.status === 404) { @@ -90,45 +99,74 @@ export function useDashboardState() { setIsLoadingDashboard(false); } }, - [searchParams] + [dashboardApiPath] ); + const loadCoachingNarratives = useCallback(async (): Promise => { + setIsLoadingNarratives(true); + try { + const resp = await fetch(`/api/dashboard/advisories${dashboardApiPath}`); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? `Advisories load failed (${resp.status})`); + } + const data = (await resp.json()) as { advisories: Advisory[]; coaching_facts: LayerAFacts }; + setAdvisories(data.advisories); + setCoachingFacts(data.coaching_facts); + return true; + } catch (e) { + console.error('[loadCoachingNarratives]', e); + return false; + } finally { + setIsLoadingNarratives(false); + } + }, [dashboardApiPath]); + useEffect(() => { loadStatements().catch(() => {}); }, [loadStatements]); useEffect(() => { - if (statements === null) { + narrativesFetchedForScopeRef.current = null; + }, [dashboardScopeKey]); + + useDashboardInitialLoadEffect({ + statements, + statementIdFromUrl, + tabParam, + isUnifiedUrl, + dashboardScopeKey, + loadDashboard, + router, + dashboardLoadedForScopeRef, + }); + + useEffect(() => { + if (tabParam !== 'insights') { return; } - if (statements.length === 0) { - void loadDashboard(new URLSearchParams()); + if (statements === null || statements.length === 0) { return; } - - if (isUnifiedUrl) { - void loadDashboard(); + if (isLoadingDashboard || !result) { return; } - - const valid = - statementIdFromUrl && statements.some((s) => s.id === statementIdFromUrl) - ? statementIdFromUrl - : statements[0].id; - - if (valid !== statementIdFromUrl) { - const q = new URLSearchParams(); - q.set('statement_id', valid); - const t = searchParams.get('tab'); - if (t && (t === 'insights' || t === 'upload' || t === 'transactions')) { - q.set('tab', t); - } - router.replace(`/dashboard?${q.toString()}`, { scroll: false }); + if (narrativesFetchedForScopeRef.current === dashboardScopeKey) { return; } - void loadDashboard(); - }, [statements, statementIdFromUrl, isUnifiedUrl, loadDashboard, router, searchParams]); + let cancelled = false; + void (async () => { + const ok = await loadCoachingNarratives(); + if (!cancelled && ok) { + narrativesFetchedForScopeRef.current = dashboardScopeKey; + } + })(); + + return () => { + cancelled = true; + }; + }, [tabParam, statements, isLoadingDashboard, result, dashboardScopeKey, loadCoachingNarratives]); const handleMonthChange = useCallback( (key: string | 'all') => { @@ -202,56 +240,15 @@ export function useDashboardState() { [router, searchParams] ); - const handleUpload = useCallback( - async (file: File, nextStatementType: StatementType, meta: UploadFormMeta) => { - setError(null); - - if (file.type && file.type !== 'application/pdf') { - setError('Please upload a PDF file.'); - return; - } - if (file.size > 10 * 1024 * 1024) { - setError('File is too large. Maximum 10 MB.'); - return; - } - - setIsParsing(true); - - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('statement_type', nextStatementType); - if (meta.nickname) { - formData.append('nickname', meta.nickname); - } - if (meta.accountPurpose) { - formData.append('account_purpose', meta.accountPurpose); - } - if (nextStatementType === 'credit_card' && meta.cardNetwork) { - formData.append('card_network', meta.cardNetwork); - } - - const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); - - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); - } - - const data: DashboardResult = await resp.json(); - await loadStatements(); - const next = new URLSearchParams(); - next.set('statement_id', data.statement_id); - router.replace(`/dashboard?${next.toString()}`, { scroll: false }); - await loadDashboard(next); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong.'); - } finally { - setIsParsing(false); - } - }, - [loadDashboard, loadStatements, router] - ); + const handleUpload = useStatementUploadHandler({ + router, + loadDashboard, + loadStatements, + setError, + setIsParsing, + dashboardLoadedForScopeRef, + narrativesFetchedForScopeRef, + }); return { router, @@ -263,6 +260,7 @@ export function useDashboardState() { error, setError, isLoadingDashboard, + isLoadingNarratives, isParsing, statementType, setStatementType, diff --git a/apps/money-mirror/src/app/dashboard/useDashboardUrlModel.ts b/apps/money-mirror/src/app/dashboard/useDashboardUrlModel.ts new file mode 100644 index 0000000..9710b1a --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/useDashboardUrlModel.ts @@ -0,0 +1,53 @@ +'use client'; + +import { useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { dashboardApiPathFromSearchParams } from '@/lib/scope'; + +/** URL-derived dashboard scope: unified date range vs legacy `statement_id`. */ +export function useDashboardUrlModel() { + const searchParams = useSearchParams(); + const statementIdFromUrl = searchParams.get('statement_id'); + const dateFrom = searchParams.get('date_from'); + const dateTo = searchParams.get('date_to'); + const statementIdsParam = searchParams.get('statement_ids'); + const tabParam = searchParams.get('tab'); + const isUnifiedUrl = Boolean(dateFrom && dateTo); + + const dashboardScopeKey = useMemo(() => { + if (dateFrom && dateTo) { + return `unified:${dateFrom}:${dateTo}:${statementIdsParam ?? ''}`; + } + if (statementIdFromUrl) { + return `legacy:${statementIdFromUrl}`; + } + return 'none'; + }, [dateFrom, dateTo, statementIdsParam, statementIdFromUrl]); + + /** Same query string as `/api/dashboard` — excludes `tab` and other non-scope params. */ + const dashboardApiPath = useMemo(() => { + const sp = new URLSearchParams(); + if (dateFrom && dateTo) { + sp.set('date_from', dateFrom); + sp.set('date_to', dateTo); + if (statementIdsParam) { + sp.set('statement_ids', statementIdsParam); + } + } else if (statementIdFromUrl) { + sp.set('statement_id', statementIdFromUrl); + } + return dashboardApiPathFromSearchParams(sp); + }, [dateFrom, dateTo, statementIdsParam, statementIdFromUrl]); + + return { + searchParams, + statementIdFromUrl, + dateFrom, + dateTo, + statementIdsParam, + tabParam, + isUnifiedUrl, + dashboardScopeKey, + dashboardApiPath, + }; +} diff --git a/apps/money-mirror/src/app/dashboard/useStatementUploadHandler.ts b/apps/money-mirror/src/app/dashboard/useStatementUploadHandler.ts new file mode 100644 index 0000000..52a41da --- /dev/null +++ b/apps/money-mirror/src/app/dashboard/useStatementUploadHandler.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useCallback, type MutableRefObject } from 'react'; +import type { StatementType } from '@/lib/statements'; +import type { UploadFormMeta } from './UploadPanel'; +import type { DashboardResult } from './dashboard-result-types'; + +type LoadDashboard = (override?: URLSearchParams) => Promise; +type LoadStatements = () => Promise; + +/** Minimal router surface for upload redirect (avoids internal Next types). */ +type DashboardRouter = { + replace: (href: string, options?: { scroll?: boolean }) => void; +}; + +export function useStatementUploadHandler(opts: { + router: DashboardRouter; + loadDashboard: LoadDashboard; + loadStatements: LoadStatements; + setError: (v: string | null) => void; + setIsParsing: (v: boolean) => void; + dashboardLoadedForScopeRef: MutableRefObject; + narrativesFetchedForScopeRef: MutableRefObject; +}) { + const { + router, + loadDashboard, + loadStatements, + setError, + setIsParsing, + dashboardLoadedForScopeRef, + narrativesFetchedForScopeRef, + } = opts; + + return useCallback( + async (file: File, nextStatementType: StatementType, meta: UploadFormMeta) => { + setError(null); + + if (file.type && file.type !== 'application/pdf') { + setError('Please upload a PDF file.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError('File is too large. Maximum 10 MB.'); + return; + } + + setIsParsing(true); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('statement_type', nextStatementType); + if (meta.nickname) { + formData.append('nickname', meta.nickname); + } + if (meta.accountPurpose) { + formData.append('account_purpose', meta.accountPurpose); + } + if (nextStatementType === 'credit_card' && meta.cardNetwork) { + formData.append('card_network', meta.cardNetwork); + } + + const resp = await fetch('/api/statement/parse', { method: 'POST', body: formData }); + + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.error ?? body.detail ?? `Upload failed (${resp.status})`); + } + + const data: DashboardResult = await resp.json(); + await loadStatements(); + const next = new URLSearchParams(); + next.set('statement_id', data.statement_id); + router.replace(`/dashboard?${next.toString()}`, { scroll: false }); + await loadDashboard(next); + dashboardLoadedForScopeRef.current = `legacy:${data.statement_id}`; + narrativesFetchedForScopeRef.current = null; + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.'); + } finally { + setIsParsing(false); + } + }, + [ + loadDashboard, + loadStatements, + router, + setError, + setIsParsing, + dashboardLoadedForScopeRef, + narrativesFetchedForScopeRef, + ] + ); +} diff --git a/apps/money-mirror/src/app/globals.css b/apps/money-mirror/src/app/globals.css index a273b60..1c0a6d1 100644 --- a/apps/money-mirror/src/app/globals.css +++ b/apps/money-mirror/src/app/globals.css @@ -1,4 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&display=swap"); @import "tailwindcss"; :root { @@ -29,12 +28,11 @@ html, body { padding: 0; background: var(--bg-base); color: var(--text-primary); - font-family: "Inter", system-ui, sans-serif; min-height: 100dvh; overflow-x: hidden; } -h1, h2, h3 { font-family: "Space Grotesk", "Inter", sans-serif; } +h1, h2, h3 { font-family: var(--font-space), var(--font-inter), system-ui, sans-serif; } .card { background: var(--bg-card); @@ -56,6 +54,12 @@ h1, h2, h3 { font-family: "Space Grotesk", "Inter", sans-serif; } transition: all 0.2s ease; width: 100%; } +/* Links styled as primary CTAs (avoid invalid + + Show Me The Truth →

@@ -131,7 +131,7 @@ export default function ScoreRevealPage() { style={{ fontSize: '3rem', fontWeight: 800, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', color: config.color, lineHeight: 1, }} diff --git a/apps/money-mirror/src/components/AdvisoryFeed.tsx b/apps/money-mirror/src/components/AdvisoryFeed.tsx index 69ad035..5f2d2ad 100644 --- a/apps/money-mirror/src/components/AdvisoryFeed.tsx +++ b/apps/money-mirror/src/components/AdvisoryFeed.tsx @@ -72,7 +72,7 @@ function AdvisoryCard({ fontSize: '0.9rem', fontWeight: 700, color: style.color, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', }} > {adv.headline} diff --git a/apps/money-mirror/src/components/MerchantRollups.tsx b/apps/money-mirror/src/components/MerchantRollups.tsx index a2395f3..4671664 100644 --- a/apps/money-mirror/src/components/MerchantRollups.tsx +++ b/apps/money-mirror/src/components/MerchantRollups.tsx @@ -48,8 +48,17 @@ export function MerchantRollups({ txnScope }: MerchantRollupsProps) { const qs = merchantsQuery(txnScope); const resp = await fetch(`/api/insights/merchants?${qs}`); if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error ?? `Load failed (${resp.status})`); + const body = (await resp.json().catch(() => ({}))) as { + error?: string; + detail?: string; + code?: string; + }; + const base = body.error ?? `Load failed (${resp.status})`; + if (body.code === 'SCHEMA_DRIFT' && body.detail) { + throw new Error(body.detail); + } + const showDetail = process.env.NODE_ENV === 'development' && body.detail; + throw new Error(showDetail ? `${base}: ${body.detail}` : base); } const data = (await resp.json()) as { merchants: MerchantRow[]; diff --git a/apps/money-mirror/src/components/MirrorCard.tsx b/apps/money-mirror/src/components/MirrorCard.tsx index 4348e1a..60440bf 100644 --- a/apps/money-mirror/src/components/MirrorCard.tsx +++ b/apps/money-mirror/src/components/MirrorCard.tsx @@ -44,7 +44,7 @@ export function MirrorCard({ label, amount_paisa, total_paisa, color, icon }: Mi style={{ fontSize: '1rem', fontWeight: 700, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', color: 'var(--text-primary)', }} > diff --git a/apps/money-mirror/src/components/PerceivedActualMirror.tsx b/apps/money-mirror/src/components/PerceivedActualMirror.tsx index acea622..f4504b4 100644 --- a/apps/money-mirror/src/components/PerceivedActualMirror.tsx +++ b/apps/money-mirror/src/components/PerceivedActualMirror.tsx @@ -54,7 +54,7 @@ export function PerceivedActualMirror({ style={{ fontSize: '1.35rem', fontWeight: 800, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', color: 'var(--text-secondary)', }} > @@ -77,7 +77,7 @@ export function PerceivedActualMirror({ style={{ fontSize: '1.35rem', fontWeight: 800, - fontFamily: 'Space Grotesk, sans-serif', + fontFamily: 'var(--font-space), sans-serif', color: 'var(--danger)', }} > diff --git a/apps/money-mirror/src/components/WebVitalsReporter.tsx b/apps/money-mirror/src/components/WebVitalsReporter.tsx new file mode 100644 index 0000000..79a47c3 --- /dev/null +++ b/apps/money-mirror/src/components/WebVitalsReporter.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useReportWebVitals } from 'next/web-vitals'; +import type { PostHog } from 'posthog-js'; + +let posthogReady: Promise | null = null; + +function getPosthogForVitals(): Promise | null { + const key = process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!key) { + return null; + } + if (!posthogReady) { + posthogReady = import('posthog-js').then(({ default: posthog }) => { + posthog.init(key, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://app.posthog.com', + capture_pageview: false, + persistence: 'memory', + }); + return posthog; + }); + } + return posthogReady; +} + +/** + * Sends Core Web Vitals to PostHog when `NEXT_PUBLIC_POSTHOG_KEY` is set. + * Does not capture pageviews; pair with a future PostHog provider if you add full client analytics. + */ +export function WebVitalsReporter() { + useReportWebVitals((metric) => { + if (process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_POSTHOG_KEY) { + console.debug('[web-vitals]', metric.name, metric.value, metric.rating); + return; + } + + const p = getPosthogForVitals(); + if (!p) { + return; + } + + void p.then((posthog) => { + posthog.capture('web_vital', { + name: metric.name, + value: metric.value, + rating: metric.rating, + id: metric.id, + navigation_type: metric.navigationType, + }); + }); + }); + + return null; +} diff --git a/apps/money-mirror/src/instrumentation.ts b/apps/money-mirror/src/instrumentation.ts index 8aff09f..31a2e83 100644 --- a/apps/money-mirror/src/instrumentation.ts +++ b/apps/money-mirror/src/instrumentation.ts @@ -3,6 +3,8 @@ import * as Sentry from '@sentry/nextjs'; export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('../sentry.server.config'); + const { runAutoSchemaUpgradeOnBoot } = await import('./lib/run-schema-upgrade-on-boot'); + await runAutoSchemaUpgradeOnBoot(); } if (process.env.NEXT_RUNTIME === 'edge') { diff --git a/apps/money-mirror/src/lib/__tests__/pg-errors.test.ts b/apps/money-mirror/src/lib/__tests__/pg-errors.test.ts new file mode 100644 index 0000000..215a63e --- /dev/null +++ b/apps/money-mirror/src/lib/__tests__/pg-errors.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isUndefinedColumnError } from '../pg-errors'; + +describe('isUndefinedColumnError', () => { + it('returns true for Postgres code 42703', () => { + expect(isUndefinedColumnError({ code: '42703', message: 'x' })).toBe(true); + }); + + it('returns true when message mentions missing column', () => { + expect(isUndefinedColumnError(new Error('column t.merchant_key does not exist'))).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isUndefinedColumnError(new Error('connection refused'))).toBe(false); + expect(isUndefinedColumnError(null)).toBe(false); + }); +}); diff --git a/apps/money-mirror/src/lib/coaching-enrich.ts b/apps/money-mirror/src/lib/coaching-enrich.ts index 7c0d506..4b76b7c 100644 --- a/apps/money-mirror/src/lib/coaching-enrich.ts +++ b/apps/money-mirror/src/lib/coaching-enrich.ts @@ -5,8 +5,21 @@ import { captureServerEvent } from '@/lib/posthog'; export type DashboardWithCoaching = DashboardData & { coaching_facts: LayerAFacts }; +/** + * Layer A facts only (no Gemini). Use for fast `/api/dashboard` responses. + * AI narratives are loaded lazily via `/api/dashboard/advisories` when the Insights tab opens. + */ +export async function attachCoachingFactsOnly( + _userId: string, + dashboard: DashboardData +): Promise { + const coaching_facts = buildLayerAFacts(dashboard); + return { ...dashboard, coaching_facts }; +} + /** * Layer A facts + optional Gemini narratives (T4). Safe fallback: rule `message` stays when AI fails. + * Prefer `attachCoachingFactsOnly` on hot paths; call this from `/api/dashboard/advisories` only. */ export async function attachCoachingLayer( userId: string, diff --git a/apps/money-mirror/src/lib/pg-errors.ts b/apps/money-mirror/src/lib/pg-errors.ts new file mode 100644 index 0000000..c822209 --- /dev/null +++ b/apps/money-mirror/src/lib/pg-errors.ts @@ -0,0 +1,22 @@ +/** + * Helpers for interpreting Postgres / Neon client errors in API routes. + */ + +export function isUndefinedColumnError(e: unknown): boolean { + if (!e || typeof e !== 'object') { + return false; + } + const o = e as Record; + if (o.code === '42703') { + return true; + } + const msg = typeof o.message === 'string' ? o.message : ''; + return /column .* does not exist/i.test(msg); +} + +/** + * User-facing hint when Postgres reports a missing column (schema drift). + * Keep in sync with README / `npm run db:upgrade`. Server also runs idempotent DDL on Node boot when possible. + */ +export const SCHEMA_UPGRADE_HINT = + 'Your MoneyMirror database is missing columns this app version needs (for example merchant rollups). The app tries to update the schema automatically when the server starts. If you still see this, run `npm run db:upgrade` from `apps/money-mirror` using the same `DATABASE_URL` as the app, or run the ALTER statements at the end of `schema.sql` in the Neon SQL Editor.'; diff --git a/apps/money-mirror/src/lib/run-schema-upgrade-on-boot.ts b/apps/money-mirror/src/lib/run-schema-upgrade-on-boot.ts new file mode 100644 index 0000000..a858b3e --- /dev/null +++ b/apps/money-mirror/src/lib/run-schema-upgrade-on-boot.ts @@ -0,0 +1,36 @@ +import { neon } from '@neondatabase/serverless'; +import { applyIdempotentSchemaUpgrades } from '@/lib/schema-upgrades'; + +/** + * Applies the same idempotent DDL as `npm run db:upgrade` when the Node server boots. + * Eliminates manual migration for typical local/prod drift (e.g. missing `merchant_key`). + * + * Disable with `MONEYMIRROR_SKIP_AUTO_SCHEMA=1`. Skipped under Vitest. + * Never throws — failures are logged; API routes still return SCHEMA_DRIFT if columns are missing. + */ +export async function runAutoSchemaUpgradeOnBoot(): Promise { + if (process.env.VITEST === 'true') { + return; + } + if (process.env.MONEYMIRROR_SKIP_AUTO_SCHEMA === '1') { + return; + } + const url = process.env.DATABASE_URL; + if (!url) { + return; + } + try { + const sql = neon(url); + await applyIdempotentSchemaUpgrades(sql); + if (process.env.NODE_ENV === 'development') { + console.info( + '[money-mirror] Database schema verified (idempotent upgrades applied if needed).' + ); + } + } catch (e) { + console.error( + '[money-mirror] Automatic schema upgrade on server start failed (non-fatal). Run `npm run db:upgrade` or apply the tail of schema.sql in Neon:', + e + ); + } +} diff --git a/apps/money-mirror/src/lib/schema-upgrades.ts b/apps/money-mirror/src/lib/schema-upgrades.ts new file mode 100644 index 0000000..184dde6 --- /dev/null +++ b/apps/money-mirror/src/lib/schema-upgrades.ts @@ -0,0 +1,27 @@ +import type { NeonQueryFunction } from '@neondatabase/serverless'; + +/** + * Idempotent DDL for Neon DBs created before Phase 2/3 columns. + * Keep in sync with the tail of `schema.sql`. + */ +export async function applyIdempotentSchemaUpgrades( + sql: NeonQueryFunction +): Promise { + await sql` + ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS nickname TEXT + `; + await sql` + ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS account_purpose TEXT + `; + await sql` + ALTER TABLE public.statements ADD COLUMN IF NOT EXISTS card_network TEXT + `; + await sql` + ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS merchant_key TEXT + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_transactions_user_merchant + ON public.transactions(user_id, merchant_key) + WHERE merchant_key IS NOT NULL + `; +} diff --git a/experiments/linear-sync/issue-010.json b/experiments/linear-sync/issue-010.json index b42d070..fe0116d 100644 --- a/experiments/linear-sync/issue-010.json +++ b/experiments/linear-sync/issue-010.json @@ -36,8 +36,18 @@ "url": "https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee" } }, - "last_sync_mode": "close", - "last_sync_timestamp": "2026-04-05T15:40:12Z", + "last_sync_mode": "release", + "last_sync_timestamp": "2026-04-06T20:35:30Z", + "production_readiness_issue": { + "identifier": "VIJ-42", + "title": "MoneyMirror — Production readiness sync (post–VIJ-37 closeout)", + "url": "https://linear.app/vijaypmworkspace/issue/VIJ-42/moneymirror-production-readiness-sync-post-vij-37-closeout", + "state": "Done", + "purpose": "PM/ops mirror for post-close hardening + launch checklist; not a reopened pipeline cycle" + }, + "vij37_production_sync_comment_id": "c595274e-311d-4990-afda-3cf7afc9b243", + "ops_followup_comment_id": "9137c798-bdbc-488c-b243-089dee8911aa", + "ops_followup_note": "2026-04-06 comment on VIJ-37 — post-close hardening, launch checklist, production push guidance", "pipeline_status": "learning_complete", "linear_status": "Done", "project_status": "Completed", diff --git a/experiments/results/production-launch-checklist-010.md b/experiments/results/production-launch-checklist-010.md new file mode 100644 index 0000000..84187d4 --- /dev/null +++ b/experiments/results/production-launch-checklist-010.md @@ -0,0 +1,50 @@ +# MoneyMirror — production launch checklist (issue-010 follow-up) + +_Use this after Phase 3 pipeline close; does not reopen quality gates._ + +## Pre-push (GitHub) + +| # | Task | Owner | +| --- | ------------------------------------------------------------------------------------------------------------------ | ----- | +| 1 | `cd apps/money-mirror && npm run lint` — zero errors | Eng | +| 2 | `npm run test` — all Vitest tests green | Eng | +| 3 | `npm run test:e2e` — Playwright smoke (`/`, `/login`); first run: `npx playwright install chromium` | Eng | +| 4 | `npm run build` — production build succeeds (Sentry upload may warn if token missing; build should still complete) | Eng | +| 5 | Branch pushed; open PR; squash/merge to `main` per team convention | Eng | + +## Environment (Vercel / Neon) + +| # | Task | Owner | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| 6 | `DATABASE_URL`, Neon Auth vars, `POSTHOG_KEY`/`POSTHOG_HOST`, `GEMINI_API_KEY`, `RESEND_API_KEY`, `CRON_SECRET`, `NEXT_PUBLIC_APP_URL` match production | Ops | +| 7 | Optional: `NEXT_PUBLIC_POSTHOG_KEY` (same project key) for browser **web_vital** events | Ops | +| 8 | Neon: schema from `apps/money-mirror/schema.sql` applied; **if DB predates Phase 3**, run `cd apps/money-mirror && npm run db:upgrade` (or tail of `schema.sql` in SQL Editor) so `transactions.merchant_key` exists — see [`apps/money-mirror/docs/SCHEMA-DRIFT.md`](../../apps/money-mirror/docs/SCHEMA-DRIFT.md) | Ops | +| 9 | Neon Auth: allowed redirect origins include production URL | Ops | + +## Smoke (production or preview) + +| # | Task | Owner | +| --- | ------------------------------------------------------------------------------------------------------------------ | ----- | +| 10 | `/` and `/login` load (200) | QA | +| 11 | OTP sign-in works | QA | +| 12 | Upload PDF → dashboard → **Transactions** tab loads rows or empty state — **no** red “Failed to load transactions” | QA | +| 13 | **Insights** tab: narratives load or fallback copy (if `GEMINI_API_KEY` unset, AI step skipped) | QA | +| 14 | `GET /api/cron/weekly-recap` → 401 without secret; 200 with `x-cron-secret` | Ops | + +## Optional quality bar + +| # | Task | Owner | +| --- | ----------------------------------------------------------------------------------------------------- | -------- | +| 15 | Local Lighthouse: `npm run build && npm run start` then `npx lighthouse http://localhost:3000 --view` | Eng | +| 16 | Address any **P0** items in `apps/money-mirror/docs/PERFORMANCE-REVIEW.md` | PM + Eng | + +## Deploy + +| # | Task | Owner | +| --- | ---------------------------------------------------------------------------------------------------------- | ----- | +| 17 | From monorepo root: `vercel deploy --prod` with project `rootDirectory` = `apps/money-mirror` (see README) | Ops | +| 18 | Verify production alias (`NEXT_PUBLIC_APP_URL`) | Ops | + +## Known dev-environment noise + +- **`uv_interface_addresses` / `getNetworkHosts` unhandled rejection** — can appear in some sandboxes or restricted Node environments when Next tries to print the LAN URL. The dev server may still work on `http://localhost:3000`. Workaround: run `next dev -H 127.0.0.1` or use full OS permissions; not a production issue. diff --git a/project-state.md b/project-state.md index 9d88ddf..c578075 100644 --- a/project-state.md +++ b/project-state.md @@ -12,17 +12,18 @@ ## Current Stage - stage: learning -- last_command_run: /learning — issue-010 2026-04-05 +- last_command_run: linear-sync production-readiness — **VIJ-42** (Done) + comment on **VIJ-37** 2026-04-06; repo state + `issue-010.json` updated - status: completed - active_issue: issue-010 — Phase 3 umbrella; Linear parent **VIJ-37**; pipeline cycle **complete** (T1–T4 shipped; **T5–T6** deferred per PM plan) ## Active Work -- active_branch: main -- last_commit: 3cdd83a -- open_pr_link: https://github.com/shadowdevcode/ai-product-os/pull/16 (merged; squash merge on `main`) +- active_branch: main (push feature branches for new work) +- last_commit: (see `git log -1`) +- open_pr_link: open a new PR when pushing MoneyMirror hardening / perf follow-ups - environments: local, production (`https://money-mirror-rho.vercel.app`) -- implementation_focus: **Phase 3 (issue-010)** — **T1–T4** complete through **`/learning`** + **`/linear-close`**. Linear project **Completed**; VIJ-37 + VIJ-38–VIJ-41 **Done**; [issue-010 Closeout Snapshot](https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee). **Next:** `/create-issue` for the next project. **T5–T6** deferred. +- implementation_focus: **Phase 3 (issue-010)** — **T1–T4** complete through **`/learning`** + **`/linear-close`**. Linear project **Completed**; VIJ-37 + VIJ-38–VIJ-41 **Done**; [issue-010 Closeout Snapshot](https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee). **Follow-up engineering** (perf, E2E, CWV, schema drift, dev ergonomics) tracked in repo; **does not** reopen VIJ-37. Use [`experiments/results/production-launch-checklist-010.md`](experiments/results/production-launch-checklist-010.md) before production promotion. **Next:** `/create-issue` for the next project. **T5–T6** deferred. +- linear_ops_note: **VIJ-42** (Done) — production-readiness mirror issue; comment **2026-04-06** on **VIJ-37** links VIJ-42 + post-close summary (`docs/PERFORMANCE-REVIEW.md`, `production-launch-checklist-010.md`, `SCHEMA-DRIFT.md`). ## Quality Gates @@ -112,6 +113,9 @@ All items sit in Linear project **issue-009 — MoneyMirror**. Feature work for ## Decisions Log (append-only) +- 2026-04-06: **Linear production-readiness sync (post–VIJ-37)** — Workspace has a single Linear team (`Vijaypmworkspace`); no separate “Market” team. Created **[VIJ-42](https://linear.app/vijaypmworkspace/issue/VIJ-42/moneymirror-production-readiness-sync-post-vij-37-closeout)** (**Done**) documenting post-close ops: gated launch checklist (`production-launch-checklist-010.md`), schema drift / `db:upgrade` / boot DDL, perf + Web Vitals + Playwright E2E, docs. Classified as **ops + checklist + small bugfixes** (e.g. `db:upgrade` `main()`), not a reopened Phase 3 feature cycle. Comment on **VIJ-37** links VIJ-42. `experiments/linear-sync/issue-010.json` + this file updated; pipeline stage remains **learning** / **completed**. +- 2026-04-06: **E2E documentation + verification closeout** — `CODEBASE-CONTEXT.md` refreshed for schema upgrades, `pg-errors` / `SCHEMA_DRIFT`, `WebVitalsReporter`, Playwright; README **Docs** links `PERFORMANCE-REVIEW.md` + `SCHEMA-DRIFT.md`; `CHANGELOG.md` entry with **Learnings / corrections** (db:upgrade `main()` vs top-level `await`; treating `42703` as schema drift with operator hint). Ran checklist commands: lint, vitest (81/81), Playwright install + `test:e2e` (2/2), repo `check:all`, `npm run db:upgrade` (success against Neon from `.env.local`). Issue-010 pipeline remains **completed**; no `/linear-sync` (no new issue). +- 2026-04-06: **MoneyMirror post-close ops + launch checklist** — Repo updates: `next/font`, lazy Gemini/Insights path, scope-keyed dashboard loads, `WebVitalsReporter` + optional `NEXT_PUBLIC_POSTHOG_KEY`, Playwright smoke E2E, viewport zoom, dev-only transaction error detail, `dev:loopback` script + README note for `uv_interface_addresses` noise. **Linear:** comment on **VIJ-37** with summary and links to `apps/money-mirror/docs/PERFORMANCE-REVIEW.md` + `experiments/results/production-launch-checklist-010.md`. Pipeline stage for issue-010 remains **completed**; this is **operations / hardening**, not a new pipeline cycle. - 2026-04-05: **`/linear-close` issue-010** — Linear project **Completed** (`418bfd75-bf67-47e1-8cb3-dd48c6bd9cb3`); **VIJ-37** + **VIJ-38–VIJ-41** set **Done**; [issue-010 Closeout Snapshot](https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee); final closeout comment on VIJ-37. `experiments/linear-sync/issue-010.json`: `last_sync_mode` **close**, `closeout_snapshot` id recorded. **`/create-issue`** when starting the next cycle. - 2026-04-05: **`/learning` issue-010** — Converted `postmortem-010.md` into durable rules: `knowledge/engineering-lessons.md` (6 entries), `knowledge/product-lessons.md` (1 entry), `knowledge/prompt-library.md` (issue-010 section). Prompt Autopsy applied to agent/command files (dated `# Added: 2026-04-05 — MoneyMirror Phase 3 (issue-010)`). `CODEBASE-CONTEXT.md` updated. Stage **`learning`** / status **`completed`**. **Next:** `/linear-close` (completed same day). - 2026-04-05: **`/postmortem` issue-010** — Artifact `experiments/results/postmortem-010.md`. Root pattern: correctness and scope-semantics gaps caught late in `/review` (two passes); architecture should state financial aggregate invariants and scope-neutral copy defaults. Prompt Autopsy targets: backend-architect, backend-engineer, code-review, frontend-engineer, product/design, deploy-check. **Next:** `/learning` per `system-orchestrator.md`. @@ -234,8 +238,8 @@ All items sit in Linear project **issue-009 — MoneyMirror**. Feature work for - linear_root_issue_identifier: VIJ-37 - linear_cycle: - linear_sync_map_path: experiments/linear-sync/issue-010.json -- linear_last_sync: 2026-04-05T15:40:12Z -- linear_sync_status: success — issue-010 project **Completed**; VIJ-37 **Done**; closeout [issue-010 Closeout Snapshot](https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee); comment id `8d7639f5-820f-4ddc-8f8c-7acc4b93fa2b`. +- linear_last_sync: 2026-04-06T20:35:30Z +- linear_sync_status: success — issue-010 project **Completed**; VIJ-37 **Done**; closeout [issue-010 Closeout Snapshot](https://linear.app/vijaypmworkspace/document/issue-010-closeout-snapshot-5ef07c9db6ee); **VIJ-42** [production readiness](https://linear.app/vijaypmworkspace/issue/VIJ-42/moneymirror-production-readiness-sync-post-vij-37-closeout) **Done** (ops mirror for post-close hardening); closeout comment id `8d7639f5-820f-4ddc-8f8c-7acc4b93fa2b`; prior ops comment `9137c798-bdbc-488c-b243-089dee8911aa`. - linear_follow_up_issue_identifier: VIJ-38 - linear_follow_up_issue_url: https://linear.app/vijaypmworkspace/issue/VIJ-38/issue-010t1-transaction-surface-api-p0-foundation - linear_prior_cycle_map: experiments/linear-sync/issue-009.json (VIJ-11 root; VIJ-25 → Duplicate of VIJ-37 as of 2026-04-05)