Skip to content

Scoped Session Browsing + Installation-Location Surfacing — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give both frontends a unified master-detail Sessions page (browse a device's sessions or a facility's mixed-device sessions, click through them in a width-bounded detail pane), and surface each device's installation location (facility name + free-text location) prominently in the device lists.

Architecture: One backend DTO field (location on the operator facility-overview device row). Each SPA gets a /sessions master-detail page: a scope bar (facility/device/status/mode) → a scoped session list (left) → a session detail pane (right) whose width is bounded by the layout shell. The admin detail pane is extracted from today's MetadataSessionDetailPage (fixing its grid/width bug); the operator detail pane is net-new, reusing the existing DeviceTimelineChart (uPlot) to render historical session streams. Consistency comes from mirroring this structure across both apps, not from a shared code package.

Tech Stack: Rust 2024 / Axum 0.8 / SQLx 0.8 (backend); Vite + React 19 + TailwindCSS 4 + TypeScript, react-router v7, @tanstack/react-query, uPlot (both frontends).

Design spec: docs/superpowers/specs/2026-07-16-scoped-session-browsing-and-location-design.md.

Global Constraints

  • Language: all code comments, docs, commit messages, and user-facing copy in English; Korean copy only as the ko i18n twin. Branding is exactly Xylolabs.
  • No emojis in the UI. Use SVG icons.
  • Mobile-first / responsive. Every changed page must render correctly at mobile 375×812, tablet 768×1024, desktop 1280×800 with zero pageerror and no horizontal body scroll. All <input>/<select> use style={{ fontSize: '16px' }} (or a ≥16px class) to prevent iOS zoom.
  • Null safety: use != null / == null (never !== null) when guarding property access.
  • i18n parity: every new key MUST be added to BOTH the en and ko blocks. Admin keys live in frontend/src/i18n/index.ts (flat dotted keys; en from line 4, ko from line 1512; keyParity.test.ts enforces parity; t(key, locale) with manual .replace('{x}', …) interpolation). Operator keys live in frontend-app/src/i18n/index.ts (en from line 8, ko ~1240; t(key, locale, vars?) interpolates {name}; key-parity.test.ts + jargon-lint.test.ts enforce parity and forbid machine jargon).
  • Validation gate (run before each commit that touches that layer): Rust → cargo check + cargo clippy (zero errors). Admin TS → cd frontend && npx tsc -b --noEmit && npx vite build. Operator TS → cd frontend-app && npx tsc -b --noEmit && npx vite build.
  • Commit recipe (every task ends with this): GPG-sign, Conventional Commit + gitmoji, one commit per task: bash git add <exact paths> git commit -S -m "<type>(<scope>): <gitmoji> <description>" ~/flash-shared/gitminer-cuda/mine_commit.sh 7 # vanity hash: 7 leading hex zeros git pull --rebase && git push
  • Do NOT deploy per-task. Deploy once at the end (Task 10) via bash scripts/deploy.sh; target is always api.xylolabs.com.
  • Reuse each app's own API client / stores / i18n — the two SPAs are independent and type the shared /v1/metadata/sessions response differently (admin PaginatedResponse<IngestSession> camelCase vs operator MeasurementListResponse snake_case). Keep each app's typing; do not unify.

Task 1: Backend — location on the facility-overview device row

Files: - Modify: crates/xylolabs-core/src/dto/facility_dashboard.rs (add field + serde test) - Modify: crates/xylolabs-server/src/routes/facility_dashboard.rs:99 (populate field) - Modify: docs/API.en.md, docs/API.ko.md (document the field on the overview device schema)

Interfaces: - Produces: FacilityOverviewDevice.location: Option<String> — consumed by the operator app in Task 6/7.

  • [ ] Step 1: Write the failing serde test. Append to crates/xylolabs-core/src/dto/facility_dashboard.rs:
#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;

    #[test]
    fn overview_device_serializes_location() {
        let d = FacilityOverviewDevice {
            id: Uuid::nil(),
            alias: None,
            name: "n".into(),
            dongle_id: None,
            is_online: true,
            health_status: "online".into(),
            last_seen_at: None,
            battery_v: None,
            firmware_version: None,
            location: Some("office bench (사무실, mac3)".into()),
            is_quarantine_sentinel: false,
        };
        let v = serde_json::to_value(&d).unwrap();
        assert_eq!(v["location"], "office bench (사무실, mac3)");
    }
}
  • [ ] Step 2: Run it — expect a COMPILE failure (location field does not exist yet):

Run: cargo test -p xylolabs-core --lib dto::facility_dashboard Expected: FAIL — struct FacilityOverviewDevice has no field named location (or missing-field error).

  • [ ] Step 3: Add the field. In facility_dashboard.rs, inside pub struct FacilityOverviewDevice, add the field immediately before is_quarantine_sentinel (line 49):
    /// Free-text installation location (`device.location`), e.g.
    /// "office bench (사무실, mac3)". Facility NAME is already the operator
    /// app's page context, so only the per-device free-text location is
    /// surfaced here. Null when the operator has not set one.
    pub location: Option<String>,
  • [ ] Step 4: Populate it in the route. In crates/xylolabs-server/src/routes/facility_dashboard.rs, inside the FacilityOverviewDevice { … } literal (constructed at line 99, from the owned d: Device), add before is_quarantine_sentinel:
                location: d.location,

(list_by_facility returns full SELECT * device rows, so d.location is already loaded — no query change. d.location is a distinct field move; name/alias are moved earlier in the same literal, which is allowed.)

  • [ ] Step 5: Run the test + build — expect PASS:

Run: cargo test -p xylolabs-core --lib dto::facility_dashboard && cargo check && cargo clippy Expected: test PASSES; check + clippy clean.

  • [ ] Step 6: Document the field. In docs/API.en.md and docs/API.ko.md, find the GET /api/v1/facility/overview response schema's device object and add a location row: EN "Free-text installation location (nullable)."; KO "설치 위치 자유 텍스트 (nullable)." Match the surrounding table/format.

  • [ ] Step 7: Commit (use the Global Constraints commit recipe):

git add crates/xylolabs-core/src/dto/facility_dashboard.rs crates/xylolabs-server/src/routes/facility_dashboard.rs docs/API.en.md docs/API.ko.md
git commit -S -m "feat(facility): ✨ expose device location on facility overview"

Task 2: Admin — prominent facility + location cell in the device table

