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
koi18n 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
pageerrorand no horizontal body scroll. All<input>/<select>usestyle={{ 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
enandkoblocks. Admin keys live infrontend/src/i18n/index.ts(flat dotted keys;enfrom line 4,kofrom line 1512;keyParity.test.tsenforces parity;t(key, locale)with manual.replace('{x}', …)interpolation). Operator keys live infrontend-app/src/i18n/index.ts(enfrom line 8,ko~1240;t(key, locale, vars?)interpolates{name};key-parity.test.ts+jargon-lint.test.tsenforce 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 alwaysapi.xylolabs.com. - Reuse each app's own API client / stores / i18n — the two SPAs are independent and type the shared
/v1/metadata/sessionsresponse differently (adminPaginatedResponse<IngestSession>camelCase vs operatorMeasurementListResponsesnake_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 (
locationfield 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, insidepub struct FacilityOverviewDevice, add the field immediately beforeis_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 theFacilityOverviewDevice { … }literal (constructed at line 99, from the ownedd: Device), add beforeis_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.mdanddocs/API.ko.md, find theGET /api/v1/facility/overviewresponse schema's device object and add alocationrow: 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 theenblock (near'devices.location'at line 371) and thekoblock (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, keepingUsbBadge, 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
/devicesat 1280×800 and confirm the Location column shows facility (bold) over free-text location, "Unknown location" where null, nopageerror. (Uses test credsxylolabs/solution_6231againstadmin.api.xylolabs.comor 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(itsgetSession/getDevicequeries,TIME_RANGES,viewMode,effectiveTimeRangeUs/effectiveAnchorUs/effectiveEndUs, custom-range state, and the charts render) intofrontend/src/components/metadata/SessionDetailPanel.tsx. Keep the same imports (StreamChart/isSingleSampleDisplay,AccelChart,GyroChart,MagChart,GroupTripletChart,TimelinePlayer,SimilarSoundPanel,TimeRangeCustomFields, localStreamPanel) 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 fourcol-span-fullwrappers (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.tsxto 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, zeropageerror. -
[ ] 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 theMainLayoutblock):
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, changeto: '/metadata'→to: '/sessions'(label alreadyt('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; zeropageerror; 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.tsxand its testfrontend/src/pages/MetadataSessionsPage.test.tsx(superseded bySessionsPage). Also removefrontend/src/pages/MetadataSessionDetailPage.tsxif it is no longer routed (its logic is inSessionDetailPanel). 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. TheSessionsPagereadsfacilityonly to seedFacilitySelect; wire it by havingSessionsPagecalluseFacilityFilterStore().setSelectedFacility(params.get('facility'))in an effect when thefacilityparam is present (add that effect toSessionsPage). -
[ ] 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
/metadataredirects 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. Zeropageerror. -
[ ] 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
locationto the operator overview type (frontend-app/src/api/types.ts, inFacilityOverviewDevice, afterfirmware_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
locationinto the tile infrontend-app/src/pages/DevicesPage.tsxwhere it maps overviewdevices→<DeviceTile device={…}>. Includelocation: d.locationin 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, addlocation: string | nullto theDeviceDetailinterface (27-33). The existingGET /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).
/devicescards show the location line (or "Unknown location");/devices/<id>shows the location under the title. Zeropageerror. -
[ ] 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.
DeviceTimelineChartexpects realwindowStart/windowEndµs. IfNumber.MAX_SAFE_INTEGERproduces 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 operatort(key, locale)convention. -
[ ] Step 2: Rewrite
SessionsPage.tsx. Keep the existing overview fetch (for the device dropdown +deviceMap) and the sessions fetch, but (a) readdevice/status/mode/sessionfromuseSearchParams, (b) adddevice_id/status/modeto the sessions request params, (c) render the scope bar + master list +SessionDetailPanelin the same responsive master-detail shell as the adminSessionsPage(Task 4 Step 6 — same Tailwind structure,max-w-[1600px] mx-auto,lg:w-96list,flex-1 min-w-0detail,hidden lg:blocklist 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 linkto={/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; zeropageerror; 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-96master +flex-1 min-w-0detail,hidden lg:blocklist 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 twoSessionsPage.tsxfiles. -
[ ] 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 zeropageerror, no horizontal body scroll, master-detail collapses correctly. Use the Playwright block fromCLAUDE.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.