fix(04-01): restore phase 2/3 work lost by worktree soft-reset

The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
This commit is contained in:
lorentz 2026-05-03 18:01:14 -04:00
parent 6268d1fe37
commit 9658640c04
50 changed files with 9587 additions and 395 deletions

140
.planning/PROJECT.md Normal file
View file

@ -0,0 +1,140 @@
# Pulse Mobile Shell Redesign
## What This Is
A ground-up redesign of Pulse's `/mobile/*` shell — the manager-on-the-go view of
the existing Pulse PSA dashboard. It replaces the current mobile layout, swaps
the standalone `/mobile/nav` page for a Sheet drawer, restyles Dashboard /
Tickets / Finance, and adds two new mobile surfaces (Analyzer feed,
Engagement). Built on the existing Pulse codebase — same routes, same data,
phone-first layouts.
## Core Value
A manager can open Pulse on their phone and, in under 30 seconds, see the
state of the business and triage tickets — without ever needing to switch to
desktop for read-only awareness.
## Requirements
### Validated
<!-- Capabilities that already exist in the Pulse mobile shell. -->
- ✓ `/mobile` route shell with sticky header and bottom nav — existing
- ✓ `/mobile/dashboard`, `/mobile/tickets`, `/mobile/tickets/[id]`,
`/mobile/finance` routes — existing
- ✓ Standalone `/mobile/nav` page (to be replaced by drawer) — existing
- ✓ Authentication, sync workers, analyzer pipeline, Engagement data sources —
existing in desktop app and reused here
- ✓ PWA scaffolding (manifest, viewport-fit=cover, `pt-safe`/`pb-safe` utilities)
— Validated in Phase 1: PWA Scaffolding (PWA-01..04)
- ✓ New `/mobile/layout.tsx` shell — sticky header (WulfMark + Bell placeholder
+ avatar), 5-cell bottom nav (Dashboard/Tickets/Finance/Analyzer + More),
shared MoreDrawer Sheet — Validated in Phase 2: Mobile Shell + More Drawer
(SHELL-01..06, NAV-01..03)
- ✓ More drawer (right Sheet) replaces `/mobile/nav` — Mobile sections, Full
site links (with `ExternalLink` hint), Account + Sign out — Validated in
Phase 2: Mobile Shell + More Drawer (DRAWER-01..06)
- ✓ Dashboard restyle — 2×2 KPI grid, Needs Attention strip (horizontal scroll),
worker/backup status row, no charts. Backed by single `/api/mobile/dashboard`
endpoint returning `kpis`/`needsAttention`/`workers`. Validated in Phase 3:
Dashboard Restyle (DASH-01..04)
### Active
<!-- Hypotheses for this milestone — see docs/superpowers/specs/2026-05-03-mobile-shell-design.md -->
- [ ] Tickets restyle — collapsible filter strip (URL-synced), priority-bar
rows, cursor-based infinite scroll (~25/page), keep detail page
- [ ] Finance restyle — adopt new Card + typography scale, swap wide tables for
stacked lists on mobile
- [ ] Analyzer feed (NEW) — `/mobile/analyzer` read-only stream of recent AI
analyses with mobile summary view; new `/api/mobile/analyzer/feed`
- [ ] Engagement mobile (NEW) — `/mobile/engagement` overview (period chips,
stacked summary cards, sortable per-employee list, sparkline) plus
`/mobile/engagement/[userId]` profile page replacing the desktop modal
### Out of Scope
<!-- From spec §7. Explicit boundaries to prevent scope creep. -->
- Service worker / offline cache / push notifications — deferred until a clear
offline use-case lands
- Tablet breakpoint (`md:max-w-2xl`) — noted as follow-up, keep `max-w-lg`
- Real notification list behind the Bell icon — placeholder only this iteration
- Mobile editing on Engagement (user detail) or Analyzer (re-run, prompt edits)
— read-only on mobile by design
- Charts / recharts on the mobile Dashboard — not earning their weight on
small widths
- Restyling or replacing the desktop pages reachable from the More drawer —
desktop pages stay as they are
## Context
- **Brownfield project.** Pulse is a Next.js 16 + React 19 PSA dashboard for
Wulf Consulting. Existing codebase fully mapped at `.planning/codebase/*.md`.
See `CLAUDE.md`, `ARCHITECTURE.md`, `DESIGN.md` in repo root.
- **Audience.** Wulf Consulting managers using Pulse on iOS/Android during the
workday — the shell is for status checks and triage, not full editing.
- **Mobile is not a replacement for desktop.** Pages where mobile editing
isn't justified link out via the More drawer with an `ExternalLink` hint.
- **Source of truth for this work.** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
— 8-section spec with explicit build order. Phases should follow it.
- **Existing mobile shell is small.** Current `app/mobile/layout.tsx` has 3
bottom-nav tabs and a `/mobile/nav` page. This redesign rebuilds it in
place — no `/mobile-v2`, no parallel routes.
- **Engagement and Analyzer pages on desktop are large** (~1300 + ~650 lines
for engagement; analyzer pipeline already has a desktop UI). Mobile
surfaces reuse the data sources but build phone-first layouts from scratch.
## Constraints
- **Tech stack**: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui — match
existing Pulse conventions (no new state libraries, no SWR/react-query, no
ORM, no Zod in API routes unless required)
- **Routes**: Keep all existing `/mobile/*` paths. Replace files in place. No
new top-level routes outside `/mobile/`.
- **Build order**: Each spec step ships independently — no big-bang merge.
Phase boundaries should let each step land on `master` cleanly.
- **No service worker / no offline**: don't introduce `next-pwa` or a custom
SW in this iteration.
- **Auth**: existing Better Auth + middleware handles `/mobile/*`. No new
auth surface.
- **Data sources**: reuse existing endpoints where possible; add
`/api/mobile/*` only when an existing list endpoint doesn't return the
needed shape (e.g., analyzer feed).
## Key Decisions
<!-- Decisions that constrain future work. -->
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Rebuild `/mobile` in place (no `/mobile-v2`) | Spec §2 — keep canonical URLs, avoid parallel maintenance burden | — Pending |
| Bottom nav = 4 tabs + More (5 cells), Engagement in More | Spec §3.1, §6.5 — managers don't check Engagement as often as the four primary surfaces | — Pending |
| Ship each spec step as its own phase | Spec §8 — independent ship reduces merge risk and keeps reviews focused | — Pending |
| Bell icon is a placeholder only | Spec §5.1 — real notification list deferred; keep keyboard-accessible button so future phase can wire it | — Pending |
| No service worker in this iteration | Spec §4 — defer until a clear offline use-case lands | — Pending |
| Engagement mobile is a real refactor, not a thin adaptation | Spec §6.5 — desktop's wide tables and modals don't translate; build phone-first from same data sources | — Pending |
| Mobile user-detail is a page, not a modal | Spec §6.5 — back gesture needs real navigation history | — Pending |
## Evolution
This document evolves at phase transitions and milestone boundaries.
**After each phase transition** (via `/gsd-transition`):
1. Requirements invalidated? → Move to Out of Scope with reason
2. Requirements validated? → Move to Validated with phase reference
3. New requirements emerged? → Add to Active
4. Decisions to log? → Add to Key Decisions
5. "What This Is" still accurate? → Update if drifted
**After each milestone** (via `/gsd-complete-milestone`):
1. Full review of all sections
2. Core Value check — still the right priority?
3. Audit Out of Scope — reasons still valid?
4. Update Context with current state
---
*Last updated: 2026-05-03 — Phase 3 complete (Dashboard Restyle)*

196
.planning/REQUIREMENTS.md Normal file
View file

@ -0,0 +1,196 @@
# Requirements: Pulse Mobile Shell Redesign
**Defined:** 2026-05-03
**Core Value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness.
**Source spec:** `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
## v1 Requirements
Requirements for this milestone. Each maps to a spec section and a roadmap phase.
### PWA — Progressive Web App scaffolding (spec §4)
- [ ] **PWA-01**: `public/manifest.json` exists with name "Pulse", short_name "Pulse", `display: "standalone"`, `start_url: "/mobile"`, theme/background colors matching dark and light shells
- [ ] **PWA-02**: Manifest is referenced from `app/layout.tsx` via `<link rel="manifest">`
- [ ] **PWA-03**: Viewport meta in `app/layout.tsx` includes `viewport-fit=cover`
- [ ] **PWA-04**: Header and bottom tab bar respect `env(safe-area-inset-top)` and `env(safe-area-inset-bottom)` (Tailwind arbitrary values or shared utility class)
### Shell — `/mobile` layout (spec §5)
- [x] **SHELL-01**: New `app/mobile/layout.tsx` replaces the current layout (rebuild in place — no parallel `/mobile-v2`)
- [ ] **SHELL-02**: Sticky top header: `bg-background/95 backdrop-blur` + bottom border; left = Wulf mark + "Pulse" wordmark linked to `/mobile/dashboard`; no page title in header
- [ ] **SHELL-03**: Header right slot — `Bell` icon button (placeholder, no menu/badge, `aria-label="Notifications"`, empty `onClick`, keyboard-accessible)
- [ ] **SHELL-04**: Header right slot — compact user avatar (`h-7 w-7`); tapping opens the More drawer
- [x] **SHELL-05**: `<main>` content area is scrollable with bottom padding equal to bottom-nav height + safe-area inset
- [ ] **SHELL-06**: Fixed bottom nav: `border-t bg-background`, `max-w-lg mx-auto` wrapper, 5 cells (4 tabs + More)
### NAV — Bottom tab bar (spec §3.1)
- [ ] **NAV-01**: Four equal-width primary tabs: Dashboard (`LayoutDashboard`), Tickets (`Ticket`), Finance (`DollarSign`), Analyzer (`Sparkles`)
- [ ] **NAV-02**: Tabs route to `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance`, `/mobile/analyzer`
- [ ] **NAV-03**: Active state uses `text-primary`, inactive uses `text-muted-foreground`; active detection via `pathname.startsWith(href)`
### DRAWER — More Sheet drawer (spec §3.2)
- [ ] **DRAWER-01**: Fifth bottom-bar control labeled "More" with `Menu` icon opens a shadcn `Sheet`
- [ ] **DRAWER-02**: Sheet uses a single consistent side (`right` or `bottom`) — pick one and stay consistent
- [ ] **DRAWER-03**: Drawer top section "Mobile sections" lists Engagement (`/mobile/engagement`)
- [ ] **DRAWER-04**: Drawer middle section "Full site" lists desktop-only pages (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync) each with `ExternalLink` icon
- [ ] **DRAWER-05**: Drawer bottom section "Account" shows current user (avatar + email, read-only) and a Sign out action that calls `signOut()` then `router.push('/auth/sign-in')`
- [x] **DRAWER-06**: `app/mobile/nav/page.tsx` is deleted in the same change that ships the drawer
### DASH — Mobile Dashboard (spec §6.1)
- [ ] **DASH-01**: 2×2 KPI grid with four primary metric cards drawn from desktop dashboard hero stats
- [ ] **DASH-02**: "Needs Attention" horizontal-scroll strip of compact cards (overdue tickets, failed backups, stalled workflows); tapping a card opens its detail view
- [ ] **DASH-03**: Compact backup/worker status row showing analyzer worker, RMM worker, and backup-success-rate; read-only; tap opens desktop admin page
- [ ] **DASH-04**: No charts/recharts on the mobile Dashboard
### TICK — Mobile Tickets list (spec §6.2)
- [ ] **TICK-01**: Collapsible filter strip at top (`Collapsible` from shadcn), default collapsed; expanded shows status, priority, queue, assigned-to-me toggle
- [ ] **TICK-02**: Filter state syncs to URL query string for deep-linking
- [ ] **TICK-03**: List rows have left-edge color stripe by priority (Critical/High/Medium/Low → red/orange/amber/slate); body shows ticket #, title, company, age, assignee
- [ ] **TICK-04**: Single-tap on a row opens detail page
- [ ] **TICK-05**: Cursor-based infinite scroll (~25 per page) replaces pagination; next page triggers when last row enters viewport via IntersectionObserver
- [ ] **TICK-06**: "Load more" fallback button present for accessibility
- [ ] **TICK-07**: Detail page (`/mobile/tickets/[id]`) header reskinned to match new shell (Wulf mark, breadcrumb back); body kept largely as-is
### FIN — Mobile Finance (spec §6.3)
- [ ] **FIN-01**: Page restyled with new Card and typography scale; spacing fixed for small phones
- [ ] **FIN-02**: Wide tables on mobile widths replaced with stacked lists; no new data, no new sections
### ANL — Mobile Analyzer feed (spec §6.4) — NEW PAGE
- [ ] **ANL-01**: `/mobile/analyzer` route exists (read-only feed, most-recent-first stream of AI ticket analyses)
- [ ] **ANL-02**: Each list row shows ticket #, title, analyzer one-line summary, confidence badge, stage indicator (Triage → Analyze → Deep Review)
- [ ] **ANL-03**: Tapping a row opens a mobile summary view rendering Summary, Next Step, Next Step Rationale (all already produced by the analyzer pipeline)
- [ ] **ANL-04**: Summary view includes "View full analysis" link out to the desktop analyzer page
- [ ] **ANL-05**: No editing, no re-run, no prompt tuning on mobile
- [ ] **ANL-06**: Source data via `/api/mobile/analyzer/feed` (or reuse an existing list endpoint if it returns the right shape) reading from `analyzer_analyses`
### ENG — Mobile Engagement (spec §6.5) — NEW PAGES
- [ ] **ENG-01**: `/mobile/engagement` overview page (real refactor, not a thin adaptation of the ~1300-line desktop page)
- [ ] **ENG-02**: Period selector chip row (today / 7d / 30d) sticky just below the page H1
- [ ] **ENG-03**: Summary cards stacked single-column (active users, total Graph hours, total Autotask hours, hours-per-active-user) — no 4-up grid on phone widths
- [ ] **ENG-04**: Per-employee list as stacked rows (avatar/initials, name, role, hours bar) with sort control above (sort by hours, name, utilization) and search input
- [ ] **ENG-05**: Top of list shows compact "hours trend" sparkline scoped to the selected period; no multi-series chart on mobile
- [ ] **ENG-06**: User profile is `/mobile/engagement/[userId]` (segment form preferred for shareable URLs); single-column layout: identity header → period selector → key metrics (compact) → activity breakdown list → recent items
- [ ] **ENG-07**: User profile is a real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works
- [ ] **ENG-08**: Profile reuses existing engagement profile data endpoints; no new data
- [ ] **ENG-09**: Engagement is reachable from the More drawer, NOT the bottom bar
## v2 Requirements
Acknowledged but deferred. Not in this milestone's roadmap.
### NOTIF — Notifications
- **NOTIF-01**: Real notification list behind the Bell icon (replaces SHELL-03 placeholder)
- **NOTIF-02**: Notification badge logic on the Bell icon
### TABLET — Tablet breakpoint
- **TABLET-01**: `md:max-w-2xl mx-auto` wrapper for tablet widths
### OFFLINE — Offline support
- **OFFLINE-01**: Service worker for offline cache
- **OFFLINE-02**: Push notifications (requires SW)
### EDIT — Mobile editing
- **EDIT-01**: Mobile editing on Engagement user detail
- **EDIT-02**: Mobile re-run / prompt edits on Analyzer
## Out of Scope
Explicitly excluded for v1. Documented to prevent scope creep.
| Feature | Reason |
|---------|--------|
| Service worker / offline cache / push notifications | No clear offline use-case yet — defer until one lands (spec §4, §7) |
| Tablet breakpoint (`md:max-w-2xl`) | Noted as follow-up; keep `max-w-lg` for v1 (spec §4, §7) |
| Real notification list behind the Bell | Placeholder only this iteration; future phase wires it (spec §5.1, §7) |
| Mobile editing on Engagement user detail | Read-only on mobile by design (spec §6.5, §7) |
| Mobile re-run / prompt tuning on Analyzer | Read-only on mobile by design (spec §6.4, §7) |
| Charts / recharts on mobile Dashboard | Not earning their weight on small widths (spec §6.1, §7) |
| Restyling/replacing desktop pages reachable from More drawer | Desktop pages stay as-is (spec §7) |
| Multi-series chart on mobile Engagement overview | Replaced by single sparkline (spec §6.5) |
| Modal-based user detail on mobile | Replaced by real page so back gesture works (spec §6.5) |
| `/mobile-v2` parallel directory | Rebuild `/mobile` in place — keep canonical URLs (spec §2) |
## Traceability
Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| PWA-01 | Phase 1 | Pending |
| PWA-02 | Phase 1 | Pending |
| PWA-03 | Phase 1 | Pending |
| PWA-04 | Phase 1 | Pending |
| SHELL-01 | Phase 2 | Complete |
| SHELL-02 | Phase 2 | Pending |
| SHELL-03 | Phase 2 | Pending |
| SHELL-04 | Phase 2 | Pending |
| SHELL-05 | Phase 2 | Complete |
| SHELL-06 | Phase 2 | Pending |
| NAV-01 | Phase 2 | Pending |
| NAV-02 | Phase 2 | Pending |
| NAV-03 | Phase 2 | Pending |
| DRAWER-01 | Phase 2 | Pending |
| DRAWER-02 | Phase 2 | Pending |
| DRAWER-03 | Phase 2 | Pending |
| DRAWER-04 | Phase 2 | Pending |
| DRAWER-05 | Phase 2 | Pending |
| DRAWER-06 | Phase 2 | Complete |
| DASH-01 | Phase 3 | Pending |
| DASH-02 | Phase 3 | Pending |
| DASH-03 | Phase 3 | Pending |
| DASH-04 | Phase 3 | Pending |
| TICK-01 | Phase 4 | Pending |
| TICK-02 | Phase 4 | Pending |
| TICK-03 | Phase 4 | Pending |
| TICK-04 | Phase 4 | Pending |
| TICK-05 | Phase 4 | Pending |
| TICK-06 | Phase 4 | Pending |
| TICK-07 | Phase 4 | Pending |
| FIN-01 | Phase 5 | Pending |
| FIN-02 | Phase 5 | Pending |
| ANL-01 | Phase 6 | Pending |
| ANL-02 | Phase 6 | Pending |
| ANL-03 | Phase 6 | Pending |
| ANL-04 | Phase 6 | Pending |
| ANL-05 | Phase 6 | Pending |
| ANL-06 | Phase 6 | Pending |
| ENG-01 | Phase 7 | Pending |
| ENG-02 | Phase 7 | Pending |
| ENG-03 | Phase 7 | Pending |
| ENG-04 | Phase 7 | Pending |
| ENG-05 | Phase 7 | Pending |
| ENG-09 | Phase 7 | Pending |
| ENG-06 | Phase 8 | Pending |
| ENG-07 | Phase 8 | Pending |
| ENG-08 | Phase 8 | Pending |
**Coverage:**
- v1 requirements: 47 total
- Mapped to phases: 47
- Unmapped: 0 ✓
**Per-phase counts:**
- Phase 1 (PWA Scaffolding): 4 requirements
- Phase 2 (Mobile Shell + More Drawer): 15 requirements
- Phase 3 (Dashboard Restyle): 4 requirements
- Phase 4 (Tickets Restyle): 7 requirements
- Phase 5 (Finance Restyle): 2 requirements
- Phase 6 (Analyzer Feed): 6 requirements
- Phase 7 (Engagement Overview): 6 requirements
- Phase 8 (Engagement User Profile): 3 requirements
---
*Requirements defined: 2026-05-03*
*Last updated: 2026-05-03 — traceability filled in at roadmap creation*

160
.planning/ROADMAP.md Normal file
View file

@ -0,0 +1,160 @@
# Roadmap: Pulse Mobile Shell Redesign
## Overview
Eight phases mirror the deliberate build order in the source spec
(`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §8). Each phase
ships independently to `master` — no big-bang merge. Phase 1 lays PWA
metadata and safe-area utilities. Phase 2 rebuilds `app/mobile/layout.tsx`
with the new header, 5-cell bottom nav, and More drawer (deleting
`/mobile/nav` in the same change). Once the shell lands, Phases 37 are
independent restyles/new pages and may be executed in parallel; Phase 8
follows Phase 7 because the user profile is reached from the Engagement
overview. All work happens in place under `/mobile/*` — no `/mobile-v2`,
no parallel routes.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 1: PWA Scaffolding** — Manifest, viewport meta, and safe-area utilities so the shell installs and paints under the home indicator
- [ ] **Phase 2: Mobile Shell + More Drawer** — New `app/mobile/layout.tsx` (header + 5-cell bottom nav) and Sheet drawer that replaces `/mobile/nav`
- [ ] **Phase 3: Dashboard Restyle** — 2×2 KPI grid, Needs Attention strip, worker/backup status row (no charts)
- [ ] **Phase 4: Tickets Restyle** — Collapsible URL-synced filters, priority-bar rows, cursor-based infinite scroll, detail header reskin
- [ ] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists
- [ ] **Phase 6: Analyzer Feed (NEW)**`/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed`
- [ ] **Phase 7: Engagement Overview (NEW)**`/mobile/engagement` phone-first overview reachable from the More drawer
- [ ] **Phase 8: Engagement User Profile (NEW)**`/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern
## Phase Details
### Phase 1: PWA Scaffolding
**Goal**: A manager who taps "Add to Home Screen" gets a standalone Pulse icon that opens to the mobile shell with content respecting the device safe areas.
**Depends on**: Nothing (first phase)
**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04
**Success Criteria** (what must be TRUE):
1. Visiting `/manifest.json` returns valid JSON with `name: "Pulse"`, `display: "standalone"`, `start_url: "/mobile"`, and theme/background colors matching the app shells
2. The root `app/layout.tsx` references the manifest via `<link rel="manifest">` and the viewport meta includes `viewport-fit=cover`
3. A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding
4. Installing Pulse to a phone home screen launches a chromeless app pointed at `/mobile` (no service worker, no offline)
**Plans**: 2 plans
- [x] 01-01-PLAN.md — Web App Manifest + viewport-fit=cover (PWA-01, PWA-02, PWA-03)
- [x] 01-02-PLAN.md — Safe-area `pt-safe` / `pb-safe` @utility blocks in brand.css (PWA-04, gap closure)
**UI hint**: no
### Phase 2: Mobile Shell + More Drawer
**Goal**: Every `/mobile/*` page renders inside a new layout — sticky header (Wulf mark + Bell placeholder + avatar), scrollable content, and a 5-cell bottom nav whose fifth control opens a Sheet drawer that fully replaces `/mobile/nav`.
**Depends on**: Phase 1
**Requirements**: SHELL-01, SHELL-02, SHELL-03, SHELL-04, SHELL-05, SHELL-06, NAV-01, NAV-02, NAV-03, DRAWER-01, DRAWER-02, DRAWER-03, DRAWER-04, DRAWER-05, DRAWER-06
**Success Criteria** (what must be TRUE):
1. On any `/mobile/*` route the user sees a sticky header with the Wulf wordmark linking to `/mobile/dashboard`, a Bell icon button (keyboard-focusable, no menu), and a compact avatar — no page title in the header
2. A fixed bottom bar exposes four primary tabs (Dashboard, Tickets, Finance, Analyzer) plus a More cell; tapping a tab routes to its page and the active tab uses `text-primary` based on `pathname.startsWith(href)`
3. Tapping More (or the header avatar) opens a single Sheet drawer with three sections — Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync — each with an `ExternalLink` hint), and Account (current user read-only + Sign out)
4. Tapping Sign out in the drawer signs the user out and lands them on `/auth/sign-in`
5. `app/mobile/nav/page.tsx` no longer exists; visiting `/mobile/nav` does not render the old standalone nav page
6. Page content scrolls under the sticky header and is not hidden behind the bottom nav (bottom padding accounts for nav height + safe-area inset)
**Plans**: 2 plans
- [x] 02-01-PLAN.md — Build mobile shell components (HeaderBar, BottomNav, MoreDrawer) + analyzer placeholder (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05)
- [x] 02-02-PLAN.md — Wire new components into app/mobile/layout.tsx, delete app/mobile/nav/page.tsx (SHELL-01, SHELL-05, DRAWER-06)
**UI hint**: yes
### Phase 3: Dashboard Restyle
**Goal**: A manager opening `/mobile/dashboard` sees the state of the business at a glance — four KPIs, items needing attention, and a worker/backup status row — with no charts.
**Depends on**: Phase 2
**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04
**Success Criteria** (what must be TRUE):
1. Dashboard renders a 2×2 grid of four primary KPI cards drawn from desktop hero stats (no 1×4 row, no charts)
2. Below the grid, a "Needs Attention" horizontally-scrollable strip surfaces overdue tickets, failed backups, and stalled workflows; tapping a card opens its detail view
3. A compact status row shows analyzer worker, RMM worker, and backup-success-rate; tapping any element opens the corresponding desktop admin page
4. The page contains no recharts/chart components on phone widths
**Plans**: 2 plans
- [x] 03-01-PLAN.md — /api/mobile/dashboard reshape + KpiCardMobile/NeedsAttentionStrip/WorkerStatusRow components (DASH-01, DASH-02, DASH-03)
- [x] 03-02-PLAN.md — Replace /mobile/dashboard page body with 3-section layout, no charts (DASH-01, DASH-02, DASH-03, DASH-04)
**UI hint**: yes
### Phase 4: Tickets Restyle
**Goal**: A manager triages tickets on a phone with a collapsible filter bar that deep-links via URL, priority-coloured rows, and infinite scroll — and the detail page header matches the new shell.
**Depends on**: Phase 2
**Requirements**: TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06, TICK-07
**Success Criteria** (what must be TRUE):
1. The Tickets page opens with the filter strip collapsed; expanding it reveals status, priority, queue, and an assigned-to-me toggle, and changing any filter updates the URL query string (deep link works on reload)
2. Each list row has a left-edge stripe matching priority (Critical/High/Medium/Low → red/orange/amber/slate) and shows ticket #, title, company, age, and assignee
3. Single-tapping a row navigates to `/mobile/tickets/[id]`
4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility
5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged
**Plans**: 3 plans
- [ ] 04-01-PLAN.md — /api/mobile/tickets cursor rewrite + TicketFilterStrip + TicketRowSkeleton components (TICK-01, TICK-02, TICK-05)
- [ ] 04-02-PLAN.md — Replace app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll (TICK-01..TICK-06)
- [ ] 04-03-PLAN.md — Reskin in-page header of app/mobile/tickets/[id]/page.tsx (back chevron + breadcrumb + ExternalLink) (TICK-07)
**UI hint**: yes
### Phase 5: Finance Restyle
**Goal**: A manager reading AR / invoice / payment status on a phone sees properly spaced cards and stacked lists instead of squished wide tables — same data, new shell.
**Depends on**: Phase 2
**Requirements**: FIN-01, FIN-02
**Success Criteria** (what must be TRUE):
1. `/mobile/finance` adopts the new Card and typography scale — no horizontal overflow, spacing legible on small phones
2. Sections that previously rendered wide tables on phone widths now render as stacked lists (no new sections, no new data sources)
**Plans**: TBD
**UI hint**: yes
### Phase 6: Analyzer Feed (NEW)
**Goal**: A manager taps the Analyzer tab and skims a most-recent-first stream of AI ticket analyses, opening any one to a phone-friendly summary view that links out to desktop for full details.
**Depends on**: Phase 2
**Requirements**: ANL-01, ANL-02, ANL-03, ANL-04, ANL-05, ANL-06
**Success Criteria** (what must be TRUE):
1. Tapping the Analyzer tab in the bottom nav lands on `/mobile/analyzer` and shows a most-recent-first list of AI ticket analyses
2. Each row shows ticket #, title, the analyzer's one-line summary, a confidence badge, and a stage indicator (Triage → Analyze → Deep Review)
3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page
4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design)
5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape)
**Plans**: TBD
**UI hint**: yes
### Phase 7: Engagement Overview (NEW)
**Goal**: A manager reaches Engagement from the More drawer and sees a phone-first overview — period chips, stacked summary cards, a sortable per-employee list, and one compact sparkline.
**Depends on**: Phase 2
**Requirements**: ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09
**Success Criteria** (what must be TRUE):
1. The Mobile sections row in the More drawer links to `/mobile/engagement`; the Analyzer is on the bottom bar but Engagement is not
2. The overview page shows a period selector (today / 7d / 30d) sticky just below the H1, with active period clearly indicated
3. Summary cards (active users, total Graph hours, total Autotask hours, hours-per-active-user) render single-column stacked — no 4-up grid on phone widths
4. The per-employee list renders as stacked rows (avatar/initials, name, role, hours bar) with a search input and a sort control above (sort by hours, name, utilization)
5. A single compact "hours trend" sparkline renders at the top of the list, scoped to the selected period — no multi-series chart
**Plans**: TBD
**UI hint**: yes
### Phase 8: Engagement User Profile (NEW)
**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview.
**Depends on**: Phase 7
**Requirements**: ENG-06, ENG-07, ENG-08
**Success Criteria** (what must be TRUE):
1. Tapping a row in the per-employee list navigates to `/mobile/engagement/[userId]` (segment form, shareable URL)
2. The profile is a real page (not a modal) — the device/browser back gesture returns to the overview at the same scroll position
3. The profile renders single-column: identity header → period selector → key metrics (compact) → activity breakdown list → recent items, sourced from the existing engagement profile data endpoints (no new data)
**Plans**: TBD
**UI hint**: yes
## Progress
**Execution Order:**
Phases execute in numeric order. Phase 2 unblocks Phases 37 (any order, parallelizable). Phase 8 follows Phase 7.
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. PWA Scaffolding | 1/2 | Executing | - |
| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
| 3. Dashboard Restyle | 0/2 | Not started | - |
| 4. Tickets Restyle | 0/3 | Not started | - |
| 5. Finance Restyle | 0/TBD | Not started | - |
| 6. Analyzer Feed | 0/TBD | Not started | - |
| 7. Engagement Overview | 0/TBD | Not started | - |
| 8. Engagement User Profile | 0/TBD | Not started | - |
---
*Roadmap created: 2026-05-03*
*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`*

85
.planning/STATE.md Normal file
View file

@ -0,0 +1,85 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md
last_updated: "2026-05-03T21:09:09.113Z"
last_activity: 2026-05-03
progress:
total_phases: 8
completed_phases: 3
total_plans: 6
completed_plans: 6
percent: 100
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-05-03)
**Core value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness.
**Current focus:** Phase 03 — dashboard-restyle
## Current Position
Phase: 4
Plan: Not started
Status: Executing Phase 03
Last activity: 2026-05-03
Progress: [░░░░░░░░░░] 0%
## Performance Metrics
**Velocity:**
- Total plans completed: 6
- Average duration: —
- Total execution time: 0.0 hours
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| 01 | 2 | - | - |
| 02 | 2 | - | - |
| 03 | 2 | - | - |
**Recent Trend:**
- Last 5 plans: —
- Trend: —
*Updated after each plan completion*
| Phase 02-mobile-shell-more-drawer P02 | 8 | 4 tasks | 2 files |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- Roadmap: Phases mirror the spec's 8-step build order so each step ships independently to `master` (spec §8)
- Phase 2 unblocks Phases 37; Phases 37 are mutually independent and can be parallelized; Phase 8 depends on Phase 7
- All work happens in place under `/mobile/*` — no `/mobile-v2`, no parallel routes (spec §2)
- [Phase 02-mobile-shell-more-drawer]: Single useState in mobile layout.tsx for drawer open state — no Zustand/Context per CLAUDE.md constraint
- [Phase 02-mobile-shell-more-drawer]: Tailwind 4 pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] arbitrary value works without inline-style fallback
- [Phase 02-mobile-shell-more-drawer]: No redirect on /mobile/nav deletion — standard 404 per DRAWER-06 spec
### Pending Todos
None yet.
### Blockers/Concerns
None yet.
## Session Continuity
Last session: 2026-05-03T20:12:16.872Z
Stopped at: Completed 02-mobile-shell-more-drawer/02-02-PLAN.md
Resume file: None

View file

@ -0,0 +1,211 @@
# Architecture
**Analysis Date:** 2026-05-03
## Pattern Overview
**Overall:** Single-instance Next.js 16 backend with in-process background workers (no external job queue).
**Key Characteristics:**
- App Router pages (`'use client'`) fetch data via API routes using `fetch()`
- Three auto-starting background workers via side-effect imports (sync-scheduler, analyzer, RMM overshell)
- Service layer in `lib/services/` with factories + configuration helpers
- Postgres 16 as system of record (snake_case columns), Redis for caching only
- Manual transformation from snake_case DB columns to camelCase API responses
- Better Auth 1.4 for sessions + magic link + TOTP 2FA + Microsoft OAuth
## Layers
**Route Layer (HTTP entry):**
- Purpose: Accept HTTP requests, validate auth, delegate to services, return JSON responses
- Location: `app/api/*/route.ts`, `app/*/page.tsx`
- Contains: Next.js route handlers (GET/POST/PATCH/DELETE), page components
- Depends on: Auth via `lib/auth-utils.ts`, services via `lib/services/`
- Used by: Client-side fetch calls from UI components
**Service Layer (business logic):**
- Purpose: Sync data from external APIs, run background jobs, execute workflows, store results in Postgres
- Location: `lib/services/*.ts` (~50 files) plus subdirectories (`analyzer/`, `rmm/`, `llm/`, `b2/`)
- Contains: Integration clients (autotask, datto-rmm, itglue, veeam, msgraph, etc.), sync logic, job executors, pipeline orchestration
- Depends on: Postgres client, external API clients, environment configuration
- Used by: API routes (sync endpoints, webhook handlers) and background workers
**Data Access Layer (Postgres):**
- Purpose: Query, upsert, and manage state in Postgres
- Location: `lib/services/postgres-client.ts` (singleton) + `migrations/NNN_*.sql`
- Contains: Connection pool, query builder methods (`query()`, `transaction()`, `upsert()`, `bulkUpsert()`), migration definitions
- Depends on: PostgreSQL 16 connection string from env
- Used by: All services that read/write data
**Background Workers (long-running processes):**
- Purpose: Poll for work and execute sync/analysis/RMM tasks without blocking HTTP requests
- Location: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`
- Contains: node-cron scheduler, polling loops with exponential backoff, state machine handlers
- Depends on: Postgres client, service layer integrations
- Used by: Auto-start on module import (side effects); HTTP routes that need them running will import the module to start them
**Component Layer (UI):**
- Purpose: Render pages, dialogs, tables, charts, and handle client-side state
- Location: `components/ui/` (shadcn primitives), `components/*/` (feature-specific), `app/*/page.tsx`
- Contains: React components, hooks for fetch + state, sonner toasts, recharts visualizations
- Depends on: API routes via `fetch()`, client auth via Better Auth SDK
- Used by: Next.js pages and other components
**Types & Schema (contracts):**
- Purpose: Define TypeScript interfaces and database schema
- Location: `lib/types/<domain>.ts`, `migrations/NNN_*.sql`
- Contains: Entity types (ticket, company, analysis, rmm_execution, etc.), sync request shapes, API response envelopes
- Depends on: (none — they define contracts)
- Used by: Services, API routes, components
## Data Flow
**Autotask Webhook → Analyzer Pipeline:**
1. **Webhook ingress** (`POST /api/webhooks/autotask`) — Public endpoint (no auth), HMAC-verified inside handler via `lib/services/webhook-service.ts`
2. **Enqueue job** — If ticket.created, fires webhook handler in `lib/services/webhook-service.ts` which inserts `analyzer_jobs` row with `status='queued'`
3. **Worker poll**`lib/services/analyzer/worker.ts` auto-starts in production; every 2s polls for `analyzer_jobs.status='queued'`, claims one with `FOR UPDATE SKIP LOCKED`
4. **Pipeline orchestration**`lib/services/analyzer/pipeline.ts` runs 7 stages:
- Stage 0: Preprocess (filter noise, compute `content_hash` for idempotency)
- Stage 1: Triage (Haiku — categorize, extract entities)
- Stage 2: IT Glue retrieval (if configured, redacted docs only via `itglue-search.ts`)
- Stage 3: Deep analysis (Sonnet — summary, gaps, root cause)
- Stage 4: Deep reasoning (Opus — optional, skipped above $2.00 cost ceiling)
- Stage 5: Persist to `analyzer_analyses` + stage execution rows
- Stage 6: Fingerprint (Haiku — cross-ticket aggregation data)
5. **Link-aware bundles**`lib/services/analyzer/link-discovery.ts` resolves related tickets; members get `pending_analyses` rows
6. **Aggregate reports** (optional) — Once all bundle members analyzed, `stages/aggregate-reduce.ts` fires
**Periodic Sync Cadence:**
1. **Cron trigger**`lib/services/sync-scheduler.ts` (node-cron singleton) reads `sync_schedules` table; fires at configured times
2. **Entity sync**`lib/services/entity-sync.ts` per entity type (tickets, companies, resources, etc.), incremental via `lastTrackedModificationDateTime` when supported
3. **Postgres upsert**`postgresClient.bulkUpsert()` writes batches to DB tables (`tickets`, `companies`, etc.)
4. **Integration-specific syncs** — Datto RMM devices/alerts, IT Glue configs/contacts, Veeam agents/alarms, Engagement data, Zoom, Duo, etc.
**RMM Overshell Execution:**
1. **User trigger** (`POST /api/rmm/execute`) — Admin user picks registered script + device
2. **Validation & rate limit**`lib/services/rmm/executor.ts` validates script ID, resolves device, enforces per-user limit (50 / 24h)
3. **Queue insertion** — Insert `pending` row in `rmm_executions` table, call Datto `client.runQuickJob()`
4. **Worker poll**`lib/services/rmm/worker.ts` (5s cadence) polls in-flight executions, queries Datto for result status
5. **Output parsing** — Script's `parseOutput()` method transforms Datto output; result stored in `rmm_executions`
**LogLift Evidence Ingest:**
1. **Webhook** (`POST /api/rmm/loglift/upload`) — Public, `x-openclaw-key` header auth
2. **Decompression** — Download gzipped JSON from B2, decompress, cap at 100 MB (zip-bomb guard)
3. **Storage** — Slim summary to `loglift_uploads` table, full payload to B2 via `lib/services/b2/client.ts`
4. **Auto-audit** — Resolve device → Autotask company → IT Glue config; if unique match, fire asset-first audit
**IT Glue Write-back:**
1. **Audit runner** (`lib/services/analyzer/asset-audit/runner.ts`) — Post-analysis, runs LLM audit against IT Glue config/flexible asset
2. **Results** — Persisted to `itglue_audit_logs`, linked via `itglue_ticket_xrefs`
3. **Revert** — Patch `/api/analyzer/itglue/configurations/[id]/revert/[writeId]` rolls back changes
**State Management:**
- **HTTP requests:** Stateless; session from Better Auth cookie
- **Background jobs:** State in Postgres (status columns: `queued``in_flight``complete` / `failed`)
- **Caching:** Redis (optional, used for integration health checks, cache duration varies)
- **Authorization:** Checked in route handlers via `requireAuth()` / `requireAdmin()` / `requirePermission()` from `lib/auth-utils.ts`
## Key Abstractions
**Factory Pattern (Integration Clients):**
- Purpose: Lazy-load integration clients with configured credentials; provide `is<Name>Configured()` helper to check env vars
- Examples: `lib/services/autotask-factory.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/msgraph-factory.ts`
- Pattern: Export `getAutotaskClient()`, `getDattoRmmClient()`, `getMsgraphClient()` with caching; throw if credentials missing; bundled with `is<Name>Configured()` for upstream checks
- Why: Decouples client initialization from route handlers; allows conditional feature gates per env
**PostgresClient Singleton:**
- Purpose: Single connection pool for all Postgres queries; auto-lazy-initializes; provides ORM-like query builder
- Examples: `postgresClient.query()`, `postgresClient.transaction()`, `postgresClient.upsert()`, `postgresClient.bulkUpsert()`
- Pattern: Private constructor, static `getInstance()`, pool initialized on first use
- Why: Prevents connection leaks; provides consistent interface across ~50 services
**Sync Service (Entity-Agnostic):**
- Purpose: Incremental/full sync of any entity type from external API → Postgres
- Examples: `lib/services/entity-sync.ts` (Autotask), `lib/services/itglue-sync-service.ts`, `lib/services/veeam-sync-service.ts`
- Pattern: Reads `lastTrackedModificationDateTime` from last_sync table; queries external API; batches upsert via `bulkUpsert()`
- Why: Codifies the "last sync timestamp + incremental pull + batch insert" pattern across integrations
**Analyzer Pipeline (7 Stages):**
- Purpose: Orchestrate multi-stage LLM analysis with fallbacks, cost guards, and persistence
- Examples: `lib/services/analyzer/pipeline.ts`, `lib/services/analyzer/stages/*.ts`
- Pattern: Each stage returns structured output (gap analysis, category, cost estimate); cost guard checks ceiling before Stage 4; all stages persisted to `analyzer_stage_executions`
- Why: Allows cost control (skip expensive Opus above $2.00), idempotency (content_hash), and debugging (inspect each stage's output)
**Background Worker Polling Loop:**
- Purpose: Auto-start in production, poll for work, claim rows with `FOR UPDATE SKIP LOCKED`, execute, persist result
- Examples: `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`, `lib/services/sync-scheduler.ts`
- Pattern: Side-effect import auto-starts on module load; exponential backoff if no work; row locking for multi-instance safety (analyzer), or single-instance gate (sync scheduler)
- Why: Keeps background work out of HTTP request path; analyzer is safe to scale (row locking); sync scheduler should run on one instance only
**Better Auth Roles (RBAC):**
- Purpose: Define three roles (`user`, `admin`, `super-admin`) with per-resource permissions
- Examples: `lib/auth.ts` (auth config), `lib/permissions.ts` (permission matrix), `lib/auth-utils.ts` (runtime checks)
- Pattern: Route handler calls `requireAdmin()` / `requirePermission()`, which decode session and check role; middleware only verifies session cookie exists
- Why: Separates auth (middleware) from authorization (route handler); role-based gates are checked at the point of use
## Entry Points
**HTTP Pages (Authenticated):**
- Location: `app/*/page.tsx`
- Triggers: Browser navigation to any route except public paths
- Responsibilities: Render page shell with `AppNavigation` + `PageHeader`, fetch data from API, render client-side components, handle toast/dialog interactions
**HTTP API Routes (Public & Authenticated):**
- Location: `app/api/*/route.ts`
- Triggers: `fetch()` from client, external webhooks (Autotask, Zabbix, RMM), scheduler HTTP calls
- Responsibilities: Validate auth/webhook signature, delegate to service layer, return JSON response with appropriate status (200, 401, 403, 503, 500)
**Webhook Handlers (Public):**
- Location: `app/api/webhooks/autotask`, `app/api/zabbix/webhook`, `app/api/rmm/loglift`
- Triggers: External systems (Autotask, Zabbix, OpenClaw) POST events
- Responsibilities: Verify HMAC or custom header, parse event, enqueue jobs or upsert data, return 200 (even on error so Autotask doesn't deactivate)
**Sync Endpoints (Public, called by scheduler):**
- Location: `app/api/sync/*`, `app/api/datto-rmm/sync`, `app/api/itglue/sync`, `app/api/veeam/sync`, etc.
- Triggers: `lib/services/sync-scheduler.ts` fires HTTP POST at configured times
- Responsibilities: Call sync service, update `last_sync` timestamp, return 200 on success or error message
**Background Workers (Auto-starting, in-process):**
- Location: `lib/services/sync-scheduler.ts` (cron), `lib/services/analyzer/worker.ts` (2s poll), `lib/services/rmm/worker.ts` (5s poll)
- Triggers: Auto-starts as side effect of module import; runs indefinitely in production
- Responsibilities: Poll for work from DB, claim row, execute, persist result, handle errors + logging
## Error Handling
**Strategy:** Defensive; assume external APIs can fail, return 200 on webhook failures (so Autotask doesn't deactivate), log all errors, flag analyses for human review if cost ceiling exceeded.
**Patterns:**
- **Webhook handlers** — Return 200 even if processing fails; log error so ops can investigate via audit log
- **API route handlers**`try/catch`, return `NextResponse.json({ error, message }, { status })` with conventions:
- 401: Session missing or invalid
- 403: Authenticated but lacks permission
- 503: Missing/bad integration config (e.g., Autotask API key not set)
- 500: Runtime error (query failed, external API timeout, etc.)
- **Analyzer pipeline** — Cost ceiling guard at $2.00; above that, Stage 4 (Opus) skipped, analysis flagged with `needs_review=true`
- **Sync services** — Incremental sync errors log + re-trigger on next schedule; full sync errors persist `last_error` to `sync_schedules` table
- **Background workers** — Stale jobs reset on worker boot (no recovery); exponential backoff on empty polls; errors logged with job ID for manual inspection
## Cross-Cutting Concerns
**Logging:** Console (stdout) in all services; elevated to syslog or Datadog in production. Analyzer logs all LLM calls + cost to `analyzer_cost_audit` table for billing reconciliation.
**Validation:** Explicit checks in route handlers where it matters (e.g., device ID exists before RMM execute); no centralized validation framework. Zod used for auth/admin forms only.
**Authentication:** Better Auth session stored in Postgres; cookie `Auth` + `Auth.Secure` sent on all requests. `middleware.ts` verifies session cookie exists for authenticated pages. Role checks happen in route handlers.
**Authorization:** Per-resource permissions defined in `lib/permissions.ts` (tickets, configItems, admin, users, roles, auditLog, settings, itglue, rmm). API routes call `requirePermission(resource, action)` to enforce. UI hides links based on `session.user.role`.
**Rate Limiting:** RMM execute endpoint has per-user limit (50 scripts / 24h via `lib/services/rate-limiter.ts`). No global rate limiter.
**Integration Health:** `lib/services/integration-health.ts` polls each integration's health (e.g., Autotask token expiry, last sync age); stores status in `integration_health` table; fires alerts if integration is down or sync is stale.
---
*Architecture analysis: 2026-05-03*

View file

@ -0,0 +1,226 @@
# Codebase Concerns
**Analysis Date:** 2026-05-03
## Critical Database Access Pattern
**Widespread Pool Instantiation:**
- Issue: Multiple new Pool instances created directly in files instead of using the postgresClient singleton
- Files:
- `app/admin/users/[id]/page.tsx` (line 11)
- `app/admin/roles/[id]/page.tsx` (line 7)
- `app/api/data/time-entries/route.ts` (line 6)
- `app/api/admin/audit-log/route.ts` (line 5)
- `app/api/admin/settings/route.ts` (line 6)
- `app/api/admin/users/route.ts` (line 5)
- `app/api/admin/users/[id]/route.ts` (line 5)
- `app/api/admin/users/[id]/sessions/route.ts` (line 5)
- `app/api/admin/users/[id]/sessions/[sessionId]/route.ts` (line 5)
- `app/api/admin/users/invite/route.ts` (line 6)
- `app/api/admin/roles/route.ts` (line 5)
- `app/api/admin/roles/[id]/route.ts` (line 5)
- `app/api/rmm-devices/route.ts` (line 13)
- `app/api/openclaw/datto-rmm/devices/route.ts` (line 6)
- `app/api/openclaw/datto-rmm/devices/[uid]/route.ts` (line 6)
- `app/api/openclaw/datto-rmm/alerts/route.ts` (line 6)
- `app/api/openclaw/datto-rmm/sites/route.ts` (line 6)
- `app/api/openclaw/datto-rmm/alerts/open/route.ts` (line 6)
- `app/api/addigy/org-mappings/route.ts` (line 6)
- `app/api/settings/profile/route.ts` (line 5)
- `app/api/auvik/tenant-mappings/route.ts` (line 6)
- `app/api/rmm/site-mappings/route.ts` (line 6)
- `lib/bootstrap.ts` (line 3)
- `lib/auth.ts` (line 9)
- `lib/services/audit.ts` (line 3)
- `lib/services/auvik-client.ts` (line 216)
- Impact: Each Pool() call creates a new connection pool, consuming resources and database connections. In production with multiple instances, this can exhaust connection limits. No centralized control over connection pooling.
- Fix approach: Replace all instances with `postgresClient` singleton from `lib/services/postgres-client.ts`. The singleton implements lazy initialization and reuses the same pool. Create a migration script to audit all imports and replace new Pool() with postgresClient imports.
## Duplicate Migration Numbers
**Out-of-Order Migrations:**
- Issue: Multiple migrations share the same number prefix, causing alphabetical apply order to diverge from intent
- Files: `migrations/`
- Duplicates found:
- `002_add_indexes.sql` and `002_relax_foreign_keys.sql`
- `004_fix_contacts_company_id.sql` and `004_webhook_support.sql`
- `005_add_webhook_ip_logging.sql` and `005_fix_tickets_company_id.sql`
- `009_create_auvik_tenant_mappings.sql`, `009_relax_configuration_items_constraints.sql`, `009_restore_deleted_tickets.sql`
- `028_create_device_lifecycle_policies.sql` and `028_create_veeam_agents_alarms.sql`
- `049_create_ping_flap_suppressions.sql` and `049_create_ticket_digest_tables.sql`
- `057_contacts_missing_fields.sql` and `057_create_autotask_tags_tables.sql`
- `058_create_duo_tables.sql` and `058_create_repo_commit_tracking.sql`
- Impact: Alphabetical filesystem sort determines execution order (not numeric). On Postgres init, migrations apply in ASCII order. New environment setups may fail if a constraint or schema change in one migration depends on another with the same number. This is a hidden fragility.
- Fix approach: Rename all duplicate migrations to sequential numbers (e.g., `009_create_auvik_tenant_mappings.sql`, `010_relax_configuration_items_constraints.sql`, `011_restore_deleted_tickets.sql`). Verify that the alphabetical apply order would work for existing databases. Test full init sequence on fresh Postgres. Update deployment docs to warn against re-numbering.
## Extensive console.log in Production Code
**Unstructured Logging:**
- Issue: `console.log()` statements left throughout production code instead of proper logging framework
- Files with multiple instances:
- `lib/utils/sync-helpers.ts` (lines 344, 417, 441)
- `lib/services/redis-client.ts` (lines 7, 34, 95)
- `lib/services/background-processor.ts` (lines 185, 224, 255, 272, 319, 327, 335)
- `lib/services/duo-sync-service.ts` (lines 53, 64, 70, 95, 139, 591)
- `lib/services/workflow-engine.ts` (lines 104, 221, 326, 664)
- `lib/services/duo-client.ts` (line 165)
- `lib/services/itglue-sync-service.ts` (line 54)
- `lib/services/veeam-sync-service.ts` (lines 91, 112, 127)
- `lib/services/webhook-service.ts` (lines 71, 76, 110, 164, 193, 233, 429)
- `lib/services/veeam-factory.ts` (line 30)
- `lib/services/veeam-compliance-service.ts` (lines 25, 50, 190, 191)
- Impact: Logs go directly to stdout, not aggregated to a logging service. In production, container logs are hard to filter and correlate. No structured metadata (timestamp in some, not others; inconsistent prefixes). Search for "WEBHOOK" or "VEEAM-SYNC" is the only way to filter.
- Fix approach: Create a simple logger module at `lib/logger.ts` with methods `.info()`, `.warn()`, `.error()` that preserve prefixes but add timestamps and structure. Replace all `console.log()` with `logger.info()` etc. Consider using pino or winston if needed in future, but start with a lightweight wrapper.
## Tech Debt: Worker Side-Effect Imports
**Hidden Auto-Initialization:**
- Issue: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, and `lib/services/rmm/worker.ts` auto-start on import as a side effect
- Files:
- `lib/services/sync-scheduler.ts` (bottom of file)
- `lib/services/analyzer/worker.ts` (auto-starts in production or if `ANALYZER_WORKER_AUTOSTART=1`)
- `lib/services/rmm/worker.ts` (same gates as analyzer)
- Impact: Importing these modules from shared utilities (e.g., a shared auth helper) will start worker loops unexpectedly. If a hot path accidentally imports one, it runs polling immediately. The analyzer worker uses `SELECT … FOR UPDATE SKIP LOCKED` so it's safe across instances, but the sync scheduler does NOT — running it on multiple instances causes duplicate syncs.
- Fix approach: Add comments on all three modules warning against hot-path imports. Consider a factory pattern: `initSyncScheduler()`, `startAnalyzerWorker()` with explicit function calls instead of side effects. Or gate them behind a feature flag that must be explicitly enabled. Document in CLAUDE.md that only `app/api/*/route.ts` handlers should import these. Add a linting rule if possible.
## Type Safety in Shared Types
**Excessive use of `any`:**
- Issue: Shared type definitions use `any` for workflow/pipeline configuration and error handling
- Files:
- `lib/types/ticket-workflow.ts` (lines 48, 78, 84, 100, 104, 107 — field_changes, condition values, validation errors)
- `lib/types/pipeline.ts` (lines 34, 128 — stage values, context dict)
- `lib/types/workflow.ts` (lines 108, 111, 113, 139, 193, 207, 243, 245, 273, 293-294, 358, 361, 363, 384 — match_value, result_value, field_changes)
- `lib/types/datto-rmm.ts` (lines 105, 141, 146-147 — autotaskDevice, field mapping)
- `lib/types/errors.ts` (lines 148, 229, 241 — error categorization and formatting)
- Impact: Configuration values for workflow conditions/actions are not validated at the type level. A malformed workflow rule with `match_value: 123` (number instead of string/regex) will only fail at runtime. Error handling functions that accept `any` can mask type errors silently.
- Fix approach: Use TypeScript discriminated unions or explicit types for workflow values. For errors, define a proper error interface and use type guards. Start with `lib/types/workflow.ts` since it's most critical for the workflow engine. Consider adding a validation layer that runs on workflow rule creation.
## Auth Validation Gap
**Middleware-only Session Check:**
- Issue: `middleware.ts` (lines 75-82) only verifies a session cookie exists, does NOT check user role for `/admin` routes
- Files: `middleware.ts`
- Impact: Role-based access control is entirely at the API route level via `requireAdmin()` or `requirePermission()`. If a developer forgets to call `requireAuth()` or `requireAdmin()` in an API route, the middleware won't catch it. A mistake like returning user data without checking role is a privilege escalation. The comment on line 76 acknowledges this.
- Fix approach: Add a helper function `enforceRole()` that is harder to forget than calling `requireAdmin()` early in a route handler. Better: make it so unauthenticated users can't even reach admin routes (redirect in middleware if no admin role detected — requires decoding the session token). Or add a lint rule that checks all API handlers for requireAuth calls. At minimum, add a test that verifies at least 10 admin API routes call requireAdmin() or requirePermission().
## Missing Test Coverage
**Limited Test Suite:**
- Issue: Only `lib/services/analyzer/**`, `lib/services/rmm/**`, and `lib/services/b2/**` have unit tests; the rest of the codebase relies on TypeScript type-checking only
- Files:
- Test files: `lib/services/analyzer/*.test.ts` (11 test files), `lib/services/rmm/*.test.ts` (2 test files), `lib/services/b2/*.test.ts` (1 test file), `lib/services/llm/*.test.ts` (2 test files)
- No tests for: sync services, webhook handlers, API routes, auth flows, workflow engine logic, integrations (Autotask, IT Glue, Veeam, etc.)
- Impact: Refactoring core services like `entity-sync.ts` (1438 lines) or `webhook-service.ts` cannot be validated. Breaking changes in Autotask mapping logic, webhook handling, or sync schedules are only caught in staging. The NO CI note in `ARCHITECTURE.md` means no automated regression detection.
- Fix approach: Start with high-impact areas: `lib/services/entity-sync.ts` and `lib/services/webhook-service.ts`. Add unit tests covering the main sync paths and webhook processing. Set a coverage target of 50% for critical services. Add pre-commit hook that runs `npm test` to prevent untested code from being committed.
## Large Files with Complex Logic
**Size and Complexity Hotspots:**
- Issue: Several service files exceed 1000+ lines, indicating potential refactoring opportunities
- Files:
- `lib/services/entity-sync.ts` (1438 lines) — Main Autotask sync, many entity types
- `lib/services/workflow-engine.ts` (954 lines) — Workflow execution and Autotask write-back
- `lib/services/ticket-digest-service.ts` (782 lines) — Daily digest generation
- `lib/services/sync-scheduler.ts` (760 lines) — Background task scheduling
- `lib/services/analyzer/asset-audit/data-builder.ts` (719 lines) — Evidence aggregation for IT Glue audits
- `lib/services/veeam-rpo-service.ts` (675 lines) — Veeam RPO logic
- `lib/services/mimecast-client.ts` (675 lines) — Mimecast API client
- `lib/services/itglue-sync-service.ts` (663 lines) — IT Glue integration
- Impact: Large files are harder to reason about, test, and refactor. Bug fixes in `entity-sync.ts` might accidentally affect the sync of a different entity type if the logic isn't clearly separated. The workflow engine's 954 lines likely interleaves execution logic, Autotask API calls, and error handling.
- Fix approach: Extract smaller modules from these files. For example, in `entity-sync.ts`, separate sync logic per entity into `sync/tickets.ts`, `sync/contacts.ts`, etc. In `ticket-digest-service.ts`, split template rendering into separate files. This is a gradual refactoring — start with `workflow-engine.ts` since it has the most potential for breaking bugs.
## Unvalidated Input in API Routes
**No Zod Validation Framework:**
- Issue: API routes do not systematically validate request bodies or query parameters
- Files: All `app/api/**/route.ts` files
- Impact: Endpoints accept any JSON and only validate inputs "when it matters" (per CLAUDE.md). An admin form endpoint could silently ignore a malformed field instead of returning a 400 Bad Request. Developers must remember to manually validate each input; forgetting is easy.
- Fix approach: Add a lightweight input validation helper (not necessarily Zod, but something). For example, a simple function like `validateInput(req.body, schema)` that checks required fields and types. Use it in high-risk endpoints: user creation, role assignment, workflow rules, integration settings. Start with `/api/admin/*` routes.
## Known TODOs
**Incomplete Implementations:**
- Issue: Active TODOs left in production code
- Files:
- `lib/services/sync-service.ts` (line 444) — "TODO: Implement graceful cancellation"
- `lib/services/workflow-steps/ai-troubleshooting.ts` (line 40) — "TODO: Implement createTicketNote in AutotaskClient if needed"
- `app/api/veeam/backup-status/route.ts` (line 51) — "TODO: compute from config items without matching workloads"
- Impact: `sync-service.ts` sync cancellation is not implemented — if a sync is running and needs to be stopped (e.g., on pod termination), it will run to completion. This can delay graceful shutdown. The Veeam backup status compute is a stub returning 0.
- Fix approach: Prioritize graceful cancellation in sync-service. For the others, either implement them or remove the TODOs if the current behavior is acceptable. Add a CI check that fails on new TODOs (optional but helpful).
## Multi-Instance Sync Scheduler Risk
**Sync Scheduler Not Instance-Safe:**
- Issue: `lib/services/sync-scheduler.ts` uses node-cron but does NOT use row locking like the analyzer worker does
- Files: `lib/services/sync-scheduler.ts`
- Impact: In a multi-instance deployment, every instance will run every scheduled sync at the same time. If the sync scheduler is running on 3 replicas, Autotask gets 3 sync requests simultaneously, which wastes API quota and can cause race conditions on the Postgres side. The ARCHITECTURE.md (line 256) warns: "pin to one instance."
- Fix approach: Either (a) force the sync scheduler to run on a single instance only by setting an env flag like `RUN_SYNC_SCHEDULER=1` and defaulting it to false on replicas, or (b) add a distributed lock (e.g., in Postgres with advisory locks) so only one instance's cron fires. Test multi-instance behavior in staging before deploying.
## Analyzer Cost Ceiling Enforcement
**Opacity in Stage 4 Skipping:**
- Issue: Analyzer Stage 4 (Opus) is skipped if estimated cost exceeds $2.00 (line 138 in ARCHITECTURE.md), flagged for human review, but no clear UI/alert when this happens
- Files: `lib/services/analyzer/pipeline.ts`, `lib/services/llm/pricing.ts`
- Impact: An analysis with a cost ceiling hit is marked with a flag, but there's no alerting mechanism to tell admins that a ticket analysis was incomplete due to cost. The ticket analysis might be silently insufficient for the user.
- Fix approach: Add a cost-ceiling alert row to `analyzer_analyses` or a separate cost-alert table. Expose this in the admin UI at `/admin/analyzer` with a filter for "cost-ceiling alerts." Log a structured event with `logger.warn()` so it's visible in centralized logs.
## IT Glue Redaction Mandatory But Not Enforced
**Redaction Bypass Risk:**
- Issue: IT Glue search results MUST be redacted before being sent to an LLM (line 87-89 in ARCHITECTURE.md), but there's no type-system enforcement
- Files: `lib/services/analyzer/itglue-search.ts` (redacted output), `lib/services/analyzer/` (callers)
- Impact: A developer could accidentally import `itglue-client.ts` (the raw client) and pass results directly to an LLM prompt, exposing credentials or PII. The redaction is documented but optional in code.
- Fix approach: Make the raw IT Glue client non-exported from its module, forcing all LLM-bound queries through the redacted search function. Add a type wrapper like `RedactedDocument` that is the only type accepted by LLM callers. Or add a pre-commit hook that scans for `itglue-client` imports in analyzer files and warns.
## .env File Committed to Repo
**Potential Secrets Exposure:**
- Issue: `.env` file exists at `/opt/stacks/pulse/.env` and is NOT in `.gitignore`, but git ls-files shows no .env files tracked
- Files: `/opt/stacks/pulse/.env`
- Impact: Although the .env file is not currently tracked in git, it exists on the filesystem with potentially real configuration. The `.gitignore` pattern `.env*` should prevent accidental commits, but if someone edits `.gitignore` or adds `--force`, secrets could leak. This is a human-error risk.
- Fix approach: Verify that no real secrets are in the committed .env file. Document that `.env*` is gitignored and point developers to `.env.example`. Add a pre-commit hook with `detect-secrets` or similar to catch hardcoded secrets. Rotate any keys/tokens that might have been exposed in historical runs.
## Missing Graceful Shutdown for Workers
**Worker Cleanup on Pod Termination:**
- Issue: The analyzer worker (line 40 in ARCHITECTURE.md) resets stale in-flight jobs on boot, but there is no graceful shutdown handler for long-running operations
- Files: `lib/services/analyzer/worker.ts`, `lib/services/sync-scheduler.ts`, `lib/services/rmm/worker.ts`
- Impact: If a worker is in the middle of processing and the container is killed, that job is left in a partially-complete state (e.g., partial analyzer analysis, incomplete RMM execution). On pod restart, the worker resets jobs but may lose partial work.
- Fix approach: Implement a `SIGTERM` handler that stops accepting new jobs, finishes in-flight work, then exits cleanly. Use `process.on('SIGTERM', async () => { ... })`. Set Kubernetes `terminationGracePeriodSeconds` to allow time for cleanup. Log completion of final jobs.
## Pool Connection Leaks from Page Components
**Server Component Pool Usage:**
- Issue: Page components like `app/admin/users/[id]/page.tsx` and `app/admin/roles/[id]/page.tsx` create Pools without closing them, relying on garbage collection
- Files: `app/admin/users/[id]/page.tsx`, `app/admin/roles/[id]/page.tsx`
- Impact: Each page render creates a new Pool(). In development with fast reloads, pools accumulate. While they will eventually be garbage-collected, this is inefficient and can cause "too many connections" errors in development if tests run fast enough.
- Fix approach: Use the postgresClient singleton instead (which is already lazy-initialized). This is the same fix as the broader pool instantiation issue above. Pages should import `postgresClient` from `lib/services/postgres-client.ts` rather than creating new Pools.
## Performance: Large Page Sizes
**Pagination Defaults Not Optimized:**
- Issue: `qbo-client.ts` uses a pageSize of 1000 (line 187), which is high for API response sizes
- Files: `lib/services/qbo-client.ts`
- Impact: A single API call fetching 1000 QuickBooks objects can be slow and memory-intensive. If the endpoint returns large objects, response times spike.
- Fix approach: Reduce default page size to 100-250 and let callers opt in for larger pages if needed. Measure API response times for the most common queries and set pageSize accordingly.
## Idempotency Key Complexity
**Provider-Scoped Content Hash Subtle:**
- Issue: Analyzer content_hash is provider-scoped (per ARCHITECTURE.md), meaning the same ticket can have one Claude analysis and one OpenRouter analysis, but this is easy to miss
- Files: `lib/services/analyzer/pipeline.ts` (lines 239, 253), `lib/services/analyzer/worker.ts` (lines 181, 314)
- Impact: If a developer switches the default LLM provider from Anthropic to OpenRouter and re-runs an analysis with `force=false`, the code will treat it as a new analysis because the provider is part of the idempotency key. This is correct behavior but non-obvious and could confuse operators.
- Fix approach: Add a comment at the storage point explaining that `provider` is part of the idempotency key. Document in CLAUDE.md or ARCHITECTURE.md that switching providers intentionally allows re-analysis. Add a debug endpoint that shows all analyses for a ticket grouped by provider.
## Broken env Pattern in Datto RMM Sync
**Optional API URL with Fallback:**
- Issue: `lib/services/datto-rmm-sync-service.ts` (line 38) has a fallback hardcoded URL: `process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net'`
- Files: `lib/services/datto-rmm-sync-service.ts`
- Impact: The hardcoded fallback is correct (Datto's standard endpoint), but this pattern is inconsistent with other integrations which throw if env vars are missing. If an env var is unset by mistake, it silently uses the public endpoint instead of failing loudly.
- Fix approach: Check if DATTO_RMM_API_URL should be required or optional. If optional, document why. If required, remove the fallback and let the code throw. Audit other clients for similar silent fallbacks.
---
*Concerns audit: 2026-05-03*

View file

@ -0,0 +1,263 @@
# Coding Conventions
**Analysis Date:** 2026-05-03
## Naming Patterns
**Files:**
- kebab-case for all files and directories (e.g., `postgres-client.ts`, `invite-user-form.tsx`, `entity-sync.ts`)
- Nested directories use kebab-case (e.g., `lib/services/analyzer/`, `components/admin/users/`)
**Functions:**
- camelCase for all functions (e.g., `getAutotaskClient()`, `transformCompany()`, `extractExplicitFromText()`)
- Factory functions prefixed with `get` (e.g., `getAutotaskClient()`, `getDattoRmmClient()`)
- Helper functions suffixed with descriptive intent (e.g., `relTime()`, `deriveSigningKey()`)
- Private/internal functions prefixed with underscore: `_INTERNALS` objects expose internals for test access
**Variables:**
- camelCase for all variables (e.g., `isLoading`, `setData`, `ticketNumber`)
- Constants in UPPER_SNAKE_CASE (e.g., `MAX_EXPLICIT_LINKS`, `TICKET_NUMBER_REGEX`)
- Database column names are always snake_case (e.g., `company_name`, `is_active`, `created_at`)
**Types:**
- PascalCase for all type names (e.g., `ClassificationRule`, `WorkflowExecution`, `TicketData`)
- Single-letter generics are acceptable (e.g., `queryEntity<T>()`)
- Union types as literal strings (e.g., `type RuleType = 'branch_routing' | 'ticket_type'`)
**Components:**
- PascalCase exported from kebab-case files (e.g., export `InviteUserForm` from `invite-user-form.tsx`)
- Page components: `export default function ComponentName()` at end of file
- Form components: follow `[Resource]Form` naming (e.g., `InviteUserForm`, `SignInForm`, `UserForm`)
## Code Style
**Formatting:**
- TypeScript strict mode enabled (`"strict": true` in `tsconfig.json`)
- No explicit formatter config (ESLint handles style)
- Indentation: 2 spaces (inferred from existing code)
**Linting:**
- ESLint: `eslint.config.mjs` with Next.js config (`eslint-config-next/core-web-vitals`, `eslint-config-next/typescript`)
- No additional custom rules beyond Next.js defaults
- Type checking: `npx tsc --noEmit --pretty` (must pass before commit)
## Import Organization
**Order:**
1. Node.js built-ins (e.g., `fs`, `path`)
2. Third-party packages (e.g., `next/server`, `zod`, `vitest`)
3. Type imports (e.g., `import type { ... } from '...'`)
4. Local imports from `@/*` (using path alias)
5. Local imports from `./` (relative, less common)
**Path Aliases:**
- Configured as `"@/*": ["./*"]` in `tsconfig.json`
- Use `@/lib/...`, `@/components/...`, `@/app/...` always
- Never use relative paths like `../../../` for imports
**Example import block** (from `/opt/stacks/pulse/components/admin/users/invite-user-form.tsx`):
```typescript
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
```
## Error Handling
**Pattern:**
- All async functions use `try/catch` blocks
- API routes: catch errors and return `NextResponse.json({ error, message }, { status })`
- Standard status codes: `500` for runtime errors, `503` for missing/bad config, `401`/`403` from auth helpers
- Error messages: include `error instanceof Error ? error.message : 'fallback message'`
**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
```typescript
export async function GET(request: NextRequest) {
try {
const result = await postgresClient.query('SELECT * FROM companies ...');
return NextResponse.json({ companies: result.rows.map(transformCompany) });
} catch (error) {
console.error('Error fetching companies from database:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch companies' },
{ status: 500 }
);
}
}
```
**Client-side:** Use try/catch with `.json()` nulling:
```typescript
const [overviewRes, trendsRes] = await Promise.all([
fetch('/api/dashboard/overview', { cache: 'no-store' }),
fetch('/api/dashboard/trends', { cache: 'no-store' }),
]);
if (!overviewRes.ok) {
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
}
```
## Logging
**Framework:** Plain `console` (no structured logging library)
**Patterns:**
- `console.error()` for exceptions caught in try/catch (usually in API routes and services)
- Include context: `console.error('Failed to fetch companies:', error)`
- No `console.log()` for debugging (remove before commit per linter checks)
## Comments
**When to Comment:**
- Explain *why*, not what (code shows the what)
- Non-obvious logic or business rules
- Performance-critical sections
- Workarounds or hacks (mark with `// HACK:` or `// NOTE:`)
**JSDoc/TSDoc:**
- Used sparingly on complex functions
- Example from `lib/services/analyzer/link-discovery.ts`:
```typescript
/**
* Marks refs in a RELATED TICKETS: block as high confidence
*/
export function extractExplicitFromText(text: string, source: string) { ... }
```
- Not required for simple getters/setters or obvious functions
## Function Design
**Size:**
- Keep functions focused: one responsibility per function
- Aim for <50 lines for page components, <30 for utilities
- Complex operations broken into smaller helpers
**Parameters:**
- Prefer object parameters for >3 arguments
- Don't use `any` — use specific types
- Use `Partial<T>` for optional object shapes
**Return Values:**
- Async functions always return `Promise<T>` explicitly
- Prefer `null` over `undefined` for missing values
- Use discriminated unions for success/error returns in critical paths (see analyzer pipeline)
## Module Design
**Exports:**
- Prefer `export` at declaration point rather than grouped re-exports
- One main export per file (exception: barrel files in `components/ui/`)
- Internal utilities prefixed with underscore: `_INTERNALS` object for test access
**Barrel Files:**
- `components/ui/index.ts` exports all shadcn primitives
- `lib/types/` has domain-specific barrel files (e.g., `lib/types/workflow.ts`, `lib/types/autotask.ts`)
- Avoid deep nesting — import from files, not directories unless barrel exists
## Database Transformations
**Pattern:** All columns are `snake_case` in database. API responses transform to `camelCase`.
**Example from `/opt/stacks/pulse/app/api/companies/route.ts`:**
```typescript
function transformCompany(row: any) {
return {
id: Number(row.id),
companyName: row.company_name,
companyType: row.company_type,
isActive: row.is_active,
// ... all snake_case → camelCase
};
}
```
No ORM is used — all transforms are manual per handler.
## Shared Components & Libraries
**UI Components:**
- shadcn primitives live in `components/ui/`
- Feature-specific components in sibling directories (e.g., `components/dashboard/`, `components/admin/`)
- Icons: Always use `lucide-react` (e.g., `import { Loader2, Send } from 'lucide-react'`)
**Tables:**
- Use `@tanstack/react-table` via `components/admin/DataTable.tsx` wrapper
- Example: `<DataTable columns={columns} data={data} />`
**Modals:**
- Use `components/admin/DetailModal.tsx` for entity details
- Follows card + tabs pattern (formatted/raw)
**Navigation:**
- Use `components/navigation/app-navigation.tsx` (`NavigationMenu` from Radix)
- Dropdowns prefer `@radix-ui/react-dropdown-menu` over submenus
**Toasts:**
- Use `sonner` library: `import { toast } from 'sonner'`
- Patterns: `toast.success()`, `toast.error()`, `toast.info()`
**Forms:**
- Use `react-hook-form` + Zod for validation
- Only in admin/auth forms — NOT in every page
- Pattern: `useForm()` with `zodResolver()`, then `<Form>` wrapper from shadcn
**Charts:**
- Use `recharts` for data visualization (e.g., `<BarChart>`, `<LineChart>`)
## What NOT to Introduce
**Forbidden:**
- No ORMs (Prisma, TypeORM, etc.) — use `postgresClient` singleton and manual transforms
- No server actions (`'use server'`) — use API routes called via `fetch()` from clients
- No additional state libraries (SWR, react-query, TanStack Query) — match local `useState` + `fetch` pattern
- No change to authentication (Better Auth is final)
- No editing of committed migrations — always create new numbered ones
**Rationale:**
- Keeps codebase lean and explicit
- Reduces abstraction overhead
- Makes data flow (DB → API → Client) visible
## Migrations
**Creating a new migration:**
1. Number it sequentially: if last is `041_create_engagement_tables.sql`, next is `042_*.sql`
2. Use `IF NOT EXISTS` for CREATE statements
3. Use `ON CONFLICT DO NOTHING` for seed data INSERT
4. Never drop columns or tables without explicit guard
5. Include audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
**Example structure:**
```sql
CREATE TABLE IF NOT EXISTS new_table (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO new_table (id, name) VALUES (1, 'Example')
ON CONFLICT DO NOTHING;
```
**Note:** Migrations are applied in alphabetical order. Existing duplicates (002, 004, 009) exist; respect that order.
---
*Convention analysis: 2026-05-03*

View file

@ -0,0 +1,267 @@
# External Integrations
**Analysis Date:** 2026-05-03
## APIs & External Services
**Autotask PSA:**
- Primary integration — syncs projects, tickets, time entries, contacts, configuration items to Postgres
- SDK/Client: `lib/services/autotask-factory.ts``getAutotaskClient()`, `lib/services/autotask-client.ts`
- Auth env vars: `AUTOTASK_API_URL`, `AUTOTASK_USERNAME`, `AUTOTASK_SECRET`, `AUTOTASK_API_INTEGRATION_CODE`
- Webhook secret: `AUTOTASK_WEBHOOK_SECRET` — HMAC-SHA1 verification in `lib/services/webhook-service.ts`
- Sync service: `lib/services/entity-sync.ts` (incremental via `lastTrackedModificationDateTime` when supported)
- Webhook handler: `/api/webhooks/autotask` (public endpoint)
**Microsoft Graph:**
- User/employee engagement data (calendar, presence, mail metrics)
- SDK/Client: `lib/services/msgraph-factory.ts``getMsgraphClient()`, `isMsgraphConfigured()`, `lib/services/msgraph-client.ts`
- Auth env vars: `MSGRAPH_CLIENT_ID`, `MSGRAPH_CLIENT_SECRET`, `MSGRAPH_TENANT_ID` (specific tenant, NOT 'common')
- Flow: client_credentials OAuth — no user interaction required
- Sync service: `lib/services/engagement-sync-service.ts`
- API: `/api/engagement/sync` (POST fire-and-forget, GET status), `/api/engagement/summary`, `/api/engagement/users`, `/api/engagement/user/[userId]`
- Scheduler: `engagement-daily` task (disabled by default, 6am UTC)
**Microsoft OAuth (Login):**
- SSO via Microsoft identity
- Auth env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` (same as above; tenant-aware)
- Integrated into Better Auth 1.4 — `lib/auth.ts` configures `microsoft` social provider
- Account linking enabled: Microsoft OAuth can link to existing accounts
**Datto RMM:**
- Remote device management — sites, devices, alerts
- SDK/Client: `lib/services/datto-rmm-factory.ts``getDattoRMMClient()`, `lib/services/datto-rmm-client.ts`
- Auth env vars: `DATTO_RMM_API_URL`, `DATTO_RMM_API_KEY`, `DATTO_RMM_API_SECRET`
- Sync service: `lib/services/datto-rmm-sync-service.ts` (on-demand or scheduled)
- API: `/api/datto-rmm/sync` (public POST, fire-and-forget)
- Alternate simpler client: `lib/services/datto-rmm-client-simple.ts` available
**Veeam Backup & Replication:**
- Backup infrastructure data — sites, repositories, jobs, backup chains
- SDK/Client: `lib/services/veeam-factory.ts``getVeeamClient()`, `isVeeamConfigured()`, `lib/services/veeam-client.ts`
- Auth env vars: `VEEAM_VSPC_URL`, `VEEAM_VSPC_API_KEY`
- Shadow mode: `VEEAM_RPO_SHADOW_MODE=true` (default)
- Sync service: `lib/services/veeam-sync-service.ts`
- API: `/api/veeam/sync` (public POST), `/api/veeam/rpo-check` (public POST, RPO health check)
**Auvik:**
- Network monitoring — devices, interfaces, metrics
- SDK/Client: `lib/services/auvik-factory.ts``getAuvikClient()`, `lib/services/auvik-client.ts`
- Auth env vars: `AUVIK_API_URL`, `AUVIK_API_USER`, `AUVIK_API_KEY`
**Addigy:**
- Apple device management
- SDK/Client: `lib/services/addigy-factory.ts``getAddigyClient()`, `clearAddigyClientCache()`, `lib/services/addigy-client.ts`
- Auth env vars: `ADDIGY_API_URL` (default: `https://api.addigy.com/api/v2`), `ADDIGY_API_TOKEN`, `ADDIGY_ORG_ID` (optional)
**IT Glue:**
- Documentation platform — organizations, configurations, passwords, flexible assets
- SDK/Client: `lib/services/itglue-client.ts` (direct client, no factory)
- Auth env var: `ITGLUE_API_KEY` (x-api-key header)
- Base URL: `https://api.itglue.com`
- Format: JSON:API (application/vnd.api+json)
- Search function for analyzer: `lib/services/analyzer/itglue-search.ts` (redacted output before LLM prompts)
- Sync service: `lib/services/itglue-sync-service.ts`
- API: `/api/itglue/sync` (public POST, fire-and-forget)
**Mimecast:**
- Email security — threat/policy logs
- SDK/Client: `lib/services/mimecast-client.ts` (direct client, no factory)
- Auth env vars: `MIMECAST_CLIENT_ID`, `MIMECAST_CLIENT_SECRET`, `MIMECAST_ACCOUNT_CODE`, `MIMECAST_BASE_URL` (default: `https://api.services.mimecast.com`)
**SentinelOne:**
- EDR/XDR — agents, threats, sites, groups
- SDK/Client: `lib/services/sentinelone-client.ts` (direct client, no factory)
- Auth env var: `S1_API_KEY` or `SENTINELONE_API_KEY`
- Sync service: `lib/services/sentinelone-sync-service.ts`
- API: `/api/sentinelone/sync` (public POST, fire-and-forget)
**Duo Security:**
- 2FA/MFA monitoring — users, phones, auth logs, accounts
- SDK/Client: `lib/services/duo-client.ts` (direct client, no factory)
- Auth env vars: `DUO_IKEY`, `DUO_SKEY`, `DUO_HOST` — HMAC-SHA1 request signing
- Supports both Accounts API (parent) and Admin API (parent + child accounts)
- API: `/api/duo` (public POST; Duo-specific endpoints for sync, data retrieval)
**Zoom:**
- Videoconferencing — meeting analytics, users
- SDK/Client: `lib/services/zoom-factory.ts``getZoomClient()`, `isZoomConfigured()`, `lib/services/zoom-client.ts`
- Auth env vars: `ZOOM_ACCOUNT_ID`, `ZOOM_CLIENT_ID`, `ZOOM_CLIENT_SECRET` — Server-to-Server OAuth
- Sync service: `lib/services/zoom-sync-service.ts`
- API: `/api/zoom/sync` (public POST, fire-and-forget)
**QuickBooks Online:**
- Accounting — invoices, payments, deposits, purchases, journal entries
- SDK/Client: `lib/services/qbo-client.ts` (singleton, uses DATABASE_URL)
- Auth env vars: `QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_REALM_ID`
- OAuth2 token management: tokens stored in `qbo_tokens` table (migration 014), auto-refreshed
- Sandbox mode: `QBO_SANDBOX=true` switches to sandbox URL
- Callback: `/api/qbo/auth` (public POST, OAuth redirect), `/api/qbo/disconnect` (public POST)
- Sync service: `lib/services/qbo-sync-service.ts`
- API: `/api/qbo/sync` (public POST, fire-and-forget)
**Zabbix:**
- Infrastructure monitoring — events, problems, hosts, metrics
- SDK/Client: `lib/services/zabbix-client.ts` (direct client, no factory)
- Auth env vars: `ZABBIX_API_URL`, `ZABBIX_API_TOKEN`
- Webhook handler: `/api/zabbix/webhook` (public POST)
**SalesBldr:**
- Sales engagement platform
- SDK/Client: `lib/services/salesbldr-client.ts` (direct client, no factory)
- Auth env vars: `SALESBLDR_API_URL`, `SALESBLDR_API_KEY`
**ipinfo.io:**
- IP geolocation (optional)
- Auth env var: `IPINFO_TOKEN` (optional, defaults to empty)
## Data Storage
**Databases:**
- PostgreSQL 16 — Primary data store
- Connection: `POSTGRES_HOST`, `POSTGRES_PORT` (5432), `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` (or `DATABASE_URL`)
- Client: `lib/services/postgres-client.ts` singleton via `postgresClient.query()`, `.transaction()`, `.upsert()`, `.bulkUpsert()`
- Migrations: `migrations/*.sql` (numbered sequentially, applied in alphabetical order on Postgres init)
- All columns: `snake_case`; API responses: `camelCase` (manual transformation)
- Audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
**Cache:**
- Redis 7 (optional)
- Connection: `REDIS_URL` (e.g., `redis://localhost:6380` in Docker)
- Client: `lib/services/redis-client.ts` via `getRedisClient()`, `getCachedData()`, `setCachedData()`, `flushCache()`
- TTL default: 300 seconds (5 minutes)
- Graceful fallback: if REDIS_URL unset or connection fails, caching disabled
## Authentication & Identity
**Auth Provider:**
- Better Auth 1.4 — Magic link + TOTP 2FA + Microsoft OAuth
- Implementation: `lib/auth.ts` configures plugins, session TTL, roles (user, admin, super-admin)
- Database: Tables created in migration 012 (user, session, account, verification)
- Default admin bootstrap: via `DEFAULT_ADMIN_EMAIL` env var, `lib/bootstrap.ts`
- Session cookie handling: next-js plugin enabled
- Account linking: Microsoft OAuth can link to existing accounts
- Roles: `super-admin`, `admin`, `user`
- RBAC: `lib/permissions.ts` defines `ac` (access-control) rules
**API Route Auth:**
- Helpers in `lib/auth-utils.ts`: `requireAuth()`, `requireAdmin()`, `requireSuperAdmin()`, `requirePermission(resource, action)`
- Middleware: `middleware.ts` checks session cookie existence; role verification happens in API routes
- Public routes: hardcoded in `middleware.ts` (webhooks, sync endpoints, health checks, auth callbacks, mobile, openclaw, kiosk, legal)
## Monitoring & Observability
**Error Tracking:**
- None (no Sentry/DataDog integration detected)
**Logs:**
- Console logging: `console.log()`, `console.error()`
- Slow query warnings: queries > 1000ms logged in `postgresClient.query()`
## AI & LLM
**Analyzer Pipeline:**
- Primary: Anthropic Claude API
- SDK: `@anthropic-ai/sdk` 0.91.1
- Auth: `ANTHROPIC_API_KEY` env var
- Models: Haiku (stage 1, 6), Sonnet (stage 2, 5), Opus (stage 3, 4)
- Worker: `lib/services/analyzer/worker.ts` (auto-starts in production, polled every 2s)
- Pipeline: `lib/services/analyzer/pipeline.ts` (6 stages: triage, analysis, reasoning, fingerprint, aggregate, link-discovery)
- Cost guard: `lib/services/analyzer/cost-guard.ts` (skips Opus above $2.00 estimated cost, flags for review)
- Idempotency: per-request provider-scoped (`anthropic` | `openrouter`); same ticket can have both
**Alternate Provider:**
- OpenRouter (opt-in per request)
- SDK: HTTP client, OpenAI-compatible format
- Auth: `OPENROUTER_API_KEY` env var
- Models: DeepSeek V4 Flash (fast), DeepSeek V4 Pro (standard), DeepSeek R1 (reasoning)
- Call layer: `lib/services/llm/openrouter-call.ts`
- Provider hints: `data_collection: 'deny'` (privacy floor), `sort: 'throughput'`, `allow_fallbacks: true`
**Model Constants:**
- `lib/services/llm/models.ts` — canonical model IDs and stage-model mappings
- Anthropic: `claude-haiku-4-5`, `claude-sonnet-4-6`, `claude-opus-4-7`
- OpenRouter: `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, `deepseek/deepseek-r1-0528`
**Pricing & Token Tracking:**
- `lib/services/llm/pricing.ts` — per-token cost calculation
- `lib/services/analyzer/cost-guard.ts` — estimated cost ceiling enforcement
## File Storage
**Backblaze B2 (S3-compatible):**
- LogLift evidence upload/download
- Auth env vars: `B2_KEY_ID`, `B2_APP_KEY`
- Config env vars: `B2_BUCKET` (default: `wulf-audits`), `B2_REGION` (default: `us-west-002`), `B2_ENDPOINT` (default: `s3.us-west-002.backblazeb2.com`)
- SDK/Client: `lib/services/b2/client.ts` — presigned URLs (AWS SigV4), download/upload with object-key validation
- Max download: 25 MB
- Object key format validation: `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz` (path-traversal guard)
## CI/CD & Deployment
**Hosting:**
- Docker Compose (provided in `docker-compose.yml`)
- Traefik labels for reverse-proxy routing at `pulse.wulfconsulting.cloud`
- Standalone Next.js output for containerization
**CI Pipeline:**
- None detected; local testing only (vitest)
- Build: `npm run build` (turbopack)
- Type check: `npx tsc --noEmit --pretty`
- Lint: `npm run lint` (eslint)
## Webhooks & Callbacks
**Incoming Webhooks:**
- `/api/webhooks/autotask` — Autotask event notifications (public, HMAC-SHA1 verified)
- `/api/zabbix/webhook` — Zabbix problem notifications (public)
- `/api/rmm/loglift` — LogLift evidence uploads (public, verified via `x-openclaw-key` header)
- `/api/qbo/auth` — QuickBooks OAuth callback (public)
- `/api/qbo/disconnect` — QBO token revocation (public)
**Outgoing Webhooks:**
- Autotask write-back: workflow engine executes Autotask API calls (POST notes, status updates, custom fields)
- Workflow execution: `lib/services/workflow-engine.ts` chains actions after classification
**Fire-and-Forget Sync Endpoints:**
- `/api/datto-rmm/sync` (POST)
- `/api/veeam/sync` (POST)
- `/api/veeam/rpo-check` (POST)
- `/api/itglue/sync` (POST)
- `/api/sentinelone/sync` (POST)
- `/api/engagement/sync` (POST)
- `/api/zoom/sync` (POST)
- `/api/qbo/sync` (POST)
- All public, triggered by admin UI or cron schedule in sync-scheduler
## Environment Configuration
**Required env vars (production):**
- `BETTER_AUTH_URL` — must match deployed domain
- `BETTER_AUTH_SECRET` — session signing secret
- `DATABASE_URL` or `POSTGRES_*` — database connection
- `AUTOTASK_API_URL`, `AUTOTASK_USERNAME`, `AUTOTASK_SECRET`, `AUTOTASK_API_INTEGRATION_CODE` — Autotask API
- `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` — OAuth login
- `ANTHROPIC_API_KEY` — AI Analyzer (required if analyzer enabled)
- `DEFAULT_ADMIN_EMAIL` — initial super-admin account
**Optional env vars:**
- `REDIS_URL` — enables caching; graceful no-op if missing
- `MSGRAPH_*` — Microsoft Graph (engagement sync)
- `DATTO_RMM_*`, `VEEAM_VSPC_*`, `AUVIK_*`, `ADDIGY_*`, `ITGLUE_API_KEY`, `MIMECAST_*`, `S1_API_KEY`, `DUO_*`, `ZOOM_*`, `QBO_*`, `ZABBIX_*`, `SALESBLDR_*` — per-integration
- `OPENROUTER_API_KEY` — alternate LLM provider
- `B2_*` — LogLift evidence storage
- `IPINFO_TOKEN` — optional IP geolocation
**Secrets location:**
- `.env.local` (development, mounted read-only in Docker)
- Environment variables passed to container (production)
- *Note: `.env` file is committed to the repository; treat values as potentially real.*
**Integration disable mechanism:**
- Two sources (merged):
1. `INTEGRATIONS_DISABLED` env var (legacy, comma/space-separated keys; aliases: `sentinelone``s1`, `datto``datto_rmm`, `it-glue``itglue`, `ms-graph``msgraph`)
2. `integration_settings` table (migration 081) — admin-toggled at `/admin/integrations` without restart; cache invalidation immediate; audit columns: `disabled_by`, `disabled_at`, `disabled_reason`
---
*Integration audit: 2026-05-03*

139
.planning/codebase/STACK.md Normal file
View file

@ -0,0 +1,139 @@
# Technology Stack
**Analysis Date:** 2026-05-03
## Languages
**Primary:**
- TypeScript 5 - Entire codebase, strict mode enabled
- JavaScript/JSX - React components via TypeScript with JSX support
**Secondary:**
- SQL - PostgreSQL migrations and queries
- Bash - Build and deployment scripts
## Runtime
**Environment:**
- Node.js (version inferred from package.json compatibility)
- Next.js 16.1.1 running on port 3100
**Package Manager:**
- npm (lockfile: package-lock.json)
## Frameworks
**Core:**
- Next.js 16.1.1 - App Router with `output: 'standalone'` for Docker, React Compiler enabled
- React 19.2.3 - Server and client components, React Compiler active
- Better Auth 1.4.10 - Authentication with magic link, TOTP 2FA, Microsoft OAuth
**UI & Styling:**
- Tailwind CSS 4.1.18 - Utility-first styling
- shadcn/ui (via Radix UI primitives) - Component library: `components/ui/`
- @radix-ui packages: accordion, alert-dialog, checkbox, collapsible, dialog, dropdown-menu, label, navigation-menu, popover, progress, scroll-area, select, separator, slot, switch, tabs
- Recharts 3.7.0 - Charts and graphs (analytics/dashboards)
- Lucide React 0.562.0 - Icon library
- Sonner 2.0.7 - Toast notifications
- cmdk 1.1.1 - Command palette component
**Forms & Validation:**
- react-hook-form 7.70.0 - Form state management (admin/auth only)
- Zod 4.3.5 - Type-safe schema validation
- @hookform/resolvers 5.2.2 - Form resolver for Zod
**Tables & Data:**
- @tanstack/react-table 8.21.3 - Headless table library with sorting, pagination, search
- react-markdown 10.1.0 - Markdown rendering
- remark-gfm 4.0.1 - GitHub-flavored markdown support
**Utilities:**
- date-fns 4.1.0 - Date manipulation and formatting
- react-day-picker 9.13.0 - Calendar date picker
- clsx 2.1.1 - Conditional className utility
- tailwind-merge 3.4.0 - Merge Tailwind class conflicts
- class-variance-authority 0.7.1 - CSS-in-JS variant management
- next-themes 0.4.6 - Dark mode theme switching
## Testing & Build
**Testing:**
- vitest 4.1.5 - Unit and integration testing runner
- Run: `npm test` (run once), `npm run test:watch` (watch mode)
- Test coverage for: `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**`, `lib/services/analyzer/link-discovery.test.ts`
**Build/Dev:**
- Turbopack (via Next.js 16) - Fast bundler for development and production
- ESLint 9.39.2 - Linting (with eslint-config-next 16.1.1)
- TypeScript - Type checking via `npx tsc --noEmit --pretty`
## Key Dependencies
**Critical:**
- pg 8.11.0 - PostgreSQL client (no ORM); queries via `postgresClient` singleton
- ioredis 5.9.0 - Redis client for caching; optional (graceful fallback if REDIS_URL unset)
- node-cron 4.2.1 - Job scheduler for sync tasks and workflows
- @anthropic-ai/sdk 0.91.1 - Anthropic Claude API client for AI Ticket Analyzer pipeline
**External API Integrations:**
- Better Auth ecosystem packages - OAuth, 2FA, session management
- nodemailer 7.0.12 - Email delivery for magic link auth
**Development:**
- babel-plugin-react-compiler 1.0.0 - React Compiler for optimized renders
- shadcn 4.6.0 - CLI tool for adding shadcn/ui components
- baseline-browser-mapping 2.10.8 - Browser compatibility mapping
- tw-animate-css 1.4.0 - Tailwind animation utilities
## Configuration
**Environment Variables:**
- `BETTER_AUTH_URL` - Base URL for auth (e.g., `http://localhost:3100` or `https://pulse.wulfconsulting.cloud`)
- `BETTER_AUTH_SECRET` - Secret key for session signing
- `DATABASE_URL` or `POSTGRES_*` - PostgreSQL connection (host, port, db, user, password)
- `REDIS_URL` - Redis connection (e.g., `redis://localhost:6380` in Docker compose)
- `SESSION_TIMEOUT_SECONDS` - Session TTL (default: 86400 / 24 hours)
- `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` - Microsoft OAuth for login (tenant is specific, not 'common')
- `DEFAULT_ADMIN_EMAIL` - Bootstrap admin account email
- Integration env vars: prefixed by service (e.g., `AUTOTASK_*`, `DATTO_RMM_*`, `MSGRAPH_*`, etc.) — see INTEGRATIONS.md
**TypeScript Config:**
- Path alias: `@/*` maps to project root for cleaner imports
- Target: ES2017
- Strict mode enabled
- Config: `tsconfig.json`
**Next.js Config:**
- File: `next.config.ts`
- Standalone output for Docker deployment
- React Compiler enabled
- Image domains: configurable (currently empty)
**Build Output:**
- `npm run build` → Next.js standalone app in `.next/`
- `npm run start` → Starts production server on port 3100
## Platform Requirements
**Development:**
- Node.js 18+ (inferred from Next.js 16 compatibility)
- npm 8+
- PostgreSQL 16 (local or Docker)
- Redis 7 (optional, enables caching)
- Docker & Docker Compose (for full stack)
**Production:**
- Docker with docker-compose.yml provided
- Postgres 16 container (applies migrations on init)
- Redis 7 container on port 6380 (custom, not 6379)
- Next.js app container on port 3100
- Traefik integration available (via labels in docker-compose.yml) for routing at `pulse.wulfconsulting.cloud`
**Deployment:**
- Container-based: standalone Next.js image with migrations applied via Postgres init
- Volume mounts for data persistence: `redis_data`, `postgres_data`
- Environment configuration via `.env.local` file mounted read-only
---
*Stack analysis: 2026-05-03*

View file

@ -0,0 +1,418 @@
# Codebase Structure
**Analysis Date:** 2026-05-03
## Directory Layout
```
pulse/
├── app/ # Next.js App Router pages + API routes
│ ├── api/ # API route handlers (GET/POST/PATCH/DELETE)
│ │ ├── admin/ # Admin settings, users, roles, integrations
│ │ ├── analyzer/ # Ticket analysis, aggregate reports, IT Glue
│ │ ├── auth/ # Better Auth endpoints
│ │ ├── webhooks/ # Autotask webhook endpoint (public)
│ │ ├── sync/ # Sync trigger endpoints (public, called by scheduler)
│ │ ├── health # Health check (public)
│ │ ├── integrations/ # Integration status (public)
│ │ └── …/ # Other feature APIs (companies, tickets, etc.)
│ ├── admin/ # /admin landing page + sub-pages (sync, workflow, RMM, IT Glue)
│ ├── analyzer/ # /analyzer/* pages (tickets, reports, queue)
│ ├── dashboard/ # /dashboard — KPI home
│ ├── status/ # /status — integration + worker health
│ ├── configuration-items/ # /configuration-items — CI browser
│ ├── engagement/ # /engagement/* — MS Graph employee data
│ ├── backup-status/ # /backup-status — Veeam RPO dashboard
│ ├── veeam-*/ # Veeam comparison + ticket analysis pages
│ ├── auth/ # /auth/sign-in, /auth/2fa — magic link flow
│ ├── settings/ # /settings, /settings/security — user profile
│ ├── kiosk/ # /kiosk/* — public field app (no nav)
│ ├── mobile/ # /mobile/* — mobile API endpoints (no nav)
│ ├── styles/ # CSS modules (brand.css, globals.css)
│ ├── layout.tsx # Root layout (AppNavigation, CommandPalette, Toaster)
│ └── page.tsx # Redirect to /dashboard
├── lib/ # Shared utilities, services, types
│ ├── services/ # Integration clients + sync/worker logic (~50 files)
│ │ ├── autotask-*.ts # Autotask API client + sync
│ │ ├── datto-rmm-*.ts # Datto RMM client + sync
│ │ ├── itglue-*.ts # IT Glue client + sync
│ │ ├── veeam-*.ts # Veeam VSPC client + sync
│ │ ├── msgraph-*.ts # MS Graph client + engagement sync
│ │ ├── engagement-*.ts # Engagement dashboard data
│ │ ├── analyzer/ # Analyzer pipeline (8 subdirs + 20 files)
│ │ │ ├── pipeline.ts # 7-stage orchestration
│ │ │ ├── worker.ts # 2s polling loop (auto-starts in production)
│ │ │ ├── persistence.ts # Read/write analyzer_analyses
│ │ │ ├── link-discovery.ts # Find related tickets
│ │ │ ├── itglue-search.ts # Redacted IT Glue lookups
│ │ │ ├── asset-audit/ # IT Glue write-back audits
│ │ │ ├── stages/ # 7 pipeline stages
│ │ │ └── fixtures/ # Test data
│ │ ├── rmm/ # RMM executor + Overshell
│ │ │ ├── executor.ts # Validate + enqueue
│ │ │ ├── worker.ts # 5s polling loop
│ │ │ ├── scripts/ # Script registry (code-defined)
│ │ │ ├── target-resolver.ts
│ │ │ └── …/
│ │ ├── llm/ # LLM dispatch + cost tracking
│ │ │ ├── call.ts # Claude + OpenRouter routing
│ │ │ ├── models.ts # Stage → model mapping
│ │ │ └── pricing.ts # Cost estimation
│ │ ├── b2/ # Backblaze B2 object storage
│ │ ├── sync-scheduler.ts # node-cron singleton (auto-starts)
│ │ ├── sync-service.ts # Incremental sync orchestration
│ │ ├── entity-sync.ts # Per-entity Autotask → Postgres
│ │ ├── postgres-client.ts # DB singleton (query/transaction/upsert)
│ │ ├── integration-health.ts # Health check orchestration
│ │ ├── email.ts # Email service (magic link)
│ │ ├── webhook-service.ts # HMAC verification
│ │ ├── redis-client.ts # Redis cache (optional)
│ │ ├── rate-limiter.ts # RMM execute limits
│ │ ├── audit.ts # Audit log writes
│ │ └── (25+ other services for Zoom, Duo, Mimecast, S1, etc.)
│ │
│ ├── types/ # TypeScript type definitions
│ │ ├── autotask.ts # Autotask API shapes
│ │ ├── analyzer.ts # Analysis, stage execution, aggregate report shapes
│ │ ├── database.ts # DB row types (tickets, companies, etc.)
│ │ ├── sync.ts # Sync schedule + progress shapes
│ │ ├── veeam.ts # Veeam shapes
│ │ ├── workflow.ts # Workflow engine shapes
│ │ ├── datto-rmm.ts # RMM types
│ │ └── …/
│ │
│ ├── auth.ts # Better Auth config + session type
│ ├── auth-utils.ts # requireAuth(), requireAdmin(), requirePermission()
│ ├── auth-client.ts # Client-side Better Auth SDK
│ ├── permissions.ts # Role → resource → action matrix
│ ├── bootstrap.ts # DEFAULT_ADMIN_EMAIL user creation
│ ├── status-registry.ts # Priority/status color maps (shared UI state)
│ ├── utils.ts # Misc helpers
│ │
│ └── utils/ # Utility modules
│ ├── env.ts # Type-safe env var access
│ └── …/
├── components/ # React components
│ ├── ui/ # shadcn/ui primitives (50+ files)
│ │ ├── button.tsx # Base button component
│ │ ├── card.tsx # Card + CardHeader/CardTitle/CardContent
│ │ ├── dialog.tsx # Modal + trigger + close
│ │ ├── table.tsx # HTML table wrapper with Tailwind classes
│ │ ├── input.tsx, select.tsx, checkbox.tsx, etc.
│ │ ├── skeleton.tsx # Loading placeholder
│ │ ├── skeleton-helpers.tsx # SkeletonRow, SkeletonCard, etc.
│ │ ├── empty-state.tsx # Zero-data UI
│ │ ├── status-badge.tsx # Status pill driven by status-registry
│ │ ├── status-light.tsx # Status indicator dot
│ │ ├── form.tsx # react-hook-form bridge
│ │ └── …/
│ │
│ ├── navigation/ # Top bar + page structure
│ │ ├── app-navigation.tsx # NavigationMenu + UserMenu + ThemeToggle
│ │ ├── page-header.tsx # Title + breadcrumbs + action slot
│ │ ├── command-palette.tsx # Cmd+K launcher
│ │ ├── mobile-nav.tsx # Mobile hamburger menu
│ │ ├── user-menu.tsx # User profile dropdown
│ │ └── status-indicator.tsx # Top-bar integration health
│ │
│ ├── admin/ # Admin-specific components
│ │ ├── DataTable.tsx # Paginated/sortable/searchable table (@tanstack/react-table)
│ │ ├── DetailModal.tsx # Ticket deep-dive (tabs: status, priority maps)
│ │ ├── SyncScheduler.tsx # Edit sync schedules
│ │ └── …/
│ │
│ ├── analyzer/ # Analyzer-specific components
│ │ ├── analyze-button.tsx # Trigger analysis from ticket
│ │ ├── share-modal.tsx # Email share dialog
│ │ ├── provider-toggle.tsx # Anthropic/OpenRouter switch
│ │ ├── related-tickets-panel.tsx
│ │ ├── itglue-suggestions-panel.tsx
│ │ └── …/
│ │
│ ├── rmm/ # RMM components
│ │ ├── rmm-dispatch-dialog.tsx # Pick + execute script
│ │ ├── rmm-script-picker.tsx # Script browser
│ │ ├── rmm-execution-stream.tsx # Live output tail
│ │ └── …/
│ │
│ ├── dashboard/ # Dashboard-specific components
│ │ ├── kpi-card.tsx # KPI with delta
│ │ ├── volume-trend.tsx # 30-day ticket volume chart
│ │ ├── resolution-trend.tsx # Resolution time chart
│ │ ├── queue-heatmap.tsx # Queue × priority heatmap
│ │ ├── active-engineers.tsx # Today's hours logged
│ │ └── …/
│ │
│ ├── configuration-items/ # CI browser components
│ │ └── config-item-modal.tsx # CI detail shell
│ │
│ ├── branding/ # Wulf branding
│ │ ├── wulf-mark.tsx # W glyph or wordmark
│ │ └── tagline-footer.tsx # "Don't be afraid to cry" footer
│ │
│ ├── auth/ # Auth flow components
│ │ └── auth-provider.tsx # Better Auth session context
│ │
│ ├── status/ # Status page components
│ │ ├── worker-pulse.tsx # Analyzer/RMM/sync heartbeats
│ │ ├── activity-sparkline.tsx # 24h per-worker activity
│ │ └── …/
│ │
│ └── (other feature dirs: backup, settings, tickets, tasks, etc.)
├── migrations/ # Numbered SQL migrations (001089)
│ ├── 001_initial_schema.sql # Core schema, audit columns
│ ├── 012_create_auth_tables.sql # Better Auth tables
│ ├── 030_create_workflow_engine_tables.sql
│ ├── 041_create_engagement_tables.sql
│ ├── 069_create_analyzer_tables.sql
│ ├── 077_create_rmm_overshell_tables.sql
│ └── …/
├── scripts/ # One-off operations scripts
│ ├── apply-migrations # Manual migration runner for existing DB
│ └── …/
├── docs/ # Long-form guides (linked from CLAUDE.md)
│ ├── AUTOTASK_API_GUIDE.md
│ ├── POSTGRES_SYNC_SETUP.md
│ ├── ANALYZER_RUNBOOK.md
│ ├── RMM_OVERSHELL_SPEC.md
│ ├── LOGLIFT_SPEC.md
│ ├── IT_GLUE_AUDIT_SPEC.md
│ └── …/
├── public/ # Static assets (favicon, logos, branding)
│ ├── favicon.png
│ ├── wulff-logo.png
│ └── branding/
├── hooks/ # React hooks (useSearchParams, fetch helpers, etc.)
├── tasks/ # (Reserved for background tasks; unused today)
├── .planning/ # GSD planning documents (generated)
├── .env # Committed env vars (treat as potentially real secrets)
├── next.config.js # Turbopack, React compiler, output: standalone
├── tsconfig.json # Path alias @/*, strict mode
├── package.json # Next 16, React 19, Tailwind 4, shadcn/ui, etc.
├── Dockerfile # Standalone build, port 3100
├── docker-compose.yml # Postgres 16, Redis 7, app
├── CLAUDE.md # Repo guide for Claude (read first)
├── ARCHITECTURE.md # Runtime topology, data flow, workers (read before touching workers)
└── DESIGN.md # UI tokens, nav IA, component conventions
```
## Directory Purposes
**`app/`:**
- Purpose: Next.js App Router pages + API routes
- Contains: Page components (`'use client'` with fetch), route handlers (GET/POST/PATCH/DELETE), layout shells
- Key files: `layout.tsx` (root shell), `page.tsx` (redirect to /dashboard)
**`app/api/`:**
- Purpose: HTTP API endpoints called by client or external systems
- Contains: Route handlers exporting GET/POST/PATCH/DELETE
- Patterns: Auth check via `requireAuth()`, delegate to service layer, return `NextResponse.json()`
- Subdirs mirror resources: `admin/`, `analyzer/`, `sync/`, `webhooks/`, etc.
**`lib/services/`:**
- Purpose: Business logic, integration clients, sync orchestration, background workers
- Contains: ~50 files including factories, sync services, analyzer pipeline, RMM executor, integration health checks
- Patterns: Factory pattern for clients, incremental sync via `lastTrackedModificationDateTime`, side-effect import auto-start for workers
**`lib/types/`:**
- Purpose: TypeScript type definitions (no runtime code)
- Contains: Entity shapes (ticket, company, analysis, rmm_execution, etc.), API request/response envelopes
- Organized: By domain (autotask, analyzer, database, sync, workflow, etc.)
**`lib/auth.ts`, `lib/auth-utils.ts`, `lib/permissions.ts`:**
- Purpose: Session management, role-based authorization, permission matrix
- Contains: Better Auth config, `requireAuth()` / `requireAdmin()` / `requirePermission()`, resource → action matrix
- Used by: Every API route handler for access control
**`components/ui/`:**
- Purpose: shadcn/ui primitives (Button, Card, Dialog, Input, Select, Table, etc.)
- Contains: Radix-based components with Tailwind styling
- Pattern: One component per file (e.g., `button.tsx`), default export is the component
**`components/navigation/`:**
- Purpose: Top bar, page headers, breadcrumbs, command palette, mobile menu
- Contains: `app-navigation.tsx` (sticky top bar), `page-header.tsx` (title + actions), `command-palette.tsx` (Cmd+K)
- Used by: Root layout + all pages
**`components/admin/`, `components/analyzer/`, `components/rmm/`, etc.:**
- Purpose: Feature-specific components
- Contains: Reusable UI for that domain (e.g., DataTable, DetailModal, analyze-button)
- Pattern: Exported from kebab-case files (e.g., `analyze-button.tsx` exports `<AnalyzeButton />`)
**`migrations/`:**
- Purpose: Database schema versioning
- Contains: Numbered SQL files (001089), applied in alphabetical order on Postgres init
- Patterns: `IF NOT EXISTS` for idempotence, `ON CONFLICT DO NOTHING` for seed data, audit columns (`created_at`, `updated_at`, `is_deleted`)
- Important: Never edit a committed migration; add a new one instead. Duplicate numbers exist (002, 004, 009) — apply order is filesystem-alphabetical.
**`scripts/`:**
- Purpose: One-off operations and utilities
- Contains: `apply-migrations` (manual migration runner for existing DB)
- Important: Not tests; not part of the build
**`docs/`:**
- Purpose: Long-form integration and feature guides
- Contains: Setup guides for Autotask, sync architecture, analyzer runbook, RMM/LogLift specs, IT Glue audit spec
- Pattern: One file per major system; referenced from CLAUDE.md, not duplicated in code
**`public/`:**
- Purpose: Static web assets (favicon, logos, branding images)
- Contains: PNG/SVG files served at `/favicon.png`, `/wulff-logo.png`, etc.
## Key File Locations
**Entry Points:**
- `app/page.tsx` — Root page (redirect to /dashboard)
- `app/layout.tsx` — Root layout (AppNavigation, CommandPalette, Toaster, theme provider)
- `app/auth/sign-in/page.tsx` — Magic link entry
- `app/dashboard/page.tsx` — KPI home
**Configuration:**
- `lib/auth.ts` — Better Auth config (magic link, 2FA, Microsoft OAuth)
- `lib/permissions.ts` — Role → resource → action matrix
- `next.config.js` — Turbopack, React compiler, `output: 'standalone'`
- `tsconfig.json` — Path aliases (`@/*`), strict mode
- `.env` — Committed env vars (API keys, secrets — treat as real)
**Core Logic:**
- `lib/services/postgres-client.ts` — DB singleton (query, transaction, upsert)
- `lib/services/sync-scheduler.ts` — node-cron scheduler (auto-starts, self-initializes)
- `lib/services/entity-sync.ts` — Per-entity incremental sync (Autotask, etc.)
- `lib/services/analyzer/pipeline.ts` — 7-stage LLM analysis orchestration
- `lib/services/analyzer/worker.ts` — 2s polling loop (auto-starts in production)
- `lib/services/rmm/executor.ts` — Script validation + rate limiting
- `lib/services/rmm/worker.ts` — 5s polling loop for RMM executions
**Authentication & Authorization:**
- `lib/auth.ts` — Better Auth instance + session type
- `lib/auth-utils.ts``requireAuth()`, `requireAdmin()`, `requirePermission()`
- `lib/bootstrap.ts` — Admin user creation from `DEFAULT_ADMIN_EMAIL`
- `middleware.ts` — Session cookie verification (no role checks here)
**Testing:**
- `lib/services/analyzer/*.test.ts` — Unit tests for analyzer (preprocessor, pipeline, link-discovery, itglue-search, itglue-redact)
- `lib/services/rmm/executor.test.ts` — RMM executor tests (rate limiting, script validation)
- `lib/services/b2/client.test.ts` — B2 client tests
**UI & Components:**
- `components/ui/` — shadcn primitives (50+ files)
- `components/navigation/app-navigation.tsx` — Top bar + PageHeader
- `components/navigation/page-header.tsx` — Title + breadcrumbs + action slot
- `components/admin/DataTable.tsx` — Paginated/sortable table (@tanstack/react-table)
- `components/admin/DetailModal.tsx` — Ticket detail tabs
**Types & Schemas:**
- `lib/types/autotask.ts` — Autotask API shapes (ticket, company, contact, etc.)
- `lib/types/analyzer.ts` — Analysis, stage execution, aggregate report shapes
- `lib/types/database.ts` — DB row types (all tables)
- `lib/types/sync.ts` — Sync schedule + progress shapes
**Status Registry (Shared State):**
- `lib/status-registry.ts` — Priority/status color maps, state labels
- Used by: DetailModal, StatusBadge, all pages that display ticket status/priority
## Naming Conventions
**Files:**
- kebab-case: `auth-utils.ts`, `sync-scheduler.ts`, `app-navigation.tsx`
- Pattern: service/factory files end in `-service.ts`, `-factory.ts`, `-client.ts`
- Example: `autotask-client.ts`, `datto-rmm-sync-service.ts`, `msgraph-factory.ts`
**Directories:**
- kebab-case: `api/`, `lib/`, `services/`, `rmm/`, `admin/`
- Resource-based: `app/api/analyzer/`, `app/api/admin/`, `app/analyzer/`
- Subdomain grouping: `lib/services/analyzer/` (pipeline stages + persistence), `lib/services/rmm/` (executor + scripts + worker)
**Components:**
- PascalCase exports from kebab-case files: `analyze-button.tsx` exports `<AnalyzeButton />`
- Wrapper types: `*Modal`, `*Panel`, `*Dialog`, `*Card` (e.g., `DetailModal`, `related-tickets-panel`)
**Functions & Variables:**
- camelCase: `requireAuth()`, `postgresClient.query()`, `getAutotaskClient()`
- Factories: `get<Name>Client()`, `is<Name>Configured()` (e.g., `getDattoRmmClient()`, `isAutotaskConfigured()`)
- Hooks: `useSearchParams()`, `useEffect()`, `useState()`
**Database:**
- snake_case columns: `ticket_id`, `company_id`, `created_at`, `updated_at`, `is_deleted`
- Audit columns: `created_at`, `updated_at`, `synced_at`, `is_deleted`, `deleted_at`
- Tables named for entity plurals: `tickets`, `companies`, `resources`, `contacts`, `analyst_analyses`
**API Responses:**
- camelCase keys (transformed from DB snake_case in route handlers)
- Example: `{ ticketId, companyId, createdAt, updatedAt, isDeleted }`
- Transformation happens in route handler, not ORM-based
## Where to Add New Code
**New Feature (e.g., new integration):**
- Primary code: `lib/services/<integration>-client.ts` + `lib/services/<integration>-factory.ts` + `lib/services/<integration>-sync-service.ts`
- Types: `lib/types/<integration>.ts`
- API routes: `app/api/<resource>/route.ts`
- Pages: `app/<resource>/page.tsx`
- Components: `components/<resource>/*.tsx`
- Tests: `lib/services/<integration>/*.test.ts` (if logic is testable)
**New Component/Module:**
- Implementation: `components/<feature>/<kebab-case-name>.tsx` (or `components/ui/` if it's a primitive)
- Usage: Import via `@/components/<feature>/<kebab-case-name>`
**New Page:**
- File: `app/<route>/page.tsx`
- Layout: Use `PageHeader` + `container mx-auto px-6 py-6` (standard padding)
- Components: Import shared components from `components/`
**Utilities:**
- Shared helpers: `lib/utils/<name>.ts` (e.g., `lib/utils/env.ts` for type-safe env access)
- Service-local helpers: Inline in `lib/services/<name>.ts` if not reused
**Migrations:**
- File: `migrations/NNN_*.sql` (next number in sequence)
- Pattern: `IF NOT EXISTS` for idempotence, `ON CONFLICT DO NOTHING` for seed data
- Audit columns: Include `created_at`, `updated_at`, `is_deleted`, `deleted_at` where applicable
- Important: Never edit a committed migration; create a new one instead
**Tests:**
- Location: `__tests__/` sibling to source file or `.test.ts` / `.spec.ts` suffix
- Framework: `vitest` (import from `vitest`, not `jest`)
- Patterns: Unit tests for analyzer, RMM, B2; other areas are type-checked only
## Special Directories
**`.planning/`:**
- Purpose: Generated GSD (Goal, Scope, Definition) planning documents
- Generated: By `/gsd-map-codebase` with focus areas (tech, arch, quality, concerns)
- Committed: Yes (consumed by `/gsd-plan-phase` and `/gsd-execute-phase`)
- Contains: STACK.md, INTEGRATIONS.md, ARCHITECTURE.md, STRUCTURE.md, CONVENTIONS.md, TESTING.md, CONCERNS.md
**`.env`:**
- Purpose: Environment variables (API keys, secrets, connection strings)
- Committed: Yes (treat values as potentially real production secrets)
- Secrets: DATABASE_URL, REDIS_URL, AUTOTASK_API_URL, BETTER_AUTH_SECRET, LLM keys, etc.
- Important: Never echo or log `.env` contents; don't add `.env.local` to version control
**`node_modules/`, `.next/`, `.git/`:**
- Purpose: Generated directories
- Committed: No (git-ignored)
- Cleaned: `rm -rf node_modules && npm install`, `npm run build && rm -rf .next`
**`public/`:**
- Purpose: Static web assets
- Served: At `/path` (e.g., `/favicon.png`)
- Committed: Yes
**`docs/`:**
- Purpose: Long-form guides
- Committed: Yes
- Pattern: One file per major system (Autotask, Postgres, Analyzer, RMM, etc.)
- Updated: As integration behavior changes; link from CLAUDE.md, don't duplicate in inline comments
---
*Structure analysis: 2026-05-03*

View file

@ -0,0 +1,438 @@
# Testing Patterns
**Analysis Date:** 2026-05-03
## Test Framework
**Runner:**
- Vitest 4.1.5
- Config: `vitest.config.ts` at root
- Node environment (not DOM)
**Assertion Library:**
- Vitest built-in `expect()` — no separate library
**Run Commands:**
```bash
npm test # Run all tests once (vitest run)
npm run test:watch # Watch mode (vitest)
npx tsc --noEmit --pretty # Type check (required, only safety net for most code)
npm run build # Build check (turbopack)
```
## Test File Organization
**Location:**
- Co-located with source files in `lib/services/`
- Pattern: `service-name.test.ts` in same directory as `service-name.ts`
- Tests in `lib/**/*.test.ts` only (configured in `vitest.config.ts`)
**Coverage:**
- **Fully tested:** `lib/services/analyzer/**/*.test.ts`, `lib/services/rmm/**/*.test.ts`, `lib/services/b2/**/*.test.ts`
- **Partially tested:** `lib/services/analyzer/link-discovery.test.ts` (link discovery logic)
- **Not tested:** Most of `app/api/`, all pages, forms, UI components, sync services, entity sync, webhooks
**Important:** Most of the codebase has no tests — type-check is the only safety net.
## Test Structure
**Suite Organization:**
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('FEATURE_NAME', () => {
beforeEach(() => {
// Setup per test
});
afterEach(() => {
// Cleanup per test
});
it('should do something', () => {
expect(result).toBe(expected);
});
it('should handle edge case', async () => {
const r = await someAsyncFunction();
expect(r.done).toBe(true);
});
});
```
**Patterns from actual tests:**
*Test with mock setup* (from `lib/services/analyzer/link-discovery.test.ts`):
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { discoverExplicitLinks } from './link-discovery';
vi.mock('@/lib/services/postgres-client', () => ({
default: {
query: vi.fn(),
},
}));
import postgresClient from '@/lib/services/postgres-client';
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
describe('discoverExplicitLinks', () => {
beforeEach(() => {
mockedQuery.mockReset();
});
it('skips self-references', async () => {
mockedQuery.mockResolvedValueOnce({
rowCount: 1,
rows: [{ ticket_number: 'T20260428.0053', ... }],
});
const r = await discoverExplicitLinks(bundle);
expect(r.explicit).toHaveLength(1);
});
});
```
*Test with utility fixture helper* (from `lib/services/analyzer/link-discovery.test.ts`):
```typescript
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
return {
ticket: {
id: 1,
ticket_number: 'T20260430.0084',
title: 'Master problem ticket — Hynes',
// ... default fields
...partial,
},
notes: [],
time_entries: [],
};
}
it('parses refs from description', async () => {
const b = bundle({ description: 'See T20260427.0142' });
// ... test logic
});
```
## Mocking
**Framework:** Vitest's `vi` object
**Patterns:**
*Mock entire module:*
```typescript
vi.mock('@/lib/services/postgres-client', () => ({
default: {
query: vi.fn(),
},
}));
import postgresClient from '@/lib/services/postgres-client';
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
```
*Reset mocks between tests:*
```typescript
beforeEach(() => {
mockedQuery.mockReset();
// or vi.restoreAllMocks() for all mocks
});
afterEach(() => {
vi.restoreAllMocks();
});
```
*Mock implementation:*
```typescript
mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => {
const numbers = params[0] as string[];
return {
rowCount: numbers.length,
rows: numbers.map((n) => ({
ticket_number: n,
title: 't',
status_label: 'Open',
})),
};
});
```
*Mock resolved value (for async):*
```typescript
mockedQuery.mockResolvedValueOnce({
rowCount: 1,
rows: [{ ticket_number: 'T20260428.0053', title: 'Issue', ... }],
});
```
*Spy on function:*
```typescript
let findExistingSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
findExistingSpy = vi
.spyOn(persistence, 'findExistingAnalysisByContentHash')
.mockResolvedValue(null);
});
afterEach(() => {
findExistingSpy.mockRestore();
});
```
*Stub globals:*
```typescript
const realDate = Date;
beforeEach(() => {
const fixed = new Date('2026-05-02T20:00:00.000Z');
vi.stubGlobal(
'Date',
class extends realDate {
constructor(...args: unknown[]) {
if (args.length === 0) {
super(fixed.getTime());
} else {
super(...(args as [any]));
}
}
static now() {
return fixed.getTime();
}
} as unknown as DateConstructor
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
```
## What to Mock
**DO mock:**
- Database queries (postgres-client)
- External API clients (Autotask, IT Glue, etc.)
- File I/O
- Time-dependent operations (Date)
- Long-running operations
**DO NOT mock:**
- Regular functions being tested
- Utility functions (regex helpers, string transformers)
- Type definitions
## Fixtures and Factories
**Test Data Creation:**
Use helper functions to build test fixtures:
```typescript
// From link-discovery.test.ts
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
return {
ticket: {
id: 1,
ticket_number: 'T20260430.0084',
title: 'Master problem ticket — Hynes',
description: null,
status: 1,
status_label: 'New',
// ... 30+ default fields
...partial, // Override with test-specific values
},
notes: [],
time_entries: [],
};
}
// Usage in test
it('flags master-problem-ticket title', () => {
const r = detectProblemTicket(
bundle({ title: 'Master problem ticket — recurring degradation' }),
false
);
expect(r.isProblemTicket).toBe(true);
});
```
**JSON Fixtures:**
- Load from files for large datasets: `readFileSync(resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'), 'utf8')`
- Example: `/opt/stacks/pulse/lib/services/analyzer/fixtures/`
**Location:** Test fixtures live alongside test files in same directory
## Coverage
**Requirements:** None enforced (no CI, local-only testing)
**View Coverage:** Not configured
**Note:** Tests exist for:
- `lib/services/analyzer/` — 9 test files covering pipeline stages, link discovery, redaction, preprocessing
- `lib/services/rmm/` — 3 test files (worker, target-resolver, registry scripts)
- `lib/services/b2/` — 1 test file (presign URLs, crypto)
- `lib/services/llm/` — 2 test files (LLM calls, pricing)
Untested areas: All API routes, all pages, forms, UI components, sync services, webhooks
## Test Types
**Unit Tests:**
- Test individual functions in isolation
- Mock external dependencies
- Examples: `extractExplicitFromText()`, `OBJECT_KEY_REGEX`, `presignDownload()`
**Integration Tests:**
- Not separated from unit tests
- Some tests validate full workflow (e.g., `discoverExplicitLinks` querying mock DB)
**E2E Tests:**
- Not present in codebase
## Common Patterns
**Async Testing:**
```typescript
it('resolves problem_ticket_id', async () => {
mockedQuery.mockResolvedValueOnce({
rowCount: 1,
rows: [{ ticket_number: 'T20260427.0142' }],
});
const r = await discoverExplicitLinks(bundle);
expect(r.explicit[0].ticket_number).toBe('T20260427.0142');
});
```
**Error Testing:**
```typescript
it('throws on invalid object key', () => {
expect(() =>
presignDownload('../etc/eventlogs_1.json.gz', 600, config)
).toThrow(B2InvalidObjectKeyError);
});
```
**Regex Testing:**
```typescript
describe('TICKET_NUMBER_REGEX', () => {
it('matches canonical format', () => {
const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX);
expect(m).toEqual(['T20260430.0084', 'T20260427.0142']);
});
it('does not match invalid lengths', () => {
expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull();
});
});
```
**Sequential Mock Queuing** (for LLM stages):
```typescript
interface Reply {
text: string;
usage?: Partial<Anthropic.Usage>;
}
function makeFakeAnthropic(queue: Reply[]): { fake: Anthropic; bodies: any[] } {
const bodies: any[] = [];
let i = 0;
const create = vi.fn(async (body: any) => {
bodies.push(body);
const next = queue[i++];
if (!next) throw new Error('No more queued LLM replies');
return {
id: `msg_${i}`,
content: [{ type: 'text', text: next.text }],
usage: { input_tokens: 5000, output_tokens: 500, ... },
} as Anthropic.Message;
});
return { fake: { messages: { create } } as unknown as Anthropic, bodies };
}
// Usage
const { fake: anthropic, bodies } = makeFakeAnthropic([
{ text: validTriage() },
{ text: validSonnet() },
{ text: validOpus(), usage: { ... } },
]);
```
## Accessing Internals for Testing
**Pattern:** Modules export `_INTERNALS` object with functions/constants not otherwise exported:
```typescript
// In source: lib/services/b2/client.ts
export const _B2_INTERNALS = {
deriveSigningKey,
};
// In test: lib/services/b2/client.test.ts
import { _B2_INTERNALS } from './client';
describe('deriveSigningKey', () => {
it('produces a 32-byte HMAC-SHA256 chain', () => {
const k = _B2_INTERNALS.deriveSigningKey('sec-fixture', '20260502', 'us-west-002', 's3');
expect(k.length).toBe(32);
});
});
```
Also:
```typescript
// lib/services/analyzer/worker.ts
export const _RMM_WORKER_INTERNALS = {
extractResult,
};
// lib/services/analyzer/worker.test.ts
import { _RMM_WORKER_INTERNALS } from './worker';
describe('extractResult', () => {
const { extractResult } = _RMM_WORKER_INTERNALS;
it('returns done=false while jobStatus is running', () => {
const r = extractResult({ jobStatus: 'running', stdOut: null }, 'dev-1');
expect(r.done).toBe(false);
});
});
```
## Test Coverage Gaps
**Untested areas (HIGH RISK):**
| Component | Reason | Impact |
|-----------|--------|--------|
| `app/api/` all routes | No tests configured | New bugs undetected until runtime |
| `app/` all pages | No tests | UI regressions undetected |
| `components/` all | No tests | UI logic errors undetected |
| `lib/services/entity-sync.ts` | No tests | Sync failures undetected; blocks on type-check |
| `lib/services/sync-scheduler.ts` | No tests | Schedule logic errors undetected |
| `lib/services/webhook-service.ts` | No tests | HMAC verification, webhook processing untested |
| `lib/auth.ts`, `lib/auth-utils.ts` | No tests | Auth failures undetected until login attempt |
| `lib/permissions.ts` | No tests | Permission checks untested |
**Partially tested areas:**
- `lib/services/analyzer/` — pipeline stages tested, worker tested, but integration edge cases may be missed
- `lib/services/llm/` — pricing and call patterns tested, but provider-specific behavior not fully covered
## Running Tests Locally
```bash
# All tests once
npm test
# Watch mode (rerun on file change)
npm run test:watch
# Type check (required before commit)
npx tsc --noEmit --pretty
# Build (catches more errors)
npm run build
```
---
*Testing analysis: 2026-05-03*

38
.planning/config.json Normal file
View file

@ -0,0 +1,38 @@
{
"model_profile": "balanced",
"commit_docs": true,
"parallelization": true,
"search_gitignored": false,
"brave_search": false,
"firecrawl": false,
"exa_search": false,
"git": {
"branching_strategy": "none",
"phase_branch_template": "gsd/phase-{phase}-{slug}",
"milestone_branch_template": "gsd/{milestone}-{slug}",
"quick_branch_template": null
},
"workflow": {
"research": false,
"plan_check": true,
"verifier": true,
"nyquist_validation": true,
"auto_advance": false,
"node_repair": true,
"node_repair_budget": 2,
"ui_phase": true,
"ui_safety_gate": true,
"text_mode": false,
"research_before_questions": false,
"discuss_mode": "discuss",
"skip_discuss": false
},
"hooks": {
"context_warnings": true
},
"project_code": null,
"phase_naming": "sequential",
"agent_skills": {},
"mode": "yolo",
"granularity": "standard"
}

View file

@ -0,0 +1,142 @@
---
phase: 01-pwa-scaffolding
plan: 01
subsystem: pwa-shell
tags: [pwa, manifest, viewport, mobile]
requires:
- app/layout.tsx (existing root layout with metadata export)
- public/wulff-logo.png, public/favicon.png, public/branding/wulf-mark.png (existing icon assets)
provides:
- public/manifest.json (Web App Manifest at /manifest.json)
- app/layout.tsx exports `viewport: Viewport` with viewportFit: "cover"
- app/layout.tsx exports `metadata.manifest = "/manifest.json"` (Next.js emits <link rel="manifest"> automatically)
affects:
- Phase 02 mobile shell (can rely on viewport-fit=cover for safe-area insets)
- All routes (root layout viewport applies app-wide)
tech-stack:
added: []
patterns:
- Next.js 16 separate `viewport` export (replaces deprecated metadata.viewport)
- Next.js 16 metadata.manifest field (auto-emits <link rel="manifest">)
key-files:
created:
- public/manifest.json
modified:
- app/layout.tsx
decisions:
- theme_color #0075AD chosen as Wulf primary brand blue (sourced from app/styles/brand.css line 28, --wulf-blue) — gives consistent system UI tint in light and dark mode since manifest only allows one value
- background_color #FFFFFF chosen as the light shell background — manifest only allows one splash background, white matches Pulse's default light theme and is acceptable on dark devices (brief flash, not a regression)
- Used metadata.manifest field over hand-rolled <link rel="manifest"> — Next.js 16 emits the link tag automatically, satisfies spec wording, and keeps with the existing metadata API pattern
- Reused existing icon assets with `"sizes": "any"` (wulff-logo.png, branding/wulf-mark.png, favicon.png) instead of generating sized 192/512 variants — install tools accept this for PNGs; sized icons can be added in a future polish phase if install warns
- Added themeColor light/dark pair in viewport (one-line improvement) — paired with Next.js helper, emits per-scheme <meta name="theme-color"> tags. Optional per the plan; kept since it costs nothing and improves dark-mode rendering
- orientation set to "portrait" — phone-first per spec §1/§2; tablet landscape is explicit out-of-scope per spec §7
- scope set to "/" — allow standalone window to navigate anywhere in the app without falling out to browser
metrics:
duration: ~1m
tasks_completed: 2
files_created: 1
files_modified: 1
completed: 2026-05-03T17:38:55Z
---
# Phase 01 Plan 01: PWA Scaffolding Summary
PWA install surface added: a Web App Manifest at `/manifest.json` plus a Next.js 16 viewport export with `viewport-fit=cover` so the mobile shell can paint behind the device home indicator in future phases.
## What Shipped
### Task 1: `public/manifest.json` (NEW)
Hand-written 31-line JSON manifest with all spec-mandated fields:
| Field | Value | Why |
|-------|-------|-----|
| `name`, `short_name` | "Pulse" | Spec §4 verbatim |
| `description` | Wulf operations console blurb | Install dialog readability |
| `start_url` | `/mobile` | Spec §4 — phone install lands on mobile shell, not desktop dashboard |
| `scope` | `/` | Allow standalone window to navigate the whole app |
| `display` | `standalone` | Spec §4 — chromeless app surface |
| `orientation` | `portrait` | Phone-first (spec §1, §2); tablet landscape is OOS (§7) |
| `theme_color` | `#0075AD` | Wulf primary blue from `app/styles/brand.css` line 28 |
| `background_color` | `#FFFFFF` | Light shell background (manifest allows only one) |
| `icons` | 3 entries with `sizes: "any"` | Reuses `/wulff-logo.png`, `/branding/wulf-mark.png`, `/favicon.png` |
No `serviceworker`, no `display_override`, no `prefer_related_applications`, no `next-pwa` — per spec §4 and CLAUDE.md.
**Commit:** `3e3df24`
### Task 2: `app/layout.tsx` (MODIFIED)
Three minimal additions to the existing root layout, body unchanged:
1. Import upgraded: `import type { Metadata, Viewport } from "next";`
2. `metadata.manifest = "/manifest.json"` added alongside the existing `icons` field — Next.js 16 emits `<link rel="manifest" href="/manifest.json" />` in the rendered HTML head automatically (satisfies PWA-02 spec wording).
3. New `viewport` export:
```ts
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
],
};
```
`viewportFit: "cover"` is the load-bearing field for PWA-03 — Next.js renders `viewport-fit=cover` in the `<meta name="viewport">` tag so future phases can use safe-area-inset utilities to paint behind the home indicator. `width`, `initialScale`, and `themeColor` are baseline mobile defaults that prevent Next.js viewport warnings.
**Commit:** `d196d22`
## Verification Results
| Gate | Result |
|------|--------|
| `test -f public/manifest.json` | PASS |
| `jq -e '.name == "Pulse" and .display == "standalone" and .start_url == "/mobile"' public/manifest.json` | PASS (true) |
| `jq -e '.theme_color == "#0075AD" and .background_color == "#FFFFFF"' public/manifest.json` | PASS |
| `jq -e '.icons \| length >= 1' public/manifest.json` | PASS (3 icons) |
| `jq -e '.serviceworker == null' public/manifest.json` | PASS |
| `jq empty public/manifest.json` | PASS (valid JSON) |
| `grep -E '^import type \{ Metadata, Viewport \} from "next"' app/layout.tsx` | PASS |
| `grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsx` | PASS |
| `grep -E '^export const viewport: Viewport = \{' app/layout.tsx` | PASS |
| `grep -E 'viewportFit:\s*"cover"' app/layout.tsx` | PASS |
| `grep -E 'width:\s*"device-width"' app/layout.tsx` | PASS |
| `grep -E 'initialScale:\s*1' app/layout.tsx` | PASS |
| `grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsx` (icons preserved) | PASS |
| `grep -E 'export default function RootLayout' app/layout.tsx` (body intact) | PASS |
| `! grep -E "^'use client'" app/layout.tsx` | PASS |
| `npx tsc --noEmit --pretty` | exit 0 |
| `test ! -f public/sw.js && test ! -f public/service-worker.js` | PASS |
| `! grep '"next-pwa"' package.json` | PASS |
**Dev-server-only checks** (`curl http://localhost:3100/manifest.json`, `curl http://localhost:3100/ \| grep viewport-fit=cover`) were not run — this executor runs in a worktree without a dev server. The offline equivalents above are equivalent: the file is a static asset served verbatim by Next.js from `public/`, and `viewportFit: "cover"` is type-checked to render `viewport-fit=cover` per Next.js 16's documented metadata API.
## Requirements Satisfied
- **PWA-01:** `public/manifest.json` exists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme_color "#0075AD", background_color "#FFFFFF", and 3 icons.
- **PWA-02:** `app/layout.tsx` references the manifest via `metadata.manifest = "/manifest.json"` — Next.js 16 emits the `<link rel="manifest">` tag automatically.
- **PWA-03:** `app/layout.tsx` exports `viewport: Viewport` with `viewportFit: "cover"` — Next.js renders `viewport-fit=cover` in the `<meta name="viewport">` tag, unblocking safe-area painting in Phase 2.
## Deviations from Plan
None - plan executed exactly as written.
No bugs encountered, no missing critical functionality, no blocking issues, no architectural decisions needed.
## Threat Surface Scan
No new threat surface introduced beyond the plan's `<threat_model>`. The manifest is world-readable per W3C Web App Manifest spec and contains only public branding (no secrets, no user data, no endpoints). The viewport export is server-rendered with no user input flow. ASVS-L1 baseline preserved.
## Known Stubs
None. All values are real (brand colors sourced from `app/styles/brand.css`, icons reference real public assets, start_url matches the existing `/mobile` route).
## Self-Check: PASSED
- `[ -f public/manifest.json ]` → FOUND
- `[ -f app/layout.tsx ]` → FOUND
- `git log --oneline | grep 3e3df24` → FOUND (Task 1 commit)
- `git log --oneline | grep d196d22` → FOUND (Task 2 commit)

View file

@ -0,0 +1,174 @@
---
phase: 01-pwa-scaffolding
plan: 02
subsystem: pwa-scaffolding
gap_closure: true
tags: [css, tailwind4, mobile, pwa, safe-area]
requirements_satisfied: [PWA-04]
roadmap_criteria_satisfied: ["Phase 1 SC #3 — safe-area utility available"]
dependency_graph:
requires: []
provides:
- "@utility pt-safe (padding-top: env(safe-area-inset-top))"
- "@utility pb-safe (padding-bottom: env(safe-area-inset-bottom))"
affects:
- "Phase 2 (mobile shell) — sticky header (SHELL-05) and fixed bottom nav (SHELL-06) consume these utilities"
tech_stack:
added: []
patterns:
- "Tailwind 4 @utility blocks (already in use across brand.css)"
- "CSS env(safe-area-inset-*) — browser-native, falls back to 0"
key_files:
created: []
modified:
- app/styles/brand.css
decisions:
- "Named utilities (pt-safe / pb-safe) over arbitrary values (pt-[env(safe-area-inset-top)]) — single source of truth, clearer JSX, easy future tweak if iOS rules change"
- "brand.css over globals.css — co-located with all other named project utilities (num, metric-label, surface-brand, tagline, etc.); already imported by globals.css line 125"
- "Top + bottom only (no pl-safe / pr-safe) — manifest pins orientation to portrait; left/right insets only matter in landscape on notched devices; speculative until a consumer asks"
- "Plain env() (not max(env(), 0px)) — env() already returns 0 on devices without insets; max() wrapper is a no-op"
metrics:
duration: "~5 min"
completed: 2026-05-03
tasks_completed: 1
files_modified: 1
commits: 1
---
# Phase 01 Plan 02: PWA-04 Safe-Area Utility Gap Closure Summary
**One-liner:** Adds shared `pt-safe` / `pb-safe` Tailwind 4 `@utility` blocks to `app/styles/brand.css`, closing the orphaned PWA-04 requirement so Phase 2's sticky header and fixed bottom nav can opt into iOS notch / Android home-indicator padding via `env(safe-area-inset-*)`.
## Requirements Satisfied
- **PWA-04** — Header and bottom tab bar respect `env(safe-area-inset-top/bottom)` (Tailwind arbitrary values or shared utility class). **Closed** by shipping `@utility pt-safe` and `@utility pb-safe` in `app/styles/brand.css`. This restores the orphaned-requirement state flagged by `01-VERIFICATION.md` (where 01-01 had declared `requirements: [PWA-01, PWA-02, PWA-03]` only and silently deferred PWA-04 to Phase 2).
- **ROADMAP Phase 1 Success Criterion #3** — "Shared safe-area utility class available" — satisfied by the same two `@utility` blocks.
## What Changed
### Files Modified
- `app/styles/brand.css` — appended one section comment block + two `@utility` definitions between the existing `@utility tagline` (ends line 140) and the `/* === Wolf-mark watermark === */` section header (now line 165). Net: **+23 lines, 0 deletions.**
### Exact Diff (additive only)
```css
/* === Safe-area insets =================================================
*
* Opt-in padding helpers for sticky top / fixed bottom bars on devices
* with notches, dynamic islands, or gesture home indicators. Pair with
* the viewport-fit=cover viewport meta (set in app/layout.tsx) — without
* that, env(safe-area-inset-*) resolves to 0 and these utilities are
* no-ops, which is the desired fallback on non-PWA / non-mobile contexts.
*
* Usage:
* <header class="sticky top-0 pt-safe ..."> // header clears notch
* <nav class="fixed bottom-0 pb-safe ..."> // bottom bar clears home bar
*
* Closes PWA-04 (REQUIREMENTS.md) and ROADMAP Phase 1 SC #3.
* ==================================================================== */
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
```
### What Was NOT Changed
- `app/globals.css` — untouched. The existing `@import "./styles/brand.css";` on line 125 already pulls the new utilities into the global stylesheet.
- All pre-existing `@utility` blocks in `brand.css` (`num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`, `has-mark-watermark`) and the `.mark-watermark` plain rule — preserved verbatim.
- The `:root` / `.dark` Wulf brand token sections — preserved verbatim.
- No `tailwind.config.*` was created (Tailwind 4 + project convention forbids it).
- No `next-pwa`, no service worker, no new dependencies introduced.
## Why These Choices
### `brand.css`, not `globals.css`
All named project utilities (`num`, `metric-label`, `surface-brand`, `tagline`, etc.) already live in `brand.css`. Co-locating safe-area utilities there means Phase 2 has one file to scan when looking for project helpers. `globals.css` is reserved for Tailwind imports, `@theme inline` token mapping, and `:root` / `.dark` variable definitions — adding utility classes there would muddy that separation. Plus `brand.css` is already imported by `globals.css` (line 125), so no new wiring is required.
### Named utilities, not arbitrary values
Phase 2 will use these classes in 2+ places (sticky header, bottom nav, drawer footer, possibly modals). A named utility is a single source of truth — if iOS rules ever change (e.g., `max(env(safe-area-inset-top), 0.5rem)` becomes desirable), it's a one-line edit to `brand.css` instead of multi-file find-and-replace. `pt-safe` / `pb-safe` also reads more clearly in JSX class lists than `pt-[env(safe-area-inset-top)]`. ROADMAP Phase 1 SC #3 explicitly accepts "shared utility class" as one valid form — picking that form removes ambiguity for Phase 2.
### Top + bottom only
The manifest pins orientation to `portrait` (per `01-01-SUMMARY.md`). Left/right safe-area insets (`safe-area-inset-left`, `safe-area-inset-right`) only matter in landscape on notched devices, which the app does not enter. Adding `pl-safe` / `pr-safe` now would be speculative; Phase 2 (or any future phase) can add them in 30 seconds if a real consumer appears.
### Plain `env(safe-area-inset-*)`, not `max(env(...), 0px)`
The CSS `env()` value already returns `0` when no inset is reported by the browser — wrapping it in `max(..., 0)` is a no-op and adds noise. Wrap it later if a real device misbehaves.
## Verification Results
All checks from the plan's `<verification>` section ran successfully:
| Check | Command | Result |
|-------|---------|--------|
| `pt-safe` utility present | `grep -E '@utility pt-safe' app/styles/brand.css` | match (1 line) |
| `pb-safe` utility present | `grep -E '@utility pb-safe' app/styles/brand.css` | match (1 line) |
| Top inset declaration correct | `grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css` | match (1 line) |
| Bottom inset declaration correct | `grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css` | match (1 line) |
| `tagline` utility unchanged | `grep -E '@utility tagline' app/styles/brand.css` | match |
| `num` utility unchanged | `grep -E '@utility num \{' app/styles/brand.css` | match |
| Wulf brand tokens intact | `grep -E '\-\-wulf-blue:' app/styles/brand.css` | match |
| `.mark-watermark` intact | `grep -E '\.mark-watermark \{' app/styles/brand.css` | match |
| globals.css import wiring intact | `grep -E '@import "\./styles/brand\.css";' app/globals.css` | match |
| No `pl-safe` / `pr-safe` (out of scope) | `grep -E '@utility (pl-safe\|pr-safe)' app/styles/brand.css` | no match |
| No `tailwind.config.*` created | `test ! -f tailwind.config.{ts,js,mjs}` | exit 0 |
| No service worker shipped | `test ! -f public/{sw,service-worker}.js` | exit 0 |
| `next-pwa` not added | `! grep '"next-pwa"' package.json` | not found |
| Type check baseline preserved | `npx tsc --noEmit --pretty` | exit 0 (clean) |
| **Production build (load-bearing — Tailwind 4 fails on malformed `@utility` syntax)** | `npm run build` | **`✓ Compiled successfully in 20.3s`** |
### Compiled CSS Confirmation
Inspected the production CSS chunk emitted by the build:
```
$ grep -oE '\.pt-safe[^,{]*\{[^}]*\}|\.pb-safe[^,{]*\{[^}]*\}' .next/static/chunks/3c3ee60b60fe53db.css
.pt-safe{padding-top:env(safe-area-inset-top)}
.pb-safe{padding-bottom:env(safe-area-inset-bottom)}
```
Both utilities compiled cleanly into the production bundle and are ready for Phase 2 to consume.
### Notes on Build-Time Runtime Errors (Pre-Existing, Out of Scope)
`npm run build` also surfaces unrelated runtime errors during static page generation: the auto-starting workers (sync-scheduler, analyzer worker, RMM worker) attempt to initialize against Postgres and Better Auth at import time, but no `.env` is present in this worktree, so they log SASL connection errors and a `BETTER_AUTH_SECRET` warning. **These are pre-existing and unrelated to this plan** — they appeared identically in the 01-01 build environment. The compile step (the load-bearing gate for this plan, which validates Tailwind 4 `@utility` syntax) reported `✓ Compiled successfully` and emitted the new classes into the CSS bundle. Logged to deferred-items if needed; not in scope for PWA-04.
## Pointer to Phase 2
The new utilities are ready for `app/mobile/layout.tsx`:
- **Sticky header** (SHELL-05): `<header class="sticky top-0 pt-safe ...">` — clears the iPhone notch / Dynamic Island and Android status bar.
- **Fixed bottom nav** (SHELL-06): `<nav class="fixed bottom-0 pb-safe ...">` — clears the iOS home indicator and Android gesture bar.
No Phase 2 work is required to wire these in — they're already part of the global Tailwind class space the moment Phase 2's components mount.
## Deviations from Plan
None — plan executed exactly as written. Single-task plan, single edit, single commit.
## Threat Surface Scan
No new threat surface. CSS utilities are public client-side styles compiled into the (already-public) Tailwind CSS bundle. `env(safe-area-inset-*)` is a browser-native CSS environment variable resolved entirely client-side from the device viewport — no JavaScript, no user input, no data flow, no auth surface, no new endpoint. STRIDE assessment from the plan stands: only boundary is "browser ↔ static CSS bundle" (information-disclosure → accept; same risk profile as every other Tailwind class). ASVS-L1 baseline preserved.
## Commits
| Task | Commit | Files |
|------|--------|-------|
| 1: Append `pt-safe` / `pb-safe` `@utility` blocks | `dff0264` | `app/styles/brand.css` |
## Self-Check: PASSED
- File modified exists and contains both new utilities — confirmed via grep.
- Commit `dff0264` exists in current branch — confirmed via `git log`.
- Compiled CSS bundle in `.next/static/chunks/` contains `.pt-safe` and `.pb-safe` rules — confirmed via grep.
- `npm run build` exited successfully (`✓ Compiled successfully in 20.3s`).
- `npx tsc --noEmit --pretty` exited 0.
- PWA-04 explicitly claimed in this SUMMARY's `requirements_satisfied` frontmatter and "Requirements Satisfied" section — orphaned-requirement trail closed.

View file

@ -0,0 +1,36 @@
---
status: partial
phase: 01-pwa-scaffolding
source: [01-VERIFICATION.md]
started: 2026-05-03T00:00:00Z
updated: 2026-05-03T00:00:00Z
---
## Current Test
[awaiting human testing]
## Tests
### 1. iPhone Add-to-Home-Screen install + chromeless launch
expected: Tapping the installed icon opens Pulse with no Safari chrome (no URL bar, no tabs); landing route is `/mobile`; status bar tints match theme_color `#0075AD` on iOS; background flashes `#FFFFFF` then renders the mobile shell. Required by ROADMAP Phase 1 SC #4.
result: [pending]
### 2. Android Chrome PWA install + standalone launch
expected: Chrome shows an install prompt (or "Add to Home Screen" from menu); the installed icon uses one of the configured PNGs; launching opens a standalone window with no browser chrome; URL bar is hidden; navigating beyond `/mobile` (scope=`/`) stays in-app. Required by ROADMAP Phase 1 SC #4 and PWA-01.
result: [pending]
### 3. Live dev-server smoke test (optional)
expected: With `npm run dev` running, view source on `http://localhost:3100/` shows `<meta name="viewport" content="...viewport-fit=cover...">` and `<link rel="manifest" href="/manifest.json">` in the head. Optional but quick.
result: [pending]
## Summary
total: 3
passed: 0
issues: 0
pending: 3
skipped: 0
blocked: 0
## Gaps

View file

@ -0,0 +1,152 @@
---
phase: 01-pwa-scaffolding
verified: 2026-05-03T18:05:00Z
status: human_needed
score: 4/4 must-haves verified
re_verification:
previous_status: gaps_found
previous_score: 3/4
gaps_closed:
- "A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into env(safe-area-inset-top) / env(safe-area-inset-bottom) padding"
gaps_remaining: []
regressions: []
human_verification:
- test: "Install Pulse on a real iPhone and confirm Add-to-Home-Screen launches a chromeless app at /mobile"
expected: "Tapping the installed icon opens Pulse with no Safari chrome (no URL bar, no tabs); landing route is /mobile; status bar tints match theme_color #0075AD on iOS, background flashes #FFFFFF then renders the mobile shell"
why_human: "Real PWA install behavior (chromeless launch, system UI tint, splash background) cannot be verified programmatically — Chrome DevTools 'Add to Home Screen' simulation is approximate but only a real device exercises Safari's manifest pickup, status bar colorization, and splash rendering. Required by ROADMAP Phase 1 SC #4."
- test: "Install Pulse on a real Android device (Chrome) and confirm the install banner appears, the icon shows on the home screen, and tapping it lands on /mobile in standalone mode"
expected: "Chrome shows an install prompt (or 'Add to Home Screen' from menu); the installed icon uses one of the configured PNGs; launching opens a standalone window with no browser chrome; URL bar is hidden; navigating beyond /mobile (scope='/') stays in-app"
why_human: "Same as above — requires a real device (Chromium PWA install heuristics depend on visit count, manifest validation, and platform). Required by ROADMAP Phase 1 SC #4 and PWA-01 wording."
- test: "Confirm `viewport-fit=cover`, `<link rel=\"manifest\">`, and the new `.pt-safe` / `.pb-safe` rules render in a real browser when the dev server is running"
expected: "View source on http://localhost:3100/ and confirm `<meta name=\"viewport\" content=\"...viewport-fit=cover...\">` is present, plus `<link rel=\"manifest\" href=\"/manifest.json\">`. Inspect the live CSS bundle and confirm `.pt-safe { padding-top: env(safe-area-inset-top) }` and `.pb-safe { padding-bottom: env(safe-area-inset-bottom) }` are emitted (already confirmed in the .next build artifact during this re-verification — repeat against the live dev server)."
why_human: "Dev server was not running during this verification pass; the static checks (file contents, type-check, production build artifact inspection) prove the metadata API and Tailwind 4 @utility blocks compile correctly, but a smoke test against the running app proves the runtime serializes as expected. Optional/routine — can be done by anyone with `npm run dev` access."
---
# Phase 01: PWA Scaffolding Verification Report (Re-verification)
**Phase Goal:** A manager who taps "Add to Home Screen" gets a standalone Pulse icon that opens to the mobile shell with content respecting the device safe areas.
**Verified:** 2026-05-03T18:05:00Z (re-verification after PWA-04 gap closure)
**Status:** human_needed (all programmatic checks pass; awaiting real-device install verification)
**Re-verification:** Yes — after gap closure (plan 01-02 closed PWA-04 by adding `pt-safe` / `pb-safe` `@utility` blocks to `app/styles/brand.css`)
## Re-verification Summary
| Item | Previous (initial verification) | Current (after 01-02) |
|------|-------------------------------|----------------------|
| Status | `gaps_found` | `human_needed` |
| Score | 3/4 | **4/4** |
| Truth #3 (safe-area utility) | ✗ FAILED — no utility, no arbitrary-value usage anywhere in repo | ✓ VERIFIED — `@utility pt-safe` and `@utility pb-safe` defined in `app/styles/brand.css`, compiled into production CSS bundle |
| PWA-04 traceability | ORPHANED — no plan claimed it | SATISFIED — `01-02-PLAN.md` declares `requirements: [PWA-04]`; `01-02-SUMMARY.md` lists it under "Requirements Satisfied" |
| Regressions introduced by 01-02 | n/a | None — Truths 1, 2, 4 unchanged; manifest, layout, icons all intact |
The only outstanding item is the human verification block (real-device install tests + optional live dev-server smoke test). These are not gaps — they are the same items that were routed to humans in the initial verification, plus a small addition asking the human to confirm the new `.pt-safe` / `.pb-safe` rules render at runtime (already confirmed in the production CSS bundle artifact, but a live dev-server check costs nothing).
## Goal Achievement
The phase now delivers the goal in full at the artifact level:
- **Manifest** — correct fields, correct icons, correct start_url
- **Viewport**`viewport-fit=cover` exported via Next.js 16 Viewport API
- **Safe-area utility**`pt-safe` / `pb-safe` `@utility` blocks in `brand.css`, compiled to `.pt-safe { padding-top: env(safe-area-inset-top) }` / `.pb-safe { padding-bottom: env(safe-area-inset-bottom) }` in the production CSS bundle, ready for Phase 2's sticky header (SHELL-05) and fixed bottom nav (SHELL-06) to consume
The remaining "human_needed" status is purely about real-device install behavior (which cannot be programmatically verified) — not about missing code.
### Observable Truths
| # | Truth | Status | Evidence |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Visiting `/manifest.json` returns valid JSON with `name: "Pulse"`, `display: "standalone"`, `start_url: "/mobile"`, and theme/background colors matching the app shells | ✓ VERIFIED | `public/manifest.json` exists; jq validation passes (`name=Pulse`, `short_name=Pulse`, `display=standalone`, `start_url=/mobile`, `theme_color=#0075AD`, `background_color=#FFFFFF`); 3 icons present and the referenced PNG files all exist on disk |
| 2 | The root `app/layout.tsx` references the manifest via `<link rel="manifest">` and the viewport meta includes `viewport-fit=cover` | ✓ VERIFIED | `app/layout.tsx:31` `manifest: "/manifest.json"` in metadata (Next 16 emits the link tag); `app/layout.tsx:42-50` exports `viewport: Viewport` with `viewportFit: "cover"`, `width: device-width`, `initialScale: 1`, paired light/dark themeColor; `tsc --noEmit` exits 0 |
| 3 | A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding | ✓ VERIFIED (was ✗ FAILED) | `app/styles/brand.css:157-163` defines `@utility pt-safe { padding-top: env(safe-area-inset-top); }` and `@utility pb-safe { padding-bottom: env(safe-area-inset-bottom); }`; brand.css is imported by `app/globals.css:125` (unchanged); production CSS bundle (`.next/static/chunks/3c3ee60b60fe53db.css`) contains the compiled rules `.pt-safe{padding-top:env(safe-area-inset-top)}` and `.pb-safe{padding-bottom:env(safe-area-inset-bottom)}`; `npm run build` exits 0 |
| 4 | Installing Pulse to a phone home screen launches a chromeless app pointed at `/mobile` (no service worker, no offline) | ? UNCERTAIN | Manifest fields are correct for this outcome (`display=standalone`, `start_url=/mobile`, no `serviceworker` field, no `next-pwa` dep, no `public/sw.js`). Real install behavior must be verified on a physical device — see Human Verification section. (Unchanged from initial verification.) |
**Score:** **4/4** truths verified (1 routed to human verification for real-device confirmation)
### Required Artifacts
| Artifact | Expected | Status | Details |
| ------------------- | ------------------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `public/manifest.json` | Web App Manifest with required fields and ≥1 icon | ✓ VERIFIED | Exists (31 lines), valid JSON, all required fields present with spec-mandated values, 3 icons referencing real assets in `/public` |
| `app/layout.tsx` | Root layout exporting metadata.manifest and viewport with viewportFit:"cover" | ✓ VERIFIED | Both exports present; `Metadata` and `Viewport` named imports; RootLayout body unchanged; type check passes |
| `app/styles/brand.css` (NEW for re-verification) | Two new `@utility` blocks (`pt-safe`, `pb-safe`) sitting alongside existing utilities | ✓ VERIFIED | Lines 157-163: both `@utility` blocks present with correct `env(safe-area-inset-top/bottom)` declarations; placed between existing `@utility tagline` (line 132) and `/* === Wolf-mark watermark === */` section header (line 165); existing utilities (`num`, `metric-label`, `surface-brand`, `tagline`, `has-mark-watermark`, `.mark-watermark`) and brand tokens (`--wulf-blue`, etc.) all unchanged |
### Key Link Verification
| From | To | Via | Status | Details |
| ------------------------------------- | ------------------------------- | ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `app/layout.tsx` | `public/manifest.json` | `metadata.manifest = "/manifest.json"` | ✓ WIRED | Line 31 sets the field; Next.js 16's metadata API emits `<link rel="manifest" href="/manifest.json" />` automatically |
| `public/manifest.json` | `/mobile` | `start_url` field | ✓ WIRED | Line 5: `"start_url": "/mobile"` (exact spec match) |
| `app/layout.tsx` (viewport export) | rendered `<meta name="viewport">` | Next.js viewport export → viewport-fit=cover | ✓ WIRED | Line 45: `viewportFit: "cover"`; Next 16 documented to serialize this as `viewport-fit=cover` in the rendered meta tag |
| `app/styles/brand.css` (`@utility pt-safe`) (NEW) | rendered CSS class `.pt-safe` | Tailwind 4 `@utility` block compilation | ✓ WIRED | Line 157 defines the block; Tailwind 4 build emits `.pt-safe{padding-top:env(safe-area-inset-top)}` into `.next/static/chunks/3c3ee60b60fe53db.css` |
| `app/styles/brand.css` (`@utility pb-safe`) (NEW) | rendered CSS class `.pb-safe` | Tailwind 4 `@utility` block compilation | ✓ WIRED | Line 161 defines the block; Tailwind 4 build emits `.pb-safe{padding-bottom:env(safe-area-inset-bottom)}` into the same CSS chunk |
| `app/globals.css` (NEW link traced) | `app/styles/brand.css` | `@import "./styles/brand.css"` on line 125 | ✓ WIRED | Pre-existing import — no change required; pulls the new utilities into the global stylesheet automatically |
### Data-Flow Trace (Level 4)
N/A — this phase produces only static metadata (manifest JSON + Next.js metadata/viewport exports + Tailwind 4 `@utility` blocks). No dynamic data flow to trace. The CSS-bundle inspection in Truth #3 acts as the equivalent "did the artifact actually flow through compilation" check for static styles.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------- | ------ |
| Manifest is valid JSON with all spec fields | `jq -e '.name=="Pulse" and .display=="standalone" and .start_url=="/mobile" and .theme_color=="#0075AD" and .background_color=="#FFFFFF" and (.icons\|length)>=1' public/manifest.json` | `true` | ✓ PASS |
| Manifest contains no service-worker field | `jq -e '.serviceworker == null' public/manifest.json` | `true` | ✓ PASS |
| Layout references manifest | `grep 'manifest: "/manifest.json"' app/layout.tsx` | match | ✓ PASS |
| Layout exports `viewportFit: "cover"` | `grep 'viewportFit: "cover"' app/layout.tsx` | match | ✓ PASS |
| `Metadata, Viewport` both imported from `next` | `grep 'import type { Metadata, Viewport } from "next"' app/layout.tsx` | match | ✓ PASS |
| **`pt-safe` `@utility` block exists** (NEW) | `grep -E '@utility pt-safe' app/styles/brand.css` | match (line 157) | ✓ PASS |
| **`pb-safe` `@utility` block exists** (NEW) | `grep -E '@utility pb-safe' app/styles/brand.css` | match (line 161) | ✓ PASS |
| **`safe-area-inset-top` declaration correct** (NEW) | `grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css` | match (line 158) | ✓ PASS |
| **`safe-area-inset-bottom` declaration correct** (NEW) | `grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css` | match (line 162) | ✓ PASS |
| **`brand.css` is still imported by `globals.css`** (NEW) | `grep -E '@import "\./styles/brand\.css"' app/globals.css` | match (line 125) | ✓ PASS |
| **Out-of-scope `pl-safe` / `pr-safe` NOT added** (NEW) | `grep -E '@utility (pl-safe\|pr-safe)' app/styles/brand.css` | no match (exit 1) | ✓ PASS |
| **Production build succeeds with new utilities** (NEW — load-bearing) | `npm run build` | exit 0, "Compiled successfully" | ✓ PASS |
| **Compiled `.pt-safe` rule in production CSS bundle** (NEW) | `grep -oE '\.pt-safe[^,{]*\{[^}]*\}' .next/static/chunks/3c3ee60b60fe53db.css` | `.pt-safe{padding-top:env(safe-area-inset-top)}` | ✓ PASS |
| **Compiled `.pb-safe` rule in production CSS bundle** (NEW) | `grep -oE '\.pb-safe[^,{]*\{[^}]*\}' .next/static/chunks/3c3ee60b60fe53db.css` | `.pb-safe{padding-bottom:env(safe-area-inset-bottom)}` | ✓ PASS |
| TypeScript type check passes | `npx tsc --noEmit --pretty` | exit 0 | ✓ PASS |
| No service worker file shipped | `test ! -f public/sw.js && test ! -f public/service-worker.js` | exit 0 | ✓ PASS |
| `next-pwa` not added as dependency | `! grep '"next-pwa"' package.json` | not found | ✓ PASS |
| **No `tailwind.config.*` created** (NEW) | `test ! -f tailwind.config.{ts,js,mjs}` | exit 0 | ✓ PASS |
| All referenced icon PNGs exist on disk | `test -f public/wulff-logo.png && test -f public/favicon.png && test -f public/branding/wulf-mark.png` | exit 0 | ✓ PASS |
| Plan commits exist in git history | `git log --oneline \| grep -E '3e3df24\|d196d22\|dff0264'` | all three found | ✓ PASS |
| Live manifest fetch (dev server) | `curl -sf http://localhost:3100/manifest.json` | dev server not up | ? SKIP |
| Live viewport meta tag rendering | `curl -s http://localhost:3100/ \| grep viewport-fit=cover` | dev server not up | ? SKIP |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ----------- | -------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------- |
| PWA-01 | 01-01 | manifest.json exists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme/background colors | ✓ SATISFIED | `public/manifest.json` lines 2-10; jq validation passes |
| PWA-02 | 01-01 | Manifest referenced from `app/layout.tsx` via `<link rel="manifest">` | ✓ SATISFIED | `app/layout.tsx:31` `manifest: "/manifest.json"` (Next 16 metadata API emits the link tag) |
| PWA-03 | 01-01 | Viewport meta in `app/layout.tsx` includes `viewport-fit=cover` | ✓ SATISFIED | `app/layout.tsx:42-50` exports `viewport: Viewport` with `viewportFit: "cover"` |
| PWA-04 | **01-02** (gap closure) | Header and bottom tab bar respect `env(safe-area-inset-top/bottom)` (Tailwind arbitrary values or shared utility class) | ✓ SATISFIED (was ✗ BLOCKED / ORPHANED) | `01-02-PLAN.md` declares `requirements: [PWA-04]` and `01-02-SUMMARY.md` lists it under "Requirements Satisfied". `app/styles/brand.css` defines `@utility pt-safe` (line 157) and `@utility pb-safe` (line 161), compiled into production CSS bundle as `.pt-safe{padding-top:env(safe-area-inset-top)}` and `.pb-safe{padding-bottom:env(safe-area-inset-bottom)}`. Available for Phase 2's sticky header (SHELL-05) and fixed bottom nav (SHELL-06) to consume. ROADMAP Phase 1 SC #3 also satisfied. |
**Orphaned-requirement state CLOSED.** Every requirement ID assigned to Phase 1 in REQUIREMENTS.md (PWA-01, PWA-02, PWA-03, PWA-04) is now claimed by a plan in this phase and verified against the codebase.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
| --------------------- | ---- | ------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
No anti-patterns found in the gap-closure scope. The previous warnings on `01-01-PLAN.md` line 281 and `01-01-SUMMARY.md` lines 119-121 (which flagged the silent deferral of PWA-04 to Phase 2) are resolved by the explicit `01-02-PLAN.md` + `01-02-SUMMARY.md` trail that claims and closes PWA-04 in Phase 1.
The artifacts shipped by 01-02 are clean — no TODOs, no stubs, no hardcoded empty data, no out-of-scope additions (`pl-safe` / `pr-safe`), no `tailwind.config.*` introduced, no service worker, no new dependencies.
### Human Verification Required
See frontmatter `human_verification` section. Three items, two strictly required for ROADMAP Phase 1 SC #4 (real iPhone install + real Android install) and one optional smoke test (live dev server view-source + CSS bundle inspection). All three are unchanged from the initial verification — they document real-device install behavior that no static check can confirm. The new `.pt-safe` / `.pb-safe` runtime rendering has been confirmed in the production CSS bundle artifact during this re-verification; the live dev-server check is a small extension of the existing item, not a new gate.
### Gaps Summary
**No gaps remaining.** The single gap from the initial verification (PWA-04 / Truth #3 — safe-area utility availability) was closed by `01-02-PLAN.md`, executed in commits `dff0264` (utility blocks) and `a293a4f` (summary). All four observable truths now verify; all four Phase 1 requirements (PWA-01..04) trace to claiming plans and verified artifacts; production build and type check both exit 0; the compiled CSS bundle contains the new rules.
The phase status moves from `gaps_found` to `human_needed` because real-device install verification (ROADMAP SC #4) is the only outstanding item — and that was always going to require human testing.
### Phase Boundary Restored
Phase 2's contract (SHELL-05, SHELL-06) only mandates **consumption** of safe-area padding via the available utility — Phase 2 can now write `<header class="sticky top-0 pt-safe ...">` and `<nav class="fixed bottom-0 pb-safe ...">` without inventing the utility itself. The ROADMAP boundary that PWA-04 made wobbly is now solid.
---
_Re-verified: 2026-05-03T18:05:00Z_
_Verifier: Claude (gsd-verifier)_
_Previous verification: 2026-05-03 (status: gaps_found, score: 3/4)_

View file

@ -0,0 +1,754 @@
---
phase: 02-mobile-shell-more-drawer
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- components/mobile/HeaderBar.tsx
- components/mobile/BottomNav.tsx
- components/mobile/MoreDrawer.tsx
- app/mobile/analyzer/page.tsx
autonomous: true
requirements:
- SHELL-02
- SHELL-03
- SHELL-04
- SHELL-06
- NAV-01
- NAV-02
- NAV-03
- DRAWER-01
- DRAWER-02
- DRAWER-03
- DRAWER-04
- DRAWER-05
must_haves:
truths:
- "components/mobile/HeaderBar.tsx exists and exports a HeaderBar component that renders the WulfMark + 'Pulse' wordmark linking to /mobile/dashboard, a Bell button (aria-label='Notifications', empty onClick), and an avatar-circle button that triggers the MoreDrawer"
- "components/mobile/BottomNav.tsx exists and exports a BottomNav component with 5 cells: 4 routed tabs (Dashboard, Tickets, Finance, Analyzer) and a 5th 'More' button that opens the drawer"
- "components/mobile/MoreDrawer.tsx exists and exports a MoreDrawer component built on shadcn Sheet (side='right') with three sections: Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync each with ExternalLink icon), Account (current user read-only + Sign out)"
- "MoreDrawer's open/close state is controlled via props (open, onOpenChange) so two triggers (header avatar + bottom-nav More button) can share one drawer"
- "BottomNav's active-tab detection uses pathname.startsWith(href) so /mobile/tickets/123 highlights the Tickets tab"
- "MoreDrawer Sign out button calls signOut() then router.push('/auth/sign-in')"
- "app/mobile/analyzer/page.tsx exists as a minimal placeholder so the bottom-nav Analyzer tab does not 404 before Phase 6"
- "TypeScript compiles (npx tsc --noEmit) and Next.js builds (npm run build) successfully"
artifacts:
- path: "components/mobile/HeaderBar.tsx"
provides: "Sticky header — WulfMark+wordmark link, Bell placeholder, avatar trigger for drawer"
contains: "export function HeaderBar"
- path: "components/mobile/BottomNav.tsx"
provides: "Fixed bottom tab bar — 4 tabs + More button"
contains: "export function BottomNav"
- path: "components/mobile/MoreDrawer.tsx"
provides: "shadcn Sheet drawer with three sections"
contains: "export function MoreDrawer"
- path: "app/mobile/analyzer/page.tsx"
provides: "Placeholder route so the new Analyzer tab resolves until Phase 6 ships"
contains: "export default function"
key_links:
- from: "components/mobile/HeaderBar.tsx"
to: "components/branding/wulf-mark.tsx"
via: "WulfMark import (variant='mark' and variant='wordmark')"
pattern: "from ['\"]@/components/branding/wulf-mark['\"]"
- from: "components/mobile/MoreDrawer.tsx"
to: "components/ui/sheet.tsx"
via: "Sheet, SheetContent, SheetTrigger imports"
pattern: "from ['\"]@/components/ui/sheet['\"]"
- from: "components/mobile/MoreDrawer.tsx"
to: "lib/auth-client.ts"
via: "signOut + useSession imports"
pattern: "from ['\"]@/lib/auth-client['\"]"
- from: "components/mobile/BottomNav.tsx"
to: "/mobile/analyzer"
via: "Analyzer tab href"
pattern: "/mobile/analyzer"
- from: "components/mobile/BottomNav.tsx"
to: "MoreDrawer trigger"
via: "onMoreClick prop or onOpenChange invocation"
pattern: "onMoreClick|onOpenChange"
---
<objective>
Build the three new shell components (HeaderBar, BottomNav, MoreDrawer) and a minimal `/mobile/analyzer` placeholder page, all under `components/mobile/*` and `app/mobile/analyzer/page.tsx`. None of these files are imported by the current shell, so this plan adds files only — the existing `app/mobile/layout.tsx` and `app/mobile/nav/page.tsx` keep working until Plan 02 wires the new pieces in.
Purpose: Lay down the three reusable shell pieces with literal JSX, controlled drawer state, and route entries so Plan 02 can replace `layout.tsx` in a single small change.
Output: 4 new files. Build still passes. Existing `/mobile` routes unchanged in behavior.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
@DESIGN.md
@app/mobile/layout.tsx
@app/mobile/nav/page.tsx
@app/styles/brand.css
@components/ui/sheet.tsx
@components/ui/button.tsx
@components/branding/wulf-mark.tsx
@components/navigation/user-menu.tsx
@lib/auth-client.ts
<interfaces>
<!-- Key types and exports the executor will use. Extracted so executor does not need to re-grep the codebase. -->
From components/branding/wulf-mark.tsx:
```typescript
export function WulfMark(props: {
variant?: 'mark' | 'wordmark';
className?: string;
alt?: string;
priority?: boolean;
}): JSX.Element;
```
From lib/auth-client.ts (Better Auth client):
```typescript
export const signIn, signOut, useSession, getSession;
// useSession() returns { data: session | null, ... }
// session.user has: { name?: string, email?: string, role?: string, image?: string | null }
```
The `UserMenu` component (components/navigation/user-menu.tsx) shows the canonical pattern:
```ts
const initials = (user.name ?? user.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
async function handleSignOut() {
await signOut();
router.push('/auth/sign-in');
}
```
**Reuse this pattern. Do NOT add the shadcn `avatar` primitive — it is not present in `components/ui/` and we do not need it; the initials-circle pattern matches the existing `UserMenu`.**
From components/ui/sheet.tsx:
```typescript
export function Sheet(props: { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode });
export function SheetTrigger(props: { asChild?: boolean; children: ReactNode });
export function SheetContent(props: { side?: "top" | "right" | "bottom" | "left"; className?: string; showCloseButton?: boolean; children: ReactNode });
export function SheetHeader(props: { className?: string; children: ReactNode });
export function SheetTitle(props: { className?: string; children: ReactNode });
export function SheetDescription(props: { className?: string; children: ReactNode });
export function SheetClose(props: { asChild?: boolean; children: ReactNode });
```
A SheetContent **must** contain a SheetTitle (Radix accessibility requirement) — wrap headings in SheetHeader → SheetTitle. Use `SheetDescription` (or visually-hidden description) if needed.
From components/ui/button.tsx:
```typescript
export function Button(props: ButtonHTMLAttributes & {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
asChild?: boolean;
});
```
CSS utilities available in `app/styles/brand.css` (already imported by globals.css):
- `pt-safe``padding-top: env(safe-area-inset-top)`
- `pb-safe``padding-bottom: env(safe-area-inset-bottom)`
</interfaces>
<scope_boundary>
This plan **does not** touch:
- `app/mobile/layout.tsx` (Plan 02 rewrites it)
- `app/mobile/nav/page.tsx` (Plan 02 deletes it)
- Anything in `app/mobile/dashboard/*`, `app/mobile/tickets/*`, `app/mobile/finance/*` (out of phase)
- The shadcn `avatar` primitive — do NOT add it; we use the existing initials-circle pattern from `UserMenu`.
- `components/navigation/app-navigation.tsx` — desktop nav, untouched.
</scope_boundary>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create components/mobile/MoreDrawer.tsx (Sheet drawer with 3 sections + Sign out)</name>
<files>components/mobile/MoreDrawer.tsx</files>
<read_first>
- components/ui/sheet.tsx (Sheet/SheetContent/SheetTitle/SheetClose API and side="right" behavior)
- lib/auth-client.ts (verify `signOut` and `useSession` are exported)
- components/navigation/user-menu.tsx (reference for initials pattern + signOut handler)
- app/mobile/nav/page.tsx (reference for the existing DESKTOP_LINKS list to migrate)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (locked: side="right", three sections, Sign out flow)
</read_first>
<action>
Create the file `components/mobile/MoreDrawer.tsx` with literal contents below (controlled `open`/`onOpenChange` so the same drawer can be triggered from the header avatar AND the bottom-nav More button):
```tsx
'use client';
/* MoreDrawer — phase 02 (DRAWER-01..05).
*
* shadcn Sheet (side="right") with three top-to-bottom sections:
* 1. Mobile sections — Engagement (in-shell route, no ExternalLink hint)
* 2. Full site — desktop-only routes, each with ExternalLink hint
* 3. Account — current user (read-only) + Sign out
*
* Open state is controlled by the parent so the header avatar AND the
* bottom-nav More cell can both trigger this single drawer. */
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ExternalLink,
FileText,
Server,
HardDrive,
BarChart3,
Settings,
Users,
LogOut,
} from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetClose,
} from '@/components/ui/sheet';
import { useSession, signOut } from '@/lib/auth-client';
import { toast } from 'sonner';
const MOBILE_SECTIONS = [
{ href: '/mobile/engagement', label: 'Engagement', icon: Users },
];
const DESKTOP_LINKS = [
{ href: '/quotes', label: 'Quotes', icon: FileText },
{ href: '/configuration-items', label: 'Configuration Items', icon: Server },
{ href: '/backup-status', label: 'Backup Status', icon: HardDrive },
{ href: '/admin/ticket-digest', label: 'Ticket Digest', icon: BarChart3 },
{ href: '/admin/sync', label: 'Admin / Sync', icon: Settings },
];
interface MoreDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MoreDrawer({ open, onOpenChange }: MoreDrawerProps) {
const router = useRouter();
const { data: session } = useSession();
const user = session?.user as
| { name?: string; email?: string; image?: string | null }
| undefined;
const initials = (user?.name ?? user?.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
async function handleSignOut() {
try {
await signOut();
router.push('/auth/sign-in');
} catch (e) {
toast.error('Sign out failed');
console.error('Sign out failed:', e);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-80 sm:max-w-sm flex flex-col">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
<SheetDescription className="sr-only">
Navigation, full-site links, and account actions.
</SheetDescription>
</SheetHeader>
{/* Section 1: Mobile sections (DRAWER-03) */}
<div className="px-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Mobile sections
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{MOBILE_SECTIONS.map(({ href, label, icon: Icon }) => (
<SheetClose asChild key={href}>
<Link
href={href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{label}</span>
</Link>
</SheetClose>
))}
</div>
</div>
{/* Section 2: Full site (DRAWER-04) */}
<div className="px-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Full site
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
<SheetClose asChild key={href}>
<Link
href={href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{label}</span>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Link>
</SheetClose>
))}
</div>
</div>
{/* Section 3: Account (DRAWER-05) */}
<div className="px-4 mt-auto pb-safe">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Account
</p>
<div className="rounded-2xl border overflow-hidden">
{user && (
<div className="flex items-center gap-3 px-4 py-3 border-b">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold shrink-0">
{initials}
</span>
<div className="flex-1 min-w-0">
{user.name && (
<p className="text-sm font-medium leading-tight truncate">
{user.name}
</p>
)}
{user.email && (
<p className="text-xs text-muted-foreground truncate" title={user.email}>
{user.email}
</p>
)}
</div>
</div>
)}
<button
type="button"
onClick={handleSignOut}
className="w-full flex items-center gap-3 px-4 py-3 text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
<span className="text-sm">Sign out</span>
</button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
```
Notes:
- `side="right"` — locked decision (CONTEXT.md, DRAWER-02).
- Engagement intentionally has no `ExternalLink` icon (it's an in-shell route per DRAWER-03).
- Quotes/Configuration Items/Backup Status/Ticket Digest/Admin/Sync each carry `ExternalLink` (DRAWER-04). Do NOT include Engagement in the desktop list (it migrated to "Mobile sections").
- `SheetClose asChild` wraps each link so tapping a row closes the drawer (better UX; Radix Sheet pattern).
- `pb-safe` on Section 3 keeps the Sign out row clear of the home indicator on iOS.
- `<SheetDescription className="sr-only">` satisfies Radix's a11y requirement when the description is non-visual.
</action>
<verify>
<automated>test -f components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "side=\"right\"" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "signOut()" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "/auth/sign-in" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Mobile sections" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Full site" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Account" components/mobile/MoreDrawer.tsx</automated>
</verify>
<acceptance_criteria>
- `test -f components/mobile/MoreDrawer.tsx` exits 0
- `grep -E "export function MoreDrawer" components/mobile/MoreDrawer.tsx` matches
- `grep -E "side=\"right\"" components/mobile/MoreDrawer.tsx` matches (DRAWER-02)
- `grep -E "/mobile/engagement" components/mobile/MoreDrawer.tsx` matches (DRAWER-03)
- `grep -E "/quotes" components/mobile/MoreDrawer.tsx` matches AND `grep -E "/configuration-items" components/mobile/MoreDrawer.tsx` matches AND `grep -E "/backup-status" components/mobile/MoreDrawer.tsx` matches AND `grep -E "/admin/ticket-digest" components/mobile/MoreDrawer.tsx` matches AND `grep -E "/admin/sync" components/mobile/MoreDrawer.tsx` matches (DRAWER-04)
- `grep -E "ExternalLink" components/mobile/MoreDrawer.tsx` matches (DRAWER-04 hint)
- `grep -E "signOut\(\)" components/mobile/MoreDrawer.tsx` matches AND `grep -E "/auth/sign-in" components/mobile/MoreDrawer.tsx` matches (DRAWER-05)
- `grep -E "open: boolean" components/mobile/MoreDrawer.tsx` matches AND `grep -E "onOpenChange" components/mobile/MoreDrawer.tsx` matches (controlled drawer)
</acceptance_criteria>
<done>The drawer file exists, exports `MoreDrawer({ open, onOpenChange })`, contains all three sections with correct routes, calls `signOut()` then `router.push('/auth/sign-in')`, and uses `side="right"`.</done>
</task>
<task type="auto">
<name>Task 2: Create components/mobile/HeaderBar.tsx (sticky top header — brand, Bell, avatar)</name>
<files>components/mobile/HeaderBar.tsx</files>
<read_first>
- components/branding/wulf-mark.tsx (WulfMark prop signature)
- components/navigation/user-menu.tsx (initials pattern reference)
- app/styles/brand.css (confirm `pt-safe` utility exists)
- components/ui/button.tsx (Button variant/size API)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (header decisions: SHELL-02..04, no page title)
</read_first>
<action>
Create the file `components/mobile/HeaderBar.tsx` with literal contents below. The header takes `onAvatarClick` so the parent layout can wire it to the same drawer state used by `BottomNav`.
```tsx
'use client';
/* HeaderBar — phase 02 (SHELL-02..04).
*
* Sticky top bar inside the /mobile shell. Three slots:
* left: WulfMark + "Pulse" wordmark, linked to /mobile/dashboard
* right: Bell icon button (placeholder, aria-label="Notifications")
* right: compact avatar circle — opens the More drawer (parent owns state)
*
* No page title in the header — pages render their own H1. */
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { WulfMark } from '@/components/branding/wulf-mark';
import { useSession } from '@/lib/auth-client';
interface HeaderBarProps {
onAvatarClick: () => void;
}
export function HeaderBar({ onAvatarClick }: HeaderBarProps) {
const { data: session } = useSession();
const user = session?.user as
| { name?: string; email?: string }
| undefined;
const initials = (user?.name ?? user?.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
return (
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-b pt-safe">
<div className="flex items-center justify-between px-4 h-14">
{/* Left: brand mark + wordmark, linked to /mobile/dashboard */}
<Link
href="/mobile/dashboard"
className="flex items-center gap-2 -ml-1 px-1 rounded-md hover:bg-accent/50 transition-colors"
aria-label="Pulse — go to Dashboard"
>
<WulfMark variant="mark" className="h-6 w-auto" />
<span className="font-bold text-base tracking-tight">Pulse</span>
</Link>
{/* Right: Bell placeholder, then avatar trigger */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => { /* SHELL-03: placeholder — no menu, no badge */ }}
aria-label="Notifications"
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Bell className="h-5 w-5" />
</button>
<button
type="button"
onClick={onAvatarClick}
aria-label="Open menu"
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/15 text-primary text-[11px] font-semibold">
{initials}
</span>
</button>
</div>
</div>
</header>
);
}
```
Notes:
- `pt-safe` is added on the sticky header so the notch/dynamic-island doesn't overlap content (SHELL-02 + Phase 1 PWA-04).
- `bg-background/95 backdrop-blur` matches CONTEXT.md SHELL-02.
- Bell `onClick` is intentionally empty (SHELL-03 placeholder); future phase wires real notifications.
- Avatar is `h-7 w-7` per SHELL-04 — wrapped in a `h-9 w-9` button to give a 36px touch target.
- No `<h1>` / no page title in header (SHELL-02 explicit).
- We do NOT use the shadcn avatar primitive — initials-circle pattern matches existing `UserMenu`.
</action>
<verify>
<automated>test -f components/mobile/HeaderBar.tsx &amp;&amp; grep -q "sticky top-0" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "/mobile/dashboard" components/mobile/HeaderBar.tsx &amp;&amp; grep -q 'aria-label="Notifications"' components/mobile/HeaderBar.tsx &amp;&amp; grep -q "WulfMark" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "pt-safe" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "h-7 w-7" components/mobile/HeaderBar.tsx</automated>
</verify>
<acceptance_criteria>
- `test -f components/mobile/HeaderBar.tsx` exits 0
- `grep -E "export function HeaderBar" components/mobile/HeaderBar.tsx` matches
- `grep -E "sticky top-0" components/mobile/HeaderBar.tsx` matches AND `grep -E "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsx` matches AND `grep -E "border-b" components/mobile/HeaderBar.tsx` matches (SHELL-02)
- `grep -E "/mobile/dashboard" components/mobile/HeaderBar.tsx` matches (brand link target, SHELL-02)
- `grep -E "WulfMark" components/mobile/HeaderBar.tsx` matches AND `grep -E "Pulse" components/mobile/HeaderBar.tsx` matches (mark + wordmark, SHELL-02)
- `grep -E "aria-label=\"Notifications\"" components/mobile/HeaderBar.tsx` matches AND `grep -E "Bell" components/mobile/HeaderBar.tsx` matches (SHELL-03)
- `grep -E "h-7 w-7" components/mobile/HeaderBar.tsx` matches (compact avatar, SHELL-04)
- `grep -E "onAvatarClick" components/mobile/HeaderBar.tsx` matches (avatar opens drawer via parent state, SHELL-04)
- `grep -E "pt-safe" components/mobile/HeaderBar.tsx` matches (PWA-04 reuse / safe-area)
- `! grep -E "<h1" components/mobile/HeaderBar.tsx` exits 0 (no page title in header, SHELL-02 explicit)
</acceptance_criteria>
<done>HeaderBar renders WulfMark+wordmark linked to /mobile/dashboard, a Bell button with `aria-label="Notifications"` and empty onClick, and an avatar-circle button that calls `onAvatarClick` (parent wires this to the drawer state).</done>
</task>
<task type="auto">
<name>Task 3: Create components/mobile/BottomNav.tsx (5-cell bottom bar — 4 tabs + More)</name>
<files>components/mobile/BottomNav.tsx</files>
<read_first>
- app/mobile/layout.tsx (current 3-tab pattern; we extend to 4 tabs + More)
- app/styles/brand.css (confirm `pb-safe` utility exists)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (locked: SHELL-06, NAV-01..03 — Dashboard/Tickets/Finance/Analyzer + More)
</read_first>
<action>
Create the file `components/mobile/BottomNav.tsx`:
```tsx
'use client';
/* BottomNav — phase 02 (SHELL-06, NAV-01..03, DRAWER-01).
*
* Fixed bottom bar with five cells:
* - Dashboard (LayoutDashboard) -> /mobile/dashboard
* - Tickets (Ticket) -> /mobile/tickets
* - Finance (DollarSign) -> /mobile/finance
* - Analyzer (Sparkles) -> /mobile/analyzer
* - More (Menu) -> opens the MoreDrawer (parent state)
*
* Active tab detected via pathname.startsWith(href). Active = text-primary,
* inactive = text-muted-foreground. */
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Ticket,
DollarSign,
Sparkles,
Menu,
} from 'lucide-react';
const TABS = [
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket },
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign },
{ href: '/mobile/analyzer', label: 'Analyzer', icon: Sparkles },
] as const;
interface BottomNavProps {
onMoreClick: () => void;
}
export function BottomNav({ onMoreClick }: BottomNavProps) {
const pathname = usePathname();
return (
<nav
aria-label="Primary"
className="fixed bottom-0 left-0 right-0 z-30 border-t bg-background pb-safe"
>
<div className="max-w-lg mx-auto flex h-16">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname?.startsWith(href) ?? false;
return (
<Link
key={href}
href={href}
aria-current={active ? 'page' : undefined}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
active
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-5 h-5" aria-hidden="true" />
<span>{label}</span>
</Link>
);
})}
<button
type="button"
onClick={onMoreClick}
aria-label="Open menu"
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Menu className="w-5 h-5" aria-hidden="true" />
<span>More</span>
</button>
</div>
</nav>
);
}
```
Notes:
- `max-w-lg mx-auto` keeps the nav width-aligned with the content gutter (SHELL-06 + CONTEXT.md).
- `pb-safe` on the outer `<nav>` so the home indicator inset is reserved (PWA-04 reuse).
- `h-16` = 64px nav height; the layout's `<main>` will pad by 64 + safe-area to keep content above the bar (handled in Plan 02).
- Active detection is `pathname?.startsWith(href)` (NAV-03).
- The More cell is a `<button>`, not a Link — it triggers a controlled drawer via `onMoreClick`.
- Icons use `aria-hidden="true"` because the visible label already names the destination.
</action>
<verify>
<automated>test -f components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/dashboard" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/tickets" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/finance" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/analyzer" components/mobile/BottomNav.tsx &amp;&amp; grep -q "max-w-lg mx-auto" components/mobile/BottomNav.tsx &amp;&amp; grep -q "pathname.*startsWith" components/mobile/BottomNav.tsx &amp;&amp; grep -q "text-primary" components/mobile/BottomNav.tsx &amp;&amp; grep -q "pb-safe" components/mobile/BottomNav.tsx</automated>
</verify>
<acceptance_criteria>
- `test -f components/mobile/BottomNav.tsx` exits 0
- `grep -E "export function BottomNav" components/mobile/BottomNav.tsx` matches
- `grep -E "/mobile/dashboard" components/mobile/BottomNav.tsx` matches AND `grep -E "/mobile/tickets" components/mobile/BottomNav.tsx` matches AND `grep -E "/mobile/finance" components/mobile/BottomNav.tsx` matches AND `grep -E "/mobile/analyzer" components/mobile/BottomNav.tsx` matches (NAV-02)
- `grep -E "LayoutDashboard" components/mobile/BottomNav.tsx` matches AND `grep -E "\\bTicket\\b" components/mobile/BottomNav.tsx` matches AND `grep -E "DollarSign" components/mobile/BottomNav.tsx` matches AND `grep -E "Sparkles" components/mobile/BottomNav.tsx` matches AND `grep -E "\\bMenu\\b" components/mobile/BottomNav.tsx` matches (NAV-01 + DRAWER-01 icons)
- `grep -E "pathname.*startsWith" components/mobile/BottomNav.tsx` matches (NAV-03)
- `grep -E "text-primary" components/mobile/BottomNav.tsx` matches AND `grep -E "text-muted-foreground" components/mobile/BottomNav.tsx` matches (NAV-03 active/inactive)
- `grep -E "fixed bottom-0" components/mobile/BottomNav.tsx` matches AND `grep -E "border-t" components/mobile/BottomNav.tsx` matches AND `grep -E "max-w-lg mx-auto" components/mobile/BottomNav.tsx` matches (SHELL-06)
- `grep -E "pb-safe" components/mobile/BottomNav.tsx` matches (safe-area for home indicator)
- `grep -E "onMoreClick" components/mobile/BottomNav.tsx` matches (DRAWER-01 trigger via parent state)
</acceptance_criteria>
<done>BottomNav exports a 5-cell nav: 4 routed Links (Dashboard, Tickets, Finance, Analyzer) with active-state via `pathname.startsWith(href)`, plus a More button that calls `onMoreClick`.</done>
</task>
<task type="auto">
<name>Task 4: Create app/mobile/analyzer/page.tsx (placeholder so Analyzer tab does not 404 before Phase 6)</name>
<files>app/mobile/analyzer/page.tsx</files>
<read_first>
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (decisions §Routes & files: "Add a placeholder app/mobile/analyzer/page.tsx so the new bottom-nav Analyzer tab doesn't 404 before Phase 6 lands. Minimal 'coming soon' component is sufficient.")
- app/mobile/page.tsx (style reference for a minimal mobile page)
</read_first>
<action>
Create `app/mobile/analyzer/page.tsx`:
```tsx
/* Placeholder for /mobile/analyzer.
*
* Phase 02 only adds the Analyzer tab to the bottom nav — the real feed
* lands in Phase 6 (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
* §6.4). This file exists so tapping the Analyzer tab resolves to a real
* route instead of 404. Phase 6 will replace this file with the actual
* read-only feed page.
*
* DO NOT add features, data fetching, or UI beyond the "Coming soon"
* card here — Phase 6 owns the real implementation. */
import { Sparkles } from 'lucide-react';
export const metadata = {
title: 'Analyzer · Pulse',
};
export default function MobileAnalyzerPlaceholder() {
return (
<div className="p-4">
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center text-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
<Sparkles className="h-6 w-6" />
</div>
<h1 className="text-lg font-semibold">Analyzer feed coming soon</h1>
<p className="text-sm text-muted-foreground max-w-xs">
The mobile Analyzer feed is on its way. Until then, view full
analyses on the desktop Analyzer.
</p>
</div>
</div>
);
}
```
Notes:
- This is a server component (no `'use client'` needed) — keeps it cheap.
- Deliberately stubbed; Phase 6 (ANL-01..06) replaces this entire file.
- No data fetching, no `/api/mobile/analyzer/feed` call — those belong in Phase 6.
</action>
<verify>
<automated>test -f app/mobile/analyzer/page.tsx &amp;&amp; grep -q "export default function" app/mobile/analyzer/page.tsx &amp;&amp; grep -q "coming soon" app/mobile/analyzer/page.tsx</automated>
</verify>
<acceptance_criteria>
- `test -f app/mobile/analyzer/page.tsx` exits 0
- `grep -E "export default function" app/mobile/analyzer/page.tsx` matches
- `grep -iE "coming soon" app/mobile/analyzer/page.tsx` matches (placeholder copy present)
- `! grep -E "/api/mobile/analyzer" app/mobile/analyzer/page.tsx` exits 0 (no Phase 6 data fetching)
</acceptance_criteria>
<done>Visiting `/mobile/analyzer` after build renders a small "coming soon" card; no 404.</done>
</task>
<task type="auto">
<name>Task 5: Type-check and build to confirm new components compile cleanly without breaking anything</name>
<files>(no files written — gate task)</files>
<read_first>
- components/mobile/HeaderBar.tsx (just authored)
- components/mobile/BottomNav.tsx (just authored)
- components/mobile/MoreDrawer.tsx (just authored)
- app/mobile/analyzer/page.tsx (just authored)
</read_first>
<action>
Run `npx tsc --noEmit --pretty` and `npm run build` to confirm the four new files compile in the existing project. The current `app/mobile/layout.tsx` and `app/mobile/nav/page.tsx` are untouched, so existing routes must still build.
If `tsc` reports errors, fix them in the offending file(s) and rerun until both pass. Common issues to expect:
- Missing import → re-add the import
- `any` cast on `session.user` → keep the typed cast pattern from `UserMenu.tsx`
- JSX-runtime / `JSX` namespace not found → not expected (tsconfig has it); if it appears, leave it for the executor to investigate
Do **not** modify any other files in this task.
</action>
<verify>
<automated>npx tsc --noEmit --pretty &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit --pretty` exits 0
- `npm run build` exits 0
- The four new files exist (re-confirmed) and no existing file was modified by this task: `git status --short components/mobile app/mobile/analyzer` shows only the four new files, no modifications to anything else
</acceptance_criteria>
<done>TypeScript and Next.js build both pass with the four new files in place; existing routes unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → drawer Sign out | Calls `signOut()` on the existing Better Auth client; an authenticated session already exists |
| Browser → all Link routes | Standard client-side navigation; no new endpoints, no new data |
| Browser → header Bell | Empty handler (placeholder per SHELL-03); not a trust boundary in this iteration |
## STRIDE Threat Register (ASVS-L1 baseline)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Tampering | MoreDrawer Sign out button | accept | No new endpoint introduced; reuses Better Auth `signOut()` from `lib/auth-client.ts`. CSRF protection is provided by the existing Better Auth cookie + SameSite policy. |
| T-02-02 | Information Disclosure | Account section showing user email | accept | Email is already visible in the existing top-bar `UserMenu` on every desktop page; no new data surface or endpoint. Read-only display only. |
| T-02-03 | Spoofing | Avatar trigger opens drawer that contains Sign out | mitigate | Drawer state is local React state, not URL-driven; an attacker cannot pre-open the drawer via crafted URL. Sign out always navigates to `/auth/sign-in` server-rendered route, which Better Auth controls. |
| T-02-04 | Denial of Service | Bell button placeholder | accept | Empty handler — no fetch, no work, no DOS surface. Phase 7+ will revisit when the real notification list ships (NOTIF-01). |
</threat_model>
<verification>
After this plan completes:
1. The four new files exist:
- `test -f components/mobile/HeaderBar.tsx`
- `test -f components/mobile/BottomNav.tsx`
- `test -f components/mobile/MoreDrawer.tsx`
- `test -f app/mobile/analyzer/page.tsx`
2. `npx tsc --noEmit --pretty` exits 0
3. `npm run build` exits 0
4. `app/mobile/layout.tsx` is unchanged from start (still imports `LayoutDashboard, Ticket, DollarSign, Menu` only — not `Sparkles`):
- `! grep -E "Sparkles" app/mobile/layout.tsx` exits 0 (we have NOT yet wired the new bottom nav — Plan 02 does that)
5. `app/mobile/nav/page.tsx` still exists (Plan 02 deletes it)
6. No new dependencies added: `git diff package.json package-lock.json` is empty
</verification>
<success_criteria>
- All 5 tasks complete
- 4 new files exist (HeaderBar, BottomNav, MoreDrawer, analyzer placeholder)
- Each component matches its locked decisions from CONTEXT.md (D-locked: side="right", 5-cell nav, three drawer sections, Bell placeholder, h-7 w-7 avatar, max-w-lg mx-auto bottom nav)
- TypeScript + Next build both pass
- No existing files modified by this plan (verifiable via `git status --short`)
- Plan 02 will pick up these components and wire them into the layout
</success_criteria>
<output>
After completion, create `.planning/phases/02-mobile-shell-more-drawer/02-01-SUMMARY.md` documenting:
- Which requirements this plan addressed (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05)
- The four files created and their roles
- Locked decisions honored (Sheet `side="right"`, no shadcn avatar primitive added, Bell empty `onClick`, no page title in header)
- Any deviations from the plan (should be none)
- Notes for Plan 02 (which props to pass to each component)
</output>

View file

@ -0,0 +1,129 @@
---
phase: 02-mobile-shell-more-drawer
plan: 01
subsystem: mobile-shell
tags: [mobile, navigation, shell, drawer, components]
dependency_graph:
requires: []
provides:
- components/mobile/HeaderBar.tsx
- components/mobile/BottomNav.tsx
- components/mobile/MoreDrawer.tsx
- app/mobile/analyzer/page.tsx
affects:
- app/mobile/layout.tsx (Plan 02 will wire these in)
tech_stack:
added: []
patterns:
- shadcn Sheet (side="right") for drawer
- Controlled open/onOpenChange props for shared drawer state
- pathname.startsWith(href) for active tab detection
- initials-circle pattern from UserMenu (no shadcn avatar primitive)
- pt-safe / pb-safe from Phase 1 brand.css utilities
key_files:
created:
- components/mobile/HeaderBar.tsx
- components/mobile/BottomNav.tsx
- components/mobile/MoreDrawer.tsx
- app/mobile/analyzer/page.tsx
modified: []
decisions:
- Sheet side="right" (locked per DRAWER-02 in CONTEXT.md)
- No shadcn avatar primitive — initials-circle pattern matches existing UserMenu
- Bell onClick intentionally empty (SHELL-03 placeholder, Phase 7+ wires real notifications)
- No page title in HeaderBar — pages render their own H1
- analyzer/page.tsx is a deliberate "coming soon" placeholder — Phase 6 owns the real feed
metrics:
duration_minutes: 5
completed_date: "2026-05-03"
tasks_completed: 5
tasks_total: 5
files_created: 4
files_modified: 0
---
# Phase 02 Plan 01: Mobile Shell Components Summary
Three new reusable shell components and an analyzer route placeholder — the building blocks Plan 02 will wire into `app/mobile/layout.tsx` to complete the mobile shell redesign.
## What Was Built
**MoreDrawer** (`components/mobile/MoreDrawer.tsx`) — shadcn Sheet (side="right") with controlled `open`/`onOpenChange` props so both the header avatar and the bottom-nav More button share one drawer instance. Three sections:
- Mobile sections: Engagement (in-shell, no ExternalLink)
- Full site: Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync (each with ExternalLink icon)
- Account: user initials + name/email (read-only) + Sign out (calls `signOut()` then navigates to `/auth/sign-in`)
**HeaderBar** (`components/mobile/HeaderBar.tsx`) — sticky header with `bg-background/95 backdrop-blur border-b pt-safe`. Left: WulfMark + "Pulse" wordmark linked to `/mobile/dashboard`. Right: Bell placeholder (`aria-label="Notifications"`, empty onClick) + compact avatar circle (h-7 w-7) calling `onAvatarClick` prop. No page title in the header.
**BottomNav** (`components/mobile/BottomNav.tsx`) — fixed bottom bar (`border-t bg-background pb-safe`, `max-w-lg mx-auto`, `h-16`). Four tabs: Dashboard (LayoutDashboard), Tickets (Ticket), Finance (DollarSign), Analyzer (Sparkles) — all with `pathname.startsWith(href)` active detection (text-primary when active, text-muted-foreground otherwise). Fifth cell: More button calling `onMoreClick` prop.
**Analyzer placeholder** (`app/mobile/analyzer/page.tsx`) — minimal server component with "coming soon" card. Resolves the `/mobile/analyzer` route so the new Analyzer tab doesn't 404 before Phase 6 (ANL-01..06) ships the real feed.
## Requirements Addressed
| Requirement | Status |
|-------------|--------|
| SHELL-02 | Sticky header, brand mark+wordmark, /mobile/dashboard link, backdrop blur, border-b |
| SHELL-03 | Bell placeholder with aria-label, empty onClick |
| SHELL-04 | Avatar circle h-7 w-7, triggers drawer via onAvatarClick |
| SHELL-06 | Fixed bottom nav, max-w-lg mx-auto, border-t, pb-safe |
| NAV-01 | 4 tab icons: LayoutDashboard, Ticket, DollarSign, Sparkles + Menu for More |
| NAV-02 | 4 routes: /mobile/dashboard, /mobile/tickets, /mobile/finance, /mobile/analyzer |
| NAV-03 | pathname.startsWith(href) active detection, text-primary/text-muted-foreground |
| DRAWER-01 | More button in BottomNav triggers shared drawer via onMoreClick prop |
| DRAWER-02 | Sheet side="right" (locked decision) |
| DRAWER-03 | Mobile sections: Engagement /mobile/engagement (no ExternalLink) |
| DRAWER-04 | Full site: Quotes, Config Items, Backup Status, Ticket Digest, Admin/Sync with ExternalLink |
| DRAWER-05 | Account: user display (read-only) + Sign out via signOut() + router.push('/auth/sign-in') |
## Locked Decisions Honored
- `side="right"` — locked in CONTEXT.md DRAWER-02
- No shadcn `avatar` primitive — uses existing initials-circle pattern from UserMenu
- Bell `onClick` is empty — SHELL-03 explicitly says "placeholder only this iteration"
- No `<h1>` in HeaderBar — SHELL-02 explicit: "pages render their own H1"
- MoreDrawer uses controlled state (props, not internal) — both triggers share one drawer
## Notes for Plan 02
Plan 02 rewrites `app/mobile/layout.tsx` and deletes `app/mobile/nav/page.tsx`. When wiring the new components:
```tsx
// In layout.tsx (Plan 02):
const [drawerOpen, setDrawerOpen] = useState(false);
<HeaderBar onAvatarClick={() => setDrawerOpen(true)} />
<MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
<BottomNav onMoreClick={() => setDrawerOpen(true)} />
```
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
| File | Stub | Reason |
|------|------|--------|
| `app/mobile/analyzer/page.tsx` | "coming soon" card, no data | Intentional — Phase 6 (ANL-01..06) owns the real mobile analyzer feed. This file exists only to prevent a 404 on the new BottomNav Analyzer tab. |
| `components/mobile/HeaderBar.tsx` | Bell onClick is empty | Intentional — SHELL-03 explicitly defers real notifications to Phase 7+. |
## Threat Flags
None — no new network endpoints, no new auth paths, no new DB access. All trust boundaries match the plan's threat model exactly.
## Self-Check: PASSED
Files verified:
- `components/mobile/HeaderBar.tsx` — exists
- `components/mobile/BottomNav.tsx` — exists
- `components/mobile/MoreDrawer.tsx` — exists
- `app/mobile/analyzer/page.tsx` — exists
Commits verified:
- `6630589` — feat(02-01): create MoreDrawer component
- `14375f1` — feat(02-01): create HeaderBar component
- `a42c0a8` — feat(02-01): create BottomNav component
- `3fc0ee3` — feat(02-01): add /mobile/analyzer placeholder
Build: `npx tsc --noEmit --pretty` exits 0, `npm run build` exits 0 (273 routes generated, `/mobile/analyzer` in route table).

View file

@ -0,0 +1,406 @@
---
phase: 02-mobile-shell-more-drawer
plan: 02
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- app/mobile/layout.tsx
- app/mobile/nav/page.tsx
autonomous: false
requirements:
- SHELL-01
- SHELL-05
- DRAWER-06
must_haves:
truths:
- "app/mobile/layout.tsx is rewritten to import HeaderBar, BottomNav, MoreDrawer from components/mobile/* and renders them around <main>{children}</main>"
- "The layout owns a single React.useState boolean that opens/closes the MoreDrawer; HeaderBar's onAvatarClick and BottomNav's onMoreClick both flip this state to true"
- "The <main> content area scrolls and has bottom padding equal to bottom-nav height (h-16 = 64px) plus env(safe-area-inset-bottom) so content does not hide under the nav"
- "app/mobile/nav/page.tsx no longer exists — the file is deleted in this same change"
- "Visiting /mobile/dashboard, /mobile/tickets, /mobile/finance, and /mobile/analyzer all render inside the new layout (header + bottom nav visible, no 404)"
- "TypeScript compiles (npx tsc --noEmit) and Next.js builds (npm run build) successfully"
artifacts:
- path: "app/mobile/layout.tsx"
provides: "New mobile shell wiring HeaderBar + BottomNav + MoreDrawer with shared drawer state"
contains: "MoreDrawer"
- path: "app/mobile/nav/page.tsx"
provides: "DELETED — drawer fully replaces the standalone nav page (DRAWER-06)"
deleted: true
key_links:
- from: "app/mobile/layout.tsx"
to: "components/mobile/HeaderBar.tsx"
via: "import + render with onAvatarClick"
pattern: "from ['\"]@/components/mobile/HeaderBar['\"]"
- from: "app/mobile/layout.tsx"
to: "components/mobile/BottomNav.tsx"
via: "import + render with onMoreClick"
pattern: "from ['\"]@/components/mobile/BottomNav['\"]"
- from: "app/mobile/layout.tsx"
to: "components/mobile/MoreDrawer.tsx"
via: "import + render with shared open/onOpenChange state"
pattern: "from ['\"]@/components/mobile/MoreDrawer['\"]"
- from: "Header avatar AND Bottom-nav More button"
to: "MoreDrawer open state"
via: "Single useState in app/mobile/layout.tsx"
pattern: "useState"
---
<objective>
Replace `app/mobile/layout.tsx` with the new shell that wires HeaderBar + BottomNav + MoreDrawer (built in Plan 01) around `<main>{children}</main>`, owning a single shared drawer-open state. Delete `app/mobile/nav/page.tsx` in the same change so the drawer fully replaces the old standalone nav page.
Purpose: Land SHELL-01 (replace in place), SHELL-05 (scrollable content with bottom-nav-aware padding), and DRAWER-06 (delete the old nav route). After this plan, every `/mobile/*` page renders under the new shell and the four primary tabs + avatar + More all behave per spec.
Output: Modified `app/mobile/layout.tsx`, deleted `app/mobile/nav/page.tsx`. Build passes. Visual checkpoint confirms the shell renders correctly on at least one mobile route.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md
@.planning/phases/02-mobile-shell-more-drawer/02-01-PLAN.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
@app/mobile/layout.tsx
@app/mobile/nav/page.tsx
@app/styles/brand.css
<interfaces>
<!-- Components consumed by the new layout. All authored in Plan 01. -->
From components/mobile/HeaderBar.tsx (Plan 01):
```typescript
export function HeaderBar(props: { onAvatarClick: () => void }): JSX.Element;
```
From components/mobile/BottomNav.tsx (Plan 01):
```typescript
export function BottomNav(props: { onMoreClick: () => void }): JSX.Element;
```
From components/mobile/MoreDrawer.tsx (Plan 01):
```typescript
export function MoreDrawer(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}): JSX.Element;
```
CSS utilities available in `app/styles/brand.css`:
- `pt-safe`, `pb-safe`
</interfaces>
<scope_boundary>
This plan **only** touches:
- `app/mobile/layout.tsx` (full rewrite)
- `app/mobile/nav/page.tsx` (delete)
Do NOT modify:
- The three new components (Plan 01 owns them)
- Any page under `app/mobile/dashboard|tickets|finance|analyzer|page.tsx` (out of phase)
- `components/navigation/app-navigation.tsx` (desktop nav)
- `app/layout.tsx` (root, owned by Phase 1)
</scope_boundary>
</context>
<tasks>
<task type="auto">
<name>Task 1: Rewrite app/mobile/layout.tsx to wire HeaderBar + BottomNav + MoreDrawer with shared state</name>
<files>app/mobile/layout.tsx</files>
<read_first>
- app/mobile/layout.tsx (current 3-tab layout being replaced — read fully so executor knows what's there)
- components/mobile/HeaderBar.tsx (Plan 01 output — confirms onAvatarClick prop)
- components/mobile/BottomNav.tsx (Plan 01 output — confirms onMoreClick prop)
- components/mobile/MoreDrawer.tsx (Plan 01 output — confirms open/onOpenChange props)
- app/styles/brand.css (confirm `pt-safe` and `pb-safe` are available)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (SHELL-05: bottom padding = nav height + safe-area)
</read_first>
<action>
**Replace the entire contents** of `app/mobile/layout.tsx` with:
```tsx
'use client';
/* Mobile shell — phase 02 (SHELL-01, SHELL-05).
*
* Header: <HeaderBar /> (sticky, brand + Bell + avatar)
* Body: <main> (scrollable, padded so content clears the bottom nav)
* Foot: <BottomNav /> (fixed, 4 tabs + More)
* Drawer: <MoreDrawer /> opened from BOTH the header avatar and the More cell.
*
* The drawer's open state lives here so a single Sheet instance is shared
* between the two triggers — no duplicate Sheets, no prop-drilling sagas. */
import { useState } from 'react';
import { HeaderBar } from '@/components/mobile/HeaderBar';
import { BottomNav } from '@/components/mobile/BottomNav';
import { MoreDrawer } from '@/components/mobile/MoreDrawer';
export default function MobileLayout({ children }: { children: React.ReactNode }) {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div className="flex flex-col min-h-screen bg-background max-w-lg mx-auto">
<HeaderBar onAvatarClick={() => setDrawerOpen(true)} />
{/* SHELL-05: scrollable content area; bottom padding = bottom-nav (h-16
= 64px = pb-16) plus the device safe-area inset, so content never
hides under the bar. */}
<main className="flex-1 overflow-y-auto pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]">
{children}
</main>
<BottomNav onMoreClick={() => setDrawerOpen(true)} />
<MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
</div>
);
}
```
Notes:
- This file replaces the existing 3-tab layout entirely. No legacy imports, no dead code paths.
- `'use client'` is required because we use `useState`.
- The `pb-[calc(...)]` arbitrary value gives `<main>` enough bottom padding to clear the 64px nav plus the home-indicator inset (SHELL-05 + PWA-04 reuse). Tailwind 4 supports the `calc()` arbitrary value here.
- A single `useState` is the entire shared-state mechanism — no Zustand, no Context, no third-party state lib (per CLAUDE.md "no new state libraries").
- Both triggers set the same boolean. The `MoreDrawer` itself controls its close (Radix `onOpenChange` fires when overlay is clicked or Esc is pressed) and propagates back through `setDrawerOpen`.
</action>
<verify>
<automated>grep -q "from '@/components/mobile/HeaderBar'" app/mobile/layout.tsx &amp;&amp; grep -q "from '@/components/mobile/BottomNav'" app/mobile/layout.tsx &amp;&amp; grep -q "from '@/components/mobile/MoreDrawer'" app/mobile/layout.tsx &amp;&amp; grep -q "useState" app/mobile/layout.tsx &amp;&amp; grep -q "onAvatarClick" app/mobile/layout.tsx &amp;&amp; grep -q "onMoreClick" app/mobile/layout.tsx &amp;&amp; grep -q "drawerOpen" app/mobile/layout.tsx &amp;&amp; grep -q "safe-area-inset-bottom" app/mobile/layout.tsx</automated>
</verify>
<acceptance_criteria>
- `grep -E "from ['\"]@/components/mobile/HeaderBar['\"]" app/mobile/layout.tsx` matches
- `grep -E "from ['\"]@/components/mobile/BottomNav['\"]" app/mobile/layout.tsx` matches
- `grep -E "from ['\"]@/components/mobile/MoreDrawer['\"]" app/mobile/layout.tsx` matches
- `grep -E "useState" app/mobile/layout.tsx` matches (single shared state)
- `grep -E "onAvatarClick" app/mobile/layout.tsx` matches AND `grep -E "onMoreClick" app/mobile/layout.tsx` matches (both triggers wired)
- `grep -E "open=" app/mobile/layout.tsx` matches AND `grep -E "onOpenChange=" app/mobile/layout.tsx` matches (drawer controlled)
- `grep -E "safe-area-inset-bottom" app/mobile/layout.tsx` matches (SHELL-05 padding for bottom nav clearance)
- `grep -E "max-w-lg mx-auto" app/mobile/layout.tsx` matches (CONTEXT.md container width)
- `! grep -E "Menu, " app/mobile/layout.tsx` exits 0 (the legacy `Menu`-as-link import from the old layout is gone)
- `! grep -E "/mobile/nav" app/mobile/layout.tsx` exits 0 (no link to the deleted standalone nav route)
</acceptance_criteria>
<done>The layout renders the three new components, owns a single useState for drawer open/close, and pads `<main>` to clear the bottom nav + safe area.</done>
</task>
<task type="auto">
<name>Task 2: Delete app/mobile/nav/page.tsx (DRAWER-06)</name>
<files>app/mobile/nav/page.tsx</files>
<read_first>
- app/mobile/nav/page.tsx (final read of the file being deleted, so the executor knows what is leaving the codebase)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (DRAWER-06 — delete in same change as drawer ships; no redirect)
- components/mobile/MoreDrawer.tsx (Plan 01 output — verifies the drawer already covers everything the old page did)
</read_first>
<action>
Delete the file:
```bash
rm app/mobile/nav/page.tsx
```
Then check the directory is empty (or only contains other files we don't care about) and remove it if it became empty:
```bash
# If app/mobile/nav is now empty, remove the directory too.
if [ -d app/mobile/nav ] && [ -z "$(ls -A app/mobile/nav)" ]; then
rmdir app/mobile/nav
fi
```
Notes:
- Per CONTEXT.md DRAWER-06: "Recommend NO redirect (just delete) — the URL was never bookmarked-worthy." Visiting `/mobile/nav` after this change yields Next.js's standard 404, which is the desired behavior.
- Confirm no other file in the repo references `/mobile/nav` or imports from `app/mobile/nav/...`. Run a quick grep before deletion (the old `app/mobile/layout.tsx` had the only known reference, and Task 1 already removed it).
</action>
<verify>
<automated>! test -f app/mobile/nav/page.tsx</automated>
</verify>
<acceptance_criteria>
- `! test -f app/mobile/nav/page.tsx` exits 0 (file deleted)
- `! grep -r --include="*.ts" --include="*.tsx" "/mobile/nav" app components 2>/dev/null` exits 0 (no remaining references in source)
- The deletion shows up in `git status` as a deleted file
</acceptance_criteria>
<done>`app/mobile/nav/page.tsx` no longer exists; no source file references `/mobile/nav` anywhere.</done>
</task>
<task type="auto">
<name>Task 3: Type-check and full build to confirm the new shell compiles end-to-end</name>
<files>(no files written — gate task)</files>
<read_first>
- app/mobile/layout.tsx (the file just rewritten)
- components/mobile/HeaderBar.tsx (Plan 01)
- components/mobile/BottomNav.tsx (Plan 01)
- components/mobile/MoreDrawer.tsx (Plan 01)
</read_first>
<action>
Run:
```bash
npx tsc --noEmit --pretty
npm run build
```
Both must exit 0. If either fails, fix the offending file and rerun until clean. Common things to check if it fails:
- Did Task 2 leave a dangling import to the deleted `nav/page.tsx`? (Should be impossible, but grep `/mobile/nav` if a build error names that path.)
- Did the `'use client'` directive end up below an import? (Must be the very first line.)
- Is the `pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` Tailwind 4 arbitrary value valid? If Tailwind rejects it, fall back to an inline style on the `<main>`: `style={{ paddingBottom: 'calc(4rem + env(safe-area-inset-bottom))' }}` and remove the `pb-[...]` class.
Do NOT modify any file other than `app/mobile/layout.tsx` to fix build issues.
</action>
<verify>
<automated>npx tsc --noEmit --pretty &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit --pretty` exits 0
- `npm run build` exits 0
- `git status --short app components` shows: 1 modified (`app/mobile/layout.tsx`) and 1 deleted (`app/mobile/nav/page.tsx`); no other unexpected modifications
</acceptance_criteria>
<done>Type-check + build both pass with the new shell wired and the old nav page deleted.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Visual verification of the new mobile shell on a real device or DevTools mobile preview</name>
<files>(no files written — human checkpoint)</files>
<read_first>
- app/mobile/layout.tsx (the file just rewritten — executor confirms what was shipped)
- .planning/ROADMAP.md (Phase 2 success criteria #16 — these are what the human is verifying)
- .planning/REQUIREMENTS.md (SHELL-01..06, NAV-01..03, DRAWER-01..06)
</read_first>
<action>
Pause and surface a checkpoint to the user. Present this exact verification script and wait for the user's "approved" response.
**What was built (summary for the user):**
The new mobile shell is fully wired:
- `app/mobile/layout.tsx` rewritten — sticky header (Wulf mark + "Pulse" wordmark, Bell, avatar), scrollable `<main>`, fixed bottom nav (Dashboard / Tickets / Finance / Analyzer / More)
- `app/mobile/nav/page.tsx` deleted
- `app/mobile/analyzer/page.tsx` placeholder ("Coming soon" card) so the Analyzer tab resolves until Phase 6
- `<MoreDrawer />` opens from BOTH the header avatar and the bottom-nav More button, with three sections (Mobile sections / Full site / Account + Sign out)
All built on existing shadcn primitives, the `WulfMark` component, and Better Auth's `signOut()` — no new state libs, no shadcn avatar primitive added.
**How to verify (user runs through this on a phone-sized viewport):**
1. **Start the dev server** if not already running: `npm run dev`. Pulse should start on http://localhost:3100.
2. **Open the mobile shell in a phone-sized viewport** — Chrome DevTools (F12) → toggle device toolbar (Ctrl+Shift+M / Cmd+Shift+M) → pick "iPhone 15 Pro" or any 390-414px wide device. Visit `http://localhost:3100/mobile/dashboard`.
3. **Verify the header (SHELL-02..04):**
- [ ] Sticky bar at the top with `bg-background/95 backdrop-blur` + bottom border
- [ ] Left side: Wulf "W" mark + "Pulse" wordmark; tapping it navigates to `/mobile/dashboard`
- [ ] Right side: a Bell icon button next to a small avatar circle (initials)
- [ ] No page title text in the header itself
- [ ] Bell button is keyboard-focusable (Tab to it, then Space/Enter — should not throw or navigate; it's a placeholder, no menu)
- [ ] Tapping the avatar opens the right-side Sheet drawer
4. **Verify the bottom nav (SHELL-06, NAV-01..03):**
- [ ] Fixed bar at the bottom, full width, `border-t bg-background`
- [ ] Five cells in order: Dashboard, Tickets, Finance, Analyzer, More
- [ ] Active tab uses `text-primary` (Wulf blue); inactive use `text-muted-foreground`
- [ ] Tapping each tab routes to its URL: `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance`, `/mobile/analyzer`
- [ ] Tapping a row INSIDE `/mobile/tickets/[id]` (e.g., open any ticket) keeps Tickets highlighted (active detection via `pathname.startsWith`)
- [ ] Tapping More opens the same drawer the avatar opens
- [ ] Visiting `/mobile/analyzer` shows the "Coming soon" placeholder card (NOT a 404)
5. **Verify the drawer (DRAWER-01..05):**
Open the drawer (avatar OR More).
- [ ] Drawer slides in from the right (`side="right"`)
- [ ] Section 1 "Mobile sections" — single row: Engagement (no `ExternalLink` hint icon)
- [ ] Section 2 "Full site" — five rows: Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync (each row has the `ExternalLink` icon on the right)
- [ ] Section 3 "Account" — shows the signed-in user's initials, name, and email; below it, a red "Sign out" button
- [ ] Tapping any row inside the drawer navigates AND closes the drawer
- [ ] Tapping the X / outside the drawer / pressing Esc closes it
- [ ] Sign out: tap it → page navigates to `/auth/sign-in` AND the user is signed out (refreshing brings you to the sign-in page; no auto-redirect to `/mobile`)
6. **Verify content does not hide under the bottom nav (SHELL-05):**
- [ ] On `/mobile/dashboard` (or any mobile page), scroll to the bottom of the content. The last visible content sits ABOVE the bottom nav, not under it.
- [ ] On a phone with a home indicator (or in DevTools with iPhone preset), the bottom nav has extra space below for the indicator inset (no overlap).
7. **Verify the old nav route is gone (DRAWER-06):**
- [ ] Visit `http://localhost:3100/mobile/nav` directly. It returns Next.js's 404 page (NOT the old standalone nav UI).
8. **Quick regression on existing pages:**
- [ ] `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance` all still render their previous content unchanged — only the chrome around them is new.
- [ ] `/mobile/tickets/[id]` (open a ticket) still renders inside the new shell.
**Resume signal:** Reply "approved" once all checks pass. If something is broken or off-spec, describe what you saw and which check failed (e.g., "Drawer opens from the bottom, not the right" or "Bottom nav overlaps the last content row on /mobile/finance"). The executor will fix and re-verify.
</action>
<verify>
<automated>echo "Manual verification — user must reply 'approved' or describe a failure. No automated check applicable; preceding tasks (1-3) verify code-level invariants."</automated>
</verify>
<acceptance_criteria>
- User replies "approved" after running the verification script above
- All 8 verification sections pass on the user's device/preview
- If any check fails, the executor returns to Task 1 or Task 2 to fix and re-runs Task 3 (build) and Task 4 (re-verify) before requesting approval again
</acceptance_criteria>
<done>User has explicitly replied "approved", confirming the new shell renders correctly on a phone-sized viewport and all 6 ROADMAP success criteria for Phase 2 are met.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → drawer Sign out | Reuses Plan 01's MoreDrawer; calls Better Auth `signOut()` and navigates to `/auth/sign-in` — same trust boundary as the existing top-bar `UserMenu`. |
| Browser → all Link routes | All routes already exist or are placeholders (`/mobile/analyzer` placeholder shipped in Plan 01). No new endpoints. |
## STRIDE Threat Register (ASVS-L1 baseline)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05 | Tampering | layout.tsx drawer-state useState | accept | Local React state, not URL-driven. An attacker cannot pre-open the drawer via crafted URL. State has no security relevance — it merely toggles UI visibility. |
| T-02-06 | Information Disclosure | Deletion of `/mobile/nav` route | accept | The deleted page surfaced no PII beyond what the new drawer surfaces (same email field). Net change: identical surface area. |
| T-02-07 | Denial of Service | New shell mounts on every `/mobile/*` request | accept | Layout is lightweight: 1 useState, 3 component imports, no fetches. Cost is negligible vs. the existing layout. |
| T-02-08 | Repudiation | Sign out action | mitigate | Better Auth records sign-out in its session table; not a Pulse-introduced repudiation surface. Inherited from `lib/auth-client.ts`. |
</threat_model>
<verification>
After this plan completes:
1. `app/mobile/layout.tsx` imports HeaderBar, BottomNav, MoreDrawer:
- `grep -E "@/components/mobile/HeaderBar" app/mobile/layout.tsx` matches
- `grep -E "@/components/mobile/BottomNav" app/mobile/layout.tsx` matches
- `grep -E "@/components/mobile/MoreDrawer" app/mobile/layout.tsx` matches
2. `app/mobile/nav/page.tsx` does not exist:
- `! test -f app/mobile/nav/page.tsx` exits 0
3. No source file references `/mobile/nav`:
- `! grep -r --include="*.ts" --include="*.tsx" "/mobile/nav" app components 2>/dev/null` exits 0
4. Build is clean:
- `npx tsc --noEmit --pretty` exits 0
- `npm run build` exits 0
5. Visual checkpoint passed (Task 4):
- Header sticky, brand link goes to `/mobile/dashboard`, Bell focusable with no menu, avatar opens drawer
- Bottom nav shows 5 cells with correct icons + routes; active tab uses `text-primary`; `/mobile/tickets/123` highlights Tickets
- Drawer (`side="right"`) shows 3 sections; Sign out signs out and lands on `/auth/sign-in`
- `/mobile/analyzer` renders the placeholder, NOT a 404
- `/mobile/nav` returns 404
- Content does not hide under the bottom nav (SHELL-05)
</verification>
<success_criteria>
- All 4 tasks complete (3 auto + 1 visual checkpoint with explicit "approved")
- `app/mobile/layout.tsx` is rewritten to use the new components with shared drawer state
- `app/mobile/nav/page.tsx` is deleted
- Type-check + build both pass
- Visual checkpoint approved by user
- Phase 2 ROADMAP success criteria #16 are all satisfied (header, 5-cell nav, drawer with 3 sections, sign-out flow, /mobile/nav gone, content not hidden under bar)
- All 15 phase requirements (SHELL-01..06, NAV-01..03, DRAWER-01..06) are now closed across Plan 01 + Plan 02
</success_criteria>
<output>
After completion, create `.planning/phases/02-mobile-shell-more-drawer/02-02-SUMMARY.md` documenting:
- Which requirements this plan addressed (SHELL-01, SHELL-05, DRAWER-06) and confirmation that combined with Plan 01 all 15 phase requirements are now satisfied
- The final wiring (layout owns one `useState`, both triggers share it)
- Any deviations from the plan during execution (e.g., if `pb-[calc(...)]` had to fall back to inline style)
- Visual checkpoint outcomes (which checks passed, any minor adjustments made)
- Notes for Phases 37: every mobile page lands inside this shell automatically; pages should NOT add their own header or bottom nav
</output>

View file

@ -0,0 +1,155 @@
---
phase: 02-mobile-shell-more-drawer
plan: 02
subsystem: mobile-shell
tags: [mobile, navigation, shell, drawer, layout, react-state]
dependency_graph:
requires:
- phase: 02-01
provides: HeaderBar, BottomNav, MoreDrawer components in components/mobile/
provides:
- app/mobile/layout.tsx (new shell wiring all three components with shared drawer state)
- DRAWER-06 fulfilled — app/mobile/nav/page.tsx deleted, /mobile/nav returns 404
affects:
- All /mobile/* pages — they automatically land inside the new shell (header + bottom nav visible)
- Phases 3-7 — page authors must NOT add their own header or bottom nav
tech-stack:
added: []
patterns:
- Single useState(drawerOpen) in layout.tsx shared between two triggers (avatar + More button)
- pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] for bottom-nav-aware main padding
- 'use client' layout with controlled Sheet drawer via child component props
key-files:
created: []
modified:
- app/mobile/layout.tsx
deleted:
- app/mobile/nav/page.tsx
key-decisions:
- "Single useState in layout.tsx — no Zustand, no Context — per CLAUDE.md no new state libraries"
- "pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] Tailwind 4 arbitrary value worked without fallback to inline style"
- "No redirect for /mobile/nav deletion — per DRAWER-06 spec, 404 is the desired behavior"
- "Cleared .next cache before type-check to resolve stale validator.ts reference to deleted page"
patterns-established:
- "Mobile layout owns all chrome (header, bottom nav, drawer) — mobile pages render content only"
- "Drawer state lifted to layout — single Sheet instance shared between multiple triggers"
requirements-completed: [SHELL-01, SHELL-05, DRAWER-06]
duration: 8min
completed: "2026-05-03"
---
# Phase 02 Plan 02: Mobile Shell Wiring Summary
**`app/mobile/layout.tsx` rewritten to wire HeaderBar + BottomNav + MoreDrawer with single shared useState, completing the Phase 2 mobile shell redesign**
## Performance
- **Duration:** ~8 min
- **Started:** 2026-05-03T20:03:00Z
- **Completed:** 2026-05-03T20:11:18Z
- **Tasks:** 3 auto + 1 visual checkpoint (auto-approved)
- **Files modified:** 2 (1 rewrite, 1 deletion)
## Accomplishments
- `app/mobile/layout.tsx` fully rewritten — sticky HeaderBar, scrollable main with bottom-nav-aware padding, fixed BottomNav, MoreDrawer with shared open state
- Single `useState(drawerOpen)` wires both the header avatar and the bottom-nav More button to the same drawer instance — no Zustand, no Context, no prop-drilling
- `app/mobile/nav/page.tsx` deleted; visiting `/mobile/nav` now returns Next.js 404 per DRAWER-06 spec
- TypeScript clean (`npx tsc --noEmit` exits 0) and full build clean (272 routes, `/mobile/nav` absent from route table)
- Combined with Plan 01, all 15 phase requirements (SHELL-01..06, NAV-01..03, DRAWER-01..06) are now satisfied
## Requirements Addressed
| Requirement | Description | Status |
|-------------|-------------|--------|
| SHELL-01 | `/mobile/layout.tsx` replaced in-place with new shell | Closed |
| SHELL-05 | Scrollable main with bottom-nav-aware padding (`pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]`) | Closed |
| DRAWER-06 | `/mobile/nav/page.tsx` deleted; no redirect; 404 on visit | Closed |
Combined with Plan 01, all 15 phase requirements are now closed:
| Plan | Requirements closed |
|------|---------------------|
| 02-01 | SHELL-02, SHELL-03, SHELL-04, SHELL-06, NAV-01, NAV-02, NAV-03, DRAWER-01, DRAWER-02, DRAWER-03, DRAWER-04, DRAWER-05 |
| 02-02 | SHELL-01, SHELL-05, DRAWER-06 |
## Task Commits
1. **Task 1: Rewrite app/mobile/layout.tsx** - `7a095fb` (feat)
2. **Task 2: Delete app/mobile/nav/page.tsx** - `2af7395` (feat)
3. **Task 3: Type-check + build gate** - no commit (gate-only task; Tasks 1-2 already committed)
4. **Task 4: Visual checkpoint** - auto-approved (auto mode active)
## Files Created/Modified
- `app/mobile/layout.tsx` — Rewritten; now imports HeaderBar/BottomNav/MoreDrawer, owns single useState for drawer, pads main content to clear bottom nav + safe-area inset
- `app/mobile/nav/page.tsx` — Deleted; 91 lines removed; standalone nav page replaced by MoreDrawer Sheet
## Decisions Made
- **Single useState in layout.tsx** — No new state libs per CLAUDE.md constraint. Both `onAvatarClick` and `onMoreClick` call `() => setDrawerOpen(true)`; the Radix Sheet's `onOpenChange` propagates close events back through `setDrawerOpen`.
- **Tailwind 4 arbitrary value worked**`pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` was accepted by the Tailwind 4 build without needing the inline-style fallback documented in the plan.
- **No redirect on /mobile/nav deletion** — Per CONTEXT.md DRAWER-06: "URL was never bookmarked-worthy." Standard 404 is correct behavior.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Cleared .next cache before type-check**
- **Found during:** Task 3 (type-check gate)
- **Issue:** `.next/types/validator.ts` contained a stale generated reference to `../../app/mobile/nav/page.js` from a previous build. Running `npx tsc --noEmit` immediately after deleting the file produced TS2307 on this generated file.
- **Fix:** `rm -rf .next` before re-running `npx tsc --noEmit`. The generated validator regenerates on build and does not include the deleted route.
- **Files modified:** None (cache directory, not source)
- **Verification:** Type-check exits 0 after cache clear; subsequent `npm run build` also exits 0
- **Committed in:** Not committed (cache directory is gitignored)
---
**Total deviations:** 1 auto-fixed (1 blocking — stale build cache)
**Impact on plan:** Necessary to unblock the type-check gate. No source file changes required.
## Issues Encountered
None beyond the stale .next cache (documented above as a deviation).
## User Setup Required
None — no external service configuration required.
## Notes for Phases 37
Every `/mobile/*` page now automatically renders inside the new shell. **Page authors must NOT add their own header or bottom nav.** The layout provides:
- Sticky `<HeaderBar>` at the top (brand + Bell placeholder + avatar → drawer)
- Scrollable `<main>` with bottom padding pre-applied (clears the 64px bottom nav + safe-area inset)
- Fixed `<BottomNav>` at the bottom (4 tabs + More → drawer)
- `<MoreDrawer>` (Sheet side="right") with three sections (Mobile sections / Full site / Account + Sign out)
Pages should render their own `<h1>` and content — the chrome is fully handled by the layout.
## Known Stubs
None introduced by this plan. (Existing stubs from Plan 01 carry forward: analyzer placeholder card and Bell empty onClick — both intentional and documented in 02-01-SUMMARY.md.)
## Threat Flags
None — no new network endpoints, no new auth paths, no new DB access. Layout is purely client-side React state + UI composition.
## Self-Check: PASSED
Files verified:
- `app/mobile/layout.tsx` — exists, imports all three components, contains useState + drawerOpen + safe-area-inset-bottom
- `app/mobile/nav/page.tsx` — does not exist (deleted)
- `/mobile/nav` absent from `npm run build` route table
Commits verified:
- `7a095fb` — feat(02-02): rewrite mobile layout
- `2af7395` — feat(02-02): delete app/mobile/nav/page.tsx

View file

@ -0,0 +1,142 @@
# Phase 02: Mobile Shell + More Drawer — Context
**Gathered:** 2026-05-03
**Status:** Ready for planning
**Source:** PRD Express Path (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`)
<domain>
## Phase Boundary
This phase delivers the new `/mobile/*` shell — the chrome that wraps every mobile page going forward. Specifically:
1. A new `app/mobile/layout.tsx` with: sticky header (Wulf mark + Bell + avatar), scrollable content area, fixed bottom nav.
2. A 5-cell bottom nav: 4 primary tabs (Dashboard / Tickets / Finance / Analyzer) + a 5th "More" cell.
3. A shadcn `Sheet` drawer that fully replaces the standalone `/mobile/nav` page — opened from the More cell AND from the header avatar — with three sections (Mobile sections, Full site, Account).
4. Deletion of `app/mobile/nav/page.tsx` in the same change.
The four primary tabs route to existing pages (Dashboard, Tickets, Finance) plus `/mobile/analyzer` which does NOT yet exist as a route — Phase 2 only adds the **nav entry**; Phase 6 builds the page itself. Until Phase 6, tapping Analyzer should land on a placeholder route or 404 — the planner should pick one and keep it consistent (recommend a minimal placeholder `app/mobile/analyzer/page.tsx` returning "Coming soon" so the bottom nav doesn't 404).
**Out of this phase:** content of any mobile page (Dashboard restyle, Tickets restyle, etc.) — those are Phases 37.
</domain>
<decisions>
## Implementation Decisions
### Routes & files (locked)
- **Replace in place** — no `/mobile-v2`, no parallel routes. Edit `app/mobile/layout.tsx` directly.
- **Delete `app/mobile/nav/page.tsx`** as part of the drawer ship — same commit/PR as the new layout.
- **Add a placeholder `app/mobile/analyzer/page.tsx`** so the new bottom-nav Analyzer tab doesn't 404 before Phase 6 lands. Minimal "coming soon" component is sufficient. Mark this as deferred-cleanup in the plan so Phase 6 knows it owns the real page.
- All existing `/mobile/*` paths preserved (`/mobile/dashboard`, `/mobile/tickets`, `/mobile/tickets/[id]`, `/mobile/finance`, `/mobile/engagement` if it exists).
### Header (`SHELL-02..04`)
- Sticky top, `bg-background/95 backdrop-blur`, bottom border.
- **Left slot:** Wulf mark logo (use the actual brand mark asset, NOT a text-only fallback) + "Pulse" wordmark. Linked to `/mobile/dashboard`.
- **Right slot order:** `Bell` icon button → compact `Avatar` (h-7 w-7).
- Bell: placeholder only — `aria-label="Notifications"`, empty `onClick`, keyboard-focusable. NO menu, NO badge logic, NO popover. Phase out-of-scope says "Real notification list behind the Bell icon — placeholder only this iteration".
- Avatar: tapping opens the More drawer (acts as second entry point; the bottom-bar More button is the first).
- **No page title in the header** — pages render their own H1.
- Honor `pt-safe` (or `pt-[env(safe-area-inset-top)]`) — Phase 1's `pt-safe` utility is available in `app/styles/brand.css`.
### Content area (`SHELL-05`)
- `<main>` between header and bottom nav, scrollable.
- Bottom padding = bottom-nav height + safe-area inset, so content doesn't hide under the bar. Use `pb-safe` (Phase 1 utility) plus a fixed offset for the nav (e.g., `pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` or similar — pick a concrete value during planning).
### Bottom nav (`SHELL-06`, `NAV-01..03`)
- Fixed, full-width, `border-t bg-background`, wrapped in `max-w-lg mx-auto` to share the gutter with content.
- 5 cells: 4 tabs + More.
- **Tabs** (in order, left to right):
| Tab | Icon (lucide) | Route |
|------------|-------------------|---------------------|
| Dashboard | `LayoutDashboard` | `/mobile/dashboard` |
| Tickets | `Ticket` | `/mobile/tickets` |
| Finance | `DollarSign` | `/mobile/finance` |
| Analyzer | `Sparkles` | `/mobile/analyzer` |
- Active state: `text-primary`. Inactive: `text-muted-foreground`. Active detection via `pathname.startsWith(href)`.
- **More cell** (5th): `Menu` icon labeled "More". Opens the Sheet drawer.
### More drawer (`DRAWER-01..06`)
- shadcn `Sheet` component. **Pick `right` OR `bottom` and stay consistent** — recommend `right` for one-handed reachability with right-thumb users; planner can override. Document the choice in the plan.
- **Three sections, top-to-bottom:**
1. **Mobile sections** — Engagement (`/mobile/engagement`). (No `ExternalLink` icon — it's an in-shell route.)
2. **Full site** — link list to desktop-only pages, each with `ExternalLink` icon hint:
- Quotes
- Configuration Items
- Backup Status
- Ticket Digest
- Admin / Sync
3. **Account** — current user (avatar + email, read-only display) and a Sign out action.
- Sign out: `signOut()` then `router.push('/auth/sign-in')`. Use the existing Better Auth `signOut` from `lib/auth-client.ts` (or wherever the client SDK exports it — planner verifies during build).
- **Replaces `/mobile/nav` page** — delete `app/mobile/nav/page.tsx`. Anyone navigating to `/mobile/nav` directly should not see the old standalone page; either redirect to `/mobile/dashboard` or rely on Next.js 404. Recommend NO redirect (just delete) — the URL was never bookmarked-worthy.
### Existing infrastructure to consume
- **Auth/session:** `useSession()` from Better Auth client (existing). `lib/auth-client.ts`.
- **Theme tokens / Wulf brand:** `app/styles/brand.css` provides `--wulf-blue`, `pt-safe`, `pb-safe` (from Phase 1).
- **Brand assets:** `/public/branding/wulf-mark.png` (mark) and `/public/branding/wulf-wordmark.png` (wordmark) — used in header.
- **shadcn primitives:** `Sheet`, `Avatar`, `Button` are already in `components/ui/`. Verify before planning; if any are missing, planner adds via `npx shadcn add`.
### Claude's Discretion
- Exact bottom-nav height (recommend `h-16` = 64px to match touch-target guidelines).
- Bottom nav micro-typography (label size, icon size) — keep consistent with existing mobile look.
- Animation/transition specifics for the Sheet (use shadcn defaults).
- Whether the avatar in the header is a real user image or initials fallback — use `Avatar` with `AvatarImage` + `AvatarFallback` (initials). Planner picks the source.
- File organization for the new shell components (e.g., `components/mobile/HeaderBar.tsx`, `components/mobile/BottomNav.tsx`, `components/mobile/MoreDrawer.tsx`) — encouraged for testability and Phase 3+ reuse, but not required by the spec.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Spec & roadmap (load-bearing — every decision derives from these)
- `docs/superpowers/specs/2026-05-03-mobile-shell-design.md` — full spec; §3 (Navigation) and §5 (Shell) are the primary scope of this phase. §3.2 (More drawer) is the secondary scope. §1, §2, §7 set goal/audience/non-goals.
- `.planning/ROADMAP.md` — Phase 2 entry with 6 success criteria.
- `.planning/REQUIREMENTS.md` — IDs SHELL-01..06, NAV-01..03, DRAWER-01..06 (15 total).
### Project conventions
- `CLAUDE.md` — repo guide. Tailwind 4, shadcn/ui, no new state libs, no SWR/react-query, no Zod in API routes.
- `DESIGN.md` — design tokens, navigation IA, current cleanup backlog (read before touching shared UI).
- `ARCHITECTURE.md` — read if questions about data flow / runtime topology arise (not strictly needed for shell work).
### Existing code to read or pattern-match against
- `app/mobile/layout.tsx` — current layout, replaced by this phase. Read to understand what's there before deleting.
- `app/mobile/nav/page.tsx` — current standalone nav page, replaced by drawer + deleted.
- `app/layout.tsx` — root layout, NOT modified by this phase. Already exports manifest + viewport from Phase 1.
- `app/styles/brand.css``pt-safe`, `pb-safe`, `--wulf-blue` available; new utilities can be added here if needed.
- `components/ui/sheet.tsx`, `components/ui/avatar.tsx`, `components/ui/button.tsx` — shadcn primitives. Planner reads these for prop signatures.
- `lib/auth-client.ts` — Better Auth client SDK. `signOut()` lives here.
- `components/navigation/app-navigation.tsx` — DESKTOP nav component. Reference only — DO NOT modify; this phase only touches `/mobile/*`.
</canonical_refs>
<specifics>
## Specific Ideas
- **Wulf mark + wordmark:** use `<Image src="/branding/wulf-mark.png" />` + the wordmark image side-by-side; or rebuild as inline SVG if existing brand component is available. Planner verifies during build.
- **Sign-out flow:** `await signOut(); router.push('/auth/sign-in');` — single `onClick` handler on the Sign out button. Wrap in try/catch and toast on failure (sonner is the project's toast library).
- **Active tab detection:** `pathname.startsWith(href)` — handles nested routes (e.g., `/mobile/tickets/123` highlights Tickets). Edge case: `/mobile/dashboard` matches `/mobile/dashboard/anything` correctly; the four routes are non-overlapping prefixes so no special-case needed.
- **Drawer entry from header avatar:** lift the Sheet's `open` state to a layout-level state hook (or use a small Zustand-free pattern with React state + ref) so both the bottom-bar More button AND the avatar can trigger it.
- **Sheet side choice:** spec says "right or bottom — pick one and stay consistent". Recommend `right` (more natural for a settings-style menu). If `bottom`, ensure it doesn't conflict with the bottom nav bar visually.
</specifics>
<deferred>
## Deferred Ideas
- **Real notification list behind the Bell** — explicitly out of scope (§7). Bell stays a placeholder.
- **Tablet breakpoint** (`md:max-w-2xl`) — explicitly deferred (§4, §7). Keep `max-w-lg`.
- **Service worker / offline** — explicitly out of scope across all phases (§4, §7).
- **Dashboard / Tickets / Finance / Analyzer / Engagement page restyles** — Phases 38.
- **`/mobile/analyzer` real page** — Phase 6. This phase only adds the nav entry + a placeholder page (or accepts the route 404s until Phase 6 — planner picks).
- **Theme toggle in drawer** — not in spec. Don't add.
- **Sign-out confirmation dialog** — not in spec. Single tap signs out.
</deferred>
---
*Phase: 02-mobile-shell-more-drawer*
*Context gathered: 2026-05-03 via PRD Express Path (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`)*

View file

@ -0,0 +1,52 @@
---
status: partial
phase: 02-mobile-shell-more-drawer
source: [02-VERIFICATION.md]
started: 2026-05-03T20:30:00Z
updated: 2026-05-03T20:30:00Z
---
## Current Test
[awaiting human testing]
## Tests
### 1. WulfMark brand image renders correctly
expected: Wulf "W" mark logo + "Pulse" wordmark visible on left of sticky header at 390px viewport — not a broken image icon.
result: [pending]
### 2. Drawer slides in from the right; both triggers open the same drawer
expected: Tapping the header avatar OR the bottom-nav "More" button slides the same Sheet drawer in from the right edge. No double-drawer flicker.
result: [pending]
### 3. Drawer sections render in correct order with ExternalLink placement
expected: Three sections: (1) Mobile sections — Engagement only, no external-link icon; (2) Full site — 5 rows each with ExternalLink icon on right; (3) Account — real signed-in user identity + red Sign out button.
result: [pending]
### 4. Sign out completes the full auth flow
expected: Tapping Sign out navigates to /auth/sign-in and the Better Auth session is fully terminated (refresh stays on sign-in). No error toast.
result: [pending]
### 5. Nested-route active tab highlighting
expected: On /mobile/tickets/123 (or any nested ticket route), the Tickets tab in the bottom nav shows text-primary (active color); other tabs remain muted.
result: [pending]
### 6. Content does not hide under bottom nav (SHELL-05)
expected: Last content item on /mobile/dashboard scrolls fully above the bottom nav. On a device with a home indicator, the nav reserves safe-area inset below the "More" label.
result: [pending]
### 7. /mobile/nav returns 404
expected: Visiting http://localhost:3100/mobile/nav directly returns the Next.js 404 page — the old standalone nav UI does not appear.
result: [pending]
## Summary
total: 7
passed: 0
issues: 0
pending: 7
skipped: 0
blocked: 0
## Gaps

View file

@ -0,0 +1,184 @@
---
phase: 02-mobile-shell-more-drawer
verified: 2026-05-03T20:30:00Z
status: human_needed
score: 15/15 must-haves verified
human_verification:
- test: "Open /mobile/dashboard in DevTools mobile view (390px wide). Confirm sticky header renders: Wulf mark logo (image, not placeholder) + 'Pulse' wordmark on the left, Bell icon + avatar circle on the right. No page title text in the header bar itself."
expected: "Sticky bar at top with bg-background/95 backdrop-blur + bottom border. WulfMark image visible. 'Pulse' text beside it. Bell and initials circle on the right. No h1/title text inside the header."
why_human: "Visual rendering of the WulfMark brand image (variant='mark') cannot be verified by grep — the component may render correctly or fall back silently depending on image availability."
- test: "Tap the avatar circle in the header. Verify the More drawer opens from the right side. Then close it. Tap the More button in the bottom nav. Verify the same drawer opens again from the right."
expected: "A single Sheet drawer slides in from the right (side=right). Both triggers open the same drawer instance (not two separate drawers). Pressing Esc or tapping outside closes it."
why_human: "Shared-state drawer wiring and slide-in animation cannot be confirmed by static code inspection alone — requires runtime observation."
- test: "Open the More drawer. Verify three sections in order: (1) 'Mobile sections' with a single Engagement row and NO ExternalLink icon on it; (2) 'Full site' with Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync — each with an ExternalLink icon on the right; (3) 'Account' showing the signed-in user's name/email and a red Sign out button."
expected: "Sections render in correct order. Engagement row has no ExternalLink hint. All five Full site rows have ExternalLink icons. Account section shows real user identity from session."
why_human: "Runtime session data (user.name, user.email) and conditional rendering of the user identity block require a live session to verify. The ExternalLink icon placement is visually confirmed in code but the section ordering and visual grouping require runtime inspection."
- test: "Tap Sign out in the More drawer. Verify it navigates to /auth/sign-in and the user is fully signed out (refreshing the page returns to sign-in, not an authenticated mobile page)."
expected: "signOut() is called, then router.push('/auth/sign-in') executes. The Better Auth session is terminated. No redirect loop, no 500 error."
why_human: "Auth session termination and redirect behavior requires a live auth session and network interaction with the Better Auth backend."
- test: "On /mobile/tickets, open a ticket detail (e.g. /mobile/tickets/123). With the detail page open, verify the Tickets tab in the bottom nav is still highlighted (active state, text-primary color)."
expected: "pathname.startsWith('/mobile/tickets') returns true for /mobile/tickets/123, so the Tickets tab shows text-primary. Other tabs show text-muted-foreground."
why_human: "Active tab highlighting for nested routes (/mobile/tickets/[id]) requires runtime navigation to confirm the startsWith logic produces the correct visual state."
- test: "Scroll to the bottom of content on /mobile/dashboard (or any mobile page with enough content to scroll). Verify the last content row is visible ABOVE the bottom nav — not hidden underneath it."
expected: "The pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))] padding on <main> ensures content does not hide under the 64px bottom nav. On a phone with a home indicator, there is also adequate space below the More button."
why_human: "CSS safe-area-inset-bottom value is device-specific and cannot be computed statically. Requires visual inspection on a phone-sized viewport, ideally a device with a home indicator."
- test: "Navigate directly to http://localhost:3100/mobile/nav. Verify it returns the Next.js 404 page and does NOT render the old standalone nav UI."
expected: "Standard Next.js 404 page. The old nav page content (links list) does not appear."
why_human: "Requires a running dev server to confirm the 404 response and that Next.js routing correctly falls through to the 404 page."
---
# Phase 2: Mobile Shell + More Drawer Verification Report
**Phase Goal:** Every `/mobile/*` page renders inside a new layout — sticky header (Wulf mark + Bell placeholder + avatar), scrollable content, and a 5-cell bottom nav whose fifth control opens a Sheet drawer that fully replaces `/mobile/nav`.
**Verified:** 2026-05-03T20:30:00Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `app/mobile/layout.tsx` mounts HeaderBar + BottomNav + MoreDrawer with shared `useState` drawer state | VERIFIED | layout.tsx imports all three; single `drawerOpen` state; both `onAvatarClick` and `onMoreClick` call `() => setDrawerOpen(true)`; `MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen}` |
| 2 | HeaderBar has Wulf mark + Pulse wordmark linked to /mobile/dashboard, Bell placeholder (aria-label="Notifications", empty onClick), avatar trigger | VERIFIED | `WulfMark variant="mark"`, `<span>Pulse</span>`, `href="/mobile/dashboard"`, `aria-label="Notifications"` with empty onClick body, avatar button calling `onAvatarClick` |
| 3 | `<main>` has bottom padding clearing bottom nav height (h-16=64px) + safe-area inset | VERIFIED | `pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` on `<main>` in layout.tsx line 28 |
| 4 | BottomNav has 4 link tabs (Dashboard, Tickets, Finance, Analyzer) + More button = 5 cells | VERIFIED | TABS const has 4 entries with LayoutDashboard/Ticket/DollarSign/Sparkles icons; fifth `<button>` with Menu icon labeled "More" |
| 5 | /mobile/analyzer route exists and does not 404 | VERIFIED | `app/mobile/analyzer/page.tsx` exists, exports `MobileAnalyzerPlaceholder`, renders "Analyzer feed coming soon" card; no data fetching |
| 6 | Active tab uses `pathname.startsWith(href)` | VERIFIED | Line 46: `const active = pathname?.startsWith(href) ?? false` — handles nested routes like /mobile/tickets/123 |
| 7 | MoreDrawer is a single Sheet opened by both header avatar AND More button (shared state) | VERIFIED | Single `useState(false)` in layout.tsx; both trigger callbacks set it true; MoreDrawer receives controlled `open`/`onOpenChange` props |
| 8 | Drawer uses `side="right"` | VERIFIED | `<SheetContent side="right" ...>` at MoreDrawer.tsx line 79 |
| 9 | Mobile sections row = Engagement only, NO ExternalLink icon | VERIFIED | MOBILE_SECTIONS array has only `/mobile/engagement`; its JSX template renders `<Icon>` + `<span>` with no ExternalLink; ExternalLink only renders in DESKTOP_LINKS map |
| 10 | Full site rows have ExternalLink hint icons | VERIFIED | DESKTOP_LINKS map renders `<ExternalLink className="w-3.5 h-3.5 ...">` after each link label (line 121) |
| 11 | Account section: user identity (read-only) + Sign out via Better Auth | VERIFIED | `useSession()` populates user; initials + name + email rendered; `handleSignOut()` calls `await signOut()` then `router.push('/auth/sign-in')` |
| 12 | `app/mobile/nav/page.tsx` deleted | VERIFIED | `test -f app/mobile/nav/page.tsx` exits non-zero; no references to `/mobile/nav` found in any .ts/.tsx file |
| 13 | TypeScript compiles clean | VERIFIED | `npx tsc --noEmit --pretty` exits 0 with no output |
| 14 | WulfMark component wired from correct import | VERIFIED | `from '@/components/branding/wulf-mark'`; `components/branding/wulf-mark.tsx` exports `WulfMark`; `variant="mark"` used |
| 15 | Sheet + auth-client properly imported in MoreDrawer | VERIFIED | `from '@/components/ui/sheet'` with SheetContent/SheetHeader/SheetTitle/SheetDescription/SheetClose; `from '@/lib/auth-client'` with `useSession, signOut` |
**Score:** 15/15 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `app/mobile/layout.tsx` | New shell — HeaderBar + BottomNav + MoreDrawer with shared state | VERIFIED | 38 lines; imports all three components; useState; both triggers wired; safe-area padding on main |
| `components/mobile/HeaderBar.tsx` | Sticky header — WulfMark+wordmark link, Bell placeholder, avatar trigger | VERIFIED | 72 lines; sticky top-0; bg-background/95 backdrop-blur; border-b; pt-safe; WulfMark; Pulse wordmark; Bell; h-7 w-7 avatar; onAvatarClick |
| `components/mobile/BottomNav.tsx` | Fixed bottom tab bar — 4 tabs + More button | VERIFIED | 76 lines; fixed bottom-0; border-t; max-w-lg mx-auto; h-16; 4 TABS const + More button; pathname.startsWith; pb-safe |
| `components/mobile/MoreDrawer.tsx` | shadcn Sheet (side=right) with 3 sections | VERIFIED | 167 lines; Sheet side=right; controlled open/onOpenChange; 3 named sections; signOut + /auth/sign-in |
| `app/mobile/analyzer/page.tsx` | Placeholder route so Analyzer tab resolves | VERIFIED | Server component; "Analyzer feed coming soon" card; no fetch calls |
| `app/mobile/nav/page.tsx` | DELETED | VERIFIED | File does not exist; no references in source files |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `app/mobile/layout.tsx` | `components/mobile/HeaderBar.tsx` | import + render with onAvatarClick | WIRED | Line 14+23: imported and rendered with `onAvatarClick={() => setDrawerOpen(true)}` |
| `app/mobile/layout.tsx` | `components/mobile/BottomNav.tsx` | import + render with onMoreClick | WIRED | Line 15+32: imported and rendered with `onMoreClick={() => setDrawerOpen(true)}` |
| `app/mobile/layout.tsx` | `components/mobile/MoreDrawer.tsx` | import + render with shared open/onOpenChange | WIRED | Line 16+34: `<MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />` |
| `components/mobile/HeaderBar.tsx` | `components/branding/wulf-mark.tsx` | WulfMark import (variant='mark') | WIRED | Line 14+43: imported and rendered `<WulfMark variant="mark" className="h-6 w-auto" />` |
| `components/mobile/MoreDrawer.tsx` | `components/ui/sheet.tsx` | Sheet, SheetContent, SheetTrigger imports | WIRED | Line 26-32: all Sheet primitives imported; `<Sheet open={open}>` + `<SheetContent side="right">` used |
| `components/mobile/MoreDrawer.tsx` | `lib/auth-client.ts` | signOut + useSession imports | WIRED | Line 33: `import { useSession, signOut } from '@/lib/auth-client'`; both called in component body |
| `components/mobile/BottomNav.tsx` | `/mobile/analyzer` | Analyzer tab href | WIRED | Line 29: `{ href: '/mobile/analyzer', label: 'Analyzer', icon: Sparkles }` in TABS |
| Header avatar AND Bottom-nav More button | MoreDrawer open state | Single useState in layout.tsx | WIRED | Both callbacks reference same `drawerOpen`/`setDrawerOpen`; single Sheet instance |
### Data-Flow Trace (Level 4)
Not applicable. These are pure UI shell components. No dynamic data is fetched in this phase — the layout, header, and bottom nav render purely from client-side React state and the existing Better Auth session (which is an ambient resource, not a fetch introduced by this phase).
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| TypeScript compiles with all 5 new files | `npx tsc --noEmit --pretty` | Exit 0, no output | PASS |
| `app/mobile/nav/page.tsx` deleted, no references | `test -f app/mobile/nav/page.tsx` | Exit 1 (non-existent) | PASS |
| No `/mobile/nav` references in source | `grep -rn "/mobile/nav" app/ components/` | Zero matches | PASS |
| MoreDrawer side=right locked | `grep -n 'side="right"' components/mobile/MoreDrawer.tsx` | Line 79: `<SheetContent side="right"` | PASS |
| Engagement in MOBILE_SECTIONS (no ExternalLink) | Read MoreDrawer.tsx section 1 JSX | No ExternalLink in MOBILE_SECTIONS map template | PASS |
| Shared state (not two Sheet instances) | `grep -n "Sheet\|useState" app/mobile/layout.tsx` | Single useState, single MoreDrawer render | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| SHELL-01 | 02-02 | New layout.tsx replaces current layout (rebuild in place) | SATISFIED | layout.tsx rewritten in-place; imports HeaderBar/BottomNav/MoreDrawer |
| SHELL-02 | 02-01 | Sticky header: bg-background/95 backdrop-blur + border-b; WulfMark + Pulse wordmark → /mobile/dashboard | SATISFIED | HeaderBar line 35: `sticky top-0 z-30 bg-background/95 backdrop-blur ... border-b pt-safe`; WulfMark + wordmark linked to /mobile/dashboard |
| SHELL-03 | 02-01 | Bell icon button (placeholder, no menu/badge, aria-label="Notifications", empty onClick) | SATISFIED | HeaderBar lines 50-55: `aria-label="Notifications"`, empty onClick comment `/* SHELL-03: placeholder */` |
| SHELL-04 | 02-01 | Compact avatar h-7 w-7; tapping opens More drawer | SATISFIED | HeaderBar line 64: `h-7 w-7 items-center justify-center rounded-full`; `onClick={onAvatarClick}` |
| SHELL-05 | 02-02 | `<main>` scrollable with bottom padding = bottom-nav height + safe-area | SATISFIED | layout.tsx line 28: `pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` on `<main>` |
| SHELL-06 | 02-01 | Fixed bottom nav: border-t bg-background, max-w-lg mx-auto, 5 cells | SATISFIED | BottomNav line 42-44: `fixed bottom-0 ... border-t bg-background pb-safe`; `max-w-lg mx-auto flex h-16`; 4 tabs + More |
| NAV-01 | 02-01 | 4 equal-width primary tabs: Dashboard/Tickets/Finance/Analyzer with correct icons | SATISFIED | TABS const with LayoutDashboard/Ticket/DollarSign/Sparkles; all flex-1 |
| NAV-02 | 02-01 | Tabs route to /mobile/dashboard, /mobile/tickets, /mobile/finance, /mobile/analyzer | SATISFIED | TABS hrefs verified; /mobile/analyzer page exists (no 404) |
| NAV-03 | 02-01 | Active=text-primary, inactive=text-muted-foreground; detection via pathname.startsWith(href) | SATISFIED | BottomNav lines 46+53-55: startsWith check; text-primary when active |
| DRAWER-01 | 02-01 | Fifth bottom-bar control "More" with Menu icon opens Sheet | SATISFIED | BottomNav More button with Menu icon; calls onMoreClick → setDrawerOpen(true) → Sheet opens |
| DRAWER-02 | 02-01 | Sheet uses consistent side: right | SATISFIED | MoreDrawer line 79: `side="right"` (locked decision per CONTEXT.md) |
| DRAWER-03 | 02-01 | "Mobile sections": Engagement (/mobile/engagement), no ExternalLink icon | SATISFIED | MOBILE_SECTIONS = [{href: '/mobile/engagement', ...}]; section 1 JSX has no ExternalLink in template |
| DRAWER-04 | 02-01 | "Full site": Quotes/Config Items/Backup Status/Ticket Digest/Admin Sync each with ExternalLink | SATISFIED | DESKTOP_LINKS has all 5 entries; section 2 JSX renders ExternalLink after each label |
| DRAWER-05 | 02-01 | "Account": user (avatar + email read-only) + Sign out → signOut() then /auth/sign-in | SATISFIED | useSession provides user data; handleSignOut calls `await signOut()` then `router.push('/auth/sign-in')` with try/catch + toast |
| DRAWER-06 | 02-02 | app/mobile/nav/page.tsx deleted in same change | SATISFIED | File does not exist; git log shows commit 2af7395 deleted it; zero /mobile/nav references in codebase |
All 15 requirements: SATISFIED
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `app/mobile/analyzer/page.tsx` | 25 | "Analyzer feed coming soon" — deliberate placeholder | Info | Intentional stub. Documented in CONTEXT.md deferred section and 02-01-SUMMARY.md. Phase 6 (ANL-01..06) owns replacement. Does not block Phase 2 goal. |
| `components/mobile/HeaderBar.tsx` | 50-51 | Bell onClick is empty `{}` | Info | Intentional placeholder per SHELL-03. Phase 7+ (NOTIF-01) wires real notifications. Documented as deferred in REQUIREMENTS.md v2. |
No blockers. No warnings. Both items are explicitly documented as intentional deferred stubs.
### Human Verification Required
#### 1. WulfMark brand image renders correctly
**Test:** Open `/mobile/dashboard` in DevTools mobile view (390px). Confirm the Wulf "W" mark image renders visibly next to the "Pulse" wordmark in the sticky header — not a broken image icon or empty space.
**Expected:** The Wulf mark logo (PNG/SVG from `components/branding/wulf-mark.tsx`) appears as an image on the left of the header bar, with the "Pulse" text beside it.
**Why human:** WulfMark renders via a Next.js Image component with a file path — image availability and rendering cannot be confirmed by static code analysis.
#### 2. Drawer slides in from the right; both triggers open the same drawer
**Test:** Visit any `/mobile/*` page. Tap the avatar circle in the top-right header. Observe the drawer animation and side. Close it. Tap "More" in the bottom nav. Confirm the same drawer opens again.
**Expected:** Sheet animates in from the right edge. No double-drawer flicker. Both entry points control the same drawer state.
**Why human:** Radix Sheet animation and single-instance behavior requires runtime DOM observation.
#### 3. Drawer sections render in correct visual order with correct ExternalLink placement
**Test:** Open the More drawer. Visually confirm: (a) "Mobile sections" header above Engagement row — Engagement has NO external link icon; (b) "Full site" header above 5 rows, each with ExternalLink icon on the far right; (c) "Account" section at the bottom with signed-in user's name/email and red Sign out button.
**Expected:** Three visually distinct sections. Engagement row clean (no hint icon). Full site rows each have the ExternalLink icon. User identity shows real session data.
**Why human:** User identity requires a live session. Visual section separation and icon placement require rendering confirmation.
#### 4. Sign out completes the full auth flow
**Test:** While signed in, open the drawer and tap "Sign out". Observe: (a) page navigates to `/auth/sign-in`; (b) refreshing the browser returns to sign-in, not an authenticated page.
**Expected:** Better Auth session is terminated server-side. No residual session cookie causes auto-redirect to /mobile. No error toast appears.
**Why human:** Auth session termination requires a live Better Auth backend call and cookie inspection.
#### 5. Nested-route active tab highlighting
**Test:** Navigate to a ticket detail page (e.g. `/mobile/tickets/123`). Observe the bottom nav.
**Expected:** The "Tickets" tab shows `text-primary` color. All other tabs remain `text-muted-foreground`.
**Why human:** `pathname.startsWith` behavior on nested routes requires runtime navigation to confirm the correct tab lights up.
#### 6. Content does not hide under bottom nav (SHELL-05)
**Test:** On `/mobile/dashboard` or any content-rich mobile page, scroll to the very bottom. Also test on a device/emulation with a home indicator (iPhone preset in DevTools).
**Expected:** Last content item is fully visible above the bottom nav. On home-indicator devices, the nav bar has visible padding below the "More" button text (the `pb-safe` utility reserves the inset space).
**Why human:** `env(safe-area-inset-bottom)` is device-dependent. The CSS calc expression cannot be validated without a rendered viewport.
#### 7. `/mobile/nav` returns 404 (not old UI)
**Test:** Visit `http://localhost:3100/mobile/nav` directly in a browser.
**Expected:** Next.js default 404 page. The old standalone navigation UI (list of links) does not appear.
**Why human:** Requires running dev/prod server to confirm Next.js routing response.
### Gaps Summary
No gaps. All 15 phase requirements are satisfied with substantive, wired implementations. The two documented stubs (analyzer placeholder, Bell empty onClick) are explicitly deferred by the spec and REQUIREMENTS.md and do not block the Phase 2 goal.
7 items require human verification — primarily visual/runtime behaviors that cannot be confirmed by static analysis (image rendering, animation, auth session termination, safe-area CSS, active tab color).
---
_Verified: 2026-05-03T20:30:00Z_
_Verifier: Claude (gsd-verifier)_

View file

@ -0,0 +1,578 @@
---
phase: 03-dashboard-restyle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/api/mobile/dashboard/route.ts
- components/mobile/KpiCardMobile.tsx
- components/mobile/NeedsAttentionStrip.tsx
- components/mobile/WorkerStatusRow.tsx
autonomous: true
requirements:
- DASH-01
- DASH-02
- DASH-03
objective: |
Reshape the /api/mobile/dashboard response and ship three presentational
components (KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow) so plan
02 can wire them into the page body without exploring the codebase.
must_haves:
truths:
- "GET /api/mobile/dashboard returns kpis (4 entries), needsAttention (3 entries), and workers (3 entries) in a single round-trip"
- "KpiCardMobile renders a phone-sized KPI card with label, value, optional caption — no chart, no recharts import"
- "NeedsAttentionStrip renders a horizontal-scrolling strip of compact attention cards, each linking to a destination URL"
- "WorkerStatusRow renders a 3-cell status row (Analyzer worker, RMM worker, backup success rate) with status indicators that link to desktop admin pages"
artifacts:
- path: "app/api/mobile/dashboard/route.ts"
provides: "Single GET endpoint shaped for the new mobile dashboard sections"
contains: "kpis: [], needsAttention: [], workers: ["
- path: "components/mobile/KpiCardMobile.tsx"
provides: "Reusable phone-sized KPI card component"
exports: ["KpiCardMobile"]
- path: "components/mobile/NeedsAttentionStrip.tsx"
provides: "Horizontal-scroll strip rendering NeedsAttention cards"
exports: ["NeedsAttentionStrip", "NeedsAttentionItem"]
- path: "components/mobile/WorkerStatusRow.tsx"
provides: "Compact 3-cell worker/backup status row"
exports: ["WorkerStatusRow", "WorkerStatusEntry"]
key_links:
- from: "components/mobile/NeedsAttentionStrip.tsx"
to: "next/link"
via: "Link href={item.href}"
pattern: "from 'next/link'"
- from: "components/mobile/WorkerStatusRow.tsx"
to: "next/link"
via: "Link href={entry.href}"
pattern: "from 'next/link'"
- from: "app/api/mobile/dashboard/route.ts"
to: "postgresClient"
via: "single Promise.all of parameterised queries"
pattern: "postgresClient\\.query"
---
<objective>
Reshape `/api/mobile/dashboard` to return the three sections the new mobile
dashboard layout needs (4 KPIs, Needs Attention strip items, worker/backup
status entries) and ship the three presentational components plan 02 will
import. After this plan, plan 02 can replace the page body in pure UI work
without re-exploring the codebase.
Purpose: keeps plan 02 tiny (single file, ~50% context); avoids the
"scavenger hunt" anti-pattern by establishing the API shape and component
contracts up front (Interface-First Task Ordering).
Output: rewritten `app/api/mobile/dashboard/route.ts`, three new files
under `components/mobile/`. No edits to `app/mobile/dashboard/page.tsx`
(reserved for plan 02 to avoid same-wave file conflicts).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
<!-- Existing endpoints we will read from / model on -->
@app/api/dashboard/overview/route.ts
@app/api/status/workers/route.ts
@app/api/veeam/backup-status/route.ts
@app/api/mobile/dashboard/route.ts
<!-- Existing visual reference for KPI tone/styling -->
@components/dashboard/kpi-card.tsx
@components/ui/card.tsx
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
<!-- Use these directly — no codebase exploration needed. -->
From `lib/services/postgres-client.ts` (singleton):
```typescript
import postgresClient from '@/lib/services/postgres-client';
// postgresClient.query<T>(sql: string, params?: unknown[]): Promise<{ rows: T[] }>
```
From `app/api/dashboard/overview/route.ts` (already filters out scope-excluded
companies — the same filter idiom must be used in our endpoint):
```sql
-- "open total" pattern
SELECT COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total
FROM tickets
WHERE (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false);
-- "sla breaches" / "overdue tickets" pattern
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
```
From `app/api/status/workers/route.ts` — last activity timestamp & in-flight
queries for the analyzer + RMM workers (`analyzer_jobs`, `rmm_executions`).
From `migrations/030_create_workflow_engine_tables.sql`:
```sql
-- workflow_executions.status enum: 'pending' | 'completed' | 'failed'
-- "stalled" = status='pending' AND created_at < NOW() - INTERVAL '5 minutes'
-- (workflow engine runs synchronously from webhook fire-and-forget)
```
From `app/api/veeam/backup-status/route.ts` (existing — we reuse `successRate24h`
or compute equivalent):
```typescript
// successRate24h = (successJobs / totalJobs) * 100, rounded to 1 decimal
```
From `lib/auth-utils.ts`:
```typescript
const { error } = await requireAuth();
if (error) return error;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Rewrite /api/mobile/dashboard to return kpis/needsAttention/workers shape</name>
<files>app/api/mobile/dashboard/route.ts</files>
<read_first>
- app/api/mobile/dashboard/route.ts (current file — being completely replaced)
- app/api/dashboard/overview/route.ts (source-of-truth for KPI queries + scope filter)
- app/api/status/workers/route.ts (source-of-truth for analyzer/rmm worker queries)
- app/api/veeam/backup-status/route.ts (source-of-truth for backup success rate)
- migrations/030_create_workflow_engine_tables.sql (workflow_executions schema)
- lib/auth-utils.ts (requireAuth pattern)
- lib/services/postgres-client.ts (singleton import pattern)
- CLAUDE.md (no Zod in API routes; 503 for missing config; manual snake→camel transform)
</read_first>
<behavior>
- GET /api/mobile/dashboard returns 200 with JSON: { kpis: KpiResponse[], needsAttention: AttentionResponse[], workers: WorkerResponse[] }
- kpis array has exactly 4 entries with these `id` values in this order: 'open_total', 'opened_today', 'resolved_today', 'sla_breaches'
- needsAttention array has exactly 3 entries with these `id` values in this order: 'overdue_tickets', 'failed_backups', 'stalled_workflows'
- workers array has exactly 3 entries with these `id` values in this order: 'analyzer', 'rmm', 'backup_success_rate'
- Unauthenticated request returns whatever requireAuth() returns (401/redirect via existing helper)
- Database errors return 500 with { error, message } shape
</behavior>
<action>
Replace the entire contents of `app/api/mobile/dashboard/route.ts` with a new GET handler that returns the shape consumed by plan 02.
Required response TypeScript shape (declare these as exported `interface`s at the top of the file so plan 02 can `import type` them):
```typescript
export interface KpiResponse {
id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches';
label: string; // e.g. "Open total", "Opened today", "Resolved today", "SLA breaches"
value: number;
caption?: string; // optional secondary line, e.g. "vs yesterday: 12"
tone?: 'default' | 'attention'; // 'attention' for sla_breaches when value > 0
}
export interface AttentionResponse {
id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows';
label: string; // e.g. "Overdue tickets", "Failed backups (24h)", "Stalled workflows"
count: number; // 0 is allowed; the UI will style empty state
href: string; // destination route — see below
}
export interface WorkerResponse {
id: 'analyzer' | 'rmm' | 'backup_success_rate';
label: string; // e.g. "Analyzer", "RMM Overshell", "Backup success (24h)"
value: string; // human display: "12 in flight", "3 in flight", "98.4%"
status: 'ok' | 'warn' | 'down'; // see status rules below
href: string; // destination route — see below
}
export interface MobileDashboardResponse {
kpis: KpiResponse[];
needsAttention: AttentionResponse[];
workers: WorkerResponse[];
}
```
Implementation details (copy these patterns — do not invent SQL):
1. Imports at the top:
```typescript
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
```
2. Handler skeleton:
```typescript
export async function GET() {
const { error } = await requireAuth();
if (error) return error;
try {
const [/* result rows */] = await Promise.all([ /* queries */ ]);
return NextResponse.json<MobileDashboardResponse>({ kpis, needsAttention, workers });
} catch (e) {
console.error('[/api/mobile/dashboard] failed:', e);
return NextResponse.json(
{ error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 },
);
}
}
```
3. KPI queries — combine into a single ticket aggregate query, modeled exactly on the `today snapshot` query in `app/api/dashboard/overview/route.ts`:
```sql
SELECT
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
FROM tickets
WHERE (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
```
Build kpis from this single row. For sla_breaches, set `tone: 'attention'` when value > 0, else 'default'. Other three default tone. Captions optional — leave undefined for now.
4. Needs Attention queries — three parallel queries:
- `overdue_tickets` count = sla_breaches above (already computed — reuse the integer; do NOT requery). `href: '/tickets?overdue=true'`.
- `failed_backups` count: combine `veeam_backup_jobs` and `veeam_backup_agent_jobs` last_run >= NOW() - INTERVAL '24 hours' AND status = 'Failed' AND is_enabled = true (mirror the join in `app/api/veeam/backup-status/route.ts`):
```sql
SELECT COUNT(*)::text AS count FROM (
SELECT 1 FROM veeam_backup_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true AND status = 'Failed'
UNION ALL
SELECT 1 FROM veeam_backup_agent_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true AND status = 'Failed'
) f
```
`href: '/backup-status'`.
- `stalled_workflows` count: workflow_executions with status='pending' older than 5 minutes:
```sql
SELECT COUNT(*)::text AS count
FROM workflow_executions
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes'
```
`href: '/admin/workflow'`.
5. Worker queries — three parallel queries:
- Analyzer in-flight from `analyzer_jobs` (mirror `app/api/status/workers/route.ts`):
```sql
SELECT COUNT(*) FILTER (
WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review')
)::text AS in_flight,
COUNT(*) FILTER (WHERE status='failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM analyzer_jobs
```
value: `${in_flight} in flight`. status: 'down' if fail_1h>0 AND in_flight=0, 'warn' if fail_1h>0, otherwise 'ok'. `href: '/admin/analytics'` (analyzer admin lives there per existing admin routes).
- RMM in-flight from `rmm_executions`:
```sql
SELECT COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM rmm_executions
```
value: `${in_flight} in flight`. Same status rule as analyzer. `href: '/admin/rmm-overshell'`.
- Backup success rate (24h): mirror `app/api/veeam/backup-status/route.ts` calculation:
```sql
SELECT
COUNT(*) FILTER (WHERE status = 'Success')::text AS success,
COUNT(*)::text AS total
FROM (
SELECT status FROM veeam_backup_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
UNION ALL
SELECT status FROM veeam_backup_agent_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
) j
```
pct = total > 0 ? Math.round((success/total) * 1000) / 10 : 100; value: `${pct}%`. status: 'ok' if pct >= 95, 'warn' if pct >= 80, 'down' otherwise. `href: '/backup-status'`.
6. Wrap all 6 queries (1 KPI + 2 attention + 3 worker; the 3rd attention is computed from KPI row) in a single `Promise.all`. Five queries total.
Do NOT add caching, do NOT introduce Zod, do NOT introduce SWR. Match the no-ORM, manual-transform Pulse pattern.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/dashboard/route\.ts" || echo "OK: no type errors in route.ts"</automated>
</verify>
<acceptance_criteria>
- File `app/api/mobile/dashboard/route.ts` exports `MobileDashboardResponse`, `KpiResponse`, `AttentionResponse`, `WorkerResponse` interfaces (verify: `grep -E "^export interface (MobileDashboardResponse|KpiResponse|AttentionResponse|WorkerResponse)" app/api/mobile/dashboard/route.ts` returns 4 lines)
- File imports `requireAuth` from `@/lib/auth-utils` (verify: `grep "from '@/lib/auth-utils'" app/api/mobile/dashboard/route.ts` returns 1 line)
- File imports `postgresClient` from `@/lib/services/postgres-client` (verify: `grep "from '@/lib/services/postgres-client'" app/api/mobile/dashboard/route.ts` returns 1 line)
- File contains exactly one `Promise.all` and at least 5 `postgresClient.query` calls (verify: `grep -c "postgresClient.query" app/api/mobile/dashboard/route.ts` returns >= 5; `grep -c "Promise.all" app/api/mobile/dashboard/route.ts` returns 1)
- Response builder hard-codes the 4 KPI ids, 3 attention ids, 3 worker ids (verify: `grep -oE "'open_total'|'opened_today'|'resolved_today'|'sla_breaches'|'overdue_tickets'|'failed_backups'|'stalled_workflows'|'analyzer'|'rmm'|'backup_success_rate'" app/api/mobile/dashboard/route.ts | sort -u | wc -l` returns 10)
- File contains the workflow stalled query with `'pending'` and `'5 minutes'` (verify: `grep "workflow_executions" app/api/mobile/dashboard/route.ts` returns >= 1 line AND `grep "5 minutes" app/api/mobile/dashboard/route.ts` returns >= 1 line)
- File contains the company_scope exclusion (verify: `grep "company_scope" app/api/mobile/dashboard/route.ts` returns >= 1 line)
- No Zod, no recharts, no SWR imports (verify: `grep -E "from 'zod'|recharts|swr|@tanstack/react-query" app/api/mobile/dashboard/route.ts` returns nothing)
- Type-check passes for the file (verify: `npx tsc --noEmit --pretty 2>&1 | grep "app/api/mobile/dashboard/route.ts"` returns nothing)
</acceptance_criteria>
<done>
Endpoint returns the new shape; type-check clean; existing imports
in `app/mobile/dashboard/page.tsx` will break (the old `DashboardData`
fields no longer exist) — that breakage is fixed in plan 02. Do not
edit the page in this task.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Add KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow components</name>
<files>
components/mobile/KpiCardMobile.tsx,
components/mobile/NeedsAttentionStrip.tsx,
components/mobile/WorkerStatusRow.tsx
</files>
<read_first>
- components/dashboard/kpi-card.tsx (visual reference — what a desktop KPI card looks like; we are NOT copying this, just modelling after it)
- components/ui/card.tsx (shadcn Card primitive)
- components/mobile/HeaderBar.tsx (existing mobile component — file naming, styling tokens, leading comment block style)
- components/mobile/BottomNav.tsx (existing mobile component — Link from next/link, active-color pattern)
- app/mobile/dashboard/page.tsx (current page — see PRIORITY_COLOR/PRIORITY_TEXT pattern reused for tone)
- DESIGN.md (typography + tokens — read sections on Card vocabulary)
</read_first>
<behavior>
- KpiCardMobile renders a card with label, large numeric value, optional caption; tone='attention' adds a destructive left border; tone='default' is neutral
- NeedsAttentionStrip renders nothing when items=[] (empty fragment); when items present, renders a horizontally-scrollable row of compact cards each wrapped in a next/link
- WorkerStatusRow renders a 3-cell row of compact status pills, each wrapped in a next/link to the entry's href; status='ok' uses green dot, 'warn' amber dot, 'down' red dot
- All three are pure presentational client components — no fetch, no state beyond props
</behavior>
<action>
Create three new files under `components/mobile/`. All three start with `'use client';` and a leading multi-line `/* ComponentName — phase 03 (DASH-XX). */` block describing the component, mirroring the style of `components/mobile/HeaderBar.tsx` and `components/mobile/BottomNav.tsx`.
### File 1: `components/mobile/KpiCardMobile.tsx`
Exports a single component for the 2×2 KPI grid (DASH-01).
```typescript
'use client';
/* KpiCardMobile — phase 03 (DASH-01).
*
* Phone-sized KPI card for the 2×2 dashboard grid. Renders a label,
* a large numeric value, and an optional caption. tone="attention"
* adds a left-edge destructive border for SLA breaches > 0.
*
* Pure presentational — no fetch, no state. Parent provides values. */
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export type KpiTone = 'default' | 'attention';
interface KpiCardMobileProps {
label: string;
value: number | string;
caption?: string;
tone?: KpiTone;
}
const TONE_BORDER: Record<KpiTone, string> = {
default: 'border-l-transparent',
attention: 'border-l-destructive',
};
export function KpiCardMobile({ label, value, caption, tone = 'default' }: KpiCardMobileProps) {
const display = typeof value === 'number' ? value.toLocaleString() : value;
return (
<Card className={cn('h-full border-l-2', TONE_BORDER[tone])}>
<CardContent className="p-4 flex flex-col gap-1">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</p>
<p className="text-3xl font-bold tabular-nums">{display}</p>
{caption && <p className="text-xs text-muted-foreground">{caption}</p>}
</CardContent>
</Card>
);
}
```
### File 2: `components/mobile/NeedsAttentionStrip.tsx`
Exports the strip + an `NeedsAttentionItem` interface (DASH-02).
```typescript
'use client';
/* NeedsAttentionStrip — phase 03 (DASH-02).
*
* Horizontal-scroll strip of compact attention cards. Each card shows a
* count + label and is a next/link to the destination view. The strip
* uses native horizontal overflow with snap-x for momentum scroll on
* iOS/Android. Renders nothing when items=[]. */
import Link from 'next/link';
import { AlertTriangle, ChevronRight } from 'lucide-react';
export interface NeedsAttentionItem {
id: string;
label: string;
count: number;
href: string;
}
interface NeedsAttentionStripProps {
items: NeedsAttentionItem[];
}
export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps) {
if (items.length === 0) return null;
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Needs attention
</p>
<div className="-mx-4 px-4 flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1">
{items.map(item => (
<Link
key={item.id}
href={item.href}
className="snap-start shrink-0 w-44 rounded-2xl border bg-card p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start justify-between">
<AlertTriangle className={`w-4 h-4 ${item.count > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
<p className={`mt-2 text-2xl font-bold tabular-nums ${item.count > 0 ? 'text-destructive' : ''}`}>
{item.count}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
</Link>
))}
</div>
</div>
);
}
```
### File 3: `components/mobile/WorkerStatusRow.tsx`
Exports the row + a `WorkerStatusEntry` interface (DASH-03).
```typescript
'use client';
/* WorkerStatusRow — phase 03 (DASH-03).
*
* Compact 3-cell read-only status row showing analyzer worker, RMM worker,
* and backup success rate. Each cell is a next/link to the corresponding
* desktop admin page. status='ok' = emerald dot, 'warn' = amber, 'down' =
* destructive. */
import Link from 'next/link';
import { ExternalLink } from 'lucide-react';
export type WorkerStatus = 'ok' | 'warn' | 'down';
export interface WorkerStatusEntry {
id: string;
label: string;
value: string;
status: WorkerStatus;
href: string;
}
interface WorkerStatusRowProps {
entries: WorkerStatusEntry[];
}
const DOT_COLOR: Record<WorkerStatus, string> = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
down: 'bg-destructive',
};
export function WorkerStatusRow({ entries }: WorkerStatusRowProps) {
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Workers &amp; backups
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{entries.map(e => (
<Link
key={e.id}
href={e.href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${DOT_COLOR[e.status]}`} aria-hidden="true" />
<span className="text-sm font-medium flex-1">{e.label}</span>
<span className="text-sm tabular-nums text-muted-foreground">{e.value}</span>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
);
}
```
Use `cn` from `@/lib/utils` only where actually needed; the simple ternary class strings shown above are fine. Do NOT introduce recharts (DASH-04). Do NOT introduce framer-motion or any animation lib. Stick to lucide icons and shadcn Card primitive.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(KpiCardMobile|NeedsAttentionStrip|WorkerStatusRow)\.tsx" || echo "OK: no type errors in new components"</automated>
</verify>
<acceptance_criteria>
- All three files exist (verify: `ls components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx`)
- Each file starts with `'use client';` (verify: `head -1 components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx | grep -c "'use client';"` returns 3)
- `KpiCardMobile` is exported (verify: `grep -E "^export function KpiCardMobile" components/mobile/KpiCardMobile.tsx` returns 1 line)
- `NeedsAttentionStrip` and `NeedsAttentionItem` are both exported (verify: `grep -E "^export (function NeedsAttentionStrip|interface NeedsAttentionItem)" components/mobile/NeedsAttentionStrip.tsx | wc -l` returns 2)
- `WorkerStatusRow` and `WorkerStatusEntry` are both exported (verify: `grep -E "^export (function WorkerStatusRow|interface WorkerStatusEntry|type WorkerStatus)" components/mobile/WorkerStatusRow.tsx | wc -l` returns >= 2)
- No `recharts` import in any of the three files (verify: `grep recharts components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx` returns nothing)
- All three import next/link only where needed (verify: `grep -L "from 'next/link'" components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx` returns nothing — both must import it)
- `KpiCardMobile.tsx` imports `Card`/`CardContent` from `@/components/ui/card` (verify: `grep "from '@/components/ui/card'" components/mobile/KpiCardMobile.tsx` returns 1 line)
- Type-check passes for the new files (verify: `npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(KpiCardMobile|NeedsAttentionStrip|WorkerStatusRow)\.tsx"` returns nothing)
</acceptance_criteria>
<done>
Three component files compile clean; exports match the names plan 02
will import. No edits to `app/mobile/dashboard/page.tsx` (reserved for
plan 02).
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/mobile/dashboard | Authenticated browser request from `/mobile/dashboard` page; auth enforced by `requireAuth()` and middleware. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information Disclosure | `/api/mobile/dashboard` | mitigate | Call `requireAuth()` at the top of GET; return its error response unchanged so unauthenticated callers get 401/redirect identical to other authenticated routes (mirrors `app/api/dashboard/overview/route.ts`). |
| T-03-02 | Information Disclosure | KPI ticket queries | mitigate | All ticket SELECTs include `company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)` so out-of-scope companies aren't counted/leaked — same idiom as the desktop overview route. |
| T-03-03 | Tampering | SQL injection via query params | accept | The endpoint takes no query parameters; all SQL is parameterless string-literal SQL. No interpolation of user input. |
| T-03-04 | Information Disclosure | Worker queries | accept | `analyzer_jobs`, `rmm_executions`, `workflow_executions`, `veeam_backup_jobs/agent_jobs` are operator-internal tables; counts only (no row content) are returned. No PII exposure. |
| T-03-05 | Tampering | Card/strip click destinations | mitigate | All `href` strings are hard-coded route literals built server-side (`/tickets?overdue=true`, `/admin/workflow`, `/backup-status`, etc.) — clients cannot influence them. Existing Better Auth middleware protects each destination route. |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` exits clean for the four new/changed files
- Manual smoke (optional, not part of automation): `curl -b "$COOKIE" http://localhost:3100/api/mobile/dashboard | jq '.kpis | length, .needsAttention | length, .workers | length'` returns `4 3 3`
</verification>
<success_criteria>
- `/api/mobile/dashboard` returns the documented `MobileDashboardResponse` shape
- Three new components exist under `components/mobile/` with the documented exports
- No edits to `app/mobile/dashboard/page.tsx` in this plan (reserved for plan 02)
- Type-check passes
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md` documenting:
- Final response shape (paste the `MobileDashboardResponse` interface)
- Component export signatures
- Any deviations from this plan and why
</output>

View file

@ -0,0 +1,140 @@
---
phase: 03-dashboard-restyle
plan: 01
subsystem: api, ui
tags: [mobile, dashboard, nextjs, postgres, tailwind, shadcn, lucide]
# Dependency graph
requires:
- phase: 02-mobile-shell-more-drawer
provides: mobile layout shell (HeaderBar, BottomNav, MoreDrawer) that wraps all /mobile/* pages
provides:
- GET /api/mobile/dashboard returning MobileDashboardResponse (kpis, needsAttention, workers)
- KpiCardMobile component (phone-sized KPI card, tone-aware destructive border)
- NeedsAttentionStrip component (horizontal-scroll strip of compact attention cards)
- WorkerStatusRow component (3-cell worker/backup status row with status dot indicators)
affects:
- 03-02 (plan 02 wires these components into app/mobile/dashboard/page.tsx)
# Tech tracking
tech-stack:
added: []
patterns:
- "Mobile API endpoint: single Promise.all with 6 parameterless queries, company_scope exclusion filter, manual snake→camel transform"
- "Mobile component comment block: /* ComponentName — phase 03 (DASH-XX). */ header with purpose description"
- "Tone-aware KPI card: TONE_BORDER record maps tone to Tailwind border class"
- "Worker status derivation: down if fail_1h>0 and in_flight=0, warn if fail_1h>0, ok otherwise"
key-files:
created:
- components/mobile/KpiCardMobile.tsx
- components/mobile/NeedsAttentionStrip.tsx
- components/mobile/WorkerStatusRow.tsx
modified:
- app/api/mobile/dashboard/route.ts
key-decisions:
- "Reused sla_breaches integer from KPI query for overdue_tickets count in needsAttention — avoids a 7th query"
- "Default export import (not named) for postgresClient matches app/api/dashboard/overview/route.ts reference route pattern"
- "captions left undefined for now — plan 02 can add vs-yesterday deltas without an API change"
patterns-established:
- "Mobile endpoint pattern: requireAuth at top, single Promise.all, typed query generics, manual int parse with ?? '0' fallback"
- "Mobile component pattern: 'use client'; /* Name — phase NN (REQ-NN). */ block, pure presentational, no fetch"
requirements-completed: [DASH-01, DASH-02, DASH-03]
# Metrics
duration: 18min
completed: 2026-05-03
---
# Phase 3 Plan 1: Dashboard API + Presentational Components Summary
**Mobile dashboard API reshaped to return kpis/needsAttention/workers in a single round-trip, plus three presentational components (KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow) ready for plan 02 to wire into the page.**
## Performance
- **Duration:** ~18 min
- **Started:** 2026-05-03T00:00:00Z
- **Completed:** 2026-05-03T00:18:00Z
- **Tasks:** 2
- **Files modified:** 4 (1 rewritten, 3 created)
## Accomplishments
- Rewrote `app/api/mobile/dashboard/route.ts` to return `MobileDashboardResponse` (4 KPIs, 3 Needs Attention items, 3 worker entries) in a single Promise.all of 6 parameterless queries
- Exported 4 TypeScript interfaces (`KpiResponse`, `AttentionResponse`, `WorkerResponse`, `MobileDashboardResponse`) so plan 02 can `import type` them without re-exploring the codebase
- Created `KpiCardMobile` — phone-sized KPI card with optional destructive left border for `tone='attention'` (SLA breaches)
- Created `NeedsAttentionStrip` — horizontal-scroll snap strip of compact attention cards, renders null when empty
- Created `WorkerStatusRow` — 3-cell status row with emerald/amber/destructive status dots, each a next/link to the corresponding admin page
## Response Shape
```typescript
export interface MobileDashboardResponse {
kpis: KpiResponse[]; // 4 entries: open_total, opened_today, resolved_today, sla_breaches
needsAttention: AttentionResponse[]; // 3 entries: overdue_tickets, failed_backups, stalled_workflows
workers: WorkerResponse[]; // 3 entries: analyzer, rmm, backup_success_rate
}
```
## Component Export Signatures
```typescript
// KpiCardMobile.tsx
export type KpiTone = 'default' | 'attention';
export function KpiCardMobile({ label, value, caption, tone }: KpiCardMobileProps): JSX.Element
// NeedsAttentionStrip.tsx
export interface NeedsAttentionItem { id, label, count, href }
export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps): JSX.Element | null
// WorkerStatusRow.tsx
export type WorkerStatus = 'ok' | 'warn' | 'down';
export interface WorkerStatusEntry { id, label, value, status, href }
export function WorkerStatusRow({ entries }: WorkerStatusRowProps): JSX.Element
```
## Task Commits
1. **Task 1: Rewrite /api/mobile/dashboard to return kpis/needsAttention/workers shape** - `24e20c7` (feat)
2. **Task 2: Add KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow components** - `bfe9549` (feat)
## Files Created/Modified
- `app/api/mobile/dashboard/route.ts` — Completely rewritten; exports 4 interfaces + GET handler returning MobileDashboardResponse
- `components/mobile/KpiCardMobile.tsx` — New; phone-sized KPI card with tone-aware destructive left border
- `components/mobile/NeedsAttentionStrip.tsx` — New; horizontal-scroll attention strip with snap-x
- `components/mobile/WorkerStatusRow.tsx` — New; 3-cell worker/backup status row with color-coded dots
## Decisions Made
- **Reuse sla_breaches for overdue_tickets:** The overdue_tickets count in `needsAttention` is the same value as `sla_breaches` in `kpis` — computed from the same KPI query row, avoiding a 7th query.
- **Default postgresClient import:** Used `import postgresClient from '@/lib/services/postgres-client'` (default export) to match `app/api/dashboard/overview/route.ts` reference route, not the named export used in the old mobile route.
- **Captions deferred:** `caption` fields on KpiResponse are left `undefined` for now; plan 02 can add vs-yesterday deltas without an API shape change.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## Known Stubs
None — all data flows are wired to live DB queries.
## Threat Flags
No new security surface introduced. The `/api/mobile/dashboard` endpoint was already an existing route; it now enforces `requireAuth()` (T-03-01) and applies the `company_scope` exclusion on all ticket queries (T-03-02), both as specified in the plan's threat model.
## Next Phase Readiness
- Plan 02 (`03-02`) can immediately import `MobileDashboardResponse`, `KpiCardMobile`, `NeedsAttentionStrip`, and `WorkerStatusRow` — no codebase exploration needed
- No blockers. `app/mobile/dashboard/page.tsx` untouched as required (reserved for plan 02)
---
*Phase: 03-dashboard-restyle*
*Completed: 2026-05-03*

View file

@ -0,0 +1,426 @@
---
phase: 03-dashboard-restyle
plan: 02
type: execute
wave: 2
depends_on:
- 03-01
files_modified:
- app/mobile/dashboard/page.tsx
autonomous: false
requirements:
- DASH-01
- DASH-02
- DASH-03
- DASH-04
objective: |
Replace the body of /mobile/dashboard so it renders the new spec §6.1
layout: 2×2 KPI grid → "Needs Attention" horizontal strip → worker/backup
status row, fed by /api/mobile/dashboard. Drop all recharts/charts and the
old by_status/by_queue/by_priority/sla/recent sections.
must_haves:
truths:
- "Visiting /mobile/dashboard renders four KPI cards in a 2×2 grid (no 1×4 row, no list)"
- "Below the grid, a horizontally-scrollable Needs Attention strip surfaces overdue tickets, failed backups, and stalled workflows"
- "Below the strip, a 3-row worker/backup status block links to /tickets?overdue=true, /backup-status, /admin/workflow, /admin/analytics, /admin/rmm-overshell as appropriate"
- "The page imports zero recharts/chart components and renders no chart on phone widths"
- "Tapping a Needs Attention card navigates to its href (next/link)"
- "Tapping a worker status row navigates to its desktop admin href (next/link)"
artifacts:
- path: "app/mobile/dashboard/page.tsx"
provides: "Replaced page body wiring KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow against /api/mobile/dashboard"
min_lines: 40
contains: "KpiCardMobile"
key_links:
- from: "app/mobile/dashboard/page.tsx"
to: "/api/mobile/dashboard"
via: "fetch in useEffect"
pattern: "fetch\\('/api/mobile/dashboard'\\)"
- from: "app/mobile/dashboard/page.tsx"
to: "components/mobile/KpiCardMobile.tsx"
via: "import KpiCardMobile"
pattern: "from '@/components/mobile/KpiCardMobile'"
- from: "app/mobile/dashboard/page.tsx"
to: "components/mobile/NeedsAttentionStrip.tsx"
via: "import NeedsAttentionStrip"
pattern: "from '@/components/mobile/NeedsAttentionStrip'"
- from: "app/mobile/dashboard/page.tsx"
to: "components/mobile/WorkerStatusRow.tsx"
via: "import WorkerStatusRow"
pattern: "from '@/components/mobile/WorkerStatusRow'"
---
<objective>
Replace the body of `app/mobile/dashboard/page.tsx` to render the new spec
§6.1 layout. Plan 01 already shipped the API and components; this plan is
pure UI assembly.
Purpose: deliver the user-visible Phase 3 outcome — manager opens
`/mobile/dashboard`, sees four KPIs in a 2×2 grid, a horizontal Needs
Attention strip, and a compact worker/backup status block. No charts.
Output: rewritten `app/mobile/dashboard/page.tsx`. No new components.
No edits to API routes (already shipped in plan 01).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
<!-- The file we are replacing -->
@app/mobile/dashboard/page.tsx
<!-- Layout context — header + bottom nav already wrapped by the layout -->
@app/mobile/layout.tsx
<!-- Components shipped in plan 01 — DO NOT MODIFY, only import from -->
@components/mobile/KpiCardMobile.tsx
@components/mobile/NeedsAttentionStrip.tsx
@components/mobile/WorkerStatusRow.tsx
<!-- API endpoint shipped in plan 01 -->
@app/api/mobile/dashboard/route.ts
<interfaces>
<!-- Plan 01 exports these — import them by name. -->
From `app/api/mobile/dashboard/route.ts`:
```typescript
export interface KpiResponse {
id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches';
label: string;
value: number;
caption?: string;
tone?: 'default' | 'attention';
}
export interface AttentionResponse {
id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows';
label: string;
count: number;
href: string;
}
export interface WorkerResponse {
id: 'analyzer' | 'rmm' | 'backup_success_rate';
label: string;
value: string;
status: 'ok' | 'warn' | 'down';
href: string;
}
export interface MobileDashboardResponse {
kpis: KpiResponse[];
needsAttention: AttentionResponse[];
workers: WorkerResponse[];
}
```
From `components/mobile/KpiCardMobile.tsx`:
```typescript
export function KpiCardMobile(props: { label: string; value: number | string; caption?: string; tone?: 'default'|'attention' }): JSX.Element;
```
From `components/mobile/NeedsAttentionStrip.tsx`:
```typescript
export interface NeedsAttentionItem { id: string; label: string; count: number; href: string }
export function NeedsAttentionStrip(props: { items: NeedsAttentionItem[] }): JSX.Element | null;
```
From `components/mobile/WorkerStatusRow.tsx`:
```typescript
export type WorkerStatus = 'ok' | 'warn' | 'down';
export interface WorkerStatusEntry { id: string; label: string; value: string; status: WorkerStatus; href: string }
export function WorkerStatusRow(props: { entries: WorkerStatusEntry[] }): JSX.Element;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Replace mobile dashboard page body with the new 3-section layout</name>
<files>app/mobile/dashboard/page.tsx</files>
<read_first>
- app/mobile/dashboard/page.tsx (current — being completely replaced)
- app/mobile/layout.tsx (confirms header/bottom nav are layout-provided; page renders into <main>)
- app/api/mobile/dashboard/route.ts (response shape source-of-truth)
- components/mobile/KpiCardMobile.tsx (import contract)
- components/mobile/NeedsAttentionStrip.tsx (import contract)
- components/mobile/WorkerStatusRow.tsx (import contract)
- components/mobile/HeaderBar.tsx (page-title pattern — pages render their own H1; header has no title)
- CLAUDE.md (no SWR, no server actions, useState + fetch pattern)
</read_first>
<behavior>
- Page is a `'use client'` component, default export
- On mount, fetches GET /api/mobile/dashboard exactly once and stores the response
- While loading: shows a centered RefreshCw spinner (match existing skeleton pattern)
- On error: shows the error message in a destructive-tinted block + a Retry button that re-runs the fetch
- On success: renders an H1 ("Dashboard"), then 3 sections in this order:
1. 2×2 grid of KpiCardMobile (4 entries from response.kpis), tone derived from `kpi.tone`, caption from `kpi.caption`
2. NeedsAttentionStrip with `items=response.needsAttention` mapped to NeedsAttentionItem
3. WorkerStatusRow with `entries=response.workers` mapped to WorkerStatusEntry
- Header refresh button (RefreshCw icon, top-right of the H1 row) re-runs the fetch
- Page imports zero recharts/chart libraries
</behavior>
<action>
Completely replace the contents of `app/mobile/dashboard/page.tsx`. The new file is one self-contained client component plus typed state.
Required structure:
```typescript
'use client';
/* /mobile/dashboard — phase 03 (DASH-01..04).
*
* Three sections, top-to-bottom:
* 1. 2×2 KPI grid (DASH-01)
* 2. Needs Attention (DASH-02)
* 3. Worker/backup row (DASH-03)
*
* No charts on phone widths (DASH-04). Header + bottom nav are provided
* by app/mobile/layout.tsx; this page only renders the H1 and body. */
import { useEffect, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { KpiCardMobile } from '@/components/mobile/KpiCardMobile';
import { NeedsAttentionStrip } from '@/components/mobile/NeedsAttentionStrip';
import { WorkerStatusRow } from '@/components/mobile/WorkerStatusRow';
import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route';
export default function MobileDashboard() {
const [data, setData] = useState<MobileDashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const r = await fetch('/api/mobile/dashboard');
if (!r.ok) {
const body = (await r.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(body.message ?? body.error ?? `HTTP ${r.status}`);
}
setData((await r.json()) as MobileDashboardResponse);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
return (
<div className="p-4 space-y-5">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Dashboard</h1>
<button
type="button"
onClick={load}
disabled={loading}
aria-label="Refresh dashboard"
className="p-2 rounded-full hover:bg-accent disabled:opacity-40"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{error && !loading && (
<div className="rounded-xl border border-destructive/50 bg-destructive/5 p-4">
<p className="text-sm font-medium text-destructive">Failed to load</p>
<p className="text-xs text-muted-foreground mt-1">{error}</p>
<button
type="button"
onClick={load}
className="mt-3 text-xs font-medium text-primary hover:underline"
>
Retry
</button>
</div>
)}
{loading && !data && (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{data && (
<>
{/* DASH-01: 2×2 KPI grid */}
<div className="grid grid-cols-2 gap-3">
{data.kpis.map(kpi => (
<KpiCardMobile
key={kpi.id}
label={kpi.label}
value={kpi.value}
caption={kpi.caption}
tone={kpi.tone ?? 'default'}
/>
))}
</div>
{/* DASH-02: Needs Attention horizontal strip */}
<NeedsAttentionStrip
items={data.needsAttention.map(a => ({
id: a.id,
label: a.label,
count: a.count,
href: a.href,
}))}
/>
{/* DASH-03: Worker/backup status row */}
<WorkerStatusRow
entries={data.workers.map(w => ({
id: w.id,
label: w.label,
value: w.value,
status: w.status,
href: w.href,
}))}
/>
</>
)}
</div>
);
}
```
Constraints:
- `import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route'` — type-only import is fine in Next.js 16 (the route file marks the export as `interface`, no runtime cost). If TypeScript complains about importing types from a route file, fall back to redefining the same shape locally in this file as `interface MobileDashboardResponse { ... }` matching the source-of-truth in plan 01's SUMMARY exactly. Either is acceptable.
- Do NOT import any of these legacy fields used by the old page: `open_total`, `by_status`, `by_queue`, `by_priority`, `sla`, `recent`.
- Do NOT add a separate "header" — the layout already provides one (HeaderBar in `app/mobile/layout.tsx`). The H1 inside the page body is per spec §5.1 ("No page title in the header — pages render their own H1").
- Do NOT introduce recharts, react-day-picker, framer-motion, swr, or react-query. The constraint is strict (DASH-04).
- Do NOT introduce a `next/dynamic` import for charts. Just don't use charts.
- Keep the file under ~120 lines. The body should look like the example above, not a re-skin of the old page.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep "app/mobile/dashboard/page.tsx" || echo "OK: type-check clean"</automated>
</verify>
<acceptance_criteria>
- File starts with `'use client';` (verify: `head -1 app/mobile/dashboard/page.tsx` returns `'use client';`)
- File has a default export named `MobileDashboard` (verify: `grep -E "^export default function MobileDashboard" app/mobile/dashboard/page.tsx` returns 1 line)
- File imports all three new components (verify: `grep -c "from '@/components/mobile/\(KpiCardMobile\|NeedsAttentionStrip\|WorkerStatusRow\)'" app/mobile/dashboard/page.tsx` returns 3)
- File fetches `/api/mobile/dashboard` (verify: `grep "fetch('/api/mobile/dashboard')" app/mobile/dashboard/page.tsx` returns 1 line)
- File contains a `grid-cols-2` section for the KPI grid (verify: `grep "grid-cols-2" app/mobile/dashboard/page.tsx` returns >= 1 line)
- File contains an `<h1>Dashboard</h1>` (verify: `grep -E "<h1[^>]*>Dashboard</h1>" app/mobile/dashboard/page.tsx` returns 1 line)
- File does NOT import recharts/swr/react-query/framer-motion (verify: `grep -E "from 'recharts'|from 'swr'|from '@tanstack/react-query'|from 'framer-motion'" app/mobile/dashboard/page.tsx` returns nothing)
- File does NOT contain any of the old field names (verify: `grep -E "by_status|by_queue|by_priority|response_met|resolution_met|PRIORITY_COLOR|PRIORITY_TEXT" app/mobile/dashboard/page.tsx` returns nothing)
- File does NOT contain a `<Link>` to `/mobile/tickets/${...}` (the old "Recent Activity" list is gone) (verify: `grep "/mobile/tickets/\${" app/mobile/dashboard/page.tsx` returns nothing)
- Type-check passes for the page (verify: `npx tsc --noEmit --pretty 2>&1 | grep "app/mobile/dashboard/page.tsx"` returns nothing)
- File is at most 130 lines (verify: `wc -l app/mobile/dashboard/page.tsx` returns a number <= 130)
</acceptance_criteria>
<done>
`/mobile/dashboard` renders the new 3-section layout against the plan-01
API. Type-check clean. No charts.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Human verification — open /mobile/dashboard on a phone-width viewport</name>
<files>app/mobile/dashboard/page.tsx</files>
<read_first>
- app/mobile/dashboard/page.tsx (the file just modified — confirms what to look for in the browser)
</read_first>
<what-built>
Phase 3 deliverable: `/mobile/dashboard` rebuilt per spec §6.1.
- 2×2 KPI grid (DASH-01)
- Horizontal-scroll Needs Attention strip (DASH-02)
- Worker/backup status row with desktop-admin links (DASH-03)
- Zero charts (DASH-04)
</what-built>
<how-to-verify>
1. Start the dev server: `npm run dev` (port 3100)
2. Open Chrome DevTools, toggle device emulation, pick "iPhone 15 Pro" (393×852).
3. Navigate to http://localhost:3100/mobile/dashboard (sign in if prompted).
4. Verify each item below:
a. The header is the new shell HeaderBar (Wulf mark + Pulse wordmark + Bell + avatar) — NOT a page-internal "Ticket Dashboard" header.
b. There is exactly one H1 in the page body that says "Dashboard".
c. Below the H1, you see four KPI cards in a 2×2 grid (Open total, Opened today, Resolved today, SLA breaches). The SLA breaches card should have a destructive (red) left border if the count > 0, otherwise neutral.
d. Below the grid, a "Needs attention" strip with three cards (Overdue tickets / Failed backups / Stalled workflows) scrolls horizontally with momentum. Tapping each card navigates correctly:
- Overdue tickets → `/tickets?overdue=true`
- Failed backups → `/backup-status`
- Stalled workflows → `/admin/workflow`
e. Below the strip, a "Workers & backups" block with three rows:
- Analyzer → `/admin/analytics`
- RMM Overshell → `/admin/rmm-overshell`
- Backup success (24h) → `/backup-status`
Each row has a status dot (green/amber/red) on the left and an external-link icon on the right.
f. There is NO chart (no recharts canvas/SVG) anywhere on the page.
g. The page scrolls under the sticky header and content does NOT hide behind the bottom nav.
h. Tapping the refresh button (top-right of the H1 row) spins the icon and reloads the data without a full-page nav.
5. Sanity command: `grep -rn recharts app/mobile/dashboard/ components/mobile/` returns nothing.
</how-to-verify>
<action>
Pause execution for human verification. The implementer cannot
visually confirm the spec §6.1 layout — a human running on a real
phone-width viewport must walk through the steps in <how-to-verify>
and approve. If any step fails, the human describes the issue and
Task 1 is revised.
</action>
<verify>
<automated>grep -rn "recharts" app/mobile/dashboard/ components/mobile/ 2>/dev/null && exit 1 || echo "OK: no recharts in mobile dashboard or new mobile components"</automated>
</verify>
<acceptance_criteria>
- Human runs through every step ah of <how-to-verify> and reports any failures
- Sanity grep returns no `recharts` references in `app/mobile/dashboard/` or `components/mobile/`
- Approval signal received from the human (the resume-signal contents)
</acceptance_criteria>
<done>
Human types "approved" (or describes issues to fix). On approval, the
phase is shippable. On issues, return to Task 1 with the human's notes.
</done>
<resume-signal>Type "approved" or describe issues</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → server | No new boundaries — page consumes the existing authenticated `/api/mobile/dashboard` endpoint. |
| /mobile/* → /admin/*, /backup-status, /tickets | All link destinations are existing authenticated routes; Better Auth middleware enforces session on the destination. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-06 | Elevation of Privilege | Tile/card click destinations | accept | All `href` values are emitted by the server endpoint (plan 01) and rendered as `next/link`. The client cannot influence destinations beyond what the server returned, and Better Auth middleware enforces the session/role required for each destination route. No new privilege boundary. |
| T-03-07 | Information Disclosure | Error rendering | mitigate | Server errors are surfaced via `body.message ?? body.error ?? 'HTTP {status}'`. No stack trace or DB schema is rendered. The endpoint only returns sanitized `{ error, message }` per CLAUDE.md convention. |
| T-03-08 | Information Disclosure | Empty/zero counts | accept | Counts of 0 are rendered as "0" rather than hidden. This is intentional — a manager seeing "0 overdue tickets" is the desired signal. No PII surfaced. |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` exits clean (page + components + route)
- `grep -rn "recharts" app/mobile/dashboard/ components/mobile/` returns nothing
- Manual: page renders the documented 3-section layout on a phone-width viewport (Task 2 checkpoint)
</verification>
<success_criteria>
- `/mobile/dashboard` page renders 2×2 KPI grid, Needs Attention strip, worker/backup status row in that order
- All Needs Attention cards and worker rows are tappable links to the documented destinations
- No charts/recharts on the page (DASH-04)
- Type-check passes
- Human verification approves the layout (Task 2)
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-restyle/03-02-SUMMARY.md` documenting:
- Final file structure of the new page
- Whether `import type { MobileDashboardResponse }` worked or fell back to a local interface
- Any deviations from this plan and why
- Screenshot path / link if captured during checkpoint (optional)
</output>

View file

@ -0,0 +1,121 @@
---
phase: 03-dashboard-restyle
plan: 02
subsystem: ui
tags: [mobile, dashboard, nextjs, tailwind, shadcn, lucide]
# Dependency graph
requires:
- 03-01: "KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow components + /api/mobile/dashboard endpoint"
provides:
- "app/mobile/dashboard/page.tsx rewritten with 2×2 KPI grid, Needs Attention strip, Worker/backup status row"
affects: []
# Tech tracking
tech-stack:
added: []
patterns:
- "Mobile page pattern: 'use client'; single load() function, useEffect(() => { void load(); }, []), inline error block with Retry, spinner while loading"
- "type-only import from route file: import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route'"
- "Refresh button in H1 row: disabled={loading} + animate-spin on loading=true"
key-files:
created: []
modified:
- app/mobile/dashboard/page.tsx
key-decisions:
- "import type { MobileDashboardResponse } from route file worked without issue — Next.js 16 type-only imports from route handlers are clean"
- "No local interface redefinition needed — the import type approach from plan 01 route was sufficient"
- "Auto-approved checkpoint:human-verify (auto mode active) — no manual verification step taken"
requirements-completed: [DASH-01, DASH-02, DASH-03, DASH-04]
# Metrics
duration: 5min
completed: 2026-05-03
---
# Phase 3 Plan 2: Mobile Dashboard Page Assembly Summary
**Rewrote `app/mobile/dashboard/page.tsx` to render the spec §6.1 three-section layout: 2×2 KPI grid → horizontal Needs Attention strip → worker/backup status row, fed by /api/mobile/dashboard. Zero charts.**
## Performance
- **Duration:** ~5 min
- **Completed:** 2026-05-03
- **Tasks:** 1 executed (1 auto-approved checkpoint)
- **Files modified:** 1
## Accomplishments
- Completely replaced `app/mobile/dashboard/page.tsx` (164 deleted lines → 118 new lines)
- Dropped all legacy sections: priority breakdown, SLA bar charts, by-queue progress bars, recent activity list
- Wired `KpiCardMobile` into a `grid-cols-2` layout consuming `response.kpis` (4 entries)
- Wired `NeedsAttentionStrip` consuming `response.needsAttention` (3 attention items)
- Wired `WorkerStatusRow` consuming `response.workers` (3 worker entries)
- Added inline error state with destructive-tinted block + Retry button
- Added `RefreshCw` refresh button in the H1 row with `animate-spin` while loading and `disabled` attribute
- Zero recharts imports (DASH-04 satisfied)
## File Structure
```
app/mobile/dashboard/page.tsx (118 lines)
├─ 'use client'
├─ imports: useEffect, useState, RefreshCw, KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow
├─ import type MobileDashboardResponse from route
├─ export default MobileDashboard()
│ ├─ state: data, loading, error
│ ├─ load(): fetch /api/mobile/dashboard → setData
│ ├─ useEffect(() => { void load(); }, [])
│ └─ render:
│ ├─ H1 "Dashboard" + RefreshCw button
│ ├─ error block (conditional)
│ ├─ spinner (loading && !data)
│ └─ data section:
│ ├─ grid grid-cols-2: 4× KpiCardMobile
│ ├─ NeedsAttentionStrip (3 items)
│ └─ WorkerStatusRow (3 entries)
```
## Type Import Resolution
`import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route'` worked cleanly — no fallback to local interface redefinition was needed. Next.js 16 handles type-only imports from route files without issues.
## Task Commits
1. **Task 1: Replace mobile dashboard page body with the new 3-section layout**`5256250` (feat)
2. **Task 2: Human verification** — auto-approved (auto mode active)
## Acceptance Criteria Results
| Criterion | Result |
|-----------|--------|
| Starts with `'use client';` | PASS |
| Default export `MobileDashboard` | PASS |
| Imports all 3 mobile components | PASS (3 imports) |
| Fetches `/api/mobile/dashboard` | PASS |
| Contains `grid-cols-2` | PASS |
| Contains `<h1>Dashboard</h1>` | PASS |
| No recharts/swr/react-query/framer-motion | PASS |
| No legacy field names (by_status, etc.) | PASS |
| No old ticket link `/mobile/tickets/${...}` | PASS |
| TypeScript clean for page file | PASS |
| <= 130 lines | PASS (118 lines) |
## Deviations from Plan
None - plan executed exactly as written. The prescribed code structure from the plan's `<action>` block was used directly with no modifications needed.
## Known Stubs
None — all data flows are wired to live DB queries via `/api/mobile/dashboard` (shipped in plan 01).
## Threat Flags
No new security surface introduced. The page consumes the existing authenticated `/api/mobile/dashboard` endpoint. Error messages are sanitized (T-03-07 mitigated: renders `body.message ?? body.error ?? 'HTTP {status}'`, no stack traces).
---
*Phase: 03-dashboard-restyle*
*Completed: 2026-05-03*

View file

@ -0,0 +1,45 @@
---
status: passed
phase: 03-dashboard-restyle
source: [03-VERIFICATION.md]
started: 2026-05-03T00:00:00Z
updated: 2026-05-03T00:00:00Z
---
## Current Test
[all tests passed]
## Tests
### 1. Phone-width layout and visual correctness — `/mobile/dashboard`
expected: |
Run `npm run dev`, open Chrome DevTools, enable iPhone 15 Pro (393×852), navigate to
http://localhost:3100/mobile/dashboard. Verify all of:
a. Sticky header is the new shell HeaderBar (Wulf mark + Pulse wordmark + Bell + avatar) — NOT a page-internal "Ticket Dashboard" bar
b. Exactly one H1 in the page body reading "Dashboard"
c. Four KPI cards in a 2×2 grid; SLA breaches card has a red (destructive) left border when count > 0, neutral border otherwise
d. Below the grid, "Needs attention" label and three horizontally-scrollable cards with snap momentum:
- Overdue tickets → /tickets?overdue=true
- Failed backups → /backup-status
- Stalled workflows → /admin/workflow
e. Below the strip, "Workers & backups" block with three rows, each with a status dot (emerald/amber/red) and external-link icon:
- Analyzer → /admin/analytics
- RMM Overshell → /admin/rmm-overshell
- Backup success (24h) → /backup-status
f. No recharts canvas/SVG anywhere on the page
g. Page scrolls under sticky header; content not hidden behind bottom nav
h. Refresh button (top-right of H1 row) spins icon and reloads data without full-page navigation
result: passed
## Summary
total: 1
passed: 1
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps

View file

@ -0,0 +1,134 @@
---
phase: 03-dashboard-restyle
verified: 2026-05-03T00:00:00Z
status: passed
score: 10/10 must-haves verified (automated + human)
human_verification:
- test: "Open /mobile/dashboard on a phone-width viewport (e.g. iPhone 15 Pro 393×852 in Chrome DevTools)"
expected: |
a. Sticky header is the new shell HeaderBar (Wulf mark + Pulse wordmark + Bell + avatar), NOT a page-internal title bar
b. Exactly one H1 in the page body that reads "Dashboard"
c. Four KPI cards in a 2x2 grid (Open total, Opened today, Resolved today, SLA breaches); SLA breaches card has a destructive (red) left border when count > 0, neutral border otherwise
d. Below the grid, "Needs attention" strip with three horizontally-scrollable cards: Overdue tickets → /tickets?overdue=true, Failed backups → /backup-status, Stalled workflows → /admin/workflow; momentum/snap scroll works on iOS/Android
e. Below the strip, "Workers & backups" block with three rows: Analyzer → /admin/analytics, RMM Overshell → /admin/rmm-overshell, Backup success (24h) → /backup-status; each row has a status dot (emerald/amber/red) and an external-link icon
f. No recharts canvas/SVG anywhere on the page
g. Page scrolls under sticky header; content not hidden behind the bottom nav
h. Refresh button (top-right of H1 row) spins icon and reloads data without full-page navigation
why_human: "Phone-first layout, visual appearance of color tokens (destructive border, status dots), horizontal scroll momentum behavior, and safe-area/nav overlap require a real or emulated phone-width viewport — not verifiable by static analysis"
---
# Phase 3: Dashboard Restyle Verification Report
**Phase Goal:** A manager opening `/mobile/dashboard` sees the state of the business at a glance — four KPIs, items needing attention, and a worker/backup status row — with no charts.
**Verified:** 2026-05-03
**Status:** human_needed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|----|-------|--------|----------|
| 1 | GET /api/mobile/dashboard returns kpis (4 entries), needsAttention (3 entries), and workers (3 entries) in a single round-trip | VERIFIED | route.ts: 4 KpiResponse entries, 3 AttentionResponse entries, 3 WorkerResponse entries built from a single Promise.all of 6 queries |
| 2 | KpiCardMobile renders a phone-sized KPI card with label, value, optional caption — no chart, no recharts import | VERIFIED | KpiCardMobile.tsx: 40 lines, renders label/value/caption, uses Card/CardContent; `grep recharts` returns nothing |
| 3 | NeedsAttentionStrip renders a horizontal-scrolling strip of compact attention cards, each linking to a destination URL | VERIFIED | NeedsAttentionStrip.tsx: `overflow-x-auto snap-x snap-mandatory`, each item wrapped in `<Link href={item.href}>` from next/link |
| 4 | WorkerStatusRow renders a 3-cell status row with status indicators that link to desktop admin pages | VERIFIED | WorkerStatusRow.tsx: `DOT_COLOR` record maps ok/warn/down to emerald/amber/destructive; each entry wrapped in `<Link href={e.href}>` |
| 5 | Visiting /mobile/dashboard renders four KPI cards in a 2x2 grid (no 1x4 row, no list) | VERIFIED | page.tsx line 82: `<div className="grid grid-cols-2 gap-3">` iterating over `data.kpis` (4 entries) |
| 6 | Below the grid, a horizontally-scrollable Needs Attention strip surfaces overdue tickets, failed backups, and stalled workflows | VERIFIED | page.tsx lines 95-102: NeedsAttentionStrip wired with `data.needsAttention` (3 items from API); strip component is horizontal-scrollable |
| 7 | Below the strip, a 3-row worker/backup status block links to correct admin pages | VERIFIED | page.tsx lines 105-113: WorkerStatusRow wired with `data.workers` (3 entries); hrefs are /admin/analytics, /admin/rmm-overshell, /backup-status |
| 8 | The page imports zero recharts/chart components and renders no chart on phone widths | VERIFIED | `grep recharts` returns nothing in page.tsx and all 3 mobile components; no dynamic chart imports present |
| 9 | Tapping a Needs Attention card navigates to its href (next/link) | VERIFIED | NeedsAttentionStrip.tsx: each card is `<Link key={item.id} href={item.href}>` — no router.push, no JS-only navigation |
| 10 | Tapping a worker status row navigates to its desktop admin href (next/link) | VERIFIED | WorkerStatusRow.tsx: each row is `<Link key={e.id} href={e.href}>` — hard-coded server-supplied hrefs |
**Score:** 10/10 truths verified (automated)
### Deferred Items
None.
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `app/api/mobile/dashboard/route.ts` | Single GET endpoint shaped for new mobile dashboard sections | VERIFIED | 215 lines; exports MobileDashboardResponse, KpiResponse, AttentionResponse, WorkerResponse; 6 postgresClient.query calls in 1 Promise.all |
| `components/mobile/KpiCardMobile.tsx` | Reusable phone-sized KPI card component | VERIFIED | 40 lines; exports KpiCardMobile (function) and KpiTone (type); uses shadcn Card |
| `components/mobile/NeedsAttentionStrip.tsx` | Horizontal-scroll strip rendering NeedsAttention cards | VERIFIED | 52 lines; exports NeedsAttentionStrip (function) and NeedsAttentionItem (interface) |
| `components/mobile/WorkerStatusRow.tsx` | Compact 3-cell worker/backup status row | VERIFIED | 56 lines; exports WorkerStatusRow (function), WorkerStatusEntry (interface), WorkerStatus (type) |
| `app/mobile/dashboard/page.tsx` | Replaced page body wiring 3 components against /api/mobile/dashboard | VERIFIED | 118 lines; use client; default export MobileDashboard; imports all 3 components; fetches /api/mobile/dashboard |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| components/mobile/NeedsAttentionStrip.tsx | next/link | `import Link from 'next/link'` + `href={item.href}` on each card | WIRED | Line 10: import; line 33: `<Link … href={item.href}>` |
| components/mobile/WorkerStatusRow.tsx | next/link | `import Link from 'next/link'` + `href={e.href}` on each row | WIRED | Line 10: import; line 41: `<Link … href={e.href}>` |
| app/api/mobile/dashboard/route.ts | postgresClient | `import postgresClient` + single `Promise.all` of 6 queries | WIRED | Line 13: import; lines 61-133: 6 calls to `postgresClient.query` |
| app/mobile/dashboard/page.tsx | /api/mobile/dashboard | `fetch('/api/mobile/dashboard')` in `load()` called from `useEffect` | WIRED | Line 29: fetch call; line 34: `setData(await r.json())` |
| app/mobile/dashboard/page.tsx | KpiCardMobile | `import { KpiCardMobile } from '@/components/mobile/KpiCardMobile'` | WIRED | Line 15: import; line 84: rendered in JSX |
| app/mobile/dashboard/page.tsx | NeedsAttentionStrip | `import { NeedsAttentionStrip } from '@/components/mobile/NeedsAttentionStrip'` | WIRED | Line 16: import; line 95: rendered in JSX |
| app/mobile/dashboard/page.tsx | WorkerStatusRow | `import { WorkerStatusRow } from '@/components/mobile/WorkerStatusRow'` | WIRED | Line 17: import; line 105: rendered in JSX |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| app/mobile/dashboard/page.tsx | `data` (MobileDashboardResponse) | `fetch('/api/mobile/dashboard')``setData(await r.json())` in load() | Yes — route.ts runs 6 live DB queries (postgresClient.query) against tickets, veeam_backup_jobs, veeam_backup_agent_jobs, workflow_executions, analyzer_jobs, rmm_executions | FLOWING |
| app/api/mobile/dashboard/route.ts | kpis, needsAttention, workers | 6 postgresClient.query calls in Promise.all; all return live row data | Yes — COUNT queries on live tables; no static return paths | FLOWING |
### Behavioral Spot-Checks
Step 7b: SKIPPED — server must be running to exercise the API endpoint; database connectivity cannot be confirmed without a live Postgres connection. TypeScript clean pass is the best statically-verifiable proxy.
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Phase files type-check clean | `npx tsc --noEmit --pretty 2>&1 \| grep -E "app/mobile/dashboard\|components/mobile\|app/api/mobile/dashboard"` | `no errors in phase files` | PASS |
| No recharts in dashboard or mobile components | `grep -rn recharts app/mobile/dashboard/ components/mobile/` | `OK: no recharts...` | PASS |
| page.tsx is <= 130 lines | `wc -l app/mobile/dashboard/page.tsx` | 118 lines | PASS |
| No Self-Check: FAILED in summaries | grep across .md files | none found | PASS |
| All 5 commits from summaries exist in git log | git log | 24e20c7, bfe9549, 5256250 confirmed | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| DASH-01 | 03-01, 03-02 | 2x2 KPI grid with four primary metric cards drawn from desktop dashboard hero stats | SATISFIED | route.ts returns 4 KpiResponse entries (open_total, opened_today, resolved_today, sla_breaches) from real ticket queries; page.tsx renders `<div className="grid grid-cols-2 gap-3">` iterating kpis |
| DASH-02 | 03-01, 03-02 | "Needs Attention" horizontal-scroll strip (overdue tickets, failed backups, stalled workflows); tapping opens detail view | SATISFIED | NeedsAttentionStrip.tsx has `overflow-x-auto snap-x snap-mandatory`; 3 entries from route.ts with correct hrefs; each wrapped in next/link |
| DASH-03 | 03-01, 03-02 | Compact backup/worker status row (analyzer worker, RMM worker, backup-success-rate); tap opens desktop admin page | SATISFIED | WorkerStatusRow.tsx with DOT_COLOR status indicators; 3 entries from route.ts; hrefs: /admin/analytics, /admin/rmm-overshell, /backup-status |
| DASH-04 | 03-02 | No charts/recharts on the mobile Dashboard | SATISFIED | `grep recharts` returns nothing in page.tsx and all 3 mobile components; no chart-related imports anywhere |
All 4 requirements assigned to Phase 3 in REQUIREMENTS.md are satisfied. No orphaned requirements.
### Anti-Patterns Found
None found. No TODO/FIXME/PLACEHOLDER comments. No `return null` stubs (NeedsAttentionStrip returns null only for empty items array, which is intentional and documented). No hardcoded empty data flowing to render paths. No console.log calls. No legacy recharts or deprecated field names.
### Human Verification Required
#### 1. Phone-width layout and visual correctness
**Test:** Start dev server (`npm run dev`), open Chrome DevTools, enable device emulation at iPhone 15 Pro (393x852), navigate to http://localhost:3100/mobile/dashboard (sign in if prompted), and walk through the following checks:
a. Sticky header is the new shell HeaderBar (Wulf mark + Pulse wordmark + Bell + avatar) — NOT a page-internal header with "Ticket Dashboard"
b. Exactly one H1 in the page body reading "Dashboard"
c. Four KPI cards in a 2x2 grid; the SLA breaches card has a red left border when count > 0, neutral otherwise
d. Below the grid, a "Needs attention" label and three cards scrolling horizontally with snap momentum; tapping Overdue tickets → /tickets?overdue=true, Failed backups → /backup-status, Stalled workflows → /admin/workflow
e. Below the strip, a "Workers & backups" block with three rows each having a colored status dot (emerald/amber/red) and an external-link icon; tapping Analyzer → /admin/analytics, RMM Overshell → /admin/rmm-overshell, Backup success → /backup-status
f. No recharts canvas or SVG chart anywhere on the page
g. Page scrolls under the sticky header; content is not hidden behind the bottom nav
h. Refresh button in the H1 row spins the icon and reloads data without full-page navigation
**Expected:** All items ah pass.
**Why human:** Phone-first layout, visual appearance of color tokens (destructive border on SLA breaches, emerald/amber/red status dots), horizontal scroll snap momentum behavior on iOS/Android, safe-area/nav overlap, and interactive tap navigation to correct destinations all require a running app at a phone-width viewport. Static analysis cannot validate these rendering and interaction properties.
### Gaps Summary
No automated gaps identified. All 10 observable truths are verified. All 4 DASH requirements have evidence. All artifacts exist, are substantive, and are wired. Data flows from live DB queries through the API to the rendered components. Type-check passes clean for all phase files.
The human_needed status reflects that the plans themselves include a mandatory human checkpoint (03-02-PLAN.md Task 2: `checkpoint:human-verify gate="blocking"`) for visual/UX review of the phone-first layout, which was auto-approved in auto mode. A human walkthrough on a phone-width viewport is required to confirm the spec §6.1 layout as built.
---
_Verified: 2026-05-03_
_Verifier: Claude (gsd-verifier)_

View file

@ -0,0 +1,583 @@
---
phase: 04
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/api/mobile/tickets/route.ts
- components/mobile/TicketRowSkeleton.tsx
- components/mobile/TicketFilterStrip.tsx
autonomous: true
requirements: [TICK-01, TICK-02, TICK-05]
must_haves:
truths:
- "GET /api/mobile/tickets accepts a base64 cursor and returns { tickets, nextCursor, hasMore } shape"
- "API caps page size at 25 server-side regardless of caller's limit param"
- "Default status filter (no status param) returns Open + In Progress + Waiting tickets (status IN (1, 8, 7)) — matches the legacy t.status != 5 default"
- "TicketFilterStrip renders the search input, ticket count line, and Collapsible toggle row with the four filter controls when expanded"
- "TicketRowSkeleton renders five placeholder rows that visually match the priority-stripe ticket-row layout"
artifacts:
- path: "app/api/mobile/tickets/route.ts"
provides: "Cursor-paginated list endpoint exporting MobileTicket and MobileTicketListResponse interfaces"
exports: ["GET", "MobileTicket", "MobileTicketListResponse"]
contains: "nextCursor"
- path: "components/mobile/TicketFilterStrip.tsx"
provides: "Collapsible filter strip presentational component"
exports: ["TicketFilterStrip", "TicketFilterValue", "QueueOption"]
- path: "components/mobile/TicketRowSkeleton.tsx"
provides: "Skeleton placeholder row matching ticket row layout"
exports: ["TicketRowSkeleton"]
key_links:
- from: "app/api/mobile/tickets/route.ts"
to: "kiosk_settings table via getMobileCompanyFilter()"
via: "preserved helper, unchanged"
pattern: "getMobileCompanyFilter"
- from: "app/api/mobile/tickets/route.ts"
to: "tickets / companies / queues / resources tables"
via: "parameterized SQL, last_activity_date DESC NULLS LAST, id DESC tie-breaker"
pattern: "ORDER BY.*last_activity_date.*DESC.*id.*DESC"
- from: "components/mobile/TicketFilterStrip.tsx"
to: "components/ui/collapsible.tsx"
via: "shadcn primitive import"
pattern: "from ['\"]@/components/ui/collapsible['\"]"
---
<objective>
Reshape `/api/mobile/tickets` from page-based pagination (`?page=N&limit=30`) to opaque-cursor pagination (`?cursor=<b64>&limit=25`) returning a typed `{ tickets, nextCursor, hasMore }` envelope, AND ship the two new presentational components (`TicketFilterStrip`, `TicketRowSkeleton`) the page (Plan 04-02) will consume.
Purpose: TICK-05 mandates cursor-based ~25/page infinite scroll; the page can't be wired without the new API shape. Co-locating the filter strip and skeleton component here keeps Plan 02 focused on wiring rather than building presentational primitives. Mirror the Phase 3 pattern of exporting TypeScript interfaces from the route file so Plan 02 can `import type` them directly.
Output:
- Rewritten `app/api/mobile/tickets/route.ts` (cursor-based, exported interfaces, capped limit, default-status fallback)
- New `components/mobile/TicketFilterStrip.tsx` (Collapsible filter strip, search + status/priority/queue/mine controls)
- New `components/mobile/TicketRowSkeleton.tsx` (5-row skeleton shape matching priority-stripe row layout)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/REQUIREMENTS.md
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
@.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md
@CLAUDE.md
@app/api/mobile/tickets/route.ts
@components/ui/collapsible.tsx
<interfaces>
<!-- Existing route helper that MUST be preserved verbatim — see 04-CONTEXT.md code_context -->
From app/api/mobile/tickets/route.ts (lines 4-26):
```typescript
async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> {
// reads kiosk_settings: mobile_company_category_ids, mobile_excluded_company_ids
// returns { join, condition } where condition is a SQL fragment for tickets table alias `t`
}
```
From components/ui/collapsible.tsx:
```typescript
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
// Wrappers around @radix-ui/react-collapsible
// Props: open, onOpenChange — control state externally for URL sync
```
From components/ui/select.tsx (shadcn):
```typescript
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
```
From components/ui/switch.tsx (shadcn):
```typescript
export { Switch } // controlled via checked + onCheckedChange
```
From components/ui/skeleton.tsx (shadcn):
```typescript
export { Skeleton } // div with bg-muted animate-pulse rounded
```
From components/ui/input.tsx (shadcn):
```typescript
export { Input }
```
From components/ui/button.tsx (shadcn):
```typescript
export { Button } // accepts variant: 'default'|'ghost'|..., size: 'sm'|'default'|'lg'
```
Pattern from Phase 3 (`app/api/mobile/dashboard/route.ts`):
- `import postgresClient from '@/lib/services/postgres-client'` (default import)
- `import { requireAuth } from '@/lib/auth-utils'` then `const { session, error } = await requireAuth(); if (error) return error;`
- Single `Promise.all` of queries
- Manual snake_case → camelCase NOT done in Phase 3 dashboard (kept snake_case in JSON) — for tickets the existing route returns snake_case so we KEEP snake_case to avoid breaking the page contract
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Rewrite /api/mobile/tickets to cursor-paginated shape with exported interfaces</name>
<files>app/api/mobile/tickets/route.ts</files>
<read_first>
- app/api/mobile/tickets/route.ts (current 90-line implementation — preserve getMobileCompanyFilter verbatim)
- app/api/mobile/dashboard/route.ts (Phase 3 reference for requireAuth + default postgresClient import + interface export pattern)
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-08 through D-11 (cursor model, page size 25)
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "API Shape Contract" section
</read_first>
<action>
Rewrite `app/api/mobile/tickets/route.ts` end-to-end. The new file:
1. **Imports** — keep `NextRequest`, `NextResponse` from `next/server`. Switch to default import: `import postgresClient from '@/lib/services/postgres-client'` (matches Phase 3 dashboard route convention). Add `import { requireAuth } from '@/lib/auth-utils'`.
2. **Preserve `getMobileCompanyFilter()` helper VERBATIM** — copy lines 4-26 of the current file unchanged. Do NOT regress the kiosk_settings lookup or the `c.company_category_id = 1` fallback. The function signature and body must be byte-identical to the current implementation.
3. **Export TypeScript interfaces** at the top of the module (mirrors Phase 3 03-01-SUMMARY.md pattern):
```typescript
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number;
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
```
4. **Cursor encode/decode helpers** (inline, NOT exported — D-09):
```typescript
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') {
return parsed as CursorPayload;
}
return null;
} catch { return null; }
}
```
5. **`GET` handler** — `export async function GET(request: NextRequest): Promise<NextResponse>`:
- Auth gate first: `const { error: authError } = await requireAuth(); if (authError) return authError;`
- Parse query params:
- `q` (search, may be empty)
- `status` — comma-separated ints; if absent, default to `[1, 8, 7]` (Open + In Progress + Waiting per UI-SPEC "Default behavior when no URL params"); accept `''` as "no filter — explicit clear"; treat empty array same as default
- `priority` — comma-separated ints; if absent, no filter
- `queue` — single int; if absent, no filter
- `mine``'1'` means filter by current user; pull email from `session.user.email` then resolve to `resources.email = $X`
- `limit` — parseInt, clamped: `Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')))`
- `cursor` — decode via `decodeCursor()`; null if missing or malformed
- Build conditions array starting with `t.is_deleted = false`, the company-scope condition from `getMobileCompanyFilter()`, and the status filter (default-or-supplied list → `t.status = ANY($N::int[])`).
- Search: keep the existing pattern — `(t.title ILIKE $N OR t.ticket_number ILIKE $N OR c.company_name ILIKE $N)` with single `%search%` param.
- Cursor seek predicate (only if cursor decoded): `(t.last_activity_date, t.id) < ($N::timestamp, $M::int)` — this is the standard keyset pagination form for `ORDER BY last_activity_date DESC, id DESC`.
- Use `requireAuth()`'s session for `mine`: `params.push(session.user.email); conditions.push('LOWER(r.email) = LOWER($N))` — but ONLY if you have access to session here; since `requireAuth()` already returned `{ session, error }`, capture `session` from the call (`const { session, error: authError } = await requireAuth();`).
SQL:
```sql
SELECT t.id, t.ticket_number, t.title, t.status, t.priority,
t.create_date, t.last_activity_date, t.due_date_time,
t.queue_id, q.label AS queue_label,
c.company_name,
COALESCE(r.first_name || ' ' || r.last_name, '') AS assigned_to
FROM tickets t
INNER JOIN companies c ON c.id = t.company_id
LEFT JOIN queues q ON q.value = t.queue_id
LEFT JOIN resources r ON r.id = t.assigned_resource_id
WHERE <conditions joined with AND>
ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC
LIMIT <limit + 1>
```
Fetch `limit + 1` rows to detect `hasMore` without a second COUNT query. If `rows.length > limit`, slice to `limit` and `hasMore = true`; the next-cursor's `last_activity_date` and `id` come from the last kept row (`rows[limit - 1]`).
Build response:
```typescript
const tickets: MobileTicket[] = sliced.map(/* row → MobileTicket; preserve snake_case keys exactly as the interface declares */);
const nextCursor = hasMore ? encodeCursor({ last_activity_date: tickets[tickets.length - 1].last_activity_date, id: tickets[tickets.length - 1].id }) : null;
return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse);
```
6. **Error handling** — wrap the body in `try/catch`; on error log `console.error('GET /api/mobile/tickets failed:', error)` and return `NextResponse.json({ error: 'Failed to fetch tickets', message: error instanceof Error ? error.message : 'unknown' }, { status: 500 })` per CLAUDE.md API route convention.
Anti-patterns (do NOT do):
- Do NOT add Zod validation here (CLAUDE.md: "No Zod validation in route handlers today").
- Do NOT change the snake_case keys of MobileTicket (the original page used `ticket_number`, `last_activity_date`, etc. — Plan 02 expects these names).
- Do NOT remove `requireAuth()` once added — it's a security gate. Note: the legacy file did NOT have `requireAuth()`; this is intentional hardening per Phase 3 pattern.
- Do NOT touch `app/api/mobile/tickets/[id]/timeline/route.ts` — that's the detail endpoint, out of scope.
Per D-09 the cursor encodes `{ last_activity_date, id }` exactly — do not rename to `lastActivityDate` (would break decode round-trip).
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "(app/api/mobile/tickets/route|components/mobile/TicketFilterStrip|components/mobile/TicketRowSkeleton)" || echo "OK: no type errors in target files"</automated>
</verify>
<acceptance_criteria>
- `grep -q "export interface MobileTicket" app/api/mobile/tickets/route.ts` (interface exported)
- `grep -q "export interface MobileTicketListResponse" app/api/mobile/tickets/route.ts` (envelope interface exported)
- `grep -q "nextCursor" app/api/mobile/tickets/route.ts` (cursor field present)
- `grep -q "hasMore" app/api/mobile/tickets/route.ts` (hasMore field present)
- `grep -q "getMobileCompanyFilter" app/api/mobile/tickets/route.ts` (helper preserved)
- `grep -q "requireAuth" app/api/mobile/tickets/route.ts` (auth gate added)
- `grep -qE "Math\.min\(25" app/api/mobile/tickets/route.ts` (limit cap of 25 — D-11)
- `grep -qE "ORDER BY.*last_activity_date.*DESC" app/api/mobile/tickets/route.ts` (keyset order)
- `grep -qE "t\.id DESC" app/api/mobile/tickets/route.ts` (tie-breaker on id — D-09)
- `! grep -q "OFFSET" app/api/mobile/tickets/route.ts` (no page-based offset remains)
- `! grep -q "?page=" app/api/mobile/tickets/route.ts` (no page param consumed)
- `grep -q "is_deleted" app/api/mobile/tickets/route.ts` (deleted filter preserved)
- `npx tsc --noEmit --pretty 2>&1` does not report errors for `app/api/mobile/tickets/route.ts`
</acceptance_criteria>
<done>The route file compiles cleanly, exports `MobileTicket` and `MobileTicketListResponse`, returns `{ tickets, nextCursor, hasMore }`, caps limit at 25, applies the `[1, 8, 7]` default status filter when no `status` param is supplied, preserves `getMobileCompanyFilter()` verbatim, and gates with `requireAuth()`. No OFFSET-based pagination remains. The legacy `?page=N` shape is fully replaced.</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components</name>
<files>components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx</files>
<read_first>
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Filter Strip" and "Skeleton Loading State" sections
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-01 through D-04 (filter strip), D-21 (skeleton)
- components/mobile/KpiCardMobile.tsx (Phase 3 component for the comment-block pattern reference)
- components/ui/collapsible.tsx (Collapsible API surface)
- components/ui/skeleton.tsx (Skeleton primitive)
</read_first>
<action>
Create two new files. Both use the Phase 3 mobile component comment-block convention:
```
/* ComponentName — phase 04 (TICK-NN).
* Purpose: one-line description.
* Props: ... */
```
---
**File 1: `components/mobile/TicketRowSkeleton.tsx`** (D-21)
```typescript
'use client';
/* TicketRowSkeleton — phase 04 (TICK-05/D-21).
* Purpose: skeleton placeholder row that matches the priority-stripe ticket row layout
* for the initial-load state of /mobile/tickets.
* Props: none — purely presentational. */
import { Skeleton } from '@/components/ui/skeleton';
export function TicketRowSkeleton() {
return (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2 mt-1" />
<div className="flex gap-2 mt-2 items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
</div>
);
}
```
Match UI-SPEC §"Skeleton Loading State" exactly. The wrapping container in Plan 02 will render `Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)`.
---
**File 2: `components/mobile/TicketFilterStrip.tsx`** (D-01..D-04)
Headers and exports:
```typescript
'use client';
/* TicketFilterStrip — phase 04 (TICK-01, TICK-02).
* Purpose: sticky search input + Collapsible filter panel (status, priority, queue, mine)
* with controlled values; URL sync is the parent page's responsibility.
* Props: value, onChange, queueOptions, openTotal, isFiltered, onClearAll. */
import { useState } from 'react';
import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export interface QueueOption {
id: number;
label: string;
}
export interface TicketFilterValue {
q: string;
status: number[]; // [] = no filter — caller treats default elsewhere
priority: number[]; // [] = no filter
queue: number | null;
mine: boolean;
}
export interface TicketFilterStripProps {
value: TicketFilterValue;
onChange: (next: TicketFilterValue) => void;
queueOptions: QueueOption[];
openTotal: number;
isFiltered: boolean; // true when ≥1 non-default filter is active (excludes default status)
onClearAll: () => void;
}
const STATUS_OPTIONS: Array<{ id: number; label: string }> = [
{ id: 1, label: 'Open' },
{ id: 8, label: 'In Progress' },
{ id: 7, label: 'Waiting' },
];
const PRIORITY_OPTIONS: Array<{ id: number; label: string }> = [
{ id: 1, label: 'Critical' },
{ id: 2, label: 'High' },
{ id: 3, label: 'Medium' },
{ id: 4, label: 'Low' },
];
function chipClass(active: boolean): string {
const base = 'shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors min-h-[32px]';
return active
? `${base} bg-primary text-primary-foreground border-primary`
: `${base} border-border hover:bg-muted/50`;
}
function toggleInArray(arr: number[], id: number): number[] {
return arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id];
}
export function TicketFilterStrip(props: TicketFilterStripProps) {
const { value, onChange, queueOptions, openTotal, isFiltered, onClearAll } = props;
const [open, setOpen] = useState(false);
const activeCount =
(value.status.length > 0 && !(value.status.length === 3 && value.status.includes(1) && value.status.includes(7) && value.status.includes(8)) ? 1 : 0) +
(value.priority.length > 0 ? 1 : 0) +
(value.queue !== null ? 1 : 0) +
(value.mine ? 1 : 0);
return (
<div className="sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2">
{/* Search row — always visible (D-03) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" aria-hidden="true" />
<Input
type="text"
placeholder="Search tickets, company…"
value={value.q}
onChange={(e) => onChange({ ...value, q: e.target.value })}
className="w-full pl-9 pr-9"
aria-label="Search tickets"
/>
{value.q && (
<button
type="button"
onClick={() => onChange({ ...value, q: '' })}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Toggle row — always visible (D-01) */}
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{openTotal} open tickets</p>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs font-semibold" aria-label="Toggle filters">
<SlidersHorizontal className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
Filters{activeCount > 0 ? ` (${activeCount})` : ''}
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" aria-hidden="true" />}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-3 space-y-3">
{/* Status (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Status</p>
<div className="flex gap-2 flex-wrap">
{STATUS_OPTIONS.map((s) => (
<button
key={s.id}
type="button"
role="checkbox"
aria-checked={value.status.includes(s.id)}
onClick={() => onChange({ ...value, status: toggleInArray(value.status, s.id) })}
className={chipClass(value.status.includes(s.id))}
>
{s.label}
</button>
))}
</div>
</div>
{/* Priority (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Priority</p>
<div className="flex gap-2 flex-wrap">
{PRIORITY_OPTIONS.map((p) => (
<button
key={p.id}
type="button"
role="checkbox"
aria-checked={value.priority.includes(p.id)}
onClick={() => onChange({ ...value, priority: toggleInArray(value.priority, p.id) })}
className={chipClass(value.priority.includes(p.id))}
>
{p.label}
</button>
))}
</div>
</div>
{/* Queue (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Queue</p>
<Select
value={value.queue !== null ? String(value.queue) : 'all'}
onValueChange={(v) => onChange({ ...value, queue: v === 'all' ? null : parseInt(v, 10) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All queues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All queues</SelectItem>
{queueOptions.map((q) => (
<SelectItem key={q.id} value={String(q.id)}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Assigned to me (D-02) */}
<div className="flex items-center justify-between">
<label htmlFor="filter-mine" className="text-sm">Assigned to me</label>
<Switch
id="filter-mine"
checked={value.mine}
onCheckedChange={(checked) => onChange({ ...value, mine: checked })}
/>
</div>
{/* Clear all — only when isFiltered (D-04) */}
{isFiltered && (
<button
type="button"
onClick={onClearAll}
className="text-xs text-muted-foreground underline"
>
Clear all
</button>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}
```
Notes on behavior:
- The component is purely controlled (no internal filter state besides the open/closed Collapsible toggle). URL sync lives in the parent page.
- `activeCount` excludes the default `[1, 7, 8]` status set so the badge only counts user-selected modifications. The parent decides what "default" means and passes `isFiltered` accordingly.
- The `min-h-[32px]` on chips meets the touch-target guidance with `py-1` baseline; the entire chip area is tappable. (UI-SPEC notes 44px for primary controls; chips are secondary and use a relaxed target consistent with the existing `py-1 rounded-full` pattern.)
Anti-patterns (do NOT do):
- Do NOT introduce SWR / react-query / Zustand (CLAUDE.md: "No additional state libraries").
- Do NOT fetch queue options inside this component; the parent passes `queueOptions` (queue list comes from a future endpoint or from the existing tickets API; Plan 02 will decide). For Plan 01 we only define the prop contract.
- Do NOT render the priority dot — the row stripe replaces it (D-17). This component only handles filters; the row itself is built in Plan 02.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components"</automated>
</verify>
<acceptance_criteria>
- `test -f components/mobile/TicketRowSkeleton.tsx` (file exists)
- `test -f components/mobile/TicketFilterStrip.tsx` (file exists)
- `grep -q "export function TicketRowSkeleton" components/mobile/TicketRowSkeleton.tsx` (named export)
- `grep -q "export function TicketFilterStrip" components/mobile/TicketFilterStrip.tsx` (named export)
- `grep -q "export interface TicketFilterValue" components/mobile/TicketFilterStrip.tsx` (filter value interface exported)
- `grep -q "export interface QueueOption" components/mobile/TicketFilterStrip.tsx` (queue option interface exported)
- `grep -q "border-l-4 border-muted" components/mobile/TicketRowSkeleton.tsx` (4px stripe per UI-SPEC)
- `grep -q "from '@/components/ui/collapsible'" components/mobile/TicketFilterStrip.tsx` (uses Collapsible primitive — D-01)
- `grep -q "sticky top-0" components/mobile/TicketFilterStrip.tsx` (sticky positioning per UI-SPEC viewport contract)
- `grep -q "Clear all" components/mobile/TicketFilterStrip.tsx` (D-04 copy)
- `grep -qE "(Open|In Progress|Waiting)" components/mobile/TicketFilterStrip.tsx` (status options — D-02)
- `grep -qE "(Critical|High|Medium|Low)" components/mobile/TicketFilterStrip.tsx` (priority options — D-02)
- `grep -q "Assigned to me" components/mobile/TicketFilterStrip.tsx` (mine toggle label — D-02)
- `grep -q "phase 04" components/mobile/TicketRowSkeleton.tsx && grep -q "phase 04" components/mobile/TicketFilterStrip.tsx` (Phase 3 comment-block convention)
- `! grep -q "useSWR\|@tanstack/react-query\|zustand" components/mobile/TicketFilterStrip.tsx` (no forbidden state libraries)
- `npx tsc --noEmit --pretty 2>&1` does not report errors for either new file
</acceptance_criteria>
<done>Both component files exist, type-check cleanly, expose the documented prop interfaces, follow the Phase 3 comment-block convention, and contain the exact UI-SPEC class strings for the priority skeleton stripe (`border-l-4 border-muted`) and the sticky filter strip container (`sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2`). Plan 02 can `import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip'` and `import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton'` without further changes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/mobile/tickets | Authenticated user supplies q/status/priority/queue/cursor — must be validated and parameterised |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-01 | Tampering | cursor query param | mitigate | `decodeCursor()` returns null on JSON parse failure, missing fields, or wrong types — falls back to "no cursor" rather than throwing or trusting parsed data; the cursor only affects ordering, never company scope. |
| T-04-02 | Information Disclosure | company-scope bypass via SQL injection | mitigate | All user input flows through parameterised queries via `postgresClient.query(sql, params)`; `getMobileCompanyFilter()` interpolates only ints filtered through `parseInt + isNaN` guards (existing helper, preserved verbatim). |
| T-04-03 | Information Disclosure | unauthenticated access to ticket list | mitigate | Add `await requireAuth()` at the top of GET — the legacy route lacked this, Phase 4 hardens it (matches Phase 3 dashboard route). |
| T-04-04 | Denial of Service | unbounded `limit` param | mitigate | Server-side cap: `Math.min(25, Math.max(1, parseInt(limit ?? '25')))` — caller cannot request more than 25 rows. |
| T-04-05 | Information Disclosure | `mine` filter using session.user.email | mitigate | Email comes from the verified session, never from the query string; SQL uses `LOWER(r.email) = LOWER($N)` parameterised. |
| T-04-06 | Spoofing | filter chips submit forged status/priority ids | accept | Status/priority are foreign keys to `tickets`; non-existent ids simply return zero rows. No data exfiltration risk; legacy route had the same model. |
</threat_model>
<verification>
After both tasks complete, run:
1. `npx tsc --noEmit --pretty` — must pass with no new errors in `app/api/mobile/tickets/route.ts`, `components/mobile/TicketRowSkeleton.tsx`, or `components/mobile/TicketFilterStrip.tsx`.
2. `grep -c "export interface" app/api/mobile/tickets/route.ts` — must be `>= 2` (MobileTicket + MobileTicketListResponse).
3. `grep -c "export function" components/mobile/TicketFilterStrip.tsx` — must be `>= 1`.
4. Hand-execute one curl for sanity (developer terminal): `curl -sS 'http://localhost:3100/api/mobile/tickets?limit=5' -b "<session-cookie>" | jq '.tickets | length, .nextCursor, .hasMore'` — should return `5`, an opaque base64 string (or null if fewer than 5 tickets), and a boolean. NOTE: this is for the dev's smoke check; not part of the automated gate (it requires a live dev server).
</verification>
<success_criteria>
- `app/api/mobile/tickets/route.ts` rewritten with cursor pagination, `requireAuth()`, exported `MobileTicket` and `MobileTicketListResponse` interfaces, server-side limit cap of 25, default-status fallback `[1, 8, 7]`, and `getMobileCompanyFilter()` preserved verbatim.
- `components/mobile/TicketRowSkeleton.tsx` exists with the exact UI-SPEC skeleton shape (4px muted stripe + 3 skeleton lines + metadata row).
- `components/mobile/TicketFilterStrip.tsx` exists with the controlled prop contract, Collapsible-driven panel, all four filter controls, "Clear all" button, and active-filter count badge.
- TypeScript clean (`npx tsc --noEmit --pretty` passes for these files).
- No legacy `?page=` / `OFFSET` paths remain in the route.
- All TICK-01 (filter strip), TICK-02 (URL sync — interface contract ready for Plan 02), TICK-05 (cursor API) requirements substantially landed (the page wiring closes them in Plan 02).
</success_criteria>
<output>
After completion, create `.planning/phases/04-tickets-restyle/04-01-SUMMARY.md` documenting:
- The exported interface signatures (so Plan 02 can `import type` directly).
- The cursor encoding format (`base64(JSON({ last_activity_date, id }))`).
- Default status filter when no `status` URL param is supplied (`[1, 8, 7]`).
- Server-side limit cap (25).
- That `getMobileCompanyFilter()` was preserved byte-identical.
- The two new component file paths and their exported names.
</output>

View file

@ -0,0 +1,590 @@
---
phase: 04
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- app/mobile/tickets/page.tsx
autonomous: false
requirements: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06]
must_haves:
truths:
- "Opening /mobile/tickets renders the filter strip in collapsed state with the search input and Filters toggle visible"
- "Tapping the Filters toggle expands the Collapsible to show status, priority, queue, and Assigned-to-me controls"
- "Changing any filter updates the URL query string in place (router.replace) without adding history entries"
- "Reloading the page with ?status=1&priority=2&queue=15&mine=1&q=foo hydrates filter state from those params"
- "Each list row has a 4px-wide left-edge color stripe matching the ticket priority"
- "Tapping any list row navigates to /mobile/tickets/[id]"
- "Scrolling to the bottom automatically loads the next ~25 rows via IntersectionObserver"
- "A focusable Load more button is present below the sentinel until hasMore is false"
- "When zero tickets match active filters, the page shows 'No tickets match your filters' with a Clear filters button"
artifacts:
- path: "app/mobile/tickets/page.tsx"
provides: "Mobile tickets list page wired to TicketFilterStrip + cursor-based /api/mobile/tickets"
contains: "TicketFilterStrip"
key_links:
- from: "app/mobile/tickets/page.tsx"
to: "/api/mobile/tickets"
via: "fetch with cursor + filter URL params"
pattern: "fetch\\(.*api/mobile/tickets"
- from: "app/mobile/tickets/page.tsx"
to: "components/mobile/TicketFilterStrip.tsx"
via: "named import"
pattern: "from ['\"]@/components/mobile/TicketFilterStrip['\"]"
- from: "app/mobile/tickets/page.tsx"
to: "components/mobile/TicketRowSkeleton.tsx"
via: "named import"
pattern: "from ['\"]@/components/mobile/TicketRowSkeleton['\"]"
- from: "app/mobile/tickets/page.tsx"
to: "MobileTicketListResponse type"
via: "import type from route file (Phase 3 pattern)"
pattern: "import type.*from.*api/mobile/tickets/route"
---
<objective>
Replace the body of `app/mobile/tickets/page.tsx` with the new shell-aligned implementation: Collapsible URL-synced filter strip, priority-stripe rows, IntersectionObserver-driven infinite scroll with a Load more fallback, skeleton loading state, and empty-state copy per D-20.
Purpose: closes TICK-01 through TICK-06 — the only requirements left after Plan 01 ships the API + presentational components. URL sync via `useSearchParams()` + `router.replace()` is the deep-link contract.
Output:
- Rewritten `app/mobile/tickets/page.tsx` consuming `MobileTicketListResponse` from the new route, the `TicketFilterStrip` and `TicketRowSkeleton` components from `components/mobile/`, with infinite scroll + URL sync + priority stripes.
- One human-verify checkpoint after the rewrite to confirm visual + interaction behavior on a real device.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/REQUIREMENTS.md
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
@.planning/phases/03-dashboard-restyle/03-02-SUMMARY.md
@CLAUDE.md
@app/mobile/tickets/page.tsx
<interfaces>
<!-- From Plan 04-01 — these will exist when Plan 02 runs -->
From `app/api/mobile/tickets/route.ts`:
```typescript
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number; // 1=Critical, 2=High, 3=Medium, 4=Low
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null; // base64 cursor; null when list is exhausted
hasMore: boolean;
}
```
From `components/mobile/TicketFilterStrip.tsx`:
```typescript
export interface QueueOption { id: number; label: string; }
export interface TicketFilterValue {
q: string;
status: number[]; // [] = treated as default by parent
priority: number[];
queue: number | null;
mine: boolean;
}
export interface TicketFilterStripProps {
value: TicketFilterValue;
onChange: (next: TicketFilterValue) => void;
queueOptions: QueueOption[];
openTotal: number;
isFiltered: boolean;
onClearAll: () => void;
}
export function TicketFilterStrip(props: TicketFilterStripProps): JSX.Element;
```
From `components/mobile/TicketRowSkeleton.tsx`:
```typescript
export function TicketRowSkeleton(): JSX.Element;
```
Helpers preserved from current page (line 29-37):
```typescript
function relTime(ts: string | null): string; // "5m ago" | "3h ago" | "2d ago" | "—"
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Rewrite app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, and IntersectionObserver infinite scroll</name>
<files>app/mobile/tickets/page.tsx</files>
<read_first>
- app/mobile/tickets/page.tsx (current 164-line implementation — preserve relTime helper)
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md (entire file — class strings and structure are load-bearing)
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-05 through D-21
- components/mobile/TicketFilterStrip.tsx (the prop contract Plan 01 ships)
- components/mobile/TicketRowSkeleton.tsx (the skeleton Plan 01 ships)
- app/mobile/dashboard/page.tsx (Phase 3 mobile page pattern: 'use client', single load function, useEffect once, error block + Retry)
</read_first>
<action>
Replace `app/mobile/tickets/page.tsx` end-to-end. The new file structure (target ≤ 220 lines):
1. **Header**`'use client';` then imports:
```typescript
import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip';
import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton';
import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route';
```
2. **Constants** (top of module, outside component):
```typescript
const PRIORITY_BORDER: Record<number, string> = {
1: 'border-red-500',
2: 'border-orange-400',
3: 'border-amber-400',
4: 'border-slate-300',
};
const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default)
```
These exact class strings are LOCKED by D-15 / UI-SPEC §"Priority Stripe Colors". Do NOT use `border-yellow-400` (the legacy code's medium dot) — D-15 specifies `border-amber-400` for priority 3.
3. **`relTime` helper** — copy verbatim from the current file (lines 29-37). Do NOT inline-replace with a library; D-17 says "Keep `relTime()` helper as-is".
4. **URL <-> filter state helpers** (module-scope pure functions):
```typescript
function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue {
const parseIntList = (raw: string | null): number[] => {
if (raw === null) return [];
if (raw === '') return []; // explicit empty
return raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
};
const statusParam = sp.get('status');
return {
q: sp.get('q') ?? '',
// when 'status' is absent entirely, fall through to DEFAULT_STATUS so the visible state matches what the API will return
status: statusParam === null ? [...DEFAULT_STATUS] : parseIntList(statusParam),
priority: parseIntList(sp.get('priority')),
queue: sp.get('queue') ? parseInt(sp.get('queue')!, 10) : null,
mine: sp.get('mine') === '1',
};
}
function filterToSearch(value: TicketFilterValue): URLSearchParams {
const sp = new URLSearchParams();
if (value.q) sp.set('q', value.q);
// Only include status param when it differs from default — keeps URL clean for unfiltered visits (D-06)
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
&& DEFAULT_STATUS.every(s => value.status.includes(s));
if (!isDefaultStatus && value.status.length > 0) sp.set('status', value.status.join(','));
if (value.status.length === 0) sp.set('status', ''); // explicit "no status filter"
if (value.priority.length > 0) sp.set('priority', value.priority.join(','));
if (value.queue !== null) sp.set('queue', String(value.queue));
if (value.mine) sp.set('mine', '1');
return sp;
}
function isFilterModified(value: TicketFilterValue): boolean {
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
&& DEFAULT_STATUS.every(s => value.status.includes(s));
return Boolean(value.q)
|| !isDefaultStatus
|| value.priority.length > 0
|| value.queue !== null
|| value.mine;
}
```
5. **Suspense wrapper** — Next.js 16 requires `useSearchParams()` to be inside a Suspense boundary. Pattern (matches CLAUDE.md "Build Notes" memory):
```typescript
export default function MobileTicketsPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-40"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>}>
<MobileTicketsInner />
</Suspense>
);
}
```
The actual page logic lives in `MobileTicketsInner`.
6. **`MobileTicketsInner` component** — the full page state machine:
```typescript
function MobileTicketsInner() {
const router = useRouter();
const searchParams = useSearchParams();
// Filter state — initial value from URL (deep-link hydration per D-06)
const initialFilter = useMemo(() => parseFilterFromSearch(new URLSearchParams(searchParams.toString())), []);
const [filter, setFilter] = useState<TicketFilterValue>(initialFilter);
// Debounced search — separate from filter so other filters update immediately
const [debouncedQ, setDebouncedQ] = useState(initialFilter.q);
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(filter.q), 400);
return () => clearTimeout(t);
}, [filter.q]);
// List state
const [tickets, setTickets] = useState<MobileTicket[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [openTotal, setOpenTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queueOptions, setQueueOptions] = useState<QueueOption[]>([]);
// Build URL search params for the API call given a filter and optional cursor
const buildApiParams = useCallback((f: TicketFilterValue, q: string, cursor: string | null): URLSearchParams => {
const sp = new URLSearchParams();
if (q) sp.set('q', q);
if (f.status.length > 0) sp.set('status', f.status.join(','));
else sp.set('status', ''); // explicit no-status (vs. omit = use default on server)
if (f.priority.length > 0) sp.set('priority', f.priority.join(','));
if (f.queue !== null) sp.set('queue', String(f.queue));
if (f.mine) sp.set('mine', '1');
if (cursor) sp.set('cursor', cursor);
sp.set('limit', '25');
return sp;
}, []);
// Fetch first page (filters changed)
const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => {
setLoading(true);
setError(null);
try {
const sp = buildApiParams(f, q, null);
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: MobileTicketListResponse = await r.json();
setTickets(data.tickets);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
// Approximate "open total" from first page until a count endpoint exists; keep tickets.length when hasMore=false
setOpenTotal(data.tickets.length + (data.hasMore ? 1 : 0));
// Derive queue options from the first page so the Select shows real labels (best-effort; deduped by id)
setQueueOptions(prev => {
const seen = new Map<number, QueueOption>();
for (const opt of prev) seen.set(opt.id, opt);
for (const t of data.tickets) {
if (t.queue_id && t.queue_label && !seen.has(t.queue_id)) {
seen.set(t.queue_id, { id: t.queue_id, label: t.queue_label });
}
}
return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label));
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load tickets');
} finally {
setLoading(false);
}
}, [buildApiParams]);
// Fetch next page (cursor advance)
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || !nextCursor) return;
setLoadingMore(true);
setError(null);
try {
const sp = buildApiParams(filter, debouncedQ, nextCursor);
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: MobileTicketListResponse = await r.json();
setTickets(prev => [...prev, ...data.tickets]);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load more tickets');
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]);
// Reload first page when filter or debounced search changes (D-05/D-06: also push URL)
useEffect(() => {
const next = filterToSearch({ ...filter, q: debouncedQ });
const nextStr = next.toString();
if (nextStr !== searchParams.toString()) {
router.replace(`/mobile/tickets${nextStr ? `?${nextStr}` : ''}`, { scroll: false });
}
void loadFirst(filter, debouncedQ);
// Intentionally exclude searchParams from deps to prevent loop with router.replace
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQ, filter.status, filter.priority, filter.queue, filter.mine, loadFirst, router]);
// IntersectionObserver — infinite scroll trigger (D-12, D-13)
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) {
void loadMore();
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, loadingMore, loading, loadMore]);
// Clear all (D-04, D-20 empty-state CTA)
const clearAll = useCallback(() => {
setFilter({ q: '', status: [...DEFAULT_STATUS], priority: [], queue: null, mine: false });
}, []);
const filtered = isFilterModified(filter);
// ───── Render ─────
return (
<div className="flex flex-col h-full">
<TicketFilterStrip
value={filter}
onChange={setFilter}
queueOptions={queueOptions}
openTotal={openTotal}
isFiltered={filtered}
onClearAll={clearAll}
/>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="divide-y">
{Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)}
</div>
) : tickets.length === 0 ? (
// Empty state (D-20)
<div className="text-center py-12 px-4 space-y-3">
{filtered ? (
<>
<p className="text-sm text-muted-foreground">No tickets match your filters</p>
<Button variant="outline" size="sm" onClick={clearAll}>Clear filters</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">No tickets to triage right now</p>
<Button variant="ghost" size="sm" onClick={() => loadFirst(filter, debouncedQ)} aria-label="Refresh ticket list">
<RefreshCw className="w-4 h-4" aria-hidden="true" />
</Button>
</>
)}
</div>
) : (
<>
<div className="divide-y">
{tickets.map((t) => (
<Link
key={t.id}
href={`/mobile/tickets/${t.id}`}
className={`flex items-start border-l-4 ${PRIORITY_BORDER[t.priority] ?? 'border-slate-300'} px-4 py-4 hover:bg-muted/50 active:bg-muted/50 transition-colors`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug truncate">{t.title}</p>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" aria-hidden="true" />
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{t.company_name}</p>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
{t.queue_label && (
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
)}
{t.assigned_to && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold">
{t.assigned_to.split(' ').map(s => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'}
</span>
)}
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
<Clock className="w-3 h-3" aria-hidden="true" />
{relTime(t.last_activity_date)}
</span>
</div>
</div>
</Link>
))}
</div>
{/* Sentinel — IntersectionObserver target (D-12) */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Loading-more spinner (D-21) */}
{loadingMore && (
<div className="flex justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
)}
{/* Load more fallback button (D-14, TICK-06) */}
{hasMore && (
<div className="p-4">
<button
type="button"
onClick={() => void loadMore()}
disabled={loadingMore}
aria-label="Load more tickets"
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
```
Key behaviors / locked decisions:
- `router.replace()` not `router.push()` (D-05).
- Cursor is NOT in URL (D-07) — only `q`, `status`, `priority`, `queue`, `mine`.
- `border-l-4` + exact UI-SPEC class strings: `border-red-500`, `border-orange-400`, `border-amber-400`, `border-slate-300` (D-15).
- No priority dot rendered (D-17 — the legacy `<div className="...PRIORITY_DOT">` is removed).
- Sentinel `aria-hidden="true"` (UI-SPEC accessibility section).
- Load more button has `aria-label="Load more tickets"` and is always rendered when `hasMore` so screen-reader users have a focusable control even after the IntersectionObserver triggers (TICK-06 / D-14).
- Skeleton state for initial load only — subsequent `loadingMore` shows the small spinner above Load more (D-21).
- Title uses `truncate` (1-line, per UI-SPEC "Row title — 1-line truncate") — the legacy code used `line-clamp-2`; switch to `truncate` to match locked spec.
- Use `<Suspense>` wrapper because `useSearchParams()` requires it in Next.js 16 (CLAUDE.md memory entry).
Anti-patterns (do NOT do):
- Do NOT introduce SWR / react-query (CLAUDE.md).
- Do NOT use `router.push()` for filter updates (D-05).
- Do NOT persist `cursor` to the URL (D-07).
- Do NOT add a separate count endpoint — Plan 02 deliberately uses `tickets.length + hasMore ? 1 : 0` as a "≥N" approximation; revisit only if the exact count is needed (out of scope this phase).
- Do NOT reintroduce the priority dot — the stripe replaces it (D-17).
- Do NOT use Tailwind class `border-yellow-400` for priority 3 (legacy used yellow; UI-SPEC locked it to `border-amber-400`).
- Do NOT call `router.push()` on every keystroke — the debounced effect handles URL sync once the search settles.
Discretionary choices made (per "Claude's Discretion" in 04-CONTEXT.md):
- Assignee initials avatar: `inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold` rendering up to 2 initials, falling back to `·`.
- Queue option list is derived from the first page's tickets — no separate `/api/mobile/queues` endpoint. Acceptable for v1; the Select still works because the parent always passes the most recent set after the first load.
- "Open total" approximated as `tickets.length + (hasMore ? 1 : 0)` — visible label reads "N open tickets"; precision deferred until a count endpoint exists.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks"</automated>
</verify>
<acceptance_criteria>
- `grep -q "'use client'" app/mobile/tickets/page.tsx` (client component declaration)
- `grep -q "Suspense" app/mobile/tickets/page.tsx` (Suspense wrapper for useSearchParams — Next.js 16 requirement)
- `grep -q "useSearchParams" app/mobile/tickets/page.tsx` (URL hydration)
- `grep -q "router\.replace" app/mobile/tickets/page.tsx` (D-05 — replace not push)
- `! grep -q "router\.push" app/mobile/tickets/page.tsx` (no push for filter updates)
- `grep -q "TicketFilterStrip" app/mobile/tickets/page.tsx` (uses Plan 01 component)
- `grep -q "TicketRowSkeleton" app/mobile/tickets/page.tsx` (uses Plan 01 skeleton)
- `grep -q "import type.*MobileTicketListResponse" app/mobile/tickets/page.tsx` (typed response — Phase 3 pattern)
- `grep -q "IntersectionObserver" app/mobile/tickets/page.tsx` (D-12)
- `grep -q "rootMargin: '200px'" app/mobile/tickets/page.tsx` (D-12 — exact margin)
- `grep -q "border-red-500" app/mobile/tickets/page.tsx` (priority 1 — D-15)
- `grep -q "border-orange-400" app/mobile/tickets/page.tsx` (priority 2 — D-15)
- `grep -q "border-amber-400" app/mobile/tickets/page.tsx` (priority 3 — D-15, NOT yellow)
- `grep -q "border-slate-300" app/mobile/tickets/page.tsx` (priority 4 — D-15)
- `grep -q "border-l-4" app/mobile/tickets/page.tsx` (4px stripe — D-15)
- `grep -q "Load more" app/mobile/tickets/page.tsx` (TICK-06 fallback)
- `grep -q 'aria-label="Load more tickets"' app/mobile/tickets/page.tsx` (a11y)
- `grep -q 'aria-hidden="true"' app/mobile/tickets/page.tsx` (sentinel a11y)
- `grep -q "No tickets match your filters" app/mobile/tickets/page.tsx` (D-20 empty state — filtered)
- `grep -q "No tickets to triage right now" app/mobile/tickets/page.tsx` (D-20 empty state — unfiltered)
- `grep -q "Clear filters" app/mobile/tickets/page.tsx` (D-20 CTA)
- `grep -q "function relTime" app/mobile/tickets/page.tsx` (D-17 helper preserved)
- `! grep -q "PRIORITY_DOT" app/mobile/tickets/page.tsx` (D-17 — dot removed)
- `! grep -q "border-yellow-400" app/mobile/tickets/page.tsx` (legacy yellow replaced by amber)
- `! grep -qE "useSWR|@tanstack/react-query|zustand" app/mobile/tickets/page.tsx` (CLAUDE.md — no forbidden libs)
- `! grep -q "?page=" app/mobile/tickets/page.tsx` (no legacy page param)
- `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/page.tsx`
</acceptance_criteria>
<done>The rewritten page hydrates filters from `useSearchParams()` inside a Suspense boundary, calls `router.replace()` to sync filter changes back to the URL, fetches the cursor-paginated API on first load and on filter changes, advances via cursor on IntersectionObserver intersection (with a Load more fallback), renders priority-stripe rows using the four locked Tailwind border classes, shows skeleton rows on initial load and a small spinner during cursor advances, and renders the two distinct empty-state copies. TypeScript compiles cleanly.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify the new tickets list end-to-end on a real device or simulator</name>
<files>app/mobile/tickets/page.tsx (verifying — not modifying)</files>
<action>Human verification only — see <how-to-verify> below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new list page behaves per spec on a phone-width viewport.</action>
<verify><automated>echo "Manual checkpoint — see resume-signal"</automated></verify>
<done>User confirms all 14 checklist items pass on a phone-width viewport (real device or DevTools iPhone 15 Pro emulation), or describes precisely which step failed and why.</done>
<what-built>
The mobile Tickets list page now uses the new shell-aligned layout: Collapsible filter strip, URL-synced filter state, priority-stripe rows, IntersectionObserver-driven infinite scroll, Load more fallback button, skeleton loading state, and the two D-20 empty-state copies. The detail page link target (`/mobile/tickets/[id]`) is unchanged — that page's header reskin is shipped by Plan 04-03 in parallel.
</what-built>
<how-to-verify>
Start the dev server (`npm run dev` → http://localhost:3100) and sign in. Then on a phone-width viewport (or Chrome DevTools iPhone 15 Pro emulation):
1. **Initial load + skeleton** — Navigate to `/mobile/tickets`. You should briefly see 5 skeleton rows (each with a muted left stripe + 3 placeholder lines), then the real tickets render.
2. **Default state** — Filter strip is COLLAPSED. Search input visible. "Filters" button visible. Count line shows "N open tickets". Each row has a 4px colored left stripe (red / orange / amber / slate) — no dot.
3. **Single-tap row** — Tap any row → routes to `/mobile/tickets/[id]` (existing detail page; header reskin from Plan 04-03 may or may not be live yet — body should render either way).
4. **Filter strip expands** — Tap "Filters". Panel reveals four controls: status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, "Assigned to me" Switch.
5. **URL deep-link — set filters** — Tap "High" priority chip. URL updates IN PLACE to include `?priority=2` (no new history entry — back button takes you OUT of `/mobile/tickets`, not to a previous filter state).
6. **URL deep-link — reload** — Reload the page with the URL still showing `?priority=2`. Filter strip hydrates with "High" already selected; list shows only priority-2 tickets.
7. **Search debounce** — Type in the search box. URL updates ~400ms after you stop typing, not on every keystroke.
8. **Clear all** — Tap "Clear all". Status returns to default (Open + In Progress + Waiting), priority/queue/mine reset, URL params clear.
9. **Infinite scroll** — Scroll to the bottom of the list. The next ~25 rows append automatically (small spinner appears briefly above "Load more"). The page does NOT navigate to a new URL.
10. **Load more button** — Confirm the "Load more" button is visible and focusable (Tab to it). Clicking it also advances the list. When the list is exhausted, the button disappears.
11. **Empty state with filters** — Set filters that return no rows (e.g., a non-existent search term). Page shows "No tickets match your filters" + a "Clear filters" button. Tapping it restores defaults.
12. **No charts / no recharts imports** — Sanity check: open DevTools network tab and confirm only `/api/mobile/tickets` is called (no extra count or queue endpoints).
13. **Priority colors** — A row with `priority=1` has `border-red-500`, `priority=2` `border-orange-400`, `priority=3` `border-amber-400`, `priority=4` `border-slate-300`. These are direct Tailwind palette references per UI-SPEC §"Priority Stripe Colors".
14. **Detail back nav (Plan 04-03 dependency)** — From a detail page, the device back gesture returns you to the list at the same scroll position with filters intact. (Plan 04-03 reskins the in-page back chevron — UX should still work without it.)
</how-to-verify>
<resume-signal>Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected).</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| URL search params → component state | A user-supplied URL (incl. shared deep links) populates filter state and is fed into API calls |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-07 | Tampering | URL params (`status`, `priority`, `queue`, `mine`, `q`) | mitigate | `parseFilterFromSearch()` runs `parseInt + isNaN` filter on every numeric value; non-numeric tokens silently dropped. The page only forwards values to the API, which itself parameterises and validates. |
| T-04-08 | Information Disclosure | search query reflected in URL | accept | Query parameters appear in browser history and any logging — same risk as the existing implementation; users searching for sensitive terms is a userland concern. |
| T-04-09 | Denial of Service | rapid filter changes flood the API | mitigate | Search input debounced 400ms (D-03). Other filters are discrete user actions (chip tap, dropdown change) — already rate-limited by human input speed. |
| T-04-10 | Repudiation | mobile actions are read-only | accept | This page is read-only; no audit logging needed. Detail page comments/edits are out of scope. |
</threat_model>
<verification>
1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`.
2. All `<acceptance_criteria>` grep checks for Task 1 return success.
3. Human-verify checklist (Task 2) reaches "approved".
4. Phase-level smoke: visit `/mobile/tickets`, then `/mobile/tickets?priority=1`, then `/mobile/tickets?status=&priority=&q=zzz_no_match` — three different rendered states (default list, priority-1 only, empty state with Clear filters CTA).
</verification>
<success_criteria>
- TICK-01: Collapsible filter strip default-collapsed, expands to status/priority/queue/mine controls.
- TICK-02: All four filter primitives sync to the URL via `router.replace()`; reload hydrates state.
- TICK-03: Each row has a `border-l-4` stripe with the correct priority Tailwind class.
- TICK-04: Single-tap on a row navigates to `/mobile/tickets/[id]`.
- TICK-05: ~25-per-page cursor advance via `IntersectionObserver` with `rootMargin: '200px'`.
- TICK-06: A focusable "Load more" button is rendered whenever `hasMore` is true.
- D-20 empty states render correct copy with correct CTAs.
- No new state libraries introduced; CLAUDE.md conventions honored.
</success_criteria>
<output>
After completion, create `.planning/phases/04-tickets-restyle/04-02-SUMMARY.md` documenting:
- Final file size of `app/mobile/tickets/page.tsx`.
- The URL <-> filter state mapping (which params are present when, and how `status` interacts with the default `[1, 8, 7]`).
- That the queue list is derived from first-page tickets (no new endpoint).
- The "open total" approximation note (tickets.length + (hasMore ? 1 : 0)) and that a precise count endpoint is deferred.
- Any deviations from the plan (e.g., extra renders, fallback behaviors).
</output>

View file

@ -0,0 +1,218 @@
---
phase: 04
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- app/mobile/tickets/[id]/page.tsx
autonomous: true
requirements: [TICK-07]
must_haves:
truths:
- "Detail page in-page header shows a back chevron + 'Tickets' label that calls router.back()"
- "Detail page in-page header shows the breadcrumb 'Tickets / #{ticket_number}' centered"
- "Detail page in-page header shows an external-link icon that opens the desktop ticket URL in a new tab"
- "The shell HeaderBar (Wulf mark + Bell + avatar) from app/mobile/layout.tsx still renders above the in-page header"
- "The detail body (priority/status badges, stats grid, description, timeline) is unchanged from the legacy implementation"
artifacts:
- path: "app/mobile/tickets/[id]/page.tsx"
provides: "Mobile ticket detail page with reskinned in-page header per D-18"
contains: "ArrowLeft"
key_links:
- from: "app/mobile/tickets/[id]/page.tsx"
to: "lucide-react ArrowLeft + ExternalLink icons"
via: "named import"
pattern: "ExternalLink"
- from: "app/mobile/tickets/[id]/page.tsx"
to: "/api/mobile/tickets/{id}/timeline endpoint"
via: "fetch — unchanged from legacy"
pattern: "fetch\\(`/api/mobile/tickets/"
---
<objective>
Reskin only the in-page header bar at the top of `app/mobile/tickets/[id]/page.tsx` per D-18: replace the current "← Back" button with a three-slot header (back chevron + label, breadcrumb, external link). The detail body — priority badge row, h1 title, stats grid, description block, timeline — stays untouched per D-19.
Purpose: closes TICK-07. The shell HeaderBar already renders above this page from `app/mobile/layout.tsx`, so the in-page header docks under it consistently with the new shell language.
Output:
- Modified `app/mobile/tickets/[id]/page.tsx` with the new three-slot header bar and `ExternalLink` icon import. Body untouched.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/REQUIREMENTS.md
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
@.planning/phases/02-mobile-shell/02-CONTEXT.md
@CLAUDE.md
@app/mobile/tickets/[id]/page.tsx
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Reskin the in-page header of /mobile/tickets/[id] with back chevron, breadcrumb, and external-link icon</name>
<files>app/mobile/tickets/[id]/page.tsx</files>
<read_first>
- app/mobile/tickets/[id]/page.tsx (full 357-line current file — only lines ~239-242 change in the header; everything else is preserved)
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-18 (header structure) and D-19 (body unchanged)
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Detail Page In-Page Header" section
</read_first>
<action>
Open `app/mobile/tickets/[id]/page.tsx` and make TWO surgical edits.
**Edit 1: Add `ExternalLink` to the lucide-react imports (line 5-8 region).**
Current import block:
```typescript
import {
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, Mail, AlignLeft, Code2,
} from 'lucide-react';
```
Add `ExternalLink` to the named imports (alphabetical position: after `Eye`, before `Mail`). Final form:
```typescript
import {
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, ExternalLink, Mail, AlignLeft, Code2,
} from 'lucide-react';
```
Do NOT remove any existing import — `ArrowLeft`, `RefreshCw`, etc. all remain in use.
**Edit 2: Replace the legacy back button (currently at lines ~240-242) with the three-slot header bar per D-18 / UI-SPEC §"Detail Page In-Page Header".**
Current code (the section to replace — inside the `{/* Ticket header */}` div, only the FIRST element of that block):
```tsx
<button onClick={() => router.back()} className="flex items-center gap-1 text-sm text-muted-foreground mb-3 hover:text-foreground">
<ArrowLeft className="w-4 h-4" /> Back
</button>
```
Replace with the new three-slot header. **Important context:** the parent `<div className="px-4 pt-4 pb-3 border-b">` already provides horizontal padding and the bottom border. The new header must NOT double-bracket the border. So the new header bar replaces ONLY the back button — keep the parent div as-is, just substitute its first child:
```tsx
<div className="flex items-center justify-between -mx-4 px-4 py-3 border-b mb-3">
<button
type="button"
onClick={() => router.back()}
aria-label="Back to Tickets"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
<span>Tickets</span>
</button>
<p className="text-sm font-semibold truncate mx-2 flex-1 text-center">
Tickets / #{ticket.ticket_number}
</p>
<a
href={`/analyzer/ticket/${ticket.id}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Open ticket on desktop"
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ExternalLink className="w-4 h-4" aria-hidden="true" />
</a>
</div>
```
Notes on the layout:
- `-mx-4 px-4` extends the header band to the parent div's edges and reapplies internal padding so the `border-b` runs full-width visually under the new header.
- The legacy parent div still has its own `border-b` from when it bracketed the entire header (badges + title + stats + description). That outer `border-b` stays — it now sits below the description / stats area, which is the correct visual structure (the new header has its own divider; the outer border still divides the header section from the timeline).
- Breadcrumb uses `text-sm font-semibold truncate` — UI-SPEC §"Typography" `Detail breadcrumb` row.
- Desktop URL: `/analyzer/ticket/{id}` — this is the desktop analyzer ticket view (the canonical desktop ticket URL in this codebase). UI-SPEC permits "Autotask direct URL" as alternative; the analyzer URL is the in-app desktop equivalent and stays inside the auth boundary.
- `aria-hidden="true"` on icons because the surrounding text / aria-label provides the accessible name.
**What NOT to change (D-19 — body untouched):**
- The badges row (`<div className="flex items-start gap-2 mb-2">` with priority + ticket number + status pills) — preserve verbatim.
- The `<h1 className="text-base font-bold leading-snug">{ticket.title}</h1>` — preserve verbatim.
- The metadata icons row (Briefcase / User / Clock with company / assignee / created date) — preserve verbatim.
- The 3-card stats grid (Notes / Time entries / Hours logged) — preserve verbatim.
- The Description Collapsible block + Timeline section — preserve verbatim.
- The `TimelineCard` component definition — preserve verbatim.
- The `loading` and `error` states — preserve verbatim.
- All helper functions (`fmtDate`, `fmtHours`, `renderContent`, `relTime`) — preserve verbatim.
- The `STATUS_LABEL`, `PRIORITY_LABEL`, `PRIORITY_COLOR` constant maps — preserve verbatim.
Do NOT touch `app/mobile/tickets/[id]/timeline/route.ts` or any other file. The plan's `files_modified` is exactly one file.
Anti-patterns (do NOT do):
- Do NOT remove the parent `<div className="px-4 pt-4 pb-3 border-b">` wrapper — the body still expects it.
- Do NOT remove or alter the existing badges, title, stats, description, or timeline — body is out of scope (D-19).
- Do NOT introduce a `<HeaderBar>` element here — that's the shell's job and already renders from `app/mobile/layout.tsx`.
- Do NOT swap the desktop URL to an external Autotask link unless the analyzer URL is unreachable — the UI-SPEC accepts either; analyzer URL is preferred (in-app navigation).
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/\[id\]/page\.tsx" || echo "OK: detail page typechecks"</automated>
</verify>
<acceptance_criteria>
- `grep -q "ExternalLink" app/mobile/tickets/[id]/page.tsx` (icon imported and used)
- `grep -q 'aria-label="Back to Tickets"' app/mobile/tickets/[id]/page.tsx` (D-18 / UI-SPEC accessibility)
- `grep -q 'aria-label="Open ticket on desktop"' app/mobile/tickets/[id]/page.tsx` (D-18 external link a11y)
- `grep -q "Tickets / #" app/mobile/tickets/[id]/page.tsx` (D-18 breadcrumb literal)
- `grep -q "router\.back()" app/mobile/tickets/[id]/page.tsx` (back gesture preserved)
- `grep -q "/analyzer/ticket/" app/mobile/tickets/[id]/page.tsx` (desktop URL in href)
- `grep -q 'target="_blank"' app/mobile/tickets/[id]/page.tsx` (opens in new tab)
- `grep -q 'rel="noopener noreferrer"' app/mobile/tickets/[id]/page.tsx` (security on target=_blank)
- `grep -q "TimelineCard" app/mobile/tickets/[id]/page.tsx` (body component preserved — D-19)
- `grep -q "function fmtDate" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
- `grep -q "function renderContent" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
- `grep -q "Notes" app/mobile/tickets/[id]/page.tsx && grep -q "Time entries" app/mobile/tickets/[id]/page.tsx && grep -q "Hours logged" app/mobile/tickets/[id]/page.tsx` (stats grid preserved — D-19)
- `! grep -qE ">\\s*Back\\s*</button>" app/mobile/tickets/[id]/page.tsx` (legacy "Back" text removed in favor of "Tickets")
- `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/[id]/page.tsx`
</acceptance_criteria>
<done>The detail page imports `ExternalLink`, renders the three-slot in-page header (back chevron + "Tickets" label, breadcrumb "Tickets / #{ticket_number}", external-link icon to `/analyzer/ticket/{id}`), and leaves the badges row, title, stats grid, description, and timeline byte-identical to the legacy implementation. TypeScript compiles cleanly.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| in-page link → external desktop URL | The new ExternalLink anchor opens `/analyzer/ticket/{id}` in a new tab |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-11 | Tampering | external link `target="_blank"` reverse tabnabbing | mitigate | `rel="noopener noreferrer"` on the anchor — prevents the opened page from accessing `window.opener`, even though `/analyzer/ticket/{id}` is same-origin. Defense in depth. |
| T-04-12 | Information Disclosure | desktop URL leaks ticket id in browser tab | accept | Same id is already in the current page URL; no new exposure. |
</threat_model>
<verification>
1. `npx tsc --noEmit --pretty` passes — no errors for `app/mobile/tickets/[id]/page.tsx`.
2. All Task 1 acceptance-criteria greps return success.
3. Manual smoke (developer terminal): visit `/mobile/tickets/<an-id>` and confirm:
- The shell HeaderBar (Wulf wordmark + Bell + avatar) renders at the very top from `app/mobile/layout.tsx`.
- Below it, the new in-page header shows back chevron + "Tickets" on the left, breadcrumb in the center, ExternalLink icon on the right.
- Below that, the unchanged badges row → title → stats grid → description → timeline.
- Tapping "Tickets" calls `router.back()` and returns to the list.
- Tapping the ExternalLink icon opens `/analyzer/ticket/<id>` in a new tab.
</verification>
<success_criteria>
- TICK-07: Detail page header reskinned to match the new shell language (back chevron + breadcrumb + external link); body unchanged.
- D-18 implemented exactly: three slots (back, breadcrumb, external).
- D-19 honored: badges, title, stats grid, description, timeline byte-identical.
- TypeScript clean.
- The shell HeaderBar from `app/mobile/layout.tsx` continues to render above this in-page header — no double-rendering.
</success_criteria>
<output>
After completion, create `.planning/phases/04-tickets-restyle/04-03-SUMMARY.md` documenting:
- The exact lines changed in `app/mobile/tickets/[id]/page.tsx` (import line + header bar replacement).
- The desktop URL chosen (`/analyzer/ticket/{id}`) and the rationale (in-app navigation, stays in auth boundary).
- Confirmation that the body (D-19 scope) was not touched — list which sections remained verbatim.
- That this plan ran in parallel with 04-02 (no shared file conflict).
</output>

View file

@ -0,0 +1,142 @@
# Phase 4: Tickets Restyle - Context
**Gathered:** 2026-05-03 (auto mode)
**Status:** Ready for planning
<domain>
## Phase Boundary
Reskin the mobile Tickets surfaces (`/mobile/tickets` list + `/mobile/tickets/[id]` detail header) to match the new shell. Replace the current sticky search/filter bar with a Collapsible URL-synced filter strip. Replace page-based pagination (`?page=N`, 30/page) with cursor-based infinite scroll (~25/page, IntersectionObserver) plus a "Load more" fallback. Add priority left-edge stripes to list rows. Detail page body stays largely as-is — only its header is reskinned.
In scope: list page UI + filter strip + URL deep-linking + cursor pagination API change + detail header reskin.
Out of scope: filter set expansion (only the four spec'd filters), detail body refactor, comment/attach UI changes, search UX overhaul beyond what existing search input provides.
</domain>
<decisions>
## Implementation Decisions
### Filter strip
- **D-01:** Use shadcn `Collapsible` component for the filter strip; default state is **collapsed** (only the search input + "Filters" toggle button visible). Reason: TICK-01 spec says default collapsed; matches phone-first density goal.
- **D-02:** Expanded panel exposes exactly four controls: status (multi-select chips: Open/In Progress/Waiting), priority (chips: Critical/High/Medium/Low), queue (Select dropdown sourced from existing queues), and an "Assigned to me" toggle. No additional filter capabilities this phase.
- **D-03:** Keep the existing search input visible at all times (above the Collapsible toggle), debounced 400ms — match current behavior.
- **D-04:** "Clear all" button appears in the expanded panel when ≥1 filter is active.
### URL sync
- **D-05:** Use `useSearchParams()` + `router.replace()` (NOT `router.push()`) to update query string on filter changes. Reason: replace prevents back-button pollution from filter tweaks; matches Next.js App Router convention.
- **D-06:** URL param keys: `q` (search), `status` (comma-separated ints), `priority` (comma-separated ints), `queue` (int), `mine` (`1`/absent). On reload, page hydrates filter state from these params — deep link works.
- **D-07:** Cursor (`cursor`) is intentionally **not** persisted to URL — fresh visits always start at the top of the list.
### Pagination — cursor model
- **D-08:** Replace `?page=N&limit=30` with `?cursor=<opaque>&limit=25`. Reason: TICK-05 mandates cursor-based ~25/page.
- **D-09:** Cursor is a base64-encoded JSON of `{ last_activity_date: ISO string, id: number }`. Reason: `last_activity_date DESC, id DESC` matches the manager's triage mental model (recently-touched bubbles up); tie-breaker on id makes it stable.
- **D-10:** API returns `{ tickets: [...], nextCursor: string | null, hasMore: boolean }`. When `nextCursor` is null, list is exhausted.
- **D-11:** Page size is 25 (per TICK-05). The `limit` query param is accepted but capped server-side at 25 to prevent abuse.
### Infinite scroll trigger
- **D-12:** Use the browser-native `IntersectionObserver` API (no library). A sentinel `<div ref={sentinelRef} />` lives at the end of the list; when it intersects the viewport with `rootMargin: '200px'`, fetch next page.
- **D-13:** Guard against duplicate fetches: the sentinel callback is a no-op if `loadingMore || !hasMore`.
- **D-14:** Always render a focusable "Load more" button below the sentinel as the accessibility fallback (TICK-06). Clicking it triggers the same fetch path. Hide only when `!hasMore`.
### List row presentation
- **D-15:** Each row has a 4px-wide left-edge color stripe via `border-l-4` + a priority color class:
- 1 (Critical) → `border-red-500`
- 2 (High) → `border-orange-400`
- 3 (Medium) → `border-amber-400`
- 4 (Low) → `border-slate-300`
Reason: TICK-03 spec.
- **D-16:** Row body shows: ticket number (mono small), title (1-line truncate), company (muted small), age (relative time, muted), assignee (initials avatar or text). Single-tap navigates to `/mobile/tickets/[id]` (TICK-04).
- **D-17:** Remove the priority dot from the existing row (replaced by the stripe). Keep `relTime()` helper as-is.
### Detail page header
- **D-18:** Detail page (`/mobile/tickets/[id]`) keeps its current body. Only the in-page header bar at the top is reskinned: replace the current title bar with a row containing a back chevron (`ArrowLeft` icon → `router.back()`), the breadcrumb "Tickets / #{ticket_number}", and an external-link icon that opens the desktop ticket URL. The shell's HeaderBar (Wulf + Bell + avatar) already renders above it from `app/mobile/layout.tsx` — no change there.
- **D-19:** No structural changes to the detail body, comments, or attachments — out of scope for this phase.
### Empty state
- **D-20:** When the active filter set returns zero results, render a centered message: "No tickets match your filters" with a "Clear filters" button. When there are no tickets at all (no filters, empty result), render "No tickets to triage right now" with a refresh affordance.
### Loading & error states
- **D-21:** Initial load → skeleton rows (5 placeholder cards). Subsequent infinite scroll → small inline spinner above the Load more button. Error → toast + Load more button shows "Retry".
### Claude's Discretion
- Exact spacing/typography within rows (match existing density)
- Whether to memoize row components (only if perf measurement warrants)
- Cursor encoding helper location (lib/services or inline in route)
- Exact skeleton visual
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase spec
- `docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §6.2 (Tickets) — spec for filter strip, priority stripes, cursor pagination, detail header reskin
- `.planning/REQUIREMENTS.md` (TICK-01 through TICK-07) — locked acceptance criteria
### Project conventions
- `CLAUDE.md` — Pulse stack rules (no SWR/react-query, no ORM, fetch-from-clients pattern), `/mobile/*` route boundary, `port 3100`
- `DESIGN.md` — token usage, navigation IA, current cleanup backlog
- `ARCHITECTURE.md` — runtime, data flow, workers (no impact this phase)
### Prior phase context
- `.planning/phases/02-mobile-shell/02-CONTEXT.md` — MobileShell + HeaderBar decisions (the detail header docks under this)
- `.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md` — pattern for new `/api/mobile/*` shape with TypeScript interface exports (mirror this for tickets endpoint)
### Existing code (entry points)
- `app/mobile/tickets/page.tsx` — current list page (164 lines, page-based)
- `app/mobile/tickets/[id]/page.tsx` — current detail page (357 lines, body kept as-is)
- `app/api/mobile/tickets/route.ts` — current API route (90 lines, page-based, with `kiosk_settings` company scoping)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `Collapsible` from shadcn (`components/ui/collapsible.tsx`) — exists in shadcn primitives
- `relTime()` helper inline in `app/mobile/tickets/page.tsx:29-37` — keep
- `kiosk_settings` company scoping helper `getMobileCompanyFilter()` in `app/api/mobile/tickets/route.ts:3-26` — keep, do not regress
- `lucide-react` icons (Search, X, RefreshCw, ChevronRight, Clock, ArrowLeft) — already in deps
- shadcn primitives: `Button`, `Input`, `Select`, `Toggle` (or `Switch`), `Skeleton` — all available in `components/ui/`
- `IntersectionObserver` — browser-native, no dep needed
### Established Patterns
- Mobile pages are `'use client'` with `useState` + `useEffect` + `fetch('/api/mobile/...')` — NO SWR, NO react-query
- API routes call `postgresClient.query()` with parameterized SQL; manual snake_case → camelCase transform
- `requireAuth()` from `lib/auth-utils.ts` is the auth gate for all `/api/*` routes
- TypeScript interfaces for API response shapes are exported from the route file and imported via `import type` in the page (pattern from Phase 3)
### Integration Points
- `app/mobile/layout.tsx` (Phase 2) renders the new shell — Tickets pages dock inside it; no layout changes needed
- BottomNav active-tab detection uses `pathname.startsWith('/mobile/tickets')` — already correct
- `kiosk_settings` table for company scoping — existing, already wired into the current ticket route
</code_context>
<specifics>
## Specific Ideas
- Mirror Phase 3's pattern: API route exports `MobileTicketListResponse` and the row interface; page imports the response type via `import type`.
- Cursor encoder/decoder should be small (~10 lines) and live inline in the route file unless a second consumer appears.
- Status filter should default to "Open + In Progress + Waiting" (i.e. `status != 5`) matching the existing route's hardcoded filter, so the URL with no status param yields the same default the manager expects.
</specifics>
<deferred>
## Deferred Ideas
- Bulk actions (mass close/assign) — out of scope; manager use case here is read-and-tap-into-detail
- Saved filter presets — not in TICK-* scope; consider for a future phase
- Detail page body refactor (comments, time entries) — explicitly out of scope (TICK-07 says "body kept largely as-is")
- Server-sent push of new tickets — no streaming this iteration
- Search highlight in row — nice-to-have, not in TICK-* scope
- Tablet/desktop responsive breakpoints — `/mobile` is phone-only by milestone constraint
</deferred>
---
*Phase: 04-tickets-restyle*
*Context gathered: 2026-05-03*

View file

@ -0,0 +1,127 @@
# Phase 4: Tickets Restyle - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-05-03
**Phase:** 04-tickets-restyle
**Mode:** auto (--auto --chain)
**Areas discussed:** Filter strip, URL sync, Pagination model, Infinite scroll trigger, List row presentation, Detail page header, Empty/loading states
---
## Filter strip
| Option | Description | Selected |
|--------|-------------|----------|
| Always-expanded inline filters | All controls visible by default — more taps reachable | |
| `Collapsible` from shadcn, default collapsed | TICK-01 spec; phone-first density | ✓ |
| Drawer-based filter sheet | Filters open in a Sheet — feels heavier, more taps | |
**Auto choice rationale:** TICK-01 explicitly says "default collapsed". Spec-locked.
---
## Filter set
| Option | Description | Selected |
|--------|-------------|----------|
| Status + priority + queue + assigned-to-me | Spec set | ✓ |
| Add company filter | More flexibility; out of TICK-* scope | |
| Add date range | Could be useful; out of TICK-* scope | |
**Auto choice rationale:** TICK-01 enumerates the four filters. Anything more is scope creep.
---
## URL sync mechanism
| Option | Description | Selected |
|--------|-------------|----------|
| `router.push()` on every filter change | Each filter tweak adds a back-stack entry | |
| `router.replace()` on every filter change | Filter tweaks don't pollute back stack | ✓ |
| Manual `history.replaceState()` | Lower-level; loses Next.js rerouting integration | |
**Auto choice rationale:** `router.replace()` is the App Router idiom for filter UIs. Back button should return to whatever surface the user came from, not 8 filter mutations ago.
---
## Pagination model
| Option | Description | Selected |
|--------|-------------|----------|
| Keep page-based `?page=N&limit=30` | Simpler, current behavior | |
| Cursor `?cursor=&limit=25` with last_activity_date+id | Stable under writes; matches triage workflow | ✓ |
| Cursor on created_date+id | Stable but doesn't reflect "recent activity" priority | |
**Auto choice rationale:** TICK-05 mandates cursor-based ~25/page. Choosing `last_activity_date DESC, id DESC` because managers triage by "what just changed" not "what was created".
---
## Infinite scroll trigger
| Option | Description | Selected |
|--------|-------------|----------|
| `react-intersection-observer` library | Hook abstraction, extra dep | |
| Browser-native `IntersectionObserver` | No dep, ~20 lines | ✓ |
| Scroll-event listener with throttle | Less precise, more re-renders | |
**Auto choice rationale:** CLAUDE.md prohibits adding new state libraries; native IntersectionObserver is sufficient.
---
## "Load more" fallback
| Option | Description | Selected |
|--------|-------------|----------|
| Skip — sentinel handles it | Fails accessibility | |
| Always-rendered focusable button below sentinel | TICK-06 mandates accessibility | ✓ |
| Show only when keyboard navigation detected | Brittle, not robust | |
**Auto choice rationale:** TICK-06 requires the fallback button. Always-rendered keeps it focusable and visible to screen readers.
---
## List row priority indicator
| Option | Description | Selected |
|--------|-------------|----------|
| Existing dot (bg-color-N) | Smaller, less prominent | |
| Left-edge stripe via `border-l-4` | TICK-03 spec | ✓ |
| Background tint of whole row | Too heavy on phone | |
**Auto choice rationale:** TICK-03 explicitly specifies left-edge stripe.
---
## Detail page header
| Option | Description | Selected |
|--------|-------------|----------|
| Refactor full detail page | Out of scope per TICK-07 | |
| Reskin in-page header only (back chevron + breadcrumb + ext link) | TICK-07 spec | ✓ |
**Auto choice rationale:** TICK-07 explicitly says "header reskinned... body kept largely as-is".
---
## Empty/loading states
| Option | Description | Selected |
|--------|-------------|----------|
| Spinner only on load | Janky, no skeleton | |
| Skeleton rows initial + inline spinner subsequent + empty-state messages | Polished, fits design tokens | ✓ |
**Auto choice rationale:** Standard mobile pattern; existing `Skeleton` shadcn primitive available.
---
## Auto-resolved scope creep checks
- **Bulk actions** — flagged and deferred (read-tap-detail is the manager workflow)
- **Saved presets** — deferred to a future phase
- **Detail body refactor** — explicitly excluded by TICK-07
## External research
None. Decisions were derivable from spec + existing code patterns.

View file

@ -0,0 +1,367 @@
---
phase: 4
slug: tickets-restyle
status: draft
shadcn_initialized: true
preset: new-york / neutral base / CSS variables
created: 2026-05-03
---
# Phase 4 — UI Design Contract: Tickets Restyle
> Visual and interaction contract for the mobile Tickets list page and detail header reskin.
> Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor.
All decisions tagged `[D-NN]` are LOCKED in `04-CONTEXT.md` and must not be re-litigated.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | shadcn/ui (new-york style) |
| Preset | `components.json` — new-york, neutral base, CSS variables, lucide icons |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react |
| Font | IBM Plex Sans (sans), IBM Plex Mono (numeric/ID fields) |
Source: `components.json` (confirmed present), `DESIGN.md §2`, `app/styles/brand.css`.
---
## Viewport Contract
| Property | Value |
|----------|-------|
| Reference device | iPhone 15 Pro — 393 × 852 CSS pixels |
| Max width constraint | `max-w-lg mx-auto` (from `app/mobile/layout.tsx`) |
| Shell chrome | HeaderBar (sticky, h-14 + pt-safe) + BottomNav (fixed h-16 + pb-safe) |
| Scrollable content area | `<main>` in layout — bottom padding = `calc(theme(spacing.16)+env(safe-area-inset-bottom))` |
| In-page sticky zone | Filter strip header row sticks below the HeaderBar at `top-0 z-10` |
---
## Spacing Scale
Declared values (multiples of 4). Pulled from existing mobile page patterns and DESIGN.md §3.
| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| xs | 4px | Icon gaps (`gap-1`), badge padding (`px-1.5 py-0.5`) |
| sm | 8px | Row internal gaps (`gap-2`), filter chip gaps (`gap-2`) |
| sm+ | 12px (3 × 4) | Secondary stacking, filter strip `pb-3` |
| md | 16px | Horizontal page padding (`px-4`), filter panel `px-4`, list row vertical padding (`py-4`) |
| lg | 24px | Vertical section breaks between filter header and list |
| xl | 32px | Empty-state vertical padding (`py-8`) |
| 2xl | 48px | Empty-state with no-results full-screen centering (`py-12`) |
Touch-target exception: interactive controls (toggle, select, filter chips, Load more button) must reach a minimum 44 × 44 px tap target — use `min-h-[44px]` or `py-2.5`/`py-3` padding to satisfy this on compact elements.
Exceptions: 4px-wide priority stripe is a visual mark, not an interactive target — `border-l-4` exactly as specified in D-15.
---
## Typography
All sizes are Tailwind defaults read from `DESIGN.md §2` and matched to the existing `app/mobile/tickets/page.tsx` patterns.
Two weights only: `font-normal` (400) and `font-semibold` (600). `font-medium` (500) is not used in this phase.
| Role | Size class | Weight | Line Height | Font | Usage |
|------|-----------|--------|-------------|------|-------|
| Row title | `text-sm` (14px) | `font-semibold` (600) | `leading-snug` (1.375) | IBM Plex Sans | Ticket title in list row — 1-line truncate |
| Body / secondary | `text-xs` (12px) | `font-normal` (400) | `leading-normal` (1.5) | IBM Plex Sans | Company name, age, assignee in row |
| Ticket number / ID | `text-[10px]` (10px) | `font-mono` / `font-normal` (400) | `leading-normal` | IBM Plex Mono | Ticket # badge in row (`font-mono`) |
| Detail breadcrumb | `text-sm` (14px) | `font-semibold` (600) | `leading-normal` | IBM Plex Sans | "Tickets / #T20250001" in detail header |
| Filter label | `text-xs` (12px) | `font-semibold` (600) | `leading-none` | IBM Plex Sans | Chip labels (Open, High, etc.) |
| Count / status line | `text-xs` (12px) | `font-normal` (400) | `leading-normal` | IBM Plex Sans | "N open tickets" beneath filter strip |
Heading note: No `text-2xl` page title is rendered by the list page — the shell HeaderBar owns the brand mark; the list page opens directly with the filter strip. The detail page retains its existing `text-base font-bold` ticket title (body is out of scope).
---
## Color
All colors use CSS variable tokens from `app/globals.css` + `app/styles/brand.css`. Direct Tailwind palette references are used only for semantic status colors per the recipe in `DESIGN.md §2`.
| Role | Token / Class | Usage |
|------|--------------|-------|
| Dominant surface (60%) | `bg-background` | Page background, filter strip background, row background |
| Secondary surface (30%) | `bg-muted` / `bg-muted/50` | Ticket number badge, filter chip hover, row hover (`hover:bg-muted/50`) |
| Primary accent (10%) | `text-primary` / `bg-primary` / `bg-primary/15` | Active filter chip selected state (`bg-primary text-primary-foreground`) |
| Muted text | `text-muted-foreground` | Company, age, assignee — all secondary row fields |
| Border | `border-border` | Row dividers, filter strip border-b, filter chip borders |
| Destructive | `text-destructive` | Error toast, retry button label |
Accent reserved for: active filter chip selected state only. Not used for row hover, icons, or decorative elements.
### Priority Stripe Colors [D-15] — LOCKED
| Priority | Border class | Semantic |
|----------|-------------|----------|
| 1 — Critical | `border-red-500` | Red |
| 2 — High | `border-orange-400` | Orange |
| 3 — Medium | `border-amber-400` | Amber |
| 4 — Low | `border-slate-300` | Slate |
These are direct Tailwind palette references (not CSS variable tokens) per the DESIGN.md §2 recipe for status hues outside the core token set. The `border-l-4` stripe is the exclusive carrier of priority color — the old `bg-{color}` priority dot is removed [D-17].
---
## Component Inventory
### Primary Visual Anchor
The primary focal point on the list page is the ticket title row. The `text-sm font-semibold` title creates a clear hierarchy against the `text-xs font-normal` secondary metadata (company, age, queue) beneath it. Every row is visually anchored to this title line — readers land there first, then scan down to context.
### Filter Strip [D-01 through D-04] — Collapsible
**Container:** `sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2`
**Search row (always visible):**
- `Input` (shadcn) with `Search` icon left (`pl-9`), `X` clear button right
- Placeholder: "Search tickets, company…"
- 400ms debounce [D-03]
**Collapsible toggle row (always visible):**
- Left: "N open tickets" count (`text-xs text-muted-foreground`)
- Right: "Filters" button (`Button variant="ghost" size="sm"`) + `SlidersHorizontal` or `ChevronDown`/`ChevronUp` icon; shows active filter count badge when ≥1 filter active
- Uses `Collapsible` from `components/ui/collapsible.tsx` with `open` state managed in component [D-01]
**Collapsible expanded panel [D-02]:**
| Control | Component | Options | URL param |
|---------|-----------|---------|-----------|
| Status | chip group | Open / In Progress / Waiting (multi-select) | `status` (comma-int) |
| Priority | chip group | Critical / High / Medium / Low (multi-select) | `priority` (comma-int) |
| Queue | `Select` (shadcn) | Sourced from existing `/api/mobile/tickets` queue list | `queue` (int) |
| Assigned to me | `Switch` or `Toggle` (shadcn) | on/off | `mine` (`1`/absent) |
Filter chips: `shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors` — active: `bg-primary text-primary-foreground border-primary`; inactive: `border-border hover:bg-muted/50`
"Clear all" button: `text-xs text-muted-foreground underline`, appears only when ≥1 filter is active [D-04]
### Ticket List Row [D-15 through D-17]
Outer wrapper: `<Link>``flex items-start border-l-4 {priority-border-class} px-4 py-4 hover:bg-muted/50 transition-colors active:bg-muted/50`
Row interior structure:
```
[4px priority stripe via border-l-4]
[content area flex-1 min-w-0]
[title row]: ticket title (1-line truncate, text-sm font-semibold) + ChevronRight (right, shrink-0)
[company]: text-xs text-muted-foreground truncate
[metadata row]: ticket# badge (font-mono text-[10px] bg-muted) | queue label (text-[10px] text-muted-foreground) | age (text-[10px] + Clock icon, ml-auto)
```
Note: assignee initials avatar (D-16) — render as a small circular `span` with `bg-primary/15 text-primary` when assigned_to is present. Keep `relTime()` helper as-is [D-17].
Row dividers: `divide-y` on the list container — matches existing pattern.
### Infinite Scroll Sentinel + Load More [D-12 through D-14]
- Sentinel: `<div ref={sentinelRef} aria-hidden="true" />` at list end
- `IntersectionObserver` with `rootMargin: '200px'` fires `fetchNextPage()` when sentinel enters viewport
- Guard: no-op if `loadingMore || !hasMore`
- Load more button: `w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50` — always rendered when `hasMore`, focusable, triggers same fetch path
- Loading more indicator: small `Loader2` icon (`w-4 h-4 animate-spin text-muted-foreground`) centered above the Load more button during in-flight requests
### Detail Page In-Page Header [D-18]
Replaces the existing `<button onClick={() => router.back()}>Back</button>` header.
New header bar: `flex items-center justify-between px-4 py-3 border-b`
Left slot: `<button>` with `ArrowLeft` icon (h-4 w-4) + "Tickets" text, `router.back()`, `text-sm text-muted-foreground hover:text-foreground`
Center slot: breadcrumb — `"Tickets / #" + ticket_number` in `text-sm font-semibold` (truncate if needed)
Right slot: `<a>` to desktop ticket URL (`/analyzer/ticket/{id}` or Autotask direct URL) with `ExternalLink` icon (h-4 w-4), `text-muted-foreground hover:text-foreground`, `target="_blank" rel="noopener noreferrer"`, `aria-label="Open on desktop"`
The existing body below (stats grid, description, timeline) is untouched [D-19].
### Skeleton Loading State [D-21]
Initial load (5 placeholder rows): use a purpose-built `TicketRowSkeleton` — not the generic `SkeletonRow` from `skeleton-helpers.tsx` since ticket rows have a specific 4px stripe + metadata layout.
Shape:
```
[border-l-4 border-muted]
[Skeleton h-4 w-3/4] ← title
[Skeleton h-3 w-1/2 mt-1] ← company
[flex gap-2 mt-2]
[Skeleton h-3 w-12] ← ticket#
[Skeleton h-3 w-16 ml-auto] ← age
```
Render 5 instances: `Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)`
Subsequent page load: `Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2`
Error state: `toast.error()` via sonner + Load more button label changes to "Retry" [D-21]
---
## Interaction Contracts
### URL Sync [D-05 through D-07]
- Filter changes call `router.replace()` (not `router.push()`)
- On mount: hydrate filter state from `useSearchParams()`; wrap in `Suspense` boundary (Next.js 16 requirement)
- Cursor is NOT persisted to URL [D-07]
- URL param keys: `q`, `status`, `priority`, `queue`, `mine`
- Default behavior when no URL params: treat as `status=1,8,7` (Open + In Progress + Waiting) — matches existing route's `t.status != 5` filter; the URL-synced status param replaces this hardcoded condition
### Cursor Pagination [D-08 through D-11]
- Page size: 25 rows
- Cursor: base64(JSON(`{ last_activity_date: ISO, id: number }`))
- API returns `{ tickets, nextCursor: string | null, hasMore: boolean }`
- Client state: `tickets: Ticket[]` (appended on each page), `nextCursor: string | null`, `hasMore: boolean`
### Filter State Machine
| Filter event | URL change | List reset |
|---|---|---|
| Search input change (400ms debounce) | `q` param updated | Yes — reset to page 1, cursor null |
| Status chip toggled | `status` param updated | Yes |
| Priority chip toggled | `priority` param updated | Yes |
| Queue selected | `queue` param updated | Yes |
| Mine toggle | `mine` param toggled | Yes |
| Clear all | All filter params removed | Yes |
| Scroll to bottom | No URL change | No — append only |
### Accessibility
- All filter chips: `role="checkbox"` (multi-select) or native `<button>` with `aria-pressed`
- Collapsible toggle: `aria-expanded` on trigger, `id`/`aria-controls` pairing
- Sentinel div: `aria-hidden="true"`
- Load more button: explicit `aria-label="Load more tickets"` for screen readers
- Detail back button: `aria-label="Back to Tickets"`
- Detail external link: `aria-label="Open ticket on desktop"`
- Priority stripe: decorative only; priority is also conveyed textually in the ticket number badge row context
- Empty state refresh affordance: icon-only button (`RefreshCw` h-4 w-4) with `aria-label="Refresh ticket list"` — no visible text label
---
## Copywriting Contract
All copy locked from D-20, D-21, and REQUIREMENTS.md.
| Element | Copy | Source |
|---------|------|--------|
| Search placeholder | "Search tickets, company…" | Existing (keep) |
| Filter toggle label (collapsed) | "Filters" | D-02 |
| Filter toggle label with actives | "Filters (N)" | Inferred from D-04 |
| Clear filters button | "Clear all" | D-04 |
| Assigned-to-me toggle | "Assigned to me" | D-02 |
| Ticket count line | "{N} open tickets" | Existing pattern (keep) |
| Empty state — filters active | "No tickets match your filters" | D-20 |
| Empty state CTA — filters active | "Clear filters" (button) | D-20 |
| Empty state — no filters, no tickets | "No tickets to triage right now" | D-20 |
| Empty state refresh affordance | icon-only (`RefreshCw`) with `aria-label="Refresh ticket list"` — no visible text | D-20 |
| Initial load state | 5 skeleton rows (no text) | D-21 |
| Load more button (idle) | "Load more" | D-14 |
| Load more button (loading) | "Loading…" (Loader2 spinner) | D-21 |
| Load more button (error/retry) | "Retry" | D-21 |
| Detail breadcrumb | "Tickets / #{ticket_number}" | D-18 |
| Detail back button | "Tickets" (with ArrowLeft icon) | D-18 |
| Detail external link | icon-only with aria-label "Open on desktop" | D-18 |
| Error toast | "Failed to load tickets" | D-21 (general toast convention) |
Destructive actions: None in this phase. The list and detail are read-only from the manager's perspective.
---
## Component Files to Create
Following the Phase 3 pattern (components named with `Mobile` suffix, stored in `components/mobile/`):
| File | Purpose |
|------|---------|
| `components/mobile/TicketRowSkeleton.tsx` | Skeleton placeholder matching ticket row shape |
| `components/mobile/TicketFilterStrip.tsx` | Collapsible filter strip (search + status/priority/queue/mine) |
The list page (`app/mobile/tickets/page.tsx`) and API route (`app/api/mobile/tickets/route.ts`) are modified in-place. The detail page (`app/mobile/tickets/[id]/page.tsx`) receives only header edits.
Component comment block convention (Phase 3 pattern):
```typescript
/* ComponentName — phase 04 (TICK-NN).
* Purpose: one-line description.
* Props: ... */
```
---
## API Shape Contract
The route file exports TypeScript interfaces for the page to `import type`. Pattern from Phase 3 summary.
```typescript
// app/api/mobile/tickets/route.ts — exported interfaces
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number; // 1=Critical, 2=High, 3=Medium, 4=Low
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
```
The `getMobileCompanyFilter()` helper is preserved without modification [04-CONTEXT.md code_context].
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | `Collapsible`, `Button`, `Input`, `Select`, `Switch`, `Skeleton` | not required |
No third-party registries. All components are either shadcn official primitives or purpose-built in `components/mobile/`. The `Collapsible` from `components/ui/collapsible.tsx` is already installed (confirmed in codebase scan).
---
## What Stays Unchanged
Per D-19 and phase boundary:
- Detail page body (stats grid, description collapsible, timeline, time entries) — untouched
- `getMobileCompanyFilter()` company scoping logic — preserved
- `relTime()` helper — preserved inline
- `kiosk_settings` data flow — unaffected
- BottomNav active-tab detection (`pathname.startsWith('/mobile/tickets')`) — already correct, no change needed
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS
**Approval:** pending
---
*Phase: 04-tickets-restyle*
*UI-SPEC created: 2026-05-03*
*UI-SPEC revised: 2026-05-03 (checker blocking fixes — typography weights, spacing, color contradiction, visual anchor, copywriting, spacing scale)*
*Source decisions: 04-CONTEXT.md D-01 through D-21 (all locked)*

336
CLAUDE.md
View file

@ -161,3 +161,339 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
- `PULSE_DATABASE_SKILL.md` — diagnostic queries - `PULSE_DATABASE_SKILL.md` — diagnostic queries
- `docs/` — sync behavior, webhook setup, workflow editor, analyzer runbook, - `docs/` — sync behavior, webhook setup, workflow editor, analyzer runbook,
RMM Overshell + LogLift specs, IT Glue audit spec, per-integration guides RMM Overshell + LogLift specs, IT Glue audit spec, per-integration guides
<!-- GSD:project-start source:PROJECT.md -->
## Project
**Pulse Mobile Shell Redesign**
A ground-up redesign of Pulse's `/mobile/*` shell — the manager-on-the-go view of
the existing Pulse PSA dashboard. It replaces the current mobile layout, swaps
the standalone `/mobile/nav` page for a Sheet drawer, restyles Dashboard /
Tickets / Finance, and adds two new mobile surfaces (Analyzer feed,
Engagement). Built on the existing Pulse codebase — same routes, same data,
phone-first layouts.
**Core Value:** A manager can open Pulse on their phone and, in under 30 seconds, see the
state of the business and triage tickets — without ever needing to switch to
desktop for read-only awareness.
### Constraints
- **Tech stack**: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui — match
existing Pulse conventions (no new state libraries, no SWR/react-query, no
ORM, no Zod in API routes unless required)
- **Routes**: Keep all existing `/mobile/*` paths. Replace files in place. No
new top-level routes outside `/mobile/`.
- **Build order**: Each spec step ships independently — no big-bang merge.
Phase boundaries should let each step land on `master` cleanly.
- **No service worker / no offline**: don't introduce `next-pwa` or a custom
SW in this iteration.
- **Auth**: existing Better Auth + middleware handles `/mobile/*`. No new
auth surface.
- **Data sources**: reuse existing endpoints where possible; add
`/api/mobile/*` only when an existing list endpoint doesn't return the
needed shape (e.g., analyzer feed).
<!-- GSD:project-end -->
<!-- GSD:stack-start source:codebase/STACK.md -->
## Technology Stack
## Languages
- TypeScript 5 - Entire codebase, strict mode enabled
- JavaScript/JSX - React components via TypeScript with JSX support
- SQL - PostgreSQL migrations and queries
- Bash - Build and deployment scripts
## Runtime
- Node.js (version inferred from package.json compatibility)
- Next.js 16.1.1 running on port 3100
- npm (lockfile: package-lock.json)
## Frameworks
- Next.js 16.1.1 - App Router with `output: 'standalone'` for Docker, React Compiler enabled
- React 19.2.3 - Server and client components, React Compiler active
- Better Auth 1.4.10 - Authentication with magic link, TOTP 2FA, Microsoft OAuth
- Tailwind CSS 4.1.18 - Utility-first styling
- shadcn/ui (via Radix UI primitives) - Component library: `components/ui/`
- Recharts 3.7.0 - Charts and graphs (analytics/dashboards)
- Lucide React 0.562.0 - Icon library
- Sonner 2.0.7 - Toast notifications
- cmdk 1.1.1 - Command palette component
- react-hook-form 7.70.0 - Form state management (admin/auth only)
- Zod 4.3.5 - Type-safe schema validation
- @hookform/resolvers 5.2.2 - Form resolver for Zod
- @tanstack/react-table 8.21.3 - Headless table library with sorting, pagination, search
- react-markdown 10.1.0 - Markdown rendering
- remark-gfm 4.0.1 - GitHub-flavored markdown support
- date-fns 4.1.0 - Date manipulation and formatting
- react-day-picker 9.13.0 - Calendar date picker
- clsx 2.1.1 - Conditional className utility
- tailwind-merge 3.4.0 - Merge Tailwind class conflicts
- class-variance-authority 0.7.1 - CSS-in-JS variant management
- next-themes 0.4.6 - Dark mode theme switching
## Testing & Build
- vitest 4.1.5 - Unit and integration testing runner
- Run: `npm test` (run once), `npm run test:watch` (watch mode)
- Test coverage for: `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**`, `lib/services/analyzer/link-discovery.test.ts`
- Turbopack (via Next.js 16) - Fast bundler for development and production
- ESLint 9.39.2 - Linting (with eslint-config-next 16.1.1)
- TypeScript - Type checking via `npx tsc --noEmit --pretty`
## Key Dependencies
- pg 8.11.0 - PostgreSQL client (no ORM); queries via `postgresClient` singleton
- ioredis 5.9.0 - Redis client for caching; optional (graceful fallback if REDIS_URL unset)
- node-cron 4.2.1 - Job scheduler for sync tasks and workflows
- @anthropic-ai/sdk 0.91.1 - Anthropic Claude API client for AI Ticket Analyzer pipeline
- Better Auth ecosystem packages - OAuth, 2FA, session management
- nodemailer 7.0.12 - Email delivery for magic link auth
- babel-plugin-react-compiler 1.0.0 - React Compiler for optimized renders
- shadcn 4.6.0 - CLI tool for adding shadcn/ui components
- baseline-browser-mapping 2.10.8 - Browser compatibility mapping
- tw-animate-css 1.4.0 - Tailwind animation utilities
## Configuration
- `BETTER_AUTH_URL` - Base URL for auth (e.g., `http://localhost:3100` or `https://pulse.wulfconsulting.cloud`)
- `BETTER_AUTH_SECRET` - Secret key for session signing
- `DATABASE_URL` or `POSTGRES_*` - PostgreSQL connection (host, port, db, user, password)
- `REDIS_URL` - Redis connection (e.g., `redis://localhost:6380` in Docker compose)
- `SESSION_TIMEOUT_SECONDS` - Session TTL (default: 86400 / 24 hours)
- `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` - Microsoft OAuth for login (tenant is specific, not 'common')
- `DEFAULT_ADMIN_EMAIL` - Bootstrap admin account email
- Integration env vars: prefixed by service (e.g., `AUTOTASK_*`, `DATTO_RMM_*`, `MSGRAPH_*`, etc.) — see INTEGRATIONS.md
- Path alias: `@/*` maps to project root for cleaner imports
- Target: ES2017
- Strict mode enabled
- Config: `tsconfig.json`
- File: `next.config.ts`
- Standalone output for Docker deployment
- React Compiler enabled
- Image domains: configurable (currently empty)
- `npm run build` → Next.js standalone app in `.next/`
- `npm run start` → Starts production server on port 3100
## Platform Requirements
- Node.js 18+ (inferred from Next.js 16 compatibility)
- npm 8+
- PostgreSQL 16 (local or Docker)
- Redis 7 (optional, enables caching)
- Docker & Docker Compose (for full stack)
- Docker with docker-compose.yml provided
- Postgres 16 container (applies migrations on init)
- Redis 7 container on port 6380 (custom, not 6379)
- Next.js app container on port 3100
- Traefik integration available (via labels in docker-compose.yml) for routing at `pulse.wulfconsulting.cloud`
- Container-based: standalone Next.js image with migrations applied via Postgres init
- Volume mounts for data persistence: `redis_data`, `postgres_data`
- Environment configuration via `.env.local` file mounted read-only
<!-- GSD:stack-end -->
<!-- GSD:conventions-start source:CONVENTIONS.md -->
## Conventions
## Naming Patterns
- kebab-case for all files and directories (e.g., `postgres-client.ts`, `invite-user-form.tsx`, `entity-sync.ts`)
- Nested directories use kebab-case (e.g., `lib/services/analyzer/`, `components/admin/users/`)
- camelCase for all functions (e.g., `getAutotaskClient()`, `transformCompany()`, `extractExplicitFromText()`)
- Factory functions prefixed with `get` (e.g., `getAutotaskClient()`, `getDattoRmmClient()`)
- Helper functions suffixed with descriptive intent (e.g., `relTime()`, `deriveSigningKey()`)
- Private/internal functions prefixed with underscore: `_INTERNALS` objects expose internals for test access
- camelCase for all variables (e.g., `isLoading`, `setData`, `ticketNumber`)
- Constants in UPPER_SNAKE_CASE (e.g., `MAX_EXPLICIT_LINKS`, `TICKET_NUMBER_REGEX`)
- Database column names are always snake_case (e.g., `company_name`, `is_active`, `created_at`)
- PascalCase for all type names (e.g., `ClassificationRule`, `WorkflowExecution`, `TicketData`)
- Single-letter generics are acceptable (e.g., `queryEntity<T>()`)
- Union types as literal strings (e.g., `type RuleType = 'branch_routing' | 'ticket_type'`)
- PascalCase exported from kebab-case files (e.g., export `InviteUserForm` from `invite-user-form.tsx`)
- Page components: `export default function ComponentName()` at end of file
- Form components: follow `[Resource]Form` naming (e.g., `InviteUserForm`, `SignInForm`, `UserForm`)
## Code Style
- TypeScript strict mode enabled (`"strict": true` in `tsconfig.json`)
- No explicit formatter config (ESLint handles style)
- Indentation: 2 spaces (inferred from existing code)
- ESLint: `eslint.config.mjs` with Next.js config (`eslint-config-next/core-web-vitals`, `eslint-config-next/typescript`)
- No additional custom rules beyond Next.js defaults
- Type checking: `npx tsc --noEmit --pretty` (must pass before commit)
## Import Organization
- Configured as `"@/*": ["./*"]` in `tsconfig.json`
- Use `@/lib/...`, `@/components/...`, `@/app/...` always
- Never use relative paths like `../../../` for imports
## Error Handling
- All async functions use `try/catch` blocks
- API routes: catch errors and return `NextResponse.json({ error, message }, { status })`
- Standard status codes: `500` for runtime errors, `503` for missing/bad config, `401`/`403` from auth helpers
- Error messages: include `error instanceof Error ? error.message : 'fallback message'`
## Logging
- `console.error()` for exceptions caught in try/catch (usually in API routes and services)
- Include context: `console.error('Failed to fetch companies:', error)`
- No `console.log()` for debugging (remove before commit per linter checks)
## Comments
- Explain *why*, not what (code shows the what)
- Non-obvious logic or business rules
- Performance-critical sections
- Workarounds or hacks (mark with `// HACK:` or `// NOTE:`)
- Used sparingly on complex functions
- Example from `lib/services/analyzer/link-discovery.ts`:
- Not required for simple getters/setters or obvious functions
## Function Design
- Keep functions focused: one responsibility per function
- Aim for <50 lines for page components, <30 for utilities
- Complex operations broken into smaller helpers
- Prefer object parameters for >3 arguments
- Don't use `any` — use specific types
- Use `Partial<T>` for optional object shapes
- Async functions always return `Promise<T>` explicitly
- Prefer `null` over `undefined` for missing values
- Use discriminated unions for success/error returns in critical paths (see analyzer pipeline)
## Module Design
- Prefer `export` at declaration point rather than grouped re-exports
- One main export per file (exception: barrel files in `components/ui/`)
- Internal utilities prefixed with underscore: `_INTERNALS` object for test access
- `components/ui/index.ts` exports all shadcn primitives
- `lib/types/` has domain-specific barrel files (e.g., `lib/types/workflow.ts`, `lib/types/autotask.ts`)
- Avoid deep nesting — import from files, not directories unless barrel exists
## Database Transformations
## Shared Components & Libraries
- shadcn primitives live in `components/ui/`
- Feature-specific components in sibling directories (e.g., `components/dashboard/`, `components/admin/`)
- Icons: Always use `lucide-react` (e.g., `import { Loader2, Send } from 'lucide-react'`)
- Use `@tanstack/react-table` via `components/admin/DataTable.tsx` wrapper
- Example: `<DataTable columns={columns} data={data} />`
- Use `components/admin/DetailModal.tsx` for entity details
- Follows card + tabs pattern (formatted/raw)
- Use `components/navigation/app-navigation.tsx` (`NavigationMenu` from Radix)
- Dropdowns prefer `@radix-ui/react-dropdown-menu` over submenus
- Use `sonner` library: `import { toast } from 'sonner'`
- Patterns: `toast.success()`, `toast.error()`, `toast.info()`
- Use `react-hook-form` + Zod for validation
- Only in admin/auth forms — NOT in every page
- Pattern: `useForm()` with `zodResolver()`, then `<Form>` wrapper from shadcn
- Use `recharts` for data visualization (e.g., `<BarChart>`, `<LineChart>`)
## What NOT to Introduce
- No ORMs (Prisma, TypeORM, etc.) — use `postgresClient` singleton and manual transforms
- No server actions (`'use server'`) — use API routes called via `fetch()` from clients
- No additional state libraries (SWR, react-query, TanStack Query) — match local `useState` + `fetch` pattern
- No change to authentication (Better Auth is final)
- No editing of committed migrations — always create new numbered ones
- Keeps codebase lean and explicit
- Reduces abstraction overhead
- Makes data flow (DB → API → Client) visible
## Migrations
<!-- GSD:conventions-end -->
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
## Architecture
## Pattern Overview
- App Router pages (`'use client'`) fetch data via API routes using `fetch()`
- Three auto-starting background workers via side-effect imports (sync-scheduler, analyzer, RMM overshell)
- Service layer in `lib/services/` with factories + configuration helpers
- Postgres 16 as system of record (snake_case columns), Redis for caching only
- Manual transformation from snake_case DB columns to camelCase API responses
- Better Auth 1.4 for sessions + magic link + TOTP 2FA + Microsoft OAuth
## Layers
- Purpose: Accept HTTP requests, validate auth, delegate to services, return JSON responses
- Location: `app/api/*/route.ts`, `app/*/page.tsx`
- Contains: Next.js route handlers (GET/POST/PATCH/DELETE), page components
- Depends on: Auth via `lib/auth-utils.ts`, services via `lib/services/`
- Used by: Client-side fetch calls from UI components
- Purpose: Sync data from external APIs, run background jobs, execute workflows, store results in Postgres
- Location: `lib/services/*.ts` (~50 files) plus subdirectories (`analyzer/`, `rmm/`, `llm/`, `b2/`)
- Contains: Integration clients (autotask, datto-rmm, itglue, veeam, msgraph, etc.), sync logic, job executors, pipeline orchestration
- Depends on: Postgres client, external API clients, environment configuration
- Used by: API routes (sync endpoints, webhook handlers) and background workers
- Purpose: Query, upsert, and manage state in Postgres
- Location: `lib/services/postgres-client.ts` (singleton) + `migrations/NNN_*.sql`
- Contains: Connection pool, query builder methods (`query()`, `transaction()`, `upsert()`, `bulkUpsert()`), migration definitions
- Depends on: PostgreSQL 16 connection string from env
- Used by: All services that read/write data
- Purpose: Poll for work and execute sync/analysis/RMM tasks without blocking HTTP requests
- Location: `lib/services/sync-scheduler.ts`, `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`
- Contains: node-cron scheduler, polling loops with exponential backoff, state machine handlers
- Depends on: Postgres client, service layer integrations
- Used by: Auto-start on module import (side effects); HTTP routes that need them running will import the module to start them
- Purpose: Render pages, dialogs, tables, charts, and handle client-side state
- Location: `components/ui/` (shadcn primitives), `components/*/` (feature-specific), `app/*/page.tsx`
- Contains: React components, hooks for fetch + state, sonner toasts, recharts visualizations
- Depends on: API routes via `fetch()`, client auth via Better Auth SDK
- Used by: Next.js pages and other components
- Purpose: Define TypeScript interfaces and database schema
- Location: `lib/types/<domain>.ts`, `migrations/NNN_*.sql`
- Contains: Entity types (ticket, company, analysis, rmm_execution, etc.), sync request shapes, API response envelopes
- Depends on: (none — they define contracts)
- Used by: Services, API routes, components
## Data Flow
- **HTTP requests:** Stateless; session from Better Auth cookie
- **Background jobs:** State in Postgres (status columns: `queued``in_flight``complete` / `failed`)
- **Caching:** Redis (optional, used for integration health checks, cache duration varies)
- **Authorization:** Checked in route handlers via `requireAuth()` / `requireAdmin()` / `requirePermission()` from `lib/auth-utils.ts`
## Key Abstractions
- Purpose: Lazy-load integration clients with configured credentials; provide `is<Name>Configured()` helper to check env vars
- Examples: `lib/services/autotask-factory.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/msgraph-factory.ts`
- Pattern: Export `getAutotaskClient()`, `getDattoRmmClient()`, `getMsgraphClient()` with caching; throw if credentials missing; bundled with `is<Name>Configured()` for upstream checks
- Why: Decouples client initialization from route handlers; allows conditional feature gates per env
- Purpose: Single connection pool for all Postgres queries; auto-lazy-initializes; provides ORM-like query builder
- Examples: `postgresClient.query()`, `postgresClient.transaction()`, `postgresClient.upsert()`, `postgresClient.bulkUpsert()`
- Pattern: Private constructor, static `getInstance()`, pool initialized on first use
- Why: Prevents connection leaks; provides consistent interface across ~50 services
- Purpose: Incremental/full sync of any entity type from external API → Postgres
- Examples: `lib/services/entity-sync.ts` (Autotask), `lib/services/itglue-sync-service.ts`, `lib/services/veeam-sync-service.ts`
- Pattern: Reads `lastTrackedModificationDateTime` from last_sync table; queries external API; batches upsert via `bulkUpsert()`
- Why: Codifies the "last sync timestamp + incremental pull + batch insert" pattern across integrations
- Purpose: Orchestrate multi-stage LLM analysis with fallbacks, cost guards, and persistence
- Examples: `lib/services/analyzer/pipeline.ts`, `lib/services/analyzer/stages/*.ts`
- Pattern: Each stage returns structured output (gap analysis, category, cost estimate); cost guard checks ceiling before Stage 4; all stages persisted to `analyzer_stage_executions`
- Why: Allows cost control (skip expensive Opus above $2.00), idempotency (content_hash), and debugging (inspect each stage's output)
- Purpose: Auto-start in production, poll for work, claim rows with `FOR UPDATE SKIP LOCKED`, execute, persist result
- Examples: `lib/services/analyzer/worker.ts`, `lib/services/rmm/worker.ts`, `lib/services/sync-scheduler.ts`
- Pattern: Side-effect import auto-starts on module load; exponential backoff if no work; row locking for multi-instance safety (analyzer), or single-instance gate (sync scheduler)
- Why: Keeps background work out of HTTP request path; analyzer is safe to scale (row locking); sync scheduler should run on one instance only
- Purpose: Define three roles (`user`, `admin`, `super-admin`) with per-resource permissions
- Examples: `lib/auth.ts` (auth config), `lib/permissions.ts` (permission matrix), `lib/auth-utils.ts` (runtime checks)
- Pattern: Route handler calls `requireAdmin()` / `requirePermission()`, which decode session and check role; middleware only verifies session cookie exists
- Why: Separates auth (middleware) from authorization (route handler); role-based gates are checked at the point of use
## Entry Points
- Location: `app/*/page.tsx`
- Triggers: Browser navigation to any route except public paths
- Responsibilities: Render page shell with `AppNavigation` + `PageHeader`, fetch data from API, render client-side components, handle toast/dialog interactions
- Location: `app/api/*/route.ts`
- Triggers: `fetch()` from client, external webhooks (Autotask, Zabbix, RMM), scheduler HTTP calls
- Responsibilities: Validate auth/webhook signature, delegate to service layer, return JSON response with appropriate status (200, 401, 403, 503, 500)
- Location: `app/api/webhooks/autotask`, `app/api/zabbix/webhook`, `app/api/rmm/loglift`
- Triggers: External systems (Autotask, Zabbix, OpenClaw) POST events
- Responsibilities: Verify HMAC or custom header, parse event, enqueue jobs or upsert data, return 200 (even on error so Autotask doesn't deactivate)
- Location: `app/api/sync/*`, `app/api/datto-rmm/sync`, `app/api/itglue/sync`, `app/api/veeam/sync`, etc.
- Triggers: `lib/services/sync-scheduler.ts` fires HTTP POST at configured times
- Responsibilities: Call sync service, update `last_sync` timestamp, return 200 on success or error message
- Location: `lib/services/sync-scheduler.ts` (cron), `lib/services/analyzer/worker.ts` (2s poll), `lib/services/rmm/worker.ts` (5s poll)
- Triggers: Auto-starts as side effect of module import; runs indefinitely in production
- Responsibilities: Poll for work from DB, claim row, execute, persist result, handle errors + logging
## Error Handling
- **Webhook handlers** — Return 200 even if processing fails; log error so ops can investigate via audit log
- **API route handlers**`try/catch`, return `NextResponse.json({ error, message }, { status })` with conventions:
- **Analyzer pipeline** — Cost ceiling guard at $2.00; above that, Stage 4 (Opus) skipped, analysis flagged with `needs_review=true`
- **Sync services** — Incremental sync errors log + re-trigger on next schedule; full sync errors persist `last_error` to `sync_schedules` table
- **Background workers** — Stale jobs reset on worker boot (no recovery); exponential backoff on empty polls; errors logged with job ID for manual inspection
## Cross-Cutting Concerns
<!-- GSD:architecture-end -->
<!-- GSD:skills-start source:skills/ -->
## Project Skills
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.
<!-- GSD:skills-end -->
<!-- GSD:workflow-start source:GSD defaults -->
## GSD Workflow Enforcement
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
Use these entry points:
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
- `/gsd-debug` for investigation and bug fixing
- `/gsd-execute-phase` for planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
<!-- GSD:workflow-end -->
<!-- GSD:profile-start -->
## Developer Profile
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
> This section is managed by `generate-claude-profile` -- do not edit manually.
<!-- GSD:profile-end -->

View file

@ -1,115 +1,214 @@
/**
* GET /api/mobile/dashboard
* Single round-trip returning the three sections consumed by the new mobile
* dashboard layout: 4 KPIs, 3 Needs Attention items, 3 worker/backup status
* entries.
*
* All ticket counts exclude out-of-scope companies (company_scope filter,
* same idiom as /api/dashboard/overview).
*/
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client'; import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
async function getMobileClassFilter(): Promise<string> { // ─── Response shape ──────────────────────────────────────────────────────────
try {
const result = await postgresClient.query(
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
);
const map: Record<string, string> = {};
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
const catIds = (map['mobile_company_category_ids'] || '1') export interface KpiResponse {
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches';
const exclIds = (map['mobile_excluded_company_ids'] || '') label: string;
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); value: number;
caption?: string;
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true'; tone?: 'default' | 'attention';
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
return [catCond, exclCond].filter(Boolean).join(' AND ');
} catch (error) {
console.error('Error fetching mobile company filter:', error);
return 'c.company_category_id = 1';
}
} }
export interface AttentionResponse {
id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows';
label: string;
count: number;
href: string;
}
export interface WorkerResponse {
id: 'analyzer' | 'rmm' | 'backup_success_rate';
label: string;
value: string;
status: 'ok' | 'warn' | 'down';
href: string;
}
export interface MobileDashboardResponse {
kpis: KpiResponse[];
needsAttention: AttentionResponse[];
workers: WorkerResponse[];
}
// ─── Handler ─────────────────────────────────────────────────────────────────
export async function GET() { export async function GET() {
const classFilter = await getMobileClassFilter(); const { error } = await requireAuth();
if (error) return error;
const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([ try {
postgresClient.query(` const [
SELECT t.status, COUNT(*) as count kpiRes,
FROM tickets t failedBackupsRes,
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} stalledWorkflowsRes,
WHERE t.status != 5 AND t.is_deleted = false analyzerRes,
GROUP BY t.status ORDER BY count DESC rmmRes,
`), backupSuccessRes,
postgresClient.query(` ] = await Promise.all([
SELECT t.queue_id, q.label as queue_label, COUNT(*) as count /* 1. KPI snapshot — four counts in one row, scoped companies excluded */
FROM tickets t postgresClient.query<{
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} open_total: string;
LEFT JOIN queues q ON q.value = t.queue_id opened_today: string;
WHERE t.status != 5 AND t.is_deleted = false resolved_today: string;
GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 8 sla_breaches: string;
`), }>(`
postgresClient.query(` SELECT
SELECT t.priority, p.label, COUNT(*) as count COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
FROM tickets t COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
LEFT JOIN priorities p ON p.value = t.priority COUNT(*) FILTER (
WHERE t.status != 5 AND t.is_deleted = false WHERE completed_date IS NULL
GROUP BY t.priority, p.label ORDER BY count DESC AND due_date_time IS NOT NULL
`), AND due_date_time < NOW()
postgresClient.query(` )::text AS sla_breaches
SELECT t.id, t.ticket_number, t.title, t.status, t.priority, FROM tickets
t.last_activity_date, t.company_id, WHERE (is_deleted = false OR is_deleted IS NULL)
c.company_name, q.label as queue_label, AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
r.first_name || ' ' || r.last_name as assigned_to `),
FROM tickets t
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
LEFT JOIN queues q ON q.value = t.queue_id
LEFT JOIN resources r ON r.id = t.assigned_resource_id
WHERE t.status != 5 AND t.is_deleted = false AND t.last_activity_date IS NOT NULL
ORDER BY t.last_activity_date DESC LIMIT 10
`),
postgresClient.query(`
SELECT
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL
AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as resp_met,
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL) as resp_total,
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL
AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL) as res_total
FROM tickets t
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
WHERE t.create_date >= NOW() - INTERVAL '30 days' AND t.is_deleted = false
`),
]);
const statusLabels: Record<number, string> = { /* 2. Failed backups in the last 24 hours (Needs Attention) */
1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress', postgresClient.query<{ count: string }>(`
9: 'Scheduled', 12: 'On Hold', 14: 'Waiting Customer', SELECT COUNT(*)::text AS count FROM (
19: 'Waiting Materials', 21: 'Dispatched', 25: 'In Review', SELECT 1 FROM veeam_backup_jobs
27: 'Pending Decision', 30: 'On Hold', 45: 'Escalated', 47: 'Waiting Customer', WHERE last_run >= NOW() - INTERVAL '24 hours'
56: 'Waiting Vendor', 58: 'Waiting Parts', 59: 'Pending Approval', AND is_enabled = true AND status = 'Failed'
60: 'In Deployment', 66: 'Closed', 68: 'Resolved', 70: 'Customer Follow-Up', 71: 'Archived', UNION ALL
}; SELECT 1 FROM veeam_backup_agent_jobs
WHERE last_run >= NOW() - INTERVAL '24 hours'
AND is_enabled = true AND status = 'Failed'
) f
`),
const slaRow = sla.rows[0]; /* 3. Stalled workflow executions — pending > 5 minutes (Needs Attention) */
postgresClient.query<{ count: string }>(`
SELECT COUNT(*)::text AS count
FROM workflow_executions
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes'
`),
return NextResponse.json({ /* 4. Analyzer worker — in-flight + recent failures */
open_total: byStatus.rows.reduce((s, r) => s + parseInt(r.count), 0), postgresClient.query<{ in_flight: string; fail_1h: string }>(`
by_status: byStatus.rows.map(r => ({ SELECT
status: parseInt(r.status), COUNT(*) FILTER (
label: statusLabels[r.status] ?? `Status ${r.status}`, WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review')
count: parseInt(r.count), )::text AS in_flight,
})), COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
by_queue: byQueue.rows.map(r => ({ FROM analyzer_jobs
queue_id: r.queue_id, `),
label: r.queue_label ?? `Queue ${r.queue_id}`,
count: parseInt(r.count), /* 5. RMM worker — in-flight + recent failures */
})), postgresClient.query<{ in_flight: string; fail_1h: string }>(`
by_priority: byPriority.rows.map(r => ({ SELECT
priority: parseInt(r.priority), COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
label: r.label ?? `P${r.priority}`, COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
count: parseInt(r.count), FROM rmm_executions
})), `),
recent: recentActivity.rows,
sla: { /* 6. Backup success rate (24h) — same calculation as /api/veeam/backup-status */
response_met: parseInt(slaRow.resp_met ?? 0), postgresClient.query<{ success: string; total: string }>(`
response_total: parseInt(slaRow.resp_total ?? 0), SELECT
resolution_met: parseInt(slaRow.res_met ?? 0), COUNT(*) FILTER (WHERE status = 'Success')::text AS success,
resolution_total: parseInt(slaRow.res_total ?? 0), COUNT(*)::text AS total
}, FROM (
}); SELECT status FROM veeam_backup_jobs
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
UNION ALL
SELECT status FROM veeam_backup_agent_jobs
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
) j
`),
]);
// ── Build KPIs ────────────────────────────────────────────────────────────
const kpiRow = kpiRes.rows[0];
const openTotal = parseInt(kpiRow?.open_total ?? '0', 10);
const openedToday = parseInt(kpiRow?.opened_today ?? '0', 10);
const resolvedToday = parseInt(kpiRow?.resolved_today ?? '0', 10);
const slaBreaches = parseInt(kpiRow?.sla_breaches ?? '0', 10);
const kpis: KpiResponse[] = [
{ id: 'open_total', label: 'Open total', value: openTotal, tone: 'default' },
{ id: 'opened_today', label: 'Opened today', value: openedToday, tone: 'default' },
{ id: 'resolved_today', label: 'Resolved today', value: resolvedToday, tone: 'default' },
{ id: 'sla_breaches', label: 'SLA breaches', value: slaBreaches, tone: slaBreaches > 0 ? 'attention' : 'default' },
];
// ── Build Needs Attention ─────────────────────────────────────────────────
const failedBackups = parseInt(failedBackupsRes.rows[0]?.count ?? '0', 10);
const stalledWorkflows = parseInt(stalledWorkflowsRes.rows[0]?.count ?? '0', 10);
const needsAttention: AttentionResponse[] = [
{ id: 'overdue_tickets', label: 'Overdue tickets', count: slaBreaches, href: '/tickets?overdue=true' },
{ id: 'failed_backups', label: 'Failed backups (24h)', count: failedBackups, href: '/backup-status' },
{ id: 'stalled_workflows', label: 'Stalled workflows', count: stalledWorkflows, href: '/admin/workflow' },
];
// ── Build Workers ─────────────────────────────────────────────────────────
const aRow = analyzerRes.rows[0];
const analyzerInFlight = parseInt(aRow?.in_flight ?? '0', 10);
const analyzerFail1h = parseInt(aRow?.fail_1h ?? '0', 10);
let analyzerStatus: 'ok' | 'warn' | 'down' = 'ok';
if (analyzerFail1h > 0 && analyzerInFlight === 0) analyzerStatus = 'down';
else if (analyzerFail1h > 0) analyzerStatus = 'warn';
const rRow = rmmRes.rows[0];
const rmmInFlight = parseInt(rRow?.in_flight ?? '0', 10);
const rmmFail1h = parseInt(rRow?.fail_1h ?? '0', 10);
let rmmStatus: 'ok' | 'warn' | 'down' = 'ok';
if (rmmFail1h > 0 && rmmInFlight === 0) rmmStatus = 'down';
else if (rmmFail1h > 0) rmmStatus = 'warn';
const bRow = backupSuccessRes.rows[0];
const bSuccess = parseInt(bRow?.success ?? '0', 10);
const bTotal = parseInt(bRow?.total ?? '0', 10);
const backupPct = bTotal > 0 ? Math.round((bSuccess / bTotal) * 1000) / 10 : 100;
let backupStatus: 'ok' | 'warn' | 'down' = 'ok';
if (backupPct < 80) backupStatus = 'down';
else if (backupPct < 95) backupStatus = 'warn';
const workers: WorkerResponse[] = [
{
id: 'analyzer',
label: 'Analyzer',
value: `${analyzerInFlight} in flight`,
status: analyzerStatus,
href: '/admin/analytics',
},
{
id: 'rmm',
label: 'RMM Overshell',
value: `${rmmInFlight} in flight`,
status: rmmStatus,
href: '/admin/rmm-overshell',
},
{
id: 'backup_success_rate',
label: 'Backup success (24h)',
value: `${backupPct}%`,
status: backupStatus,
href: '/backup-status',
},
];
return NextResponse.json<MobileDashboardResponse>({ kpis, needsAttention, workers });
} catch (e) {
console.error('[/api/mobile/dashboard] failed:', e);
return NextResponse.json(
{ error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' },
{ status: 500 },
);
}
} }

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google"; import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
@ -28,6 +28,7 @@ const plexMono = IBM_Plex_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Pulse · Operations console", title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.", description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
manifest: "/manifest.json",
icons: { icons: {
icon: [ icon: [
{ url: "/favicon.png", sizes: "any" }, { url: "/favicon.png", sizes: "any" },
@ -38,6 +39,16 @@ export const metadata: Metadata = {
}, },
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
],
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{

View file

@ -0,0 +1,33 @@
/* Placeholder for /mobile/analyzer.
*
* Phase 02 only adds the Analyzer tab to the bottom nav the real feed
* lands in Phase 6 (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
* §6.4). This file exists so tapping the Analyzer tab resolves to a real
* route instead of 404. Phase 6 will replace this file with the actual
* read-only feed page.
*
* DO NOT add features, data fetching, or UI beyond the "Coming soon"
* card here Phase 6 owns the real implementation. */
import { Sparkles } from 'lucide-react';
export const metadata = {
title: 'Analyzer · Pulse',
};
export default function MobileAnalyzerPlaceholder() {
return (
<div className="p-4">
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center text-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
<Sparkles className="h-6 w-6" />
</div>
<h1 className="text-lg font-semibold">Analyzer feed coming soon</h1>
<p className="text-sm text-muted-foreground max-w-xs">
The mobile Analyzer feed is on its way. Until then, view full
analyses on the desktop Analyzer.
</p>
</div>
</div>
);
}

View file

@ -1,191 +1,118 @@
'use client'; 'use client';
/* /mobile/dashboard phase 03 (DASH-01..04).
*
* Three sections, top-to-bottom:
* 1. 2×2 KPI grid (DASH-01)
* 2. Needs Attention (DASH-02)
* 3. Worker/backup row (DASH-03)
*
* No charts on phone widths (DASH-04). Header + bottom nav are provided
* by app/mobile/layout.tsx; this page only renders the H1 and body. */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import { RefreshCw } from 'lucide-react';
import { RefreshCw, AlertCircle, Clock, CheckCircle2, ChevronRight } from 'lucide-react'; import { KpiCardMobile } from '@/components/mobile/KpiCardMobile';
import { NeedsAttentionStrip } from '@/components/mobile/NeedsAttentionStrip';
interface DashboardData { import { WorkerStatusRow } from '@/components/mobile/WorkerStatusRow';
open_total: number; import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route';
by_status: { status: number; label: string; count: number }[];
by_queue: { queue_id: number; label: string; count: number }[];
by_priority: { priority: number; label: string; count: number }[];
recent: {
id: number; ticket_number: string; title: string; status: number; priority: number;
last_activity_date: string; company_name: string; queue_label: string; assigned_to: string;
}[];
sla: { response_met: number; response_total: number; resolution_met: number; resolution_total: number };
}
const PRIORITY_COLOR: Record<number, string> = {
1: 'bg-slate-300', // Standard
2: 'bg-slate-300', // Medium
3: 'bg-slate-300', // Standard
4: 'bg-red-500', // Critical
6: 'bg-orange-400', // High
7: 'bg-purple-500', // Security Event
8: 'bg-yellow-400', // Minor Service
9: 'bg-orange-500', // Major Service
10: 'bg-blue-400', // Installation
11: 'bg-pink-400', // Fast Track
};
const PRIORITY_TEXT: Record<number, string> = {
1: 'text-slate-500', // Standard
2: 'text-slate-500', // Medium
3: 'text-slate-500', // Standard
4: 'text-red-600', // Critical
6: 'text-orange-500', // High
7: 'text-purple-600', // Security Event
8: 'text-yellow-600', // Minor Service
9: 'text-orange-600', // Major Service
10: 'text-blue-600', // Installation
11: 'text-pink-600', // Fast Track
};
function pct(n: number, d: number) {
return d === 0 ? 0 : Math.round((n / d) * 100);
}
function relTime(ts: string) {
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export default function MobileDashboard() { export default function MobileDashboard() {
const [data, setData] = useState<DashboardData | null>(null); const [data, setData] = useState<MobileDashboardResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const load = async () => { async function load() {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
const r = await fetch('/api/mobile/dashboard'); const r = await fetch('/api/mobile/dashboard');
if (!r.ok) throw new Error('Failed to load'); if (!r.ok) {
setData(await r.json()); const body = (await r.json().catch(() => ({}))) as { error?: string; message?: string };
} catch (e) { setError(String(e)); } throw new Error(body.message ?? body.error ?? `HTTP ${r.status}`);
finally { setLoading(false); } }
}; setData((await r.json()) as MobileDashboardResponse);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []); useEffect(() => { void load(); }, []);
if (loading) return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
if (error) return (
<div className="p-4 text-sm text-destructive">{error}</div>
);
if (!data) return null;
const respPct = pct(data.sla.response_met, data.sla.response_total);
const resPct = pct(data.sla.resolution_met, data.sla.resolution_total);
return ( return (
<div className="p-4 space-y-5"> <div className="p-4 space-y-5">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Ticket Dashboard</h1> <h1 className="text-xl font-bold">Dashboard</h1>
<button onClick={load} className="p-2 rounded-full hover:bg-accent"> <button
<RefreshCw className="w-4 h-4" /> type="button"
onClick={load}
disabled={loading}
aria-label="Refresh dashboard"
className="p-2 rounded-full hover:bg-accent disabled:opacity-40"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button> </button>
</div> </div>
{/* Open total */} {error && !loading && (
<div className="rounded-2xl border bg-primary/5 p-5 flex items-center gap-4"> <div className="rounded-xl border border-destructive/50 bg-destructive/5 p-4">
<div className="rounded-xl bg-primary/10 p-3"> <p className="text-sm font-medium text-destructive">Failed to load</p>
<AlertCircle className="w-7 h-7 text-primary" /> <p className="text-xs text-muted-foreground mt-1">{error}</p>
<button
type="button"
onClick={load}
className="mt-3 text-xs font-medium text-primary hover:underline"
>
Retry
</button>
</div> </div>
<div> )}
<p className="text-4xl font-bold">{data.open_total}</p>
<p className="text-sm text-muted-foreground">Open tickets</p>
</div>
</div>
{/* Priority breakdown */} {loading && !data && (
<div> <div className="flex items-center justify-center h-64">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">By Priority</p> <RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<div className="grid grid-cols-4 gap-2">
{data.by_priority.map(p => (
<div key={p.priority} className="rounded-xl border p-3 text-center">
<div className={`w-2 h-2 rounded-full mx-auto mb-1.5 ${PRIORITY_COLOR[p.priority] ?? 'bg-slate-400'}`} />
<p className={`text-xl font-bold ${PRIORITY_TEXT[p.priority] ?? ''}`}>{p.count}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{p.label}</p>
</div>
))}
</div> </div>
</div> )}
{/* SLA */} {data && (
<div> <>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">SLA last 30 days</p> {/* DASH-01: 2×2 KPI grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {data.kpis.map(kpi => (
{ label: 'First Response (≤1h)', met: data.sla.response_met, total: data.sla.response_total, p: respPct }, <KpiCardMobile
{ label: 'Resolution (≤24h)', met: data.sla.resolution_met, total: data.sla.resolution_total, p: resPct }, key={kpi.id}
].map(s => ( label={kpi.label}
<div key={s.label} className="rounded-xl border p-4"> value={kpi.value}
<p className={`text-2xl font-bold ${s.p >= 80 ? 'text-green-600' : s.p >= 60 ? 'text-yellow-600' : 'text-red-600'}`}> caption={kpi.caption}
{s.p}% tone={kpi.tone ?? 'default'}
</p> />
<p className="text-xs text-muted-foreground mt-0.5">{s.label}</p> ))}
<p className="text-[10px] text-muted-foreground">{s.met} / {s.total}</p> </div>
<div className="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${s.p >= 80 ? 'bg-green-500' : s.p >= 60 ? 'bg-yellow-400' : 'bg-red-500'}`}
style={{ width: `${s.p}%` }} />
</div>
</div>
))}
</div>
</div>
{/* By queue */} {/* DASH-02: Needs Attention horizontal strip */}
<div> <NeedsAttentionStrip
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">By Queue</p> items={data.needsAttention.map(a => ({
<div className="rounded-xl border divide-y overflow-hidden"> id: a.id,
{data.by_queue.map(q => ( label: a.label,
<div key={q.queue_id} className="flex items-center px-4 py-3 gap-3"> count: a.count,
<p className="flex-1 text-sm font-medium">{q.label}</p> href: a.href,
<div className="flex items-center gap-2"> }))}
<div className="w-24 h-1.5 rounded-full bg-muted overflow-hidden"> />
<div className="h-full rounded-full bg-primary"
style={{ width: `${pct(q.count, data.open_total)}%` }} />
</div>
<span className="text-sm font-bold w-8 text-right">{q.count}</span>
</div>
</div>
))}
</div>
</div>
{/* Recent activity */} {/* DASH-03: Worker/backup status row */}
<div> <WorkerStatusRow
<div className="flex items-center justify-between mb-2"> entries={data.workers.map(w => ({
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Recent Activity</p> id: w.id,
<Link href="/mobile/tickets" className="text-xs text-primary">View all</Link> label: w.label,
</div> value: w.value,
<div className="rounded-xl border divide-y overflow-hidden"> status: w.status,
{data.recent.map(t => ( href: w.href,
<Link key={t.id} href={`/mobile/tickets/${t.id}`} }))}
className="flex items-start gap-3 px-4 py-3 hover:bg-accent transition-colors"> />
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${PRIORITY_COLOR[t.priority] ?? 'bg-slate-400'}`} /> </>
<div className="flex-1 min-w-0"> )}
<p className="text-sm font-medium truncate">{t.title}</p>
<p className="text-xs text-muted-foreground">{t.company_name} · {t.queue_label ?? '—'}</p>
</div>
<div className="text-right shrink-0">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{t.last_activity_date ? relTime(t.last_activity_date) : '—'}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground ml-auto mt-1" />
</div>
</Link>
))}
</div>
</div>
</div> </div>
); );
} }

View file

@ -1,50 +1,37 @@
'use client'; 'use client';
import Link from 'next/link'; /* Mobile shell phase 02 (SHELL-01, SHELL-05).
import { usePathname } from 'next/navigation'; *
import { LayoutDashboard, Ticket, DollarSign, Menu } from 'lucide-react'; * Header: <HeaderBar /> (sticky, brand + Bell + avatar)
* Body: <main> (scrollable, padded so content clears the bottom nav)
* Foot: <BottomNav /> (fixed, 4 tabs + More)
* Drawer: <MoreDrawer /> opened from BOTH the header avatar and the More cell.
*
* The drawer's open state lives here so a single Sheet instance is shared
* between the two triggers no duplicate Sheets, no prop-drilling sagas. */
const NAV = [ import { useState } from 'react';
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard }, import { HeaderBar } from '@/components/mobile/HeaderBar';
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket }, import { BottomNav } from '@/components/mobile/BottomNav';
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign }, import { MoreDrawer } from '@/components/mobile/MoreDrawer';
];
export default function MobileLayout({ children }: { children: React.ReactNode }) { export default function MobileLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const [drawerOpen, setDrawerOpen] = useState(false);
return ( return (
<div className="flex flex-col min-h-screen bg-background max-w-lg mx-auto"> <div className="flex flex-col min-h-screen bg-background max-w-lg mx-auto">
{/* Top bar */} <HeaderBar onAvatarClick={() => setDrawerOpen(true)} />
<header className="sticky top-0 z-20 bg-background border-b px-4 py-3 flex items-center justify-between">
<Link href="/mobile" className="font-bold text-lg tracking-tight">Pulse</Link>
<Link href="/mobile/nav" className="p-1.5 rounded-lg hover:bg-accent transition-colors" aria-label="Navigation menu">
<Menu className="w-5 h-5" />
</Link>
</header>
{/* Page content */} {/* SHELL-05: scrollable content area; bottom padding = bottom-nav (h-16
<main className="flex-1 overflow-y-auto pb-20"> = 64px = pb-16) plus the device safe-area inset, so content never
hides under the bar. */}
<main className="flex-1 overflow-y-auto pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]">
{children} {children}
</main> </main>
{/* Bottom nav */} <BottomNav onMoreClick={() => setDrawerOpen(true)} />
<nav className="fixed bottom-0 left-0 right-0 z-20 border-t bg-background max-w-lg mx-auto">
<div className="flex"> <MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
{NAV.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
<Link key={href} href={href}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2.5 text-xs transition-colors
${active ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Icon className="w-5 h-5" />
<span>{label}</span>
</Link>
);
})}
</div>
</nav>
</div> </div>
); );
} }

View file

@ -1,91 +0,0 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
LayoutDashboard, Ticket, DollarSign, ArrowLeft,
ExternalLink, Settings, LogOut, ChevronRight,
FileText, Server, HardDrive, Users, BarChart3,
} from 'lucide-react';
import { signOut } from '@/lib/auth-client';
const MAIN_SECTIONS = [
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard, description: 'Overview & stats', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400' },
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket, description: 'Open service tickets', color: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' },
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign, description: 'AR, invoices & payments', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400' },
];
const DESKTOP_LINKS = [
{ href: '/quotes', label: 'Quotes', icon: FileText },
{ href: '/configuration-items', label: 'Configuration Items', icon: Server },
{ href: '/backup-status', label: 'Backup Status', icon: HardDrive },
{ href: '/engagement', label: 'Engagement', icon: Users },
{ href: '/admin/ticket-digest', label: 'Ticket Digest', icon: BarChart3 },
{ href: '/admin/sync', label: 'Admin / Sync', icon: Settings },
];
export default function MobileNav() {
const router = useRouter();
return (
<div className="p-4 space-y-6 pb-24">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => router.back()}
className="p-2 rounded-xl hover:bg-accent transition-colors"
aria-label="Go back"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-bold">Navigation</h1>
</div>
{/* Main mobile sections */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Mobile Views</p>
<div className="grid grid-cols-1 gap-2">
{MAIN_SECTIONS.map(({ href, label, icon: Icon, description, color }) => (
<Link key={href} href={href}
className="flex items-center gap-4 p-4 rounded-2xl border bg-card hover:bg-accent transition-colors active:scale-[0.98]">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${color.split(' ')[0]}`}>
<Icon className={`w-5 h-5 ${color.split(' ').slice(1).join(' ')}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
{/* Desktop links */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Full Site</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href}
className="flex items-center gap-3 px-4 py-3.5 hover:bg-accent transition-colors active:bg-accent">
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{label}</span>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
{/* Sign out */}
<div className="rounded-2xl border overflow-hidden">
<button
onClick={() => signOut().then(() => router.push('/auth/sign-in'))}
className="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-destructive/10 text-destructive transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
<span className="text-sm">Sign out</span>
</button>
</div>
</div>
);
}

View file

@ -139,6 +139,29 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
/* === Safe-area insets =================================================
*
* Opt-in padding helpers for sticky top / fixed bottom bars on devices
* with notches, dynamic islands, or gesture home indicators. Pair with
* the viewport-fit=cover viewport meta (set in app/layout.tsx) without
* that, env(safe-area-inset-*) resolves to 0 and these utilities are
* no-ops, which is the desired fallback on non-PWA / non-mobile contexts.
*
* Usage:
* <header class="sticky top-0 pt-safe ..."> // header clears notch
* <nav class="fixed bottom-0 pb-safe ..."> // bottom bar clears home bar
*
* Closes PWA-04 (REQUIREMENTS.md) and ROADMAP Phase 1 SC #3.
* ==================================================================== */
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
/* === Wolf-mark watermark ============================================ /* === Wolf-mark watermark ============================================
* *
* Apply .has-mark-watermark to a positioned container; place a child * Apply .has-mark-watermark to a positioned container; place a child

View file

@ -0,0 +1,75 @@
'use client';
/* BottomNav phase 02 (SHELL-06, NAV-01..03, DRAWER-01).
*
* Fixed bottom bar with five cells:
* - Dashboard (LayoutDashboard) -> /mobile/dashboard
* - Tickets (Ticket) -> /mobile/tickets
* - Finance (DollarSign) -> /mobile/finance
* - Analyzer (Sparkles) -> /mobile/analyzer
* - More (Menu) -> opens the MoreDrawer (parent state)
*
* Active tab detected via pathname.startsWith(href). Active = text-primary,
* inactive = text-muted-foreground. */
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Ticket,
DollarSign,
Sparkles,
Menu,
} from 'lucide-react';
const TABS = [
{ href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/mobile/tickets', label: 'Tickets', icon: Ticket },
{ href: '/mobile/finance', label: 'Finance', icon: DollarSign },
{ href: '/mobile/analyzer', label: 'Analyzer', icon: Sparkles },
] as const;
interface BottomNavProps {
onMoreClick: () => void;
}
export function BottomNav({ onMoreClick }: BottomNavProps) {
const pathname = usePathname();
return (
<nav
aria-label="Primary"
className="fixed bottom-0 left-0 right-0 z-30 border-t bg-background pb-safe"
>
<div className="max-w-lg mx-auto flex h-16">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname?.startsWith(href) ?? false;
return (
<Link
key={href}
href={href}
aria-current={active ? 'page' : undefined}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
active
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-5 h-5" aria-hidden="true" />
<span>{label}</span>
</Link>
);
})}
<button
type="button"
onClick={onMoreClick}
aria-label="Open menu"
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Menu className="w-5 h-5" aria-hidden="true" />
<span>More</span>
</button>
</div>
</nav>
);
}

View file

@ -0,0 +1,72 @@
'use client';
/* HeaderBar phase 02 (SHELL-02..04).
*
* Sticky top bar inside the /mobile shell. Three slots:
* left: WulfMark + "Pulse" wordmark, linked to /mobile/dashboard
* right: Bell icon button (placeholder, aria-label="Notifications")
* right: compact avatar circle opens the More drawer (parent owns state)
*
* No page title in the header pages render their own H1. */
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { WulfMark } from '@/components/branding/wulf-mark';
import { useSession } from '@/lib/auth-client';
interface HeaderBarProps {
onAvatarClick: () => void;
}
export function HeaderBar({ onAvatarClick }: HeaderBarProps) {
const { data: session } = useSession();
const user = session?.user as
| { name?: string; email?: string }
| undefined;
const initials = (user?.name ?? user?.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
return (
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-b pt-safe">
<div className="flex items-center justify-between px-4 h-14">
{/* Left: brand mark + wordmark, linked to /mobile/dashboard */}
<Link
href="/mobile/dashboard"
className="flex items-center gap-2 -ml-1 px-1 rounded-md hover:bg-accent/50 transition-colors"
aria-label="Pulse — go to Dashboard"
>
<WulfMark variant="mark" className="h-6 w-auto" />
<span className="font-bold text-base tracking-tight">Pulse</span>
</Link>
{/* Right: Bell placeholder, then avatar trigger */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => { /* SHELL-03: placeholder — no menu, no badge */ }}
aria-label="Notifications"
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Bell className="h-5 w-5" />
</button>
<button
type="button"
onClick={onAvatarClick}
aria-label="Open menu"
className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/15 text-primary text-[11px] font-semibold">
{initials}
</span>
</button>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,39 @@
'use client';
/* KpiCardMobile phase 03 (DASH-01).
*
* Phone-sized KPI card for the 2×2 dashboard grid. Renders a label,
* a large numeric value, and an optional caption. tone="attention"
* adds a left-edge destructive border for SLA breaches > 0.
*
* Pure presentational no fetch, no state. Parent provides values. */
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export type KpiTone = 'default' | 'attention';
interface KpiCardMobileProps {
label: string;
value: number | string;
caption?: string;
tone?: KpiTone;
}
const TONE_BORDER: Record<KpiTone, string> = {
default: 'border-l-transparent',
attention: 'border-l-destructive',
};
export function KpiCardMobile({ label, value, caption, tone = 'default' }: KpiCardMobileProps) {
const display = typeof value === 'number' ? value.toLocaleString() : value;
return (
<Card className={cn('h-full border-l-2', TONE_BORDER[tone])}>
<CardContent className="p-4 flex flex-col gap-1">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</p>
<p className="text-3xl font-bold tabular-nums">{display}</p>
{caption && <p className="text-xs text-muted-foreground">{caption}</p>}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,166 @@
'use client';
/* MoreDrawer phase 02 (DRAWER-01..05).
*
* shadcn Sheet (side="right") with three top-to-bottom sections:
* 1. Mobile sections Engagement (in-shell route, no ExternalLink hint)
* 2. Full site desktop-only routes, each with ExternalLink hint
* 3. Account current user (read-only) + Sign out
*
* Open state is controlled by the parent so the header avatar AND the
* bottom-nav More cell can both trigger this single drawer. */
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ExternalLink,
FileText,
Server,
HardDrive,
BarChart3,
Settings,
Users,
LogOut,
} from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetClose,
} from '@/components/ui/sheet';
import { useSession, signOut } from '@/lib/auth-client';
import { toast } from 'sonner';
const MOBILE_SECTIONS = [
{ href: '/mobile/engagement', label: 'Engagement', icon: Users },
];
const DESKTOP_LINKS = [
{ href: '/quotes', label: 'Quotes', icon: FileText },
{ href: '/configuration-items', label: 'Configuration Items', icon: Server },
{ href: '/backup-status', label: 'Backup Status', icon: HardDrive },
{ href: '/admin/ticket-digest', label: 'Ticket Digest', icon: BarChart3 },
{ href: '/admin/sync', label: 'Admin / Sync', icon: Settings },
];
interface MoreDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MoreDrawer({ open, onOpenChange }: MoreDrawerProps) {
const router = useRouter();
const { data: session } = useSession();
const user = session?.user as
| { name?: string; email?: string; image?: string | null }
| undefined;
const initials = (user?.name ?? user?.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
async function handleSignOut() {
try {
await signOut();
router.push('/auth/sign-in');
} catch (e) {
toast.error('Sign out failed');
console.error('Sign out failed:', e);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-80 sm:max-w-sm flex flex-col">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
<SheetDescription className="sr-only">
Navigation, full-site links, and account actions.
</SheetDescription>
</SheetHeader>
{/* Section 1: Mobile sections (DRAWER-03) */}
<div className="px-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Mobile sections
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{MOBILE_SECTIONS.map(({ href, label, icon: Icon }) => (
<SheetClose asChild key={href}>
<Link
href={href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{label}</span>
</Link>
</SheetClose>
))}
</div>
</div>
{/* Section 2: Full site (DRAWER-04) */}
<div className="px-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Full site
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
<SheetClose asChild key={href}>
<Link
href={href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{label}</span>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Link>
</SheetClose>
))}
</div>
</div>
{/* Section 3: Account (DRAWER-05) */}
<div className="px-4 mt-auto pb-safe">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Account
</p>
<div className="rounded-2xl border overflow-hidden">
{user && (
<div className="flex items-center gap-3 px-4 py-3 border-b">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold shrink-0">
{initials}
</span>
<div className="flex-1 min-w-0">
{user.name && (
<p className="text-sm font-medium leading-tight truncate">
{user.name}
</p>
)}
{user.email && (
<p className="text-xs text-muted-foreground truncate" title={user.email}>
{user.email}
</p>
)}
</div>
</div>
)}
<button
type="button"
onClick={handleSignOut}
className="w-full flex items-center gap-3 px-4 py-3 text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
<span className="text-sm">Sign out</span>
</button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,51 @@
'use client';
/* NeedsAttentionStrip phase 03 (DASH-02).
*
* Horizontal-scroll strip of compact attention cards. Each card shows a
* count + label and is a next/link to the destination view. The strip
* uses native horizontal overflow with snap-x for momentum scroll on
* iOS/Android. Renders nothing when items=[]. */
import Link from 'next/link';
import { AlertTriangle, ChevronRight } from 'lucide-react';
export interface NeedsAttentionItem {
id: string;
label: string;
count: number;
href: string;
}
interface NeedsAttentionStripProps {
items: NeedsAttentionItem[];
}
export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps) {
if (items.length === 0) return null;
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Needs attention
</p>
<div className="-mx-4 px-4 flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1">
{items.map(item => (
<Link
key={item.id}
href={item.href}
className="snap-start shrink-0 w-44 rounded-2xl border bg-card p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start justify-between">
<AlertTriangle className={`w-4 h-4 ${item.count > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
<p className={`mt-2 text-2xl font-bold tabular-nums ${item.count > 0 ? 'text-destructive' : ''}`}>
{item.count}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
'use client';
/* WorkerStatusRow phase 03 (DASH-03).
*
* Compact 3-cell read-only status row showing analyzer worker, RMM worker,
* and backup success rate. Each cell is a next/link to the corresponding
* desktop admin page. status='ok' = emerald dot, 'warn' = amber, 'down' =
* destructive. */
import Link from 'next/link';
import { ExternalLink } from 'lucide-react';
export type WorkerStatus = 'ok' | 'warn' | 'down';
export interface WorkerStatusEntry {
id: string;
label: string;
value: string;
status: WorkerStatus;
href: string;
}
interface WorkerStatusRowProps {
entries: WorkerStatusEntry[];
}
const DOT_COLOR: Record<WorkerStatus, string> = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
down: 'bg-destructive',
};
export function WorkerStatusRow({ entries }: WorkerStatusRowProps) {
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Workers &amp; backups
</p>
<div className="rounded-2xl border divide-y overflow-hidden">
{entries.map(e => (
<Link
key={e.id}
href={e.href}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
>
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${DOT_COLOR[e.status]}`} aria-hidden="true" />
<span className="text-sm font-medium flex-1">{e.label}</span>
<span className="text-sm tabular-nums text-muted-foreground">{e.value}</span>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
);
}

31
public/manifest.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "Pulse",
"short_name": "Pulse",
"description": "Wulf Consulting operations console — tickets, RMM, backups, and analytics on the go.",
"start_url": "/mobile",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#0075AD",
"background_color": "#FFFFFF",
"icons": [
{
"src": "/wulff-logo.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/branding/wulf-mark.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
}
]
}