Files: - Modify: frontend/src/pages/DevicesPage.tsx (Location <th> ~724-733 and <td> ~825-830; the facility name currently sits in a separate "Info" <td> at 836-846 — move it into the Location cell) - Modify: frontend/src/i18n/index.ts (add devices.unknownLocation)

Interfaces: - Consumes: existing facilityNameById: Map<string,string> (DevicesPage.tsx:620) and device.location: string | null, device.facility_id: string.

  • [ ] Step 1: Add i18n keys. In frontend/src/i18n/index.ts, add to the en block (near 'devices.location' at line 371) and the ko block (near line 1863):
// en
'devices.unknownLocation': 'Unknown location',
// ko
'devices.unknownLocation': '위치 미상',
  • [ ] Step 2: Rewrite the Location <td> (DevicesPage.tsx ~825-830) to a two-line cell — facility name primary, free-text location secondary:
<td className="px-4 py-3 max-w-[220px]">
  <div className="flex flex-col gap-0.5">
    {facilityNameById.get(device.facility_id) != null && (
      <span className="truncate font-medium text-slate-800 dark:text-slate-100"
            title={facilityNameById.get(device.facility_id)}>
        {facilityNameById.get(device.facility_id)}
      </span>
    )}
    <span className="truncate text-xs text-slate-500 dark:text-slate-400"
          title={device.location ?? undefined}>
      {device.location ?? t('devices.unknownLocation')}
    </span>
  </div>
</td>
  • [ ] Step 3: Remove the now-duplicated facility name from the "Info" <td> (DevicesPage.tsx:836-846) — delete only the {facilityNameById.get(device.facility_id) != null && (<span>…</span>)} block, keeping UsbBadge, battery, and rssi. This avoids showing the facility twice.

  • [ ] Step 4: Verify types + build:

Run: cd frontend && npx tsc -b --noEmit && npx vite build Expected: zero errors.

  • [ ] Step 5: Visual check (desktop). With the app running, load /devices at 1280×800 and confirm the Location column shows facility (bold) over free-text location, "Unknown location" where null, no pageerror. (Uses test creds xylolabs / solution_6231 against admin.api.xylolabs.com or local.)

  • [ ] Step 6: Commit:

git add frontend/src/pages/DevicesPage.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(devices): ✨ show facility + location prominently in admin device list"

Task 3: Admin — extract SessionDetailPanel and fix the width/grid layout

Files: - Create: frontend/src/components/metadata/SessionDetailPanel.tsx - Modify: frontend/src/pages/MetadataSessionDetailPage.tsx (delegate its body to the new panel)

Interfaces: - Produces: export default function SessionDetailPanel({ sessionId }: { sessionId: string }): JSX.Element — consumed by the admin /sessions page (Task 4) and the interim detail route.

This moves the session-detail body (info panels + time-range controls + charts) out of MetadataSessionDetailPage.tsx into a reusable, width-bounded panel and replaces the broken charts grid.

  • [ ] Step 1: Create the panel by moving the detail logic from MetadataSessionDetailPage.tsx (its getSession/getDevice queries, TIME_RANGES, viewMode, effectiveTimeRangeUs/effectiveAnchorUs/effectiveEndUs, custom-range state, and the charts render) into frontend/src/components/metadata/SessionDetailPanel.tsx. Keep the same imports (StreamChart/isSingleSampleDisplay, AccelChart, GyroChart, MagChart, GroupTripletChart, TimelinePlayer, SimilarSoundPanel, TimeRangeCustomFields, local StreamPanel) and the same data hooks. Signature:
export default function SessionDetailPanel({ sessionId }: { sessionId: string }) {
  // getSession(sessionId), getDevice(session.deviceId), time-range + viewMode state — moved verbatim.
  // Header: session name/status + device link + session-info + device-info panels.
  // Then the charts region (Step 2).
}
  • [ ] Step 2: Replace the charts grid (THE WIDTH FIX). In the panel, replace the old container <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> (was line 464) and its four col-span-full wrappers (was 504/521/537/553) with a single-column stack. Full-width charts become plain children; only compact single-sample cards are collected into a compact sub-grid:
{/* single-column stack — the pane is already width-bounded by the shell */}
<div className="flex flex-col gap-4">
  {/* grouped/triplet + audio charts: now plain full-width children (drop the col-span-full wrappers) */}
  {tripletGroups.map((g) => (
    <GroupTripletChart key={`group-${g.groupId}`} sessionId={sessionId} groupId={g.groupId}
      streams={g.streams} timeRangeUs={effectiveTimeRangeUs} queryAnchorUs={effectiveAnchorUs}
      queryEndUs={effectiveEndUs} sessionStartedAt={session.createdAt} />
  ))}
  {accelStreams.length > 0 && (
    <AccelChart sessionId={sessionId} streams={accelStreams} timeRangeUs={effectiveTimeRangeUs}
      queryAnchorUs={effectiveAnchorUs} queryEndUs={effectiveEndUs} sessionStartedAt={session.createdAt} />
  )}
  {gyroStreams.length > 0 && (
    <GyroChart sessionId={sessionId} streams={gyroStreams} timeRangeUs={effectiveTimeRangeUs}
      queryAnchorUs={effectiveAnchorUs} queryEndUs={effectiveEndUs} sessionStartedAt={session.createdAt} />
  )}
  {magStreams.length > 0 && (
    <MagChart sessionId={sessionId} streams={magStreams} timeRangeUs={effectiveTimeRangeUs}
      queryAnchorUs={effectiveAnchorUs} queryEndUs={effectiveEndUs} sessionStartedAt={session.createdAt} />
  )}

  {/* full-width single-stream line/audio panels (non-compact) */}
  {lineStreams.map((s) => (
    <StreamPanel key={s.id} sessionId={sessionId} stream={s} timeRangeUs={effectiveTimeRangeUs}
      queryAnchorUs={effectiveAnchorUs} queryEndUs={effectiveEndUs}
      sessionName={session.name} deviceLabel={deviceLabel} sessionStartedAt={session.createdAt} />
  ))}

  {/* compact single-sample cards grouped into a tidy sub-grid (no lonely 1/3 cells) */}
  {compactStreams.length > 0 && (
    <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
      {compactStreams.map((s) => (
        <StreamPanel key={s.id} sessionId={sessionId} stream={s} timeRangeUs={effectiveTimeRangeUs}
          queryAnchorUs={effectiveAnchorUs} queryEndUs={effectiveEndUs}
          sessionName={session.name} deviceLabel={deviceLabel} sessionStartedAt={session.createdAt} />
      ))}
    </div>
  )}
