The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.
The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.
Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
Both buttons in /admin/users were broken:
- "Send Reset Email" called a no-op stub in
src/actions/admin/users.ts that always returned an error.
- "Reset Password" called resetAdminPassword with a hard-coded
'Tuxedo2026!' password, and the function itself queried a
non-existent `users` table and called a non-existent
`update_user_password` RPC (leftover Supabase-era code).
Rewritten against Neon Auth:
- sendPasswordResetEmail(email) — platform_admin-only action that
calls auth.requestPasswordReset with the configured
NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
a clear success/error.
- resetAdminPassword(email) — platform_admin-only action that:
1. Looks up neon_auth.user by email
2. Generates a strong server-side random temp password
3. Tries auth.admin.setUserPassword first (instant credential,
returned to the UI for the platform admin to share)
4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
case — provision-admin.ts does not promote callers to
role='admin' in Neon Auth), falls back to
auth.requestPasswordReset, which sends a reset link the user
can click to set their own password.
5. On the privileged path, flips admin_users.must_change_password
so the user is forced to pick a real password on next sign-in.
UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.
Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.
Switch the public return type to a structured result:
export type EmailSendResult = { ok: true } | { ok: false; error: string };
- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
Add it to .env.local (or your hosting dashboard) and restart the
server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.
Callers updated:
- src/actions/admin/users.ts (createAdminUser): now propagates the
real emailError into the modal UI — no more generic 'sendWelcome
Email returned false'.
- src/actions/checkout.ts: logs the real error.
- src/app/api/cron/send-scheduled/route.ts: per-recipient error
logged for the scheduled-campaigns cron.
Tests:
- tests/unit/email-service.test.ts (new, 5 tests): covers happy
path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
body, and network failure.
- tests/unit/create-admin-user.test.ts: updated to use the new
{ ok, error? } return shape; new assertion that the action
surfaces the real emailError string into the result.
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:
1. User clicks the Google button.
2. Client calls the signInWithGoogleAction server action.
3. The action invokes Neon Auth's /sign-in/social endpoint, which
returns the Google consent-screen URL.
4. Client navigates the browser to that URL.
5. Google redirects back through Neon Auth to the callbackURL.
Files:
- src/actions/auth-actions.ts: new signInWithGoogleAction server
action that wraps signIn.social and returns the redirect URL
to the client. Structured { url, error } return — never throws.
- src/app/login/LoginClient.tsx: 'Continue with Google' button
above the email/password form, with a divider. Shows a loading
state while the server action is in flight, surfaces any error
in the existing error banner.
- src/app/wholesale/login/page.tsx: same button for wholesale
buyers. Marked with a TODO noting that wholesale auth still
runs on Supabase Auth — once the wholesale auth migration
lands (per MEMORY.md 'What's left'), the button will start
working for them. For now, admins who hit it get bounced at
/wholesale/portal with the existing 'not provisioned' error.
- tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
the happy path, defaults, custom callbacks, Neon Auth error
responses, missing URL, and unexpected exceptions.
Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.
This change makes the create flow:
1. Authorize: only platform_admin can mint new admin users.
2. Create the Neon Auth user via auth.admin.createUser, falling back
to the public /sign-up/email + emailVerified=true pattern when
the caller's Neon Auth session isn't an admin (the common case
in dev — provision-admin.ts does not set neon_auth.user.role).
3. Wrap the admin_users INSERT + admin_user_brands link in a
transaction, returning an error if the local insert fails (the
Neon Auth user is left orphaned and surfaced in the message).
4. Send the welcome email best-effort, returning success/failure
info to the UI.
The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.
10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
The create-user action in src/actions/admin/users.ts was written
against a richer admin_users schema than the one that landed in
0001_init.sql. The 0001 schema has just `name` plus the role-derived
can_manage_<X> columns; the action also references display_name,
phone_number, brand_id, can_manage_pickup/messages/refunds/users,
active, must_change_password, auth_provider, auth_subject, and
last_login. Result: 'column "display_name" of relation
"admin_users" does not exist' on any create-user submit.
Migration 0043 adds the missing columns with sensible defaults
(ADD COLUMN IF NOT EXISTS, so re-runnable). The four extra
can_manage_* flags are vestigial — getAdminUser() in
lib/admin-permissions.ts derives per-user permissions from the role
via permissionsForRole() and never reads them — but they exist so
the create-user form's per-user toggles persist, and the migration
header documents the cleanup target.
The db/schema/brands.ts Drizzle adminUsers definition is extended
in lockstep so the inferred TS types stay in sync with the table.
brand_id is left as a denormalized column (the link table
admin_user_brands is the source of truth and is what
getAdminUser() reads); getAdminUsers() in the action joins on
au.brand_id so keeping the column avoids rewriting the SELECT.
Gitea CI runs scripts/migrate.js before the build, so this lands
on prod automatically on the next push.
- page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon
(matches the pattern used by /admin/orders, /admin/stops, etc.)
- ShippingFulfillmentPanel.tsx: rewrite to use design system components
- AdminFilterTabs for the status filter pills
- AdminSearchInput for the name/phone/order search
- AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards
- AdminEmptyState for the no-orders view
- GlassModal for the FedEx rate modal (was a raw dark modal)
- Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with
the cream + botanical-green + amber-accent tokens so it reads as part
of the same admin shell as the rest of the app.
The /admin/shipping page was 500-ing with 'column c.name does not
exist' because the getShippingOrders query in src/actions/shipping.ts
was still using legacy column names on the new-schema customers
table (name, email, phone) — the actual columns are first_name,
last_name, primary_email, primary_phone.
The dashboard and analytics pages were logging the same kind of
errors but catching them, so the UI just showed zeroed stats instead
of crashing. Same fix:
- customers: name → first_name || ' ' || last_name
- customers: email → primary_email
- customers: phone → primary_phone
- orders: subtotal → total_cents / 100
- orders: created_at → placed_at
- orders: customer_name → join customers and concat first/last
These are the same kind of fixes that landed in migration 0041 for
the command-center RPCs. The application-layer queries just hadn't
been updated.
The <ViewTransition name="page-content"> wrapper around the admin
main content was triggering a 'shared element' morph on every page
load and navigation: the browser tries to animate the bounding box
of the named element across the old and new pages, and since each
admin page has a different height, the browser was scaling the
snapshot from the top-left corner. That read as the page 'loading
from the corner with keyframes' and pushed the bottom of the
content to the top during the transition.
Setting update="none" opts out of the named transition entirely.
The children just swap on navigation — no fade, no scale, no morph.
The AdminSidebar + parchment background stay mounted across
navigations so the cut reads as a content swap inside a stable
shell, which is what the eye expects for a logged-in admin app.
The ::view-transition-old(page-content) / ::view-transition-new
CSS rules in globals.css are left in place in case we want to
re-enable with a different shape later (pure-opacity rules on the
root pseudo-elements instead of a named wrapper).
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.
Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)
Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
- orders.created_at → use orders.placed_at
- orders.subtotal → use orders.total_cents / 100
- stops.date regex → column is already DATE, not TEXT, drop the
~ '^\d{4}-...$' check
Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.
The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.
Verified on prod:
METRICS: { orders_today:0, active_brands:1, active_routes:538, ... }
BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
ACTIVITY: 0 rows (operational_events has no brand_id in payload, no error)
Aesthetic shift from generic dark glassmorphism to an industrial
mission-control look: phosphor amber on near-black, Major Mono Display
for the wordmark, JetBrains Mono for all labels/numbers/codes, Inter
Tight for body copy.
- Top status bar with live clock, pulsing LIVE LED, and scrolling brand
ticker (plan tiers + slugs)
- Serial-numbered sections (// 01-07) with CAD-style corner crosshairs
and scale-rule dividers
- 6 KPIs in a flush 1px-separated grid with tabular numerals, sparklines,
and per-cell M.0N serials
- AI briefing as a numbered readout with model metadata sidebar
- Brand health panels with severity LEDs, vertical dividers between
metrics, and open-pain indicators
- Activity feed as terminal-style rows with color-coded event codes
- Pain log items with severity LEDs and P.NNN serials
- Quick Access links with L.NN prefixes and slide-on-hover
- Loading state replaced with terminal-style blinking bar + 'BOOT
SEQUENCE' eyebrow
- 'Connection Lost' replaced with 'SIGNAL LOST · Platform Disconnected'
panel that surfaces the actual error, reconnect action, and a
diagnostic hint footer
- prefers-reduced-motion respected across ticker, LED pulse, and
boot-reveal animations
- TV Mode scales up the title, KPIs, and briefing for wall display
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).
Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
Follow-up to 9fcc514. The global motion pass hit the public site;
this targets the admin design system, which had its own animation
budget that was still producing positional movement on every
interaction.
Killed:
- TabSwitcher y:4 slide (every tab change in /admin)
- CommandPalette scale(0.98) panel pop
- ha-drawer-in: 100% translateX (full-screen sweep)
- ha-popover-in: translateY(-4px) + scale(0.98)
- ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot
- slide-in-from-right: 100% translateX (toasts)
- dashboard-fade-up: 10px Y slide on every card
- tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel)
Calmed:
- All durations cut to 120-200ms range with ease-out
- All keyframes that survived are opacity-only
Added a @media (prefers-reduced-motion: reduce) block scoped to
the admin selectors so users with that OS setting see instant
transitions across toasts/drawers/popovers/dashboard cards.
The site had a lot of aggressive motion that compounded into a
vertigo-inducing experience. Visual design (colors, type, layout) is
unchanged — only the movement has been calmed.
Single-commit overview:
- Route transitions: 90/140ms pure-opacity crossfade (was 220ms with
4-6px Y-shift on enter/exit).
- atelier-* modal animations: 180ms opacity-only, 4px max (was 420ms
with 20px slide + scale(0.96→1) and 80-440ms cumulative stagger).
- Hover transforms: -1px lift or no lift (was -2px + scale(0.98)).
- CTA shimmer: 400ms (was 700ms).
- Toggle thumb: ease-out (was cubic-bezier(0.34, 1.56, 0.64, 1) bouncy
overshoot).
- GSAP ScrollAnimations: capped at 12-16px translation, 320ms duration,
power1.out, reduced-motion guards at the top of every effect.
ParallaxLayer no longer scrolls content; only the data-parallax
attribute can opt in, and only to 24px.
- TuxedoVideoHero: killed 80px scroll-driven Y-shift on hero, killed
video 1.15 scale-on-scroll, killed parallax-float scroll effect, cut
hero-reveal to 8px/320ms (was 40px/1s/power3.out), removed the
motion.scale on the logo and CTA buttons, slowed the bouncing
scroll indicator from 1.5s to 2.4s.
- CinematicShowcase: killed morphing product cards (rotateY ±5°,
scale 0.95→1.02), killed parallax background HARVEST text (-100px),
killed translateX carousel, killed scale(0.9→1) back.out(1.4) reveal
in favor of opacity-only 8px/320ms entrance, removed progress-dot
scale, removed progress-bar transition lag.
- OnboardingFlow: removed scale(0.9→1) and y:20→0 entrance animations.
- Global MotionConfig: caps every framer-motion animation in the tree
to 0.2s easeOut, and sets reducedMotion='user' so framer-motion
automatically strips x/y/scale/rotate from all 71 motion.div reveals
across the public site when the OS prefers-reduced-motion is set.
- globals.css prefers-reduced-motion block: comprehensive kill switch
that disables animation/transition duration app-wide, wipes the
route view-transition, and clears the .parallax-float / .hero-reveal
transforms.
How to test:
- Default: motion is calmer, ~10x faster, with no parallax
- OS-level 'reduce motion' on: zero positional movement, opacity fades
only.
Files changed: 7 (no new files)
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA
- New/Edit pages: PageHeader with eyebrow, icon, status badge (edit)
- ProductsClient: EmptyState for zero products, AdminBadge for status,
Fragment Mono on stats, token-based colors throughout
- ProductTableBody: AdminBadge tones for status/taxable, token-based
hover/selected states, Fragment Mono on price column
- NewProductForm / ProductEditForm / ProductFormModal: surrounding
chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost
- .atelier-* body preserved as editorial treatment
- Add PageHeader + Operations eyebrow on list page
- Detail page: PageHeader with order-id/date eyebrow, customer title,
total/status subtitle, AdminBadge tone for pickup + payment processor
- AdminOrdersPanel: KPIStat for stat cards, EmptyState with Create your
first order CTA, AdminBadge tone for status pills, lucide-react icons,
all hardcoded Tailwind colors replaced with var(--admin-*) tokens
- OrderTableBody: AdminBadge tone for status + pickup pills, all hardcoded
colors replaced with tokens
- OrderEditForm: ha-field-label + ha-field-input / ha-field-textarea classes
for form fields, ha-segment control for status buttons, semantic pickup
toggle with primary/warning tokens, all hardcoded colors replaced
Behavior preserved end-to-end. Type-check clean.
- KPI strip uses new <KPIStat> component (4 cards)
- New 'What needs attention' feed replaces 4 tabs of cards:
* no orders today → suggest campaign
* pending stops → link to stops
* usage > 85% → warn
* starter plan → upgrade prompt
- Quick actions + plan usage consolidated into one card
- Recent orders uses new <EmptyState>
- All 15 sections in a single grid (not 4 tabs), with 'All / Operations / Fulfillment / Management / Tools' filter
- 'Press ⌘K to search' hint under section grid
- All inline SVGs replaced with lucide-react
- All hardcoded colors replaced with new design tokens
- mount <CommandPalette /> in admin layout (Cmd+K)
- fetch getEnabledAddons(activeBrandId) for sidebar gating
- pass enabledAddons to AdminSidebar so Water Log / Route Trace hide when off
Phase 2 pattern extraction from the design/ui-revamp-2026-06 spec.
- KPIStat: extract the stat card pattern from DashboardClient (used four
times inline). Supports tone (default/primary/accent/warning/danger)
and an optional trend indicator. Reuses .admin-stat-card* CSS classes.
- EmptyState: generic centered icon + title + description + single
primary CTA. Action renders as <Link> when href is provided, else a
<button>; both use .ha-btn-primary.
- LoadingState: skeleton list driven by .ha-skeleton. Renders an
optional ha-eyebrow label above N rows (icon tile + label + value
placeholders). Each row uses the new admin design tokens.
- AdminBadge: tighten variants to the new design tokens. Legacy
variant= prop kept for back-compat (default/success/warning/danger/
info); new tone= prop adds neutral/primary/accent/success. All
colors now reference --admin-* tokens; no hardcoded hex values.
Added a 1px tone-aware border for stronger definition on the cream
canvas. AdminStatusBadge + AdminCountBadge updated to use the new
tone prop directly.
- design-system/index.tsx: re-export KPIStat, EmptyState, LoadingState
from the design-system barrel alongside AdminBadge.
npx tsc --noEmit clean.
Adds a self-contained <CommandPalette /> client component plus a
static data file that lists every admin page (per the new IA in
docs/superpowers/specs/2026-06-17-admin-redesign.md §4) and a
small set of quick actions.
Behavior:
- Toggles on Cmd+K (mac) / Ctrl+K (win/linux) at document level.
- Escape or backdrop click closes; Enter or click navigates.
- Fuzzy case-insensitive match on label / category / keywords,
top 8 results, highlighted match substring.
- ↑/↓ keyboard nav, mouse hover updates selection, auto-focus
on input when opened, body scroll lock while open.
- Visual: uses existing --admin-card-bg / --admin-border /
--admin-primary-soft / --admin-radius-lg / --admin-shadow-lg
tokens; fade-in 180ms, scale 0.98→1 animation.
- Returns null when closed (no DOM noise).
Out of scope for this pass:
- Component is NOT mounted in the admin layout (main thread wires
it in). The data file is the source of truth; the palette does
not import from AdminSidebar.
- localStorage-based Recent items are left as a TODO comment
in the component; v1 ships without persistence.
No new dependencies; uses lucide-react (already in package.json).
npx tsc --noEmit is clean.
- --admin-bg: warm cream (#FAF7F0)
- --admin-text-primary: ink black (#1A1814)
- --admin-text-muted: warm grey (#8A867E) — was too dim
- --admin-border: soft beige (#E8E4D7) — was too dirty
- --admin-primary: deep botanical (#1F4D2A)
- --admin-accent: warm amber (#B8761E) — the 'one thing to do'
- --admin-warning: amber (was #aba278 — looked like dirt)
- --admin-danger: warmer rust (#A8321C)
- sidebar: deeper, more refined (#2A2520)
- drop purple/blue/citrus stat icons in DashboardClient; use semantic tokens
- use --admin-warning instead of hardcoded #f59e0b in usage bar
- design spec: aesthetic, color tokens, IA, phased plan
- checklist: 7 phases, every task has notes column, revert cheatsheet
- working branch: design/ui-revamp-2026-06
- Remove Google Fonts @import from 4 components (SiteHeader, LandingPageWrapper,
TestimonialsAndCTA, FeaturesAndStats) — eliminate render-blocking external
request and font flash
- Replace all unloaded Cormorant Garamond / Playfair Display / DM Sans /
Plus Jakarta Sans references with the existing next/font variables
(Fraunces, Manrope, Fragment_Mono) — visual coherence with the design system
- Update 9 pages (blog, brands, changelog, maintenance, protected-example,
roadmap, security, waitlist, WaitlistForm) to use the loaded Fraunces
- Fix dead --font-geist / --font-jetbrains-mono references in
admin-design-system.css (now point to --font-manrope / --font-fragment-mono
which are actually loaded)
- Add atelier-pill, atelier-numerals, atelier-fineprint, atelier-canvas-soft
utility classes; .atelier-input:focus-visible refined ring
- Add .ha-field-textarea, .ha-scroll, .ha-skeleton to admin design system
- Extend prefers-reduced-motion guard to atelier-enter/stagger/shimmer
- Apply atelier-fineprint to login/not-found/error pages for consistency
- No structural changes; build passes, 0 type errors
User pain point: skeleton loading.tsx files made the app feel like
a sequence of page reloads, exposing backend latency. Replaced with
a single 1px shimmer bar + crossfade via React's <ViewTransition>.
Changes:
- Enable experimental.viewTransition in next.config.ts
- Add SmoothViewTransition wrapper (ViewTransition name=page-content)
- Add LoadingFade component: thin animated bar instead of skeleton
- Add RouteAnnouncer for a11y (screen readers + focus reset)
- Add ::view-transition-old/new CSS for the crossfade (220ms, no
jarring slide, respects prefers-reduced-motion)
- Wrap admin/tuxedo/IRD layout children in SmoothViewTransition
(sidebar/header/footer stay mounted; only the body fades)
- Replace 19 skeleton loading.tsx files with the fade component
Result: navigation now feels like a single app, not a series of
preload-and-render events. The user never sees a 'skeleton of the
page they're about to load.'
Visualizes the current layout nesting in the App Router, identifies
gaps where shared sub-layouts could be added (cart/checkout, wholesale,
water, admin/communications, admin/settings), and shows how to add
a new nested layout or parallel route for modal-style navigation.
- Use StopTableClient in server page component
- Add view mode toggle (table/card) matching products page
- Match stats cards styling to products page
- Add consistent filter bar layout with AdminViewModeTabs
- Add card view for stops with inline delete confirm
- Improve row actions with Edit button + dropdown menu
- Add 'Today' badge and past stop dimming in card view
- Add EditStopModal for unified add/edit stop workflow
- Enhance StopTableClient with sortable columns (date, city, location, status)
- Add stats bar showing total, active, upcoming, and draft counts
- Improve pagination with 'Showing X-Y of Z' indicator
- Add 'Today' badge for current-day stops
- Dim past stops visually
- Add Edit action to row menu with icons
- Reduce page size to 25 for better UX
- AdminSidebar now renders BrandSelector when brands are available
- Stops page uses getActiveBrandId() instead of adminUser.brand_id
directly, so platform admins see all stops and brand selection
via the sidebar picker works correctly
- BrandSelector already existed and was wired up in the layout,
just never rendered in the sidebar
The page was importing from @/lib/supabase which is a mock client that
returns empty results. Replaced with pool.query() against the real
Postgres stops table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>