21 KiB
21 KiB
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.jsonexists 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.tsxvia<link rel="manifest"> - PWA-03: Viewport meta in
app/layout.tsxincludesviewport-fit=cover - PWA-04: Header and bottom tab bar respect
env(safe-area-inset-top)andenv(safe-area-inset-bottom)(Tailwind arbitrary values or shared utility class)
Shell — /mobile layout (spec §5)
- SHELL-01: New
app/mobile/layout.tsxreplaces 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 —
Bellicon button (placeholder, no menu/badge,aria-label="Notifications", emptyonClick, keyboard-accessible) - SHELL-04: Header right slot — compact user avatar (
h-7 w-7); tapping opens the More drawer - 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-autowrapper, 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 usestext-muted-foreground; active detection viapathname.startsWith(href)
DRAWER — More Sheet drawer (spec §3.2)
- DRAWER-01: Fifth bottom-bar control labeled "More" with
Menuicon opens a shadcnSheet - DRAWER-02: Sheet uses a single consistent side (
rightorbottom) — 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
ExternalLinkicon - DRAWER-05: Drawer bottom section "Account" shows current user (avatar + email, read-only) and a Sign out action that calls
signOut()thenrouter.push('/auth/sign-in') - DRAWER-06:
app/mobile/nav/page.tsxis 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 (
Collapsiblefrom 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/analyzerroute 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 fromanalyzer_analyses
ENG — Mobile Engagement (spec §6.5) — NEW PAGES
- ENG-01:
/mobile/engagementoverview 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
TZ — User Timezone (Phase 7.1, urgent insertion)
- TZ-01: Better Auth users table extended with a
timezonefield (IANA string). Default for users with no value =process.env.DEFAULT_TIMEZONE || 'UTC'. Existing rows backfill to the default; UTC remains the storage timezone for all date columns - TZ-02: Date math that powers dashboards, ticket filters, finance views, and engagement period selectors computes day/week boundaries against the viewing user's
timezone— NOT UTC, NOT the browser's local zone (browsers may differ from the user's chosen tz, e.g. travel) (Exception:engagement_snapshots-derived metrics — active users D7/D30/D90, MS Graph hours — bucket by UTC at sync time and remain UTC-bucketed in this phase; ≤24h drift is accepted because engagement is an admin-overview surface, not an operational date display. Per-user snapshot bucketing is deferred to a future phase if needed.) - TZ-03:
GET /api/me/timezone(auth required) returns{ timezone, source: 'user' | 'default' }.PUT /api/me/timezoneaccepts{ timezone }, validates againstIntl.supportedValuesOf('timeZone'), persists to the user row, returns the new value - TZ-04: Shared client hook
useUserTimezone()reads tz fromuseSession()(Better Auth additionalField). All date-formatting and range-bucketing in mobile + desktop pages goes through this hook — no scatteredIntl.DateTimeFormatinstantiations with hardcoded zones
PROF — Profile page surface (Phase 9)
- PROF-01:
/mobile/profileexists as a real page (not a modal), gated byrequireAuth(), accessible from the More drawer - PROF-02: Page renders four sections in this order, each a shadcn
Card: Timezone, Theme, Notifications, Channels - PROF-03: Per-section save model — switch/select toggles save on change (debounced 400ms for the notifications matrix); text inputs save on blur or via an explicit Save button next to the input. Sonner toast confirms each save; errors render inline and do NOT optimistically update
- PROF-04:
MoreDrawer.tsxAccount section gains a "Profile & preferences" link (routes to/mobile/profile) above the Sign-out destructive action; the existing user-identity row becomes the tappable link
TZ-CHOOSER — Timezone chooser UI (Phase 9)
- TZ-CHOOSER-01: Profile Timezone section uses a shadcn
Combobox(orSelectif list overflows) populated fromIntl.supportedValuesOf('timeZone')plus theEXTRA_ALLOWED_TIMEZONESconstants (UTC,Etc/UTC,GMT,Etc/GMT); writes via the existingPUT /api/me/timezoneendpoint unchanged - TZ-CHOOSER-02: Currently-rendering time displayed alongside as read-only (e.g., "Your current time: 2026-05-09 14:32 in America/Chicago"), formatted via
useUserTimezone()
THEME — Theme persistence (Phase 9)
- THEME-01:
theme TEXT NOT NULL DEFAULT 'system'column added tousertable (allowed values:'light'|'dark'|'system'); existing rows backfilled to'system'; exposed via Better AuthadditionalFieldssosession.user.themeis available the same waysession.user.timezoneis today - THEME-02:
GET /api/me/theme(auth required) returns{ theme, source: 'user' | 'default' }.PUT /api/me/themeaccepts{ theme }, validates against the three-string allowlist, writes session.user.id only, returns the new value - THEME-03: On session load and after sign-in, a client-side effect compares
session.user.themeagainstuseTheme()and callssetTheme(session.user.theme)if different — server is canonical; brief flash on auth boundary is acceptable - THEME-04: Existing desktop
ThemeToggle(components/theme-toggle.tsx) becomes session-aware: thesetTheme()callback also issuesPUT /api/me/themeso the desktop affordance writes through to the server - THEME-05: Default value for users who never visit the settings page is
'system'; next-themes handles OS-preference detection at render time — zero behavior change for existing signed-in users
CHAN — Personal notification channels (Phase 9)
- CHAN-01:
notification_channels.owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADEcolumn added;NULL= global channel (existing rows),NOT NULL= personal channel - CHAN-02: A user has at most one personal Teams channel and one personal ntfy channel at a time. The constraint is enforced in the API layer (UPSERT keyed by
(owner_user_id, channel_type)) rather than via a partial-unique index - CHAN-03: ntfy topic is Pulse-minted on first save — API generates a UUID-prefixed topic (e.g.,
pulse-7f3a9c2b) and returns it. UI surfaces the subscribe link (https://ntfy.sh/<topic>) and a QR code so the user can subscribe in their ntfy app. Power users can override via an "Edit advanced" disclosure with a custom topic string - CHAN-04: Teams webhook URL is user-supplied free text, validated as an
https://URL whose host matches*.webhook.office.comor*.logic.azure.com - CHAN-05: On save (Teams URL or first ntfy mint) the API issues a single best-effort test send ("Pulse channel verified — you can ignore this message."). The save itself succeeds even if the test fails; the test result (success / HTTP status / error message) surfaces inline next to the input
- CHAN-06: Admins (role
adminorsuper-admin) can read AND edit any user's personal channels via/admin/workflow/channels(extended with an Owner column + filter). Non-admin users only see/edit their own personal channels - CHAN-07:
/api/me/channelsexposes the user's personal channels —GETreturns the configured set,PUT /api/me/channels/teamsandPUT /api/me/channels/ntfyupsert the row,DELETE /api/me/channels/{type}removes one,POST /api/me/channels/{type}/testissues a test send. All routes auth-gated tosession.user.id
SUB — Per-event notification subscriptions (Phase 9)
- SUB-01: New table
notify_event_keys(key TEXT PRIMARY KEY,display_label TEXT NOT NULL,description TEXT,sort_order INTEGER DEFAULT 0,is_active BOOLEAN DEFAULT true,created_at TIMESTAMP DEFAULT NOW()); admins manage it at a new/admin/workflow/event-keyspage (CRUD on label/description/sort/active). Keys not in the lookup are still routable — the lookup is a humanization layer, not a gate - SUB-02: New table
user_event_subscriptions(user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,event_key TEXT NOT NULL,channel_type VARCHAR(20) NOT NULL,enabled BOOLEAN NOT NULL DEFAULT true,updated_at TIMESTAMP NOT NULL DEFAULT NOW(),PRIMARY KEY (user_id, event_key, channel_type)). Row absence = default enabled (opt-out model) - SUB-03: Profile Notifications section renders a matrix — rows = active
notify_event_keys, columns = personal channel types the user has configured. When only one personal channel is configured, the matrix collapses to a single Channel column. New event keys go live for everyone immediately (default enabled) - SUB-04:
/api/me/notification-subscriptions—GETreturns the full matrix for the calling user (joining active event keys with stored rows, defaulting missing rows to enabled),PUTwrites a single row. Auth-gated tosession.user.id
ROUTE — notify.ts per-user routing (Phase 9)
- ROUTE-01: The
notifystep config gains an optionalroute_to_userblock:{ source, field, resolve, event_key, channel_type? }. When absent, behavior is unchanged (backward compatible). When present, it is attempted before falling back to the step's existingchannel_id - ROUTE-02: Resolvers shipped in v1 —
autotask_resource_email(joinsresources.emailfrom a resource ID),direct_email(the field IS already an email),pulse_user_id(the field IS already a Pulseuser.id). Resolvers live inlib/services/pipeline-steps/notify-resolvers.tsand are registered in aMap<string, Resolver>so adding a resolver is a one-file change - ROUTE-03: notify.ts user-route order when
route_to_useris present: readcontext[source][field]→ call resolver → look up Pulse user → checkuser_event_subscriptions(event_key, channel_type)→ look up personal channel → send. Each branch can short-circuit per the muting / fallback rules below - ROUTE-04: When the user route can't deliver (no personal channel of the requested type, or send returned non-2xx), notify.ts falls back to the step's
channel_id(global) and recordsoutput.user_route_fallback = { reason: 'no_channel' | 'send_failed' | 'user_not_found', user_id?, channel_type, error? }on the execution-step row. Step still returnssuccess: true(the fallback succeeded) - ROUTE-05: When the user has the relevant
(event_key, channel_type)toggle DISABLED, notify.ts recordsoutput.skipped_reason = 'user_muted'and does NOT fall back to global — muting must actually mute. Step returnssuccess: true(intended skip) - ROUTE-06: When
channel_typeis omitted inroute_to_user, notify.ts attemptsntfyfirst, thenteams, then global fallback — favoring push semantics for managers on mobile - ROUTE-07:
/admin/workflow/executionspage gains a filter "Show executions that fell back to global" — a one-line UI addition that surfacesuser_route_fallbackevents so admins can repair broken personal channels
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-autowrapper 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) |
Per-user-tz engagement_snapshots bucketing (TZ-02 carve-out) |
Engagement metrics derived from engagement_snapshots (active users D7/D30/D90, MS Graph hours) are bucketed by UTC at sync time. Per-user re-bucketing would require either per-request re-bucket (expensive) or per-user snapshot rebuild (doubles storage). ≤24h drift accepted on this admin-overview surface. May revisit in a future phase. |
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 | Complete |
| FIN-02 | Phase 5 | Complete |
| 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
- Phase 9 (User Profile & Preferences): 31 requirements (PROF-01..04, TZ-CHOOSER-01..02, THEME-01..05, CHAN-01..07, SUB-01..04, ROUTE-01..07)
Requirements defined: 2026-05-03
Last updated: 2026-05-09 — Phase 9 requirements added (PROF, TZ-CHOOSER, THEME, CHAN, SUB, ROUTE) after /gsd-discuss-phase 9