</div>

Where the stream partitions are computed once (replacing the old inline IIFE), using the existing helpers:

const numeric = session.streams
const compactStreams = numeric.filter((s) => isSingleSampleDisplay(/* latest data or */ null, s.sampleRateHz))
// group/triplet/accel/gyro/mag partitioning is moved verbatim from the old IIFE;
// lineStreams = the remaining non-compact, non-grouped streams.

Also change the local StreamPanel root class (was line 91) to drop the col-span-full branch — it is always a full-width child now:

// before: className={`… ${compact ? '' : 'col-span-full'}`}
// after:
className="rounded-xl border-2 border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 …"
  • [ ] Step 3: Slim MetadataSessionDetailPage.tsx to a width-capped wrapper that delegates to the panel (interim, until Task 5 redirects it):
import { useParams } from 'react-router'
import SessionDetailPanel from '../components/metadata/SessionDetailPanel'

export default function MetadataSessionDetailPage() {
  const { id } = useParams<{ id: string }>()
  if (id == null) return null
  return (
    <div className="max-w-[1100px] mx-auto">
      <SessionDetailPanel sessionId={id} />
    </div>
  )
}
  • [ ] Step 4: Verify types + build:

Run: cd frontend && npx tsc -b --noEmit && npx vite build Expected: zero errors.

  • [ ] Step 5: Visual check. Open an existing session at /metadata/<id> on desktop 1280 and a narrow 768 viewport. Confirm: content is centered/width-capped (no edge-to-edge bleed), charts stack in one column, single-sample cards sit in a tidy 2–3 col grid with no lonely 1/3-width card beside empty space, zero pageerror.

  • [ ] Step 6: Commit:

git add frontend/src/components/metadata/SessionDetailPanel.tsx frontend/src/pages/MetadataSessionDetailPage.tsx
git commit -S -m "refactor(metadata): 🎨 extract SessionDetailPanel and fix opened-session width/grid"

Task 4: Admin — unified /sessions master-detail page + scope bar

Files: - Create: frontend/src/pages/SessionsPage.tsx - Create: frontend/src/lib/deviceColor.ts (+ test frontend/src/lib/deviceColor.test.ts) - Modify: frontend/src/App.tsx (add sessions route + lazy import) - Modify: frontend/src/components/layout/Sidebar.tsx:275 (retarget nav to /sessions) - Modify: frontend/src/i18n/index.ts (scope-bar + empty-state keys)

Interfaces: - Consumes: listSessions(params: ListSessionsParams): Promise<PaginatedResponse<IngestSession>>, listDevices(facilityId?): Promise<Device[]>, listFacilities(), SessionDetailPanel, useFacilityFilterStore, FacilitySelect. - Produces: route /sessions with URL params facility, device (id | all), status, mode, session, page.

  • [ ] Step 1: Write the failing test for the device-color helper:
// frontend/src/lib/deviceColor.test.ts
import { describe, it, expect } from 'vitest'
import { deviceColor } from './deviceColor'

