From 1a03c3b8867435a21e9e39aacef0bd50dd88bbeb Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:09:35 +0530 Subject: [PATCH 1/9] docs: Phase 1 shell plan and UI revamp deferred backlog --- .../2026-10-20-ui-revamp-phase1-shell.md | 218 ++++++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 13 ++ 2 files changed, 231 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md diff --git a/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md b/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md new file mode 100644 index 0000000..c2ba7f5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-ui-revamp-phase1-shell.md @@ -0,0 +1,218 @@ +# UI Revamp Phase 1 (App Shell) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the November-2025 shell with the new information architecture. That means a new sidebar with an icon-rail collapse, a top-right avatar menu, and new URLs with redirects from the old ones. Old page bodies stay as they are until Phase 5. + +**Architecture:** +- **Routes.** Page folders are renamed or added under `app/(app)/`. Old URLs move to new ones through `next.config.js` `redirects()`, which run before middleware and carry query strings over. +- **Sidebar.** It keeps the shadcn `Sidebar` primitives, with new items. The collapsed state is read from the existing `sidebar_state` cookie on the server, so the first paint is already correct and nothing jumps. +- **Avatar menu.** It lives in the existing sticky header. +- **Tests.** A Playwright fixture fakes a signed-in session: a Supabase session in localStorage, plus stubbed backend responses. This lets e2e drive the real shell. + +**Tech Stack:** Next.js 14 app router, shadcn sidebar/dropdown, `lucide-react`, Playwright (from Phase 0). + +**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`, sections 3 and 6.3 and phase 1 of section 10. Builds on the Phase 0 branch `ui-revamp/phase0-foundations` (PR #89). + +## Global Constraints + +- **IA, verbatim from spec section 3:** + - Home. + - TRACK: Spending, Bills & subscriptions. + - PLAN: Budget, Forecast. **Both are hidden until their pages exist, so Phase 1 renders no PLAN group.** + - A separator, then the stand-alone items: Receipts & statements (with the review badge) and Ask Lumen (⌘K). +- **Group labels name what the user does.** Never "Workspace", "AI", "Data" or "Compare". +- **Profile, settings, API keys (BYOK) and log out all live in the top-right avatar menu.** The sidebar is navigation only, and there is no theme item yet. +- **Collapse:** + - An icon rail of about 56px (`3.5rem`) with tooltips. + - Toggled with a button or Ctrl/⌘+B, and remembered across visits. + - Content must not jump on load. +- **The zero layout-shift rule (spec 6.3) applies.** The Phase 0 shift test pattern is reused for the avatar menu. +- **Suggested new routes, adopted:** `/home`, `/spending`, `/bills`, `/receipts`, `/ask`, plus the existing `/settings`. +- **Old URLs must keep working** for bookmarks and the demo. +- **Git:** no "claude" in branch names and no attribution lines. Open one PR into `refactor`. Keep `docs/CODEBASE-GUIDE.md` current. + +## Decisions this plan makes (owner may override) + +- **Old → new redirects:** + - `/dashboard` → `/home` + - `/analytics` → `/spending` + - `/ai-analytics` → `/spending` + - `/chatbot` → `/ask` + - `/documents` → `/receipts` + - `/import` → `/receipts?tab=import` + - `/review` → `/receipts?tab=review` + - `/upload` → `/home?upload=1` + + All are `permanent: false`, because routes may still change before launch. +- **Interim page bodies until Phase 5:** + - `/spending`: `AnalyticsContent`, then `AiAnalyticsContent`, stacked. + - `/bills`: `PaymentCalendarCard` and `ReminderDetailsCard`. These two cards also still appear inside `/spending`. That duplication is accepted until Phase 5. + - `/receipts`: a tab bar (Documents | Import | Review, driven by `?tab=`) over the existing contents. +- **Collapsed state is server-read with `cookies()` in `(app)/layout.tsx`.** This makes the app routes render dynamically, which costs one serverless invocation per navigation on Vercel. The alternative, reading it on the client, causes a visible width jump on every hard load, which breaks the owner's no-shift rule. The cost should be revisited in Phase 6 (perf). +- **⌘K / Ctrl+K navigates to `/ask` for now.** The sidebar Ask input with handoff is Phase 4. + +## Review Focus + +1. **Hard reload with the sidebar collapsed:** the first paint must already be a rail, with no expand-then-collapse. Task 3 test `collapsed state survives reload without a jump`. +2. **Old bookmarked URLs with query strings:** `/analytics?tab=weekly` must land on `/spending?tab=weekly`. Task 2 test `old URLs redirect`. +3. **Unauthenticated access to new URLs:** these must be protected exactly like the old ones, through the middleware list and matcher. Task 2 test `new URLs require a session`. +4. **Opening the avatar menu must not move the page,** which was the original bug's trigger. Task 4 test `avatar menu does not move the page`. +5. **Keyboard users:** Ctrl+B and Ctrl+K must not fire while typing in an input or textarea. Ask Lumen's composer is a textarea, and Ctrl+K inside it must not navigate away. Task 3 test `shortcuts ignore text fields`. + +## File map + +| File | Change | +|---|---| +| `frontend/e2e/fixtures.ts` (new) | `signIn(page)`: fake Supabase session plus `lumen_session` cookie, and stubs for every backend call to `NEXT_PUBLIC_BACKEND_URL` | +| `frontend/e2e/shell.spec.ts` (new) | Phase 1 tests | +| `frontend/src/app/(app)/home/` | `git mv dashboard home` (title "Home") | +| `frontend/src/app/(app)/ask/` | `git mv chatbot ask` (title "Ask Lumen") | +| `frontend/src/app/(app)/spending/page.tsx`, `bills/page.tsx`, `receipts/page.tsx` (new) | Interim compositions (see Decisions). `analytics/`, `ai-analytics/`, `documents/`, `import/` and `review/` keep only their `*Content.tsx`; their `page.tsx` files and `upload/` are deleted | +| `frontend/next.config.js` | Redirect list | +| `frontend/src/middleware.ts`, `frontend/src/app/robots.ts` | Protect and disallow the new prefixes (`/home`, `/spending`, `/bills`, `/receipts`, `/ask`, `/settings`); drop the old ones, since redirects run first | +| In-app links | `login-content.tsx`, `onboarding-content.tsx` (×2), `reset-password/page.tsx`, `error.tsx`, `import/importContent.tsx`: `/dashboard` → `/home`. `demo/page.tsx` (×2): `/chatbot` → `/ask`. `animatedListItemUse.tsx`: `/upload` → `/home?upload=1`. `dashboardContent.tsx` keeps reading `?upload=1` | +| `frontend/src/components/app-sidebar.tsx` | Rewritten (Task 3) | +| `frontend/src/components/avatar-menu.tsx` (new) | Header avatar dropdown (Task 4) | +| `frontend/src/components/ui/sidebar.tsx:32` | `SIDEBAR_WIDTH_ICON = "3.5rem"` | +| `frontend/src/app/(app)/layout.tsx` | Server component; reads the cookie; header gets `AvatarMenu` | +| Delete | `nav-main.tsx`, `nav-projects.tsx`, `nav-user.tsx`, `team-switcher.tsx` (only `app-sidebar` imports them) | + +--- + +### Task 1: Signed-in e2e fixture + +**Files:** Create `frontend/e2e/fixtures.ts`, `frontend/e2e/shell.spec.ts` + +**Interfaces:** +- Produces `export async function signIn(page: Page, opts?: { reviewCount?: number }): Promise`. It must run **before** `page.goto` and does three things: + 1. Adds the cookie `lumen_session=1` for `localhost`. + 2. Adds an init script that writes the localStorage key `sb-127-auth-token`. This is supabase-js's default `sb--auth-token` for `http://127.0.0.1:54321`; confirm it by reading what `getSession` looks up. The value is `{access_token:"e2e", refresh_token:"e2e", token_type:"bearer", expires_in:3600, expires_at:, user:{id:"e2e-user", email:"e2e@lumen.test", user_metadata:{full_name:"E2E User"}}}`. + 3. Adds `page.route("http://127.0.0.1:9/**", …)`. Use `GET /api/review` → `{items: Array(reviewCount ?? 0).fill(minimal flagged item)}` and `GET /api/profile` → `{display_name:"E2E User"}`. Every other request gets `200 {}`. Return an empty array instead wherever the calling code maps over the result. Find those by watching the console in Step 2. +- Every later e2e test in this plan uses `signIn`. + +- [ ] **Step 1: Write the test** `signed-in shell renders` in `shell.spec.ts`. It calls `signIn(page)` and `page.goto("/dashboard")` (the old URL; Task 2 renames it), then expects `getByRole("navigation")` or the sidebar to be visible, and expects no element with the text "Redirecting to sign in". +- [ ] **Step 2: Run** `npx playwright test e2e/shell.spec.ts`. The expected result is a PASS once the fixture works. A first failure that names a crashing page because a stub has the wrong shape is fixed in `fixtures.ts`, not in app code. Check `page.on("pageerror")`: there must be no uncaught errors. +- [ ] **Step 3: Commit** with `test: signed-in e2e fixture (fake supabase session + backend stubs)`. + +### Task 2: New routes, redirects, protection, links + +**Files:** See the file map rows for routes, `next.config.js`, middleware, robots and in-app links. + +**Interfaces:** +- Produces the routes `/home`, `/spending`, `/bills`, `/receipts` (where `?tab=documents|import|review` defaults to documents) and `/ask`. +- Page titles are, exactly: "Home", "Spending", "Bills & subscriptions", "Receipts & statements" and "Ask Lumen". + +- [ ] **Step 1: Write the failing tests** in `shell.spec.ts`: + +```ts +const moved: [string, string][] = [ + ["/dashboard", "/home"], ["/analytics?tab=weekly", "/spending?tab=weekly"], ["/ai-analytics", "/spending"], + ["/chatbot", "/ask"], ["/documents", "/receipts"], ["/import", "/receipts?tab=import"], + ["/review", "/receipts?tab=review"], ["/upload", "/home?upload=1"], +]; +test("old URLs redirect", async ({ request }) => { + for (const [from, to] of moved) { + const r = await request.get(from, { maxRedirects: 0 }); + expect([307, 308]).toContain(r.status()); + expect(r.headers()["location"]).toBe(to); + } +}); +test("new URLs require a session", async ({ request }) => { + for (const p of ["/home", "/spending", "/bills", "/receipts", "/ask", "/settings"]) { + const r = await request.get(p, { maxRedirects: 0 }); + expect(r.headers()["location"]).toContain("/login?reason=unauthorized"); + } +}); +test("new pages render for a signed-in user", async ({ page }) => { + await signIn(page); + for (const [p, title] of [["/home","Home"],["/spending","Spending"],["/bills","Bills & subscriptions"], + ["/receipts","Receipts & statements"],["/receipts?tab=review","Receipts & statements"],["/ask","Ask Lumen"]]) { + await page.goto(p); + await expect(page).toHaveTitle(new RegExp(`^${title}`)); + } +}); +``` + +- [ ] **Step 2: Run them.** The expected result is all three FAIL, with a 404 or no redirect. +- [ ] **Step 3: Implement.** + - Run the `git mv` renames. + - Add the three new `page.tsx` files, each exporting `metadata.title`. + - `receipts/page.tsx` is a client tab bar of ``, styled `label-mono`, with the active tab underlined. Below it, render the matching existing `*Content`. + - Add the redirects to the list in `next.config.js`. + - Update the middleware prefixes and matcher, and update `robots.ts`. + - Update the in-app links listed in the file map. + - Then confirm none are left: `grep -rnE '"/(dashboard|chatbot|analytics|ai-analytics|documents|import|review|upload)["?]' src` should return no output, except `nav-projects.tsx`, which Task 5 deletes. +- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes, including the Phase 0 suite. +- [ ] **Step 5: Commit** with `feat: new app routes (/home /spending /bills /receipts /ask) with redirects from old URLs`. + +### Task 3: Sidebar: new IA, icon rail, remembered state, shortcuts + +**Files:** `app-sidebar.tsx` (rewrite), `ui/sidebar.tsx:32`, `(app)/layout.tsx`, `shell.spec.ts` + +**Interfaces:** +- Produces the `AppSidebar` items as data, in this order: + - Home: `/home`, icon `House`. + - Group label "Track": + - Spending: `/spending`, icon `ChartLine`. + - Bills & subscriptions: `/bills`, icon `CalendarClock`. + - `SidebarSeparator`. + - Receipts & statements: `/receipts`, icon `Receipt`. Shows a badge with the `reviewApi.list()` length when it is greater than 0, and hides it on error. + - Ask Lumen: `/ask`, icon `Sparkles`. Shows a `⌘K` hint, or `Ctrl K` when `navigator.platform` isn't a Mac. Detect this after mount; the hint is the same width either way, so nothing shifts. +- Active item rule: `pathname === url || pathname.startsWith(url + "/")`. +- Every item passes `tooltip={label}`. +- Header: the logo plus "Lumen", with no tagline. +- `(app)/layout.tsx` becomes a server component. It runs `const defaultOpen = cookies().get("sidebar_state")?.value !== "false"` and passes that to `SidebarProvider defaultOpen`. Client-only wrappers (`AuthGuard`, `ProfileProvider`, `MotionConfig`) stay as children. +- The ⌘K handler lives in `app-sidebar.tsx`: a `keydown` listener that calls `router.push("/ask")`. + +- [ ] **Step 1: Write the failing tests:** + - **`sidebar shows the new IA`:** after `signIn` and `/home`, the visible sidebar link texts are exactly `["Home","Spending","Bills & subscriptions","Receipts & statements","Ask Lumen"]`, there is a group label "Track", and the page has no text "Workspace", "Compare" or "Financial command center". + - **`review badge counts flagged receipts`:** with `signIn(page,{reviewCount:3})`, the Receipts item contains "3". + - **`ctrl+b collapses to a 56px rail with tooltips`:** + 1. Press `Control+b`. + 2. The width of `[data-slot=sidebar-gap]` (or the sidebar container) is 56px ± 1. + 3. Item labels are not visible. + 4. Hovering the Spending icon shows the tooltip "Spending". + - **`collapsed state survives reload without a jump`:** + 1. Collapse, then reload. + 2. Before any script runs (`page.goto(url, { waitUntil: "commit" })` plus reading the HTML through `request.get` with the `sidebar_state=false` cookie), the server HTML contains `data-state="collapsed"`. + 3. After load, the rail width is 56px. + - **`ctrl+k opens Ask`:** on `/home`, pressing `Control+k` lands on `/ask`. + - **`shortcuts ignore text fields`:** on `/ask`, focus the composer textarea and press `Control+k` and `Control+b`. The URL is still `/ask` and the sidebar state is unchanged. The shadcn handler may need an `isTypingTarget(e.target)` guard: skip when the target is an `input`, a `textarea` or `[contenteditable]`. +- [ ] **Step 2: Run them.** The expected result is FAIL, because the old IA still shows "Workspace". +- [ ] **Step 3: Implement** according to the Interfaces above. Delete the `NavMain` and `NavProjects` imports. +- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes. +- [ ] **Step 5: Commit** with `feat: sidebar with new IA, 56px icon rail, remembered collapse, Ctrl+K to Ask`. + +### Task 4: Header avatar menu (profile, settings, keys, log out) + +**Files:** Create `components/avatar-menu.tsx`; modify `(app)/layout.tsx` (header) and `app-sidebar.tsx` (remove the `SidebarFooter`/`NavUser`). + +**Interfaces:** +- `export function AvatarMenu(): JSX.Element` uses `useAuth` and `useProfile`, with the same name and avatar fallback logic `app-sidebar` has today. +- Trigger: a round 32px avatar button with `aria-label="Account menu"`. +- Menu (shadcn `DropdownMenu`, `align="end"`): + - A label with the name and email. + - "Settings" → `/settings`. + - "API keys" → `/settings#keys`. Add `id="keys"` to the BYOK section in `settingsContent.tsx`. + - A separator. + - "Log out", which calls `authApi.logout()` and then `router.replace("/login")`, the same as `nav-user` does today. +- The header shows the sidebar trigger on the left and `AvatarMenu` on the right. The "Lumen" text in the header is removed. + +- [ ] **Step 1: Write the failing tests:** + - **`avatar menu has account actions`:** after `signIn` and `/home`, click `getByRole("button",{name:"Account menu"})`. The menu items are "Settings", "API keys" and "Log out", and the sidebar has no email text. + - **`avatar menu does not move the page`:** the Phase 0 pattern. Take the bounding box of `#main` before and after opening the menu on `/ask` and on `/home`; x and width must be unchanged. + - **`log out returns to login`:** click "Log out". The URL matches `/login`. Supabase `signOut` will call the stubbed host, so return 204 for `**/auth/v1/logout`. +- [ ] **Step 2: Run them.** The expected result is FAIL, because there is no "Account menu" button. +- [ ] **Step 3: Implement** according to the Interfaces above. +- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes. +- [ ] **Step 5: Commit** with `feat: top-right avatar menu with settings, API keys and log out`. + +### Task 5: Clean up and ship + +- [ ] **Step 1:** Delete `nav-main.tsx`, `nav-projects.tsx`, `nav-user.tsx` and `team-switcher.tsx`. Running `grep -rn "nav-main\|nav-projects\|nav-user\|team-switcher" src` should return no output. +- [ ] **Step 2:** Run each of these: + - `npx tsc --noEmit`, `npm run lint` and `npm run build`, with the dummy env from Phase 0. All should be clean. + - `npm run test:e2e`: everything passes. +- [ ] **Step 3:** Update `docs/CODEBASE-GUIDE.md` §7b2 with the new routes and their redirects, the sidebar data and badge, the cookie-driven collapse (and why the layout is dynamic), the avatar menu and the e2e `signIn` fixture. +- [ ] **Step 4:** Commit, push the `ui-revamp/phase1-shell` branch and open a PR into `refactor`. If #89 isn't merged yet, base the PR on `ui-revamp/phase0-foundations` and say so in the body. The PR body lists the tests, the redirect table and the decisions above. Run the whole-branch review first, as in Phase 0. diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index 7b9de5b..aff6e1c 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -203,3 +203,16 @@ Keep `docs/CODEBASE-GUIDE.md` current in each PR. - Chart implementation (keep recharts or hand-rolled SVG like the mockup), as long as it matches the look and passes the perf budget. - Mobile layout details beyond "sidebar becomes a sheet". - Copy for empty states and the brief's rule templates. + +## 12. Deferred backlog (revisit in the phase named) + +Small items found while building, parked here so they aren't lost. Tick them off in the PR that fixes them. + +- [ ] **Phase 2.** The `theme-color` meta is still `#050505` inside the light app, so the mobile browser bar stays black. Make it per route group (`viewport` export in `(app)/layout`). *(Phase 0 review)* +- [ ] **Phase 2.** Muted text on the `#cfe2fb` end of `--band` is about 4.2:1. Use `foreground` for text on the band. *(Phase 0 review)* +- [ ] **Phase 4.** The Ask Lumen chat still uses the dark `.bui` palette inside the light shell. Restyle it to Sky. *(Phase 0)* +- [ ] **Phase 6.** The global reduced-motion rule also stops loading spinners. Consider exempting spinners with `motion-safe:` or giving them a static "Loading…" label. *(Phase 0 review)* +- [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)* +- [ ] **Phase 6.** `(app)/layout` reads the sidebar cookie on the server, which makes the app routes dynamic (one Vercel invocation per navigation). Measure it, and switch to a no-jump client approach if it's costly. *(Phase 1 decision)* +- [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)* +- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)* From edf6db640349ca31af3be67c8a594aba1eacdac5 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:10:35 +0530 Subject: [PATCH 2/9] test: signed-in e2e fixture (fake supabase session + backend stubs) --- frontend/e2e/fixtures.ts | 33 +++++++++++++++++++++++++++++++++ frontend/e2e/shell.spec.ts | 12 ++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 frontend/e2e/fixtures.ts create mode 100644 frontend/e2e/shell.spec.ts diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 0000000..e7a4a51 --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,33 @@ +import type { Page } from "@playwright/test"; + +// Fake a signed-in user without Supabase or the backend. Call before page.goto. +// - supabase-js reads a non-expired session from localStorage without a network call +// (key = sb--auth-token; the e2e host is 127.0.0.1). +// - the `lumen_session` cookie gets past middleware. +// - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed. +export async function signIn(page: Page, opts: { reviewCount?: number } = {}): Promise { + await page.context().addCookies([{ name: "lumen_session", value: "1", domain: "localhost", path: "/" }]); + await page.addInitScript(() => { + const now = Math.floor(Date.now() / 1000); + localStorage.setItem( + "sb-127-auth-token", + JSON.stringify({ + access_token: "e2e", + refresh_token: "e2e", + token_type: "bearer", + expires_in: 3600, + expires_at: now + 3600, + user: { id: "e2e-user", email: "e2e@lumen.test", user_metadata: { full_name: "E2E User" } }, + }), + ); + }); + const flagged = { id: "r1", status: "flagged", reasons: [], fields: {}, created_at: null, note: null }; + await page.route("http://127.0.0.1:9/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path === "/api/review") return route.fulfill({ json: { items: Array(opts.reviewCount ?? 0).fill(flagged) } }); + if (path === "/api/profile") return route.fulfill({ json: { display_name: "E2E User", avatar_key: null } }); + return route.fulfill({ json: {} }); + }); + // supabase signOut and any other auth call + await page.route("http://127.0.0.1:54321/**", (route) => route.fulfill({ status: 204, body: "" })); +} diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts new file mode 100644 index 0000000..31913a4 --- /dev/null +++ b/frontend/e2e/shell.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from "@playwright/test"; +import { signIn } from "./fixtures"; + +test("signed-in shell renders", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await signIn(page); + await page.goto("/dashboard"); + await expect(page.locator('[data-slot="sidebar"]').first()).toBeVisible(); + await expect(page.getByText("Redirecting to sign in")).toHaveCount(0); + expect(errors).toEqual([]); +}); From 953cc3ac6d0b7af64eeae6977388286486e4ed65 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:14:28 +0530 Subject: [PATCH 3/9] feat: new app routes (/home /spending /bills /receipts /ask) with redirects from old URLs --- frontend/e2e/foundations.spec.ts | 4 +- frontend/e2e/shell.spec.ts | 42 +++++++++++++++++++ frontend/next.config.js | 13 +++++- frontend/src/app/(app)/ai-analytics/page.tsx | 8 ---- frontend/src/app/(app)/analytics/page.tsx | 8 ---- .../(app)/{chatbot => ask}/chatbotContent.tsx | 0 .../src/app/(app)/{chatbot => ask}/page.tsx | 0 frontend/src/app/(app)/bills/page.tsx | 18 ++++++++ frontend/src/app/(app)/documents/page.tsx | 8 ---- .../{dashboard => home}/dashboardContent.tsx | 0 .../app/(app)/{dashboard => home}/page.tsx | 2 +- .../src/app/(app)/import/importContent.tsx | 2 +- frontend/src/app/(app)/import/page.tsx | 8 ---- frontend/src/app/(app)/receipts/page.tsx | 41 ++++++++++++++++++ frontend/src/app/(app)/review/page.tsx | 8 ---- frontend/src/app/(app)/spending/page.tsx | 15 +++++++ frontend/src/app/(app)/upload/page.tsx | 5 --- frontend/src/app/demo/page.tsx | 4 +- frontend/src/app/error.tsx | 2 +- frontend/src/app/login/login-content.tsx | 4 +- .../src/app/onboarding/onboarding-content.tsx | 4 +- frontend/src/app/reset-password/page.tsx | 2 +- frontend/src/app/robots.ts | 11 ++--- .../src/components/animatedListItemUse.tsx | 2 +- frontend/src/middleware.ts | 20 ++++----- 25 files changed, 155 insertions(+), 76 deletions(-) delete mode 100644 frontend/src/app/(app)/ai-analytics/page.tsx delete mode 100644 frontend/src/app/(app)/analytics/page.tsx rename frontend/src/app/(app)/{chatbot => ask}/chatbotContent.tsx (100%) rename frontend/src/app/(app)/{chatbot => ask}/page.tsx (100%) create mode 100644 frontend/src/app/(app)/bills/page.tsx delete mode 100644 frontend/src/app/(app)/documents/page.tsx rename frontend/src/app/(app)/{dashboard => home}/dashboardContent.tsx (100%) rename frontend/src/app/(app)/{dashboard => home}/page.tsx (73%) delete mode 100644 frontend/src/app/(app)/import/page.tsx create mode 100644 frontend/src/app/(app)/receipts/page.tsx delete mode 100644 frontend/src/app/(app)/review/page.tsx create mode 100644 frontend/src/app/(app)/spending/page.tsx delete mode 100644 frontend/src/app/(app)/upload/page.tsx diff --git a/frontend/e2e/foundations.spec.ts b/frontend/e2e/foundations.spec.ts index 461147b..6c36eba 100644 --- a/frontend/e2e/foundations.spec.ts +++ b/frontend/e2e/foundations.spec.ts @@ -96,8 +96,8 @@ test("reduced motion kills transitions", async ({ browser }) => { test("app pages are sky from the first server paint (no dark auth loader)", async ({ request }) => { // the `lumen_session` hint cookie gets past middleware, like a signed-in user's hard refresh - const html = await (await request.get("/dashboard", { headers: { cookie: "lumen_session=1" } })).text(); - expect(html).toContain('data-app-theme="sky"'); + const html = await (await request.get("/home", { headers: { cookie: "lumen_session=1" } })).text(); + expect(html.includes('data-app-theme="sky"'), "sky wrapper in server HTML").toBe(true); }); test("dark .bui panels keep readable muted text inside sky", async ({ page }) => { diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 31913a4..0c75580 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -10,3 +10,45 @@ test("signed-in shell renders", async ({ page }) => { await expect(page.getByText("Redirecting to sign in")).toHaveCount(0); expect(errors).toEqual([]); }); + +const moved: [string, string][] = [ + ["/dashboard", "/home"], + ["/analytics?tab=weekly", "/spending?tab=weekly"], + ["/ai-analytics", "/spending"], + ["/chatbot", "/ask"], + ["/documents", "/receipts"], + ["/import", "/receipts?tab=import"], + ["/review", "/receipts?tab=review"], + ["/upload", "/home?upload=1"], +]; + +test("old URLs redirect", async ({ request }) => { + for (const [from, to] of moved) { + const r = await request.get(from, { maxRedirects: 0 }); + expect([307, 308], from).toContain(r.status()); + expect(r.headers()["location"], from).toBe(to); + } +}); + +test("new URLs require a session", async ({ request }) => { + for (const p of ["/home", "/spending", "/bills", "/receipts", "/ask", "/settings"]) { + const r = await request.get(p, { maxRedirects: 0 }); + expect(r.headers()["location"] ?? "", p).toContain("/login?reason=unauthorized"); + } +}); + +test("new pages render for a signed-in user", async ({ page }) => { + await signIn(page); + const pages: [string, string][] = [ + ["/home", "Home"], + ["/spending", "Spending"], + ["/bills", "Bills & subscriptions"], + ["/receipts", "Receipts & statements"], + ["/receipts?tab=review", "Receipts & statements"], + ["/ask", "Ask Lumen"], + ]; + for (const [p, title] of pages) { + await page.goto(p); + await expect(page, p).toHaveTitle(new RegExp(`^${title}`)); + } +}); diff --git a/frontend/next.config.js b/frontend/next.config.js index 74a5938..fdcda45 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -22,7 +22,18 @@ const nextConfig = { // The login page used to live at /signin; old bookmarks and already-sent email links still work // (query strings carry over, and browsers keep the #fragment Supabase puts on email links). async redirects() { - return [{ source: "/signin", destination: "/login", permanent: true }]; + // UI revamp Phase 1 renamed the app pages; temporary (307) while names may still change before launch. + const moved = [ + ["/dashboard", "/home"], + ["/analytics", "/spending"], + ["/ai-analytics", "/spending"], + ["/chatbot", "/ask"], + ["/documents", "/receipts"], + ["/import", "/receipts?tab=import"], + ["/review", "/receipts?tab=review"], + ["/upload", "/home?upload=1"], + ].map(([source, destination]) => ({ source, destination, permanent: false })); + return [{ source: "/signin", destination: "/login", permanent: true }, ...moved]; }, }; diff --git a/frontend/src/app/(app)/ai-analytics/page.tsx b/frontend/src/app/(app)/ai-analytics/page.tsx deleted file mode 100644 index 769ae54..0000000 --- a/frontend/src/app/(app)/ai-analytics/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { Metadata } from "next"; -import AIAnalyticsContent from "./aiAnalyticsContent"; - -export const metadata: Metadata = { title: "AI insights" }; - -export default function Page() { - return ; -} diff --git a/frontend/src/app/(app)/analytics/page.tsx b/frontend/src/app/(app)/analytics/page.tsx deleted file mode 100644 index 161c4f3..0000000 --- a/frontend/src/app/(app)/analytics/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { Metadata } from "next"; -import AnalyticsContent from "./analyticsContent"; - -export const metadata: Metadata = { title: "Analytics" }; - -export default function Page() { - return ; -} diff --git a/frontend/src/app/(app)/chatbot/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx similarity index 100% rename from frontend/src/app/(app)/chatbot/chatbotContent.tsx rename to frontend/src/app/(app)/ask/chatbotContent.tsx diff --git a/frontend/src/app/(app)/chatbot/page.tsx b/frontend/src/app/(app)/ask/page.tsx similarity index 100% rename from frontend/src/app/(app)/chatbot/page.tsx rename to frontend/src/app/(app)/ask/page.tsx diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx new file mode 100644 index 0000000..b8af91a --- /dev/null +++ b/frontend/src/app/(app)/bills/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { DashboardShell } from "@/components/dashboard-shell"; +import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard"; +import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard"; + +export const metadata: Metadata = { title: "Bills & subscriptions" }; + +// Interim (UI revamp Phase 1): the payment calendar and reminders until Phase 5 redesigns Bills. +export default function Page() { + return ( + +
+ + +
+
+ ); +} diff --git a/frontend/src/app/(app)/documents/page.tsx b/frontend/src/app/(app)/documents/page.tsx deleted file mode 100644 index 9f1b4af..0000000 --- a/frontend/src/app/(app)/documents/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { Metadata } from "next"; -import DocumentsContent from "./documentsContent"; - -export const metadata: Metadata = { title: "Documents" }; - -export default function Page() { - return ; -} diff --git a/frontend/src/app/(app)/dashboard/dashboardContent.tsx b/frontend/src/app/(app)/home/dashboardContent.tsx similarity index 100% rename from frontend/src/app/(app)/dashboard/dashboardContent.tsx rename to frontend/src/app/(app)/home/dashboardContent.tsx diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/home/page.tsx similarity index 73% rename from frontend/src/app/(app)/dashboard/page.tsx rename to frontend/src/app/(app)/home/page.tsx index 0abb86f..1a31dc8 100644 --- a/frontend/src/app/(app)/dashboard/page.tsx +++ b/frontend/src/app/(app)/home/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import DashboardContent from "./dashboardContent"; -export const metadata: Metadata = { title: "Overview" }; +export const metadata: Metadata = { title: "Home" }; export default function Page() { return ; diff --git a/frontend/src/app/(app)/import/importContent.tsx b/frontend/src/app/(app)/import/importContent.tsx index a938809..804140c 100644 --- a/frontend/src/app/(app)/import/importContent.tsx +++ b/frontend/src/app/(app)/import/importContent.tsx @@ -208,7 +208,7 @@ export default function ImportContent() { Imported {result.imported} · {result.duplicates} duplicates · {result.matched} already recorded ·{" "} {result.credits} credits skipped {result.unreadable.length > 0 && ` · unreadable lines: ${result.unreadable.join(", ")}`}.{" "} - + See the dashboard

diff --git a/frontend/src/app/(app)/import/page.tsx b/frontend/src/app/(app)/import/page.tsx deleted file mode 100644 index 435e2fe..0000000 --- a/frontend/src/app/(app)/import/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { Metadata } from "next"; -import ImportContent from "./importContent"; - -export const metadata: Metadata = { title: "Import statement" }; - -export default function Page() { - return ; -} diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx new file mode 100644 index 0000000..6db7090 --- /dev/null +++ b/frontend/src/app/(app)/receipts/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import DocumentsContent from "../documents/documentsContent"; +import ImportContent from "../import/importContent"; +import ReviewContent from "../review/reviewContent"; + +export const metadata: Metadata = { title: "Receipts & statements" }; + +const TABS = [ + { id: "documents", label: "Documents", Content: DocumentsContent }, + { id: "import", label: "Import", Content: ImportContent }, + { id: "review", label: "Review", Content: ReviewContent }, +] as const; + +// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page. +export default function Page({ searchParams }: { searchParams: { tab?: string } }) { + const active = TABS.find((t) => t.id === searchParams.tab) ?? TABS[0]; + return ( + <> + + + + ); +} diff --git a/frontend/src/app/(app)/review/page.tsx b/frontend/src/app/(app)/review/page.tsx deleted file mode 100644 index 9eb07ea..0000000 --- a/frontend/src/app/(app)/review/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { Metadata } from "next"; -import ReviewContent from "./reviewContent"; - -export const metadata: Metadata = { title: "Review" }; - -export default function Page() { - return ; -} diff --git a/frontend/src/app/(app)/spending/page.tsx b/frontend/src/app/(app)/spending/page.tsx new file mode 100644 index 0000000..8e0b98a --- /dev/null +++ b/frontend/src/app/(app)/spending/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import AnalyticsContent from "../analytics/analyticsContent"; +import AIAnalyticsContent from "../ai-analytics/aiAnalyticsContent"; + +export const metadata: Metadata = { title: "Spending" }; + +// Interim (UI revamp Phase 1): the old Analytics and AI Insights pages stacked until Phase 5 redesigns Spending. +export default function Page() { + return ( + <> + + + + ); +} diff --git a/frontend/src/app/(app)/upload/page.tsx b/frontend/src/app/(app)/upload/page.tsx deleted file mode 100644 index 9b90f50..0000000 --- a/frontend/src/app/(app)/upload/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function UploadPage() { - redirect("/dashboard?upload=1"); -} diff --git a/frontend/src/app/demo/page.tsx b/frontend/src/app/demo/page.tsx index 25cfa08..3640781 100644 --- a/frontend/src/app/demo/page.tsx +++ b/frontend/src/app/demo/page.tsx @@ -28,7 +28,7 @@ export default function DemoPage() { headers: { Authorization: `Bearer ${data.session.access_token}` }, }); if (!res.ok) throw new Error(`Demo setup failed (${res.status})`); - router.replace("/chatbot"); + router.replace("/ask"); } catch (err) { const message = err instanceof Error ? err.message : "Something went wrong."; // Don't leave the visitor signed in to an empty demo account: sign out so a retry starts clean. @@ -41,7 +41,7 @@ export default function DemoPage() { useEffect(() => { if (loading || started.current) return; started.current = true; - if (user) router.replace("/chatbot"); + if (user) router.replace("/ask"); else start(); }, [loading, user, router, start]); diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx index 058f8d6..0d7c65b 100644 --- a/frontend/src/app/error.tsx +++ b/frontend/src/app/error.tsx @@ -22,7 +22,7 @@ export default function Error({
diff --git a/frontend/src/app/login/login-content.tsx b/frontend/src/app/login/login-content.tsx index 64e35db..1c83d7a 100644 --- a/frontend/src/app/login/login-content.tsx +++ b/frontend/src/app/login/login-content.tsx @@ -35,8 +35,8 @@ const TITLES: Record = { // Only allow same-origin relative paths. "//host" and "/\host" are treated by // browsers as protocol-relative URLs and would redirect off-site. function safeNextPath(next: string | null): string { - if (!next || !next.startsWith("/")) return "/dashboard"; - if (next.startsWith("//") || next.startsWith("/\\")) return "/dashboard"; + if (!next || !next.startsWith("/")) return "/home"; + if (next.startsWith("//") || next.startsWith("/\\")) return "/home"; return next; } diff --git a/frontend/src/app/onboarding/onboarding-content.tsx b/frontend/src/app/onboarding/onboarding-content.tsx index 76b220a..a582790 100644 --- a/frontend/src/app/onboarding/onboarding-content.tsx +++ b/frontend/src/app/onboarding/onboarding-content.tsx @@ -50,7 +50,7 @@ const GOALS: [Goal, string][] = [ ]; function safeNext(next: string | null): string { - if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/dashboard"; + if (!next || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/home"; return next; } @@ -120,7 +120,7 @@ export default function OnboardingContent() { return; } if (user.is_anonymous) { - router.replace("/dashboard"); // demo visitors never onboard + router.replace("/home"); // demo visitors never onboard return; } loadedFor.current = user.id; diff --git a/frontend/src/app/reset-password/page.tsx b/frontend/src/app/reset-password/page.tsx index ee87011..df42e14 100644 --- a/frontend/src/app/reset-password/page.tsx +++ b/frontend/src/app/reset-password/page.tsx @@ -50,7 +50,7 @@ export default function ResetPasswordPage() { password, }); if (updateError) throw updateError; - router.replace("/dashboard"); + router.replace("/home"); } catch (err) { setError(err instanceof Error ? err.message : "Couldn't update your password."); setSubmitting(false); diff --git a/frontend/src/app/robots.ts b/frontend/src/app/robots.ts index d16bd0a..7d3d7f6 100644 --- a/frontend/src/app/robots.ts +++ b/frontend/src/app/robots.ts @@ -9,11 +9,12 @@ export default function robots(): MetadataRoute.Robots { userAgent: "*", allow: "/", disallow: [ - "/dashboard", - "/analytics", - "/ai-analytics", - "/chatbot", - "/upload", + "/home", + "/spending", + "/bills", + "/receipts", + "/ask", + "/settings", "/login", "/demo", "/reset-password", diff --git a/frontend/src/components/animatedListItemUse.tsx b/frontend/src/components/animatedListItemUse.tsx index 655bbfc..30a7443 100644 --- a/frontend/src/components/animatedListItemUse.tsx +++ b/frontend/src/components/animatedListItemUse.tsx @@ -198,7 +198,7 @@ export default function AnimatedListItemUse() {