describe('deviceColor', () => {
  it('is deterministic per id', () => {
    expect(deviceColor('abc')).toBe(deviceColor('abc'))
  })
  it('differs for different ids', () => {
    expect(deviceColor('abc')).not.toBe(deviceColor('xyz'))
  })
  it('returns an hsl string', () => {
    expect(deviceColor('abc')).toMatch(/^hsl\(/)
  })
})
  • [ ] Step 2: Run it — expect FAIL (module missing):

Run: cd frontend && npx vitest run src/lib/deviceColor.test.ts Expected: FAIL — cannot find ./deviceColor.

  • [ ] Step 3: Implement frontend/src/lib/deviceColor.ts:
/** Deterministic, well-spread hue per device id — used for the per-device
 *  color chip in the facility-mixed session list. */
export function deviceColor(id: string): string {
  let h = 0
  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0
  return `hsl(${h % 360}, 65%, 45%)`
}
  • [ ] Step 4: Run the test — expect PASS:

Run: cd frontend && npx vitest run src/lib/deviceColor.test.ts Expected: PASS.

  • [ ] Step 5: Add i18n keys to frontend/src/i18n/index.ts (en + ko):
// en
'sessions.scopeDevice': 'Device',
'sessions.allDevices': 'All devices',
'sessions.scopeStatus': 'Status',
'sessions.scopeMode': 'Mode',
'sessions.selectPrompt': 'Select a session to view',
'sessions.empty': 'No sessions in this scope',
'sessions.backToList': 'Back to list',
'sessions.openSessions': 'Open sessions',
// ko
'sessions.scopeDevice': '장치',
'sessions.allDevices': '모든 장치',
'sessions.scopeStatus': '상태',
'sessions.scopeMode': '모드',
'sessions.selectPrompt': '세션을 선택하세요',
'sessions.empty': '이 범위에 세션이 없습니다',
'sessions.backToList': '목록으로',
'sessions.openSessions': '세션 열기',
  • [ ] Step 6: Create frontend/src/pages/SessionsPage.tsx. Full component:
import { useMemo } from 'react'
import { useSearchParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { listSessions } from '../api/metadata'
import { listDevices } from '../api/devices'
import { listFacilities } from '../api/facilities'
import { useFacilityFilterStore } from '../stores/facilityFilterStore'
import { useAuthStore } from '../stores/authStore'
import FacilitySelect from '../components/ui/FacilitySelect'
import SessionDetailPanel from '../components/metadata/SessionDetailPanel'
import { deviceColor } from '../lib/deviceColor'
import { useTranslation } from '../hooks/useTranslation'
import { usePageTitle } from '../hooks/usePageTitle'
import { formatSmartTime } from '../lib/formatters' // use whatever the codebase exports; else formatRelative

const SELECT_CLS =
  'min-h-9 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-2 py-1 text-slate-800 dark:text-slate-100'

export default function SessionsPage() {
  const { t } = useTranslation()
  usePageTitle(t('nav.sessions'))
  const [params, setParams] = useSearchParams()
  const user = useAuthStore((s) => s.user)
  const { selectedFacility } = useFacilityFilterStore()
  const effectiveFacility = selectedFacility || user?.facility_id || ''

  const device = params.get('device') || 'all'
  const status = params.get('status') || 'all'
  const mode = params.get('mode') || 'all'
  const selected = params.get('session')
  const page = Number(params.get('page') || '1')

  const setParam = (k: string, v: string | null) => {
    const next = new URLSearchParams(params)
    if (v == null || v === '' || v === 'all') next.delete(k)
    else next.set(k, v)
    if (k !== 'session' && k !== 'page') next.delete('page') // scope change resets page
    setParams(next, { replace: false })
  }

  const { data: facilities } = useQuery({ queryKey: ['facilities'], queryFn: listFacilities })
  const { data: devices } = useQuery({
    queryKey: ['devices', effectiveFacility],
    queryFn: () => listDevices(effectiveFacility || undefined),
  })
  const { data: sessions, isLoading } = useQuery({
    queryKey: ['sessions', effectiveFacility, device, status, mode, page],
    queryFn: () =>
      listSessions({
        facility_id: effectiveFacility || undefined,
        device_id: device === 'all' ? undefined : device,
        status: status === 'all' ? undefined : (status as 'active' | 'closed'),
        mode: mode === 'all' ? undefined : (mode as 'continuous' | 'sampling'),
        page,
        per_page: 30,
      }),
    placeholderData: (prev) => prev,
  })

  const deviceName = useMemo(() => {
    const m = new Map<string, string>()
    for (const d of devices ?? []) m.set(d.id, d.alias || d.dongle_id || d.name)
    return m
  }, [devices])

  const showFacility = !user?.facility_id && (facilities?.length ?? 0) > 1

  return (
    <div className="max-w-[1600px] mx-auto flex flex-col gap-4">
      {/* scope bar */}
      <div className="flex flex-wrap items-center gap-3">
        {showFacility && <FacilitySelect />}
        <label className="flex items-center gap-1 text-sm">
          <span className="text-slate-500 dark:text-slate-400">{t('sessions.scopeDevice')}</span>
          <select className={SELECT_CLS} style={{ fontSize: '16px' }} value={device}
            aria-label={t('sessions.scopeDevice')}
            onChange={(e) => setParam('device', e.target.value)}>
            <option value="all">{t('sessions.allDevices')}</option>
            {(devices ?? []).map((d) => (
              <option key={d.id} value={d.id}>{d.alias || d.dongle_id || d.name}</option>
            ))}
          </select>
        </label>
        <select className={SELECT_CLS} style={{ fontSize: '16px' }} value={status}
          aria-label={t('sessions.scopeStatus')} onChange={(e) => setParam('status', e.target.value)}>
          <option value="all">{t('sessions.scopeStatus')}</option>
          <option value="active">{t('metadata.statusActive')}</option>
          <option value="closed">{t('metadata.statusClosed')}</option>
        </select>
        <select className={SELECT_CLS} style={{ fontSize: '16px' }} value={mode}
          aria-label={t('sessions.scopeMode')} onChange={(e) => setParam('mode', e.target.value)}>
          <option value="all">{t('sessions.scopeMode')}</option>
          <option value="continuous">{t('metadata.modeContinuous')}</option>
          <option value="sampling">{t('metadata.modeSampling')}</option>
        </select>
      </div>

      {/* master-detail: side-by-side ≥lg, list-only below lg (detail is full-screen when ?session set) */}
      <div className="flex flex-col lg:flex-row gap-4">
        {/* master list — hidden on small screens once a session is selected */}
        <div className={`lg:w-96 lg:shrink-0 ${selected ? 'hidden lg:block' : 'block'}`}>
          <div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
            {isLoading && <div className="p-4 text-sm text-slate-500">{t('common.loading')}</div>}
            {!isLoading && (sessions?.items.length ?? 0) === 0 && (
              <div className="p-6 text-center text-sm text-slate-500 dark:text-slate-400">{t('sessions.empty')}</div>
            )}
            <ul className="divide-y divide-slate-100 dark:divide-slate-800 max-h-[70vh] overflow-y-auto">
              {(sessions?.items ?? []).map((s) => {
                const active = s.id === selected
                return (
                  <li key={s.id}>
                    <button
                      onClick={() => setParam('session', s.id)}
                      aria-current={active ? 'true' : undefined}
                      className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${
                        active ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-slate-50 dark:hover:bg-slate-800/60'
                      }`}
                    >
                      {device === 'all' && (
                        <span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full"
                          style={{ backgroundColor: deviceColor(s.deviceId) }} aria-hidden="true" />
                      )}
                      <span className="min-w-0 flex-1">
                        <span className="block text-sm font-medium text-slate-800 dark:text-slate-100 tabular-nums">
                          {formatSmartTime(s.createdAt)}
                        </span>
                        <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                          {deviceName.get(s.deviceId) ?? s.deviceId} · {s.streams.length} · {s.totalSamples}
                        </span>
                      </span>
                    </button>
                  </li>
                )
              })}
            </ul>
          </div>
          {/* pagination: reuse the existing Pagination component if present, else prev/next buttons setting ?page */}
        </div>

        {/* detail pane — width-bounded by flex-1 */}
        <div className="min-w-0 flex-1">
          {selected == null ? (
            <div className="rounded-xl border border-dashed border-slate-300 dark:border-slate-700 p-10 text-center text-sm text-slate-500 dark:text-slate-400">
              {t('sessions.selectPrompt')}
            </div>
          ) : (
            <div>
              <button onClick={() => setParam('session', null)}
                className="mb-3 inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 lg:hidden">
                ← {t('sessions.backToList')}
              </button>
              <SessionDetailPanel sessionId={selected} />
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

Note: formatSmartTime / t('common.loading') / t('metadata.statusActive') etc. — use the exact export names already in the admin codebase; if formatSmartTime is not exported, use the formatter MetadataSessionsPage uses for session start times. If a Pagination component exists (as in MetadataSessionsPage), render it below the list wired to setParam('page', …).

  • [ ] Step 7: Register the route. In frontend/src/App.tsx, add a lazy import mirroring line 41 and a route beside line 125 (inside the MainLayout block):
const SessionsPage = lazyRetry(() => import('./pages/SessionsPage'))
// …
<Route path="sessions" element={<SessionsPage />} />
  • [ ] Step 8: Retarget the sidebar nav. In frontend/src/components/layout/Sidebar.tsx:275, change to: '/metadata'to: '/sessions' (label already t('nav.sessions')).

  • [ ] Step 9: Verify types + build + unit test:

Run: cd frontend && npx vitest run src/lib/deviceColor.test.ts && npx tsc -b --noEmit && npx vite build Expected: PASS + zero errors.

  • [ ] Step 10: Visual check (all three viewports). /sessions: scope bar filters work; clicking a row loads the detail on the right (≥lg) or full-screen with "Back to list" (<lg); "All devices" shows color chips; zero pageerror; no horizontal body scroll.

  • [ ] Step 11: Commit:

git add frontend/src/pages/SessionsPage.tsx frontend/src/lib/deviceColor.ts frontend/src/lib/deviceColor.test.ts frontend/src/App.tsx frontend/src/components/layout/Sidebar.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(sessions): ✨ unified admin master-detail sessions page with scope bar"

Task 5: Admin — entry points + /metadata redirects + retire the old list page

Files: - Modify: frontend/src/App.tsx (redirect /metadata + /metadata/:id; drop the old list route) - Modify: frontend/src/pages/DevicesPage.tsx (device-row "Open sessions" action) - Modify: frontend/src/pages/FacilitiesPage.tsx (facility "Open sessions" action → ?facility=)

Interfaces: - Consumes: the /sessions route + params from Task 4.

  • [ ] Step 1: Add redirects. In frontend/src/App.tsx, import { Navigate } from 'react-router' and replace the two metadata routes (125-126). The detail redirect maps the path param to ?session=:
<Route path="metadata" element={<Navigate to="/sessions" replace />} />
<Route path="metadata/:id" element={<MetadataDetailRedirect />} />

Add a tiny redirect component (top of App.tsx or a small file frontend/src/pages/MetadataDetailRedirect.tsx):

import { Navigate, useParams } from 'react-router'
export default function MetadataDetailRedirect() {
  const { id } = useParams<{ id: string }>()
  return <Navigate to={`/sessions?session=${id ?? ''}`} replace />
}

Remove the now-unused MetadataSessionsPage lazy import + route (the list is superseded by /sessions). Keep the MetadataSessionDetailPage lazy import only if still referenced; otherwise remove it too (its body now lives in SessionDetailPanel).

  • [ ] Step 2: Delete the superseded list page. Remove frontend/src/pages/MetadataSessionsPage.tsx and its test frontend/src/pages/MetadataSessionsPage.test.tsx (superseded by SessionsPage). Also remove frontend/src/pages/MetadataSessionDetailPage.tsx if it is no longer routed (its logic is in SessionDetailPanel). Grep first to ensure no other importers:
grep -rn "MetadataSessionsPage\|MetadataSessionDetailPage" frontend/src

Only App.tsx should match; fix/remove those references.

  • [ ] Step 3: Device-row "Open sessions". In frontend/src/pages/DevicesPage.tsx, in the actions <td> (last column) add a link (use the existing icon-button/link style in that column):
<Link to={`/sessions?device=${device.id}`} title={t('sessions.openSessions')}
  className="inline-flex items-center rounded-md px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30">
  {t('sessions.openSessions')}
</Link>

(Ensure Link is imported from react-router.)

  • [ ] Step 4: Facility "Open sessions". In frontend/src/pages/FacilitiesPage.tsx, add an equivalent <Link to={/sessions?facility=${facility.id}}> in each facility row/card actions. The SessionsPage reads facility only to seed FacilitySelect; wire it by having SessionsPage call useFacilityFilterStore().setSelectedFacility(params.get('facility')) in an effect when the facility param is present (add that effect to SessionsPage).

  • [ ] Step 5: Verify types + build:

Run: cd frontend && npx tsc -b --noEmit && npx vite build Expected: zero errors, no dangling imports.

  • [ ] Step 6: Visual check. Navigating to /metadata redirects to /sessions; /metadata/<id> redirects to /sessions?session=<id> and opens that session; device-row "Open sessions" lands pre-scoped to that device; facility "Open sessions" lands facility-scoped. Zero pageerror.

  • [ ] Step 7: Commit:

git add frontend/src/App.tsx frontend/src/pages/DevicesPage.tsx frontend/src/pages/FacilitiesPage.tsx frontend/src/pages/MetadataDetailRedirect.tsx
git rm frontend/src/pages/MetadataSessionsPage.tsx frontend/src/pages/MetadataSessionsPage.test.tsx frontend/src/pages/MetadataSessionDetailPage.tsx
git commit -S -m "feat(sessions): ✨ redirect /metadata to /sessions + device/facility open-sessions entry points"

Task 6: Operator — location type + metadata API client + stream-mapping util

Files: - Modify: frontend-app/src/api/types.ts (add location to FacilityOverviewDevice) - Create: frontend-app/src/api/metadata.ts (operator session detail client) - Create: frontend-app/src/lib/sessionStream.ts (+ test frontend-app/src/lib/sessionStream.test.ts)

Interfaces: - Produces: - FacilityOverviewDevice.location: string | null - getSession(id: string): Promise<OperatorSession> and queryStreamData(sessionId, streamId, params): Promise<OperatorStreamData> - streamDataToTimelineStream(stream: OperatorStream, data: OperatorStreamData, windowStart: number, windowEnd: number): TimelineStream — consumed by Task 8.

  • [ ] Step 1: Add location to the operator overview type (frontend-app/src/api/types.ts, in FacilityOverviewDevice, after firmware_version):
  location: string | null
  • [ ] Step 2: Write the failing mapping test:
// frontend-app/src/lib/sessionStream.test.ts
import { describe, it, expect } from 'vitest'
import { streamDataToTimelineStream } from './sessionStream'

const stream = { id: 's1', name: 'temp_c', value_type: 'f32', unit: '°C', sample_rate_hz: 1 }

describe('streamDataToTimelineStream', () => {
  it('maps points into TimelineStream.points (µs t_us, numeric v)', () => {
    const data = { stream_id: 's1', points: [
      { timestamp_us: 1000, value: 21.5 },
      { timestamp_us: 2000, value: 22.0, value_min: 21.9, value_max: 22.1 },
    ] }
    const ts = streamDataToTimelineStream(stream, data, 0, 3000)
    expect(ts.name).toBe('temp_c')
    expect(ts.points.length).toBe(2)
    expect(ts.points[0].t_us).toBe(1000)
    expect(ts.points[0].v).toBe(21.5)
    expect(ts.points[1].v_min).toBe(21.9)
    expect(ts.points[1].v_max).toBe(22.1)
  })
  it('passes null values through as gaps', () => {
    const data = { stream_id: 's1', points: [{ timestamp_us: 1000, value: null }] }
    const ts = streamDataToTimelineStream(stream, data, 0, 2000)
    expect(ts.points[0].v).toBeNull()
  })
})
  • [ ] Step 3: Run it — expect FAIL (module missing):

Run: cd frontend-app && npx vitest run src/lib/sessionStream.test.ts Expected: FAIL — cannot find ./sessionStream.

  • [ ] Step 4: Create the operator metadata client frontend-app/src/api/metadata.ts:
import { api } from './client'

export interface OperatorStream {
  id: string
  name: string
  value_type: string
  unit: string | null
  sample_rate_hz: number | null
}
export interface OperatorSession {
  id: string
  device_id: string
  facility_id: string
  name: string | null
  status: string
  mode: string
  started_at: string
  total_samples: number
  streams: OperatorStream[]
}
export interface OperatorStreamPoint {
  timestamp_us: number
  value: number | number[] | string | null
  value_min?: number | null
  value_max?: number | null
}
export interface OperatorStreamData {
  stream_id: string
  points: OperatorStreamPoint[]
}

export function getSession(id: string, signal?: AbortSignal): Promise<OperatorSession> {
  return api.get<OperatorSession>(`/v1/metadata/sessions/${id}`, signal)
}

export function queryStreamData(
  sessionId: string,
  streamId: string,
  params: { start_us?: number; end_us?: number; downsample?: number } = {},
  signal?: AbortSignal,
): Promise<OperatorStreamData> {
  const q = new URLSearchParams()
  if (params.start_us != null) q.set('start_us', String(params.start_us))
  if (params.end_us != null) q.set('end_us', String(params.end_us))
  if (params.downsample != null) q.set('downsample', String(params.downsample))
  const qs = q.toString()
  return api.get<OperatorStreamData>(
    `/v1/metadata/sessions/${sessionId}/streams/${streamId}/data${qs ? `?${qs}` : ''}`, signal)
}

Verify the raw field names (started_at, timestamp_us, value_min/value_max, streams[].value_type) against one real response with curl before relying on them — the admin client re-maps to camelCase, so these snake_case names are the raw server shape and must be confirmed:

curl -s "https://admin.api.xylolabs.com/api/v1/metadata/sessions/<id>" -H "Authorization: Bearer <jwt>" | head -c 600

Adjust the interfaces if the raw names differ.

  • [ ] Step 5: Create the mapping util frontend-app/src/lib/sessionStream.ts:
import type { TimelineStream } from '../api/timeline'
import type { OperatorStream, OperatorStreamData } from '../api/metadata'

/** Map a historical session stream + its sample rows into the `TimelineStream`
 *  shape that `DeviceTimelineChart` (uPlot) already knows how to render. */
export function streamDataToTimelineStream(
  stream: OperatorStream,
  data: OperatorStreamData,
  _windowStart: number,
  _windowEnd: number,
): TimelineStream {
  return {
    name: stream.name,
    value_type: stream.value_type,
    unit: stream.unit,
    sample_rate_hz: stream.sample_rate_hz,
    points: data.points.map((p) => ({
      t_us: p.timestamp_us,
      v: (p.value as number | number[] | string | null),
      s: p.value == null ? 'gap' : 'ok',
      v_min: p.value_min ?? undefined,
      v_max: p.value_max ?? undefined,
    })),
  }
}

Confirm the TimelinePoint field names (t_us, v, s, v_min, v_max) against frontend-app/src/api/timeline.ts:29-36; adjust if the s status field expects a different sentinel.

  • [ ] Step 6: Run the test — expect PASS:

Run: cd frontend-app && npx vitest run src/lib/sessionStream.test.ts Expected: PASS.

  • [ ] Step 7: Verify build:

Run: cd frontend-app && npx tsc -b --noEmit Expected: zero errors.

  • [ ] Step 8: Commit:
git add frontend-app/src/api/types.ts frontend-app/src/api/metadata.ts frontend-app/src/lib/sessionStream.ts frontend-app/src/lib/sessionStream.test.ts
git commit -S -m "feat(app): ✨ operator session-detail API client + stream mapping util"

Task 7: Operator — show location on device cards + device detail

Files: - Modify: frontend-app/src/components/devices/DeviceTile.tsx (interface + JSX) - Modify: frontend-app/src/pages/DevicesPage.tsx (pass location from the overview device into the tile) - Modify: frontend-app/src/pages/DeviceDetailPage.tsx (interface + fetch already returns it + JSX) - Modify: frontend-app/src/i18n/index.ts (app.devices.unknownLocation)

Interfaces: - Consumes: FacilityOverviewDevice.location (Task 6), device.location from GET /v1/devices/{id} (already returned by the backend DeviceResponse).

  • [ ] Step 1: Add i18n key (frontend-app/src/i18n/index.ts, en + ko):
// en
'app.devices.unknownLocation': 'Unknown location',
// ko
'app.devices.unknownLocation': '위치 미상',
  • [ ] Step 2: Extend DeviceTileDevice (DeviceTile.tsx:21-30), add:
  location?: string | null
  • [ ] Step 3: Render the location line in the tile's meta row (DeviceTile.tsx ~112, alongside firmware). Location is the prominent field, so put it first:
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-slate-500 dark:text-slate-400">
  <span className="truncate font-medium text-slate-600 dark:text-slate-300"
    title={device.location ?? undefined}>
    {device.location ?? t('app.devices.unknownLocation', locale)}
  </span>
  {device.firmware_version && <span>{t('app.firmwareVersion', locale)} {device.firmware_version}</span>}
</div>
  • [ ] Step 4: Pass location into the tile in frontend-app/src/pages/DevicesPage.tsx where it maps overview devices<DeviceTile device={…}>. Include location: d.location in the object passed (the overview device now carries it after Task 6 Step 1).

  • [ ] Step 5: Show location on the device detail page. In frontend-app/src/pages/DeviceDetailPage.tsx, add location: string | null to the DeviceDetail interface (27-33). The existing GET /v1/devices/${id} already returns it. Add a location line under the device title (near the three summary cards ~289):

{device.location != null && (
  <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{device.location}</p>
)}
  • [ ] Step 6: Verify types + build:

Run: cd frontend-app && npx tsc -b --noEmit && npx vite build Expected: zero errors.

  • [ ] Step 7: Visual check (mobile + desktop). /devices cards show the location line (or "Unknown location"); /devices/<id> shows the location under the title. Zero pageerror.

  • [ ] Step 8: Commit:

git add frontend-app/src/components/devices/DeviceTile.tsx frontend-app/src/pages/DevicesPage.tsx frontend-app/src/pages/DeviceDetailPage.tsx frontend-app/src/i18n/index.ts
git commit -S -m "feat(app): ✨ surface device location on operator device cards + detail"

Task 8: Operator — net-new SessionDetailPanel (historical session charts)

Files: - Create: frontend-app/src/components/sessions/SessionDetailPanel.tsx

Interfaces: - Consumes: getSession, queryStreamData, OperatorStream/OperatorSession (Task 6); streamDataToTimelineStream (Task 6); DeviceTimelineChart (frontend-app/src/components/timeline/DeviceTimelineChart.tsx, default export) with props { stream: TimelineStream; windowStart: number; windowEnd: number; color: string; showXAxis: boolean; label: string }; getStreamLabel/isOperatorVisibleStream from the operator i18n stream helpers (as used in SessionsPage). - Produces: export default function SessionDetailPanel({ sessionId }: { sessionId: string }): JSX.Element — consumed by Task 9.

  • [ ] Step 1: Create the panel:
import { useMemo } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query'
import { getSession, queryStreamData } from '../../api/metadata'
import { streamDataToTimelineStream } from '../../lib/sessionStream'
import DeviceTimelineChart from '../timeline/DeviceTimelineChart'
import { isOperatorVisibleStream, getStreamLabel } from '../../i18n/streams' // match the module SessionsPage imports these from
import { useTranslation } from '../../hooks/useTranslation'
import { formatSmartTime } from '../../lib/formatters' // match operator export

function streamColor(i: number): string {
  const hues = [210, 145, 275, 25, 340, 190, 95, 55]
  return `hsl(${hues[i % hues.length]}, 65%, 45%)`
}

export default function SessionDetailPanel({ sessionId }: { sessionId: string }) {
  const { t, locale } = useTranslation()

  const { data: session, isLoading, error } = useQuery({
    queryKey: ['operator-session', sessionId],
    queryFn: ({ signal }) => getSession(sessionId, signal),
    staleTime: 30_000,
  })

  const visibleStreams = useMemo(
    () => (session?.streams ?? []).filter((s) => isOperatorVisibleStream(s.name)),
    [session],
  )

  // window = full session span; use a wide bound and let the chart clamp.
  const windowStart = 0
  const windowEnd = Number.MAX_SAFE_INTEGER

  const streamQueries = useQueries({
    queries: visibleStreams.map((s) => ({
      queryKey: ['operator-stream', sessionId, s.id],
      queryFn: ({ signal }: { signal: AbortSignal }) =>
        queryStreamData(sessionId, s.id, { downsample: 2000 }, signal),
      staleTime: 30_000,
    })),
  })

  if (isLoading) return <div className="p-6 text-sm text-slate-500">{t('common.loading')}</div>
  if (error != null || session == null)
    return <div role="alert" className="p-6 text-sm text-red-600 dark:text-red-400">{t('sessions.detailError')}</div>

  return (
    <div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/60 p-4">
      <div className="mb-3">
        <h2 className="text-base font-semibold text-slate-800 dark:text-slate-100 tabular-nums">
          {formatSmartTime(session.started_at, locale)}
        </h2>
        <p className="text-xs text-slate-500 dark:text-slate-400">
          {session.mode} · {session.total_samples} · {session.status}
        </p>
      </div>
      <div className="flex flex-col gap-4">
        {visibleStreams.map((s, i) => {
          const data = streamQueries[i]?.data
          if (data == null) return (
            <div key={s.id} className="h-32 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800" />
          )
          const ts = streamDataToTimelineStream(s, data, windowStart, windowEnd)
          return (
            <DeviceTimelineChart key={s.id} stream={ts} windowStart={windowStart} windowEnd={windowEnd}
              color={streamColor(i)} showXAxis={i === visibleStreams.length - 1}
              label={getStreamLabel(s.name, locale).label} />
          )
        })}
        {visibleStreams.length === 0 && (
          <p className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">{t('sessions.noStreams')}</p>
        )}
      </div>
    </div>
  )
}

Add i18n keys sessions.detailError / sessions.noStreams (EN "Could not load session." / "No chartable streams."; KO "세션을 불러오지 못했습니다." / "표시할 스트림이 없습니다.") to frontend-app/src/i18n/index.ts (both blocks).

  • [ ] Step 2: Reconcile the chart window. DeviceTimelineChart expects real windowStart/windowEnd µs. If Number.MAX_SAFE_INTEGER produces a bad axis, compute the window from the data instead: windowStart = min(points.t_us), windowEnd = max(points.t_us) across all streams, and pass those. Adjust after the first visual check.

  • [ ] Step 3: Verify build:

Run: cd frontend-app && npx tsc -b --noEmit && npx vite build Expected: zero errors.

  • [ ] Step 4: Commit:
git add frontend-app/src/components/sessions/SessionDetailPanel.tsx frontend-app/src/i18n/index.ts
git commit -S -m "feat(app): ✨ operator historical-session detail pane (uPlot charts)"

Task 9: Operator — rewrite /sessions as master-detail + scope bar + entry points

Files: - Modify: frontend-app/src/pages/SessionsPage.tsx (rewrite to master-detail) - Modify: frontend-app/src/components/devices/DeviceTile.tsx and/or frontend-app/src/pages/DevicesPage.tsx (device "Open sessions" link) - Modify: frontend-app/src/pages/DeviceDetailPage.tsx ("Open sessions" link) - Modify: frontend-app/src/i18n/index.ts (scope keys, reuse Task 4 naming under app./sessions. as the app convention dictates)

Interfaces: - Consumes: existing MeasurementListResponse fetch (SessionsPage:249-265), useFacilityId(), FacilityOverviewResponse for the device dropdown + names, SessionDetailPanel (Task 8). - Produces: /sessions URL params device (id | all), status, mode, session.

  • [ ] Step 1: Add scope i18n keys (frontend-app/src/i18n/index.ts, both blocks) mirroring Task 4 (sessions.scopeDevice, sessions.allDevices, sessions.scopeStatus, sessions.scopeMode, sessions.selectPrompt, sessions.empty, sessions.backToList, sessions.openSessions) with the operator t(key, locale) convention.

  • [ ] Step 2: Rewrite SessionsPage.tsx. Keep the existing overview fetch (for the device dropdown + deviceMap) and the sessions fetch, but (a) read device/status/mode/session from useSearchParams, (b) add device_id/status/mode to the sessions request params, (c) render the scope bar + master list + SessionDetailPanel in the same responsive master-detail shell as the admin SessionsPage (Task 4 Step 6 — same Tailwind structure, max-w-[1600px] mx-auto, lg:w-96 list, flex-1 min-w-0 detail, hidden lg:block list when a session is selected, mobile "Back to list"). The sessions request becomes:

const device = params.get('device') || 'all'
const status = params.get('status') || 'all'
const mode = params.get('mode') || 'all'
// …
queryFn: async ({ signal }) => {
  const p = new URLSearchParams()
  if (facilityId) p.set('facility_id', facilityId)
  if (device !== 'all') p.set('device_id', device)
  if (status !== 'all') p.set('status', status)
  if (mode !== 'all') p.set('mode', mode)
  p.set('page', String(page)); p.set('per_page', String(PER_PAGE))
  return api.get<MeasurementListResponse>(`/v1/metadata/sessions?${p.toString()}`, signal)
},
queryKey: ['measurement-records', facilityId, device, status, mode, page],

The device dropdown options come from overview?.devices (label d.alias ?? d.name). The color chip in device === 'all' mode uses the same deterministic hue approach as streamColor/admin deviceColor (add a small frontend-app/src/lib/deviceColor.ts mirroring Task 4 Step 3 + its test, or inline). Clicking a RecordRow sets ?session=<id>; the detail pane renders <SessionDetailPanel sessionId={session} />.

  • [ ] Step 3: Entry points. Add an "Open sessions" link to the operator device tile/detail:
  • DeviceDetailPage.tsx: <Link to={/sessions?device=${id}}>{t('sessions.openSessions', locale)}</Link> near the back button.
  • Device list: either make the tile menu offer it, or add a small link on DeviceTile. (The tile is currently a card; add a footer link to={/sessions?device=${device.id}}.)

  • [ ] Step 4: Verify types + build + unit tests:

Run: cd frontend-app && npx vitest run && npx tsc -b --noEmit && npx vite build Expected: PASS + zero errors (incl. key-parity.test.ts + jargon-lint.test.ts).

  • [ ] Step 5: Visual check (all three viewports). /sessions: device/status/mode scope works; "All devices" shows mixed sessions with color chips; clicking a row opens the historical charts on the right (≥lg) or full-screen (<lg) with "Back to list"; device "Open sessions" pre-scopes; zero pageerror; no horizontal body scroll.

  • [ ] Step 6: Commit:

git add frontend-app/src/pages/SessionsPage.tsx frontend-app/src/pages/DeviceDetailPage.tsx frontend-app/src/components/devices/DeviceTile.tsx frontend-app/src/i18n/index.ts frontend-app/src/lib/deviceColor.ts frontend-app/src/lib/deviceColor.test.ts
git commit -S -m "feat(app): ✨ operator master-detail sessions page with scope bar + entry points"

Task 10: Consistency doc + full validation + deploy

Files: - Create: docs/superpowers/notes/2026-07-16-master-detail-sessions-pattern.md (short pattern note) - Modify: CLAUDE.md / AGENTS.md project-structure lines only if page inventory counts must stay accurate (optional; a linter may already do this)

  • [ ] Step 1: Write the pattern note (≤1 page): the master-detail shell (max-w-[1600px] mx-auto, lg:w-96 master + flex-1 min-w-0 detail, hidden lg:block list when selected, mobile back-link), scope-bar param conventions (device=all, status, mode, session), and the rule that both apps mirror this structure. Reference the two SessionsPage.tsx files.

  • [ ] Step 2: Full validation sweep:

cargo check && cargo clippy
cd frontend && npx tsc -b --noEmit && npx vite build && cd ..
cd frontend-app && npx vitest run && npx tsc -b --noEmit && npx vite build && cd ..

Expected: all clean.

  • [ ] Step 3: Playwright viewport tests for both apps at 375 / 768 / 1280 on /sessions, /devices, and an opened session — assert zero pageerror, no horizontal body scroll, master-detail collapses correctly. Use the Playwright block from CLAUDE.md.

  • [ ] Step 4: Commit the doc:

git add docs/superpowers/notes/2026-07-16-master-detail-sessions-pattern.md
git commit -S -m "docs(sessions): 📝 master-detail sessions pattern note"
  • [ ] Step 5: Deploy + post-deploy verification.
bash scripts/deploy.sh

Then confirm: app container Up (healthy); api.xylolabs.com, admin.api.xylolabs.com, docs.api.xylolabs.com respond; new bundle hashes served (curl -s https://admin.api.xylolabs.com/ | grep -o 'assets/index-[^"]*' and the same for app.xylolabs.com); hard-refresh and smoke-test /sessions + /devices on both apps; the footer build stamp matches the pushed rev on both.


Self-Review

Spec coverage: - Master-detail scoped session browsing (device + facility-mixed) → Tasks 4, 9 (scope bar with device=all), detail panes Tasks 3, 8. - Width/layout fix → Task 3 (single-column stack, drop col-span-full, max-w shell) + the bounded flex-1 detail column in Tasks 4/9. - Cross-app consistency → mirrored shell in Tasks 4/9 + pattern note Task 10. - Installation location prominent in device list → Task 2 (admin), Task 7 (operator), backed by Task 1 (backend field). - Entry points ("select a device / facility and open sessions") → Task 5 (admin), Task 9 (operator). - /metadata/sessions redirects → Task 5. - Backend location on overview DTO → Task 1. Testing (viewport/tsc/build/cargo/clippy) → each task + Task 10. - Out-of-scope items (facility-map keying, fleet benchmark) → untouched. ✓

Placeholder scan: every code step has real code; the two "verify raw field names / chart window" steps (Task 6 Step 4, Task 8 Step 2) are explicit verification steps with the exact command/decision, not vague TODOs — they exist because the operator detail pane is the net-new piece the spec flagged as highest-risk.

Type consistency: SessionDetailPanel({ sessionId }) used identically in Tasks 3→4/5 (admin) and 8→9 (operator). streamDataToTimelineStream(stream, data, windowStart, windowEnd) defined in Task 6, consumed in Task 8 with matching arity. deviceColor(id) defined Task 4, mirrored in Task 9. Admin session fields are camelCase (createdAt, deviceId, totalSamples); operator fields are snake_case (started_at, device_id, total_samples) — kept distinct per app, per Global Constraints.