Device Timeline View 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: Build a Grafana-style per-device multi-panel timeline that joins all sessions for a single device into one wall-clock view (admin frontend only).
Architecture: New backend endpoint GET /api/v1/devices/{id}/timeseries aggregates chunks across sessions by stream name; new admin route /devices/:id/timeline renders one Recharts panel per stream with linear time x-axis, line breaks at session boundaries and sample-rate gaps, and a dedicated dot track for audio recording events. Live refresh via 15 s react-query polling with delta-fetch.
Tech Stack: Rust 2024 / Axum / SQLx / Tokio (backend); React 19 / Vite / Recharts / react-query / TailwindCSS (frontend). Reuses existing xylolabs_core::downsample, xylolabs_core::chunk_format::decode_chunk_compressed, and xylolabs_storage clients.
Spec: docs/superpowers/specs/2026-05-22-device-timeline-design.md
File map
Backend (new files)
crates/xylolabs-core/src/dto/device_timeline.rs— request/response DTOscrates/xylolabs-server/src/routes/device_timeline.rs— handlercrates/xylolabs-server/tests/api_device_timeline.rs— integration tests
Backend (modified files)
crates/xylolabs-core/src/dto/mod.rs— addpub mod device_timeline;crates/xylolabs-server/src/routes/mod.rs— addpub mod device_timeline;crates/xylolabs-server/src/router.rs— register/devices/{id}/timeseries
Frontend (new files)
frontend/src/lib/timeline/gap-detection.tsfrontend/src/lib/timeline/gap-detection.test.tsfrontend/src/lib/timeline/session-boundary.tsfrontend/src/lib/timeline/session-boundary.test.tsfrontend/src/lib/timeline/url-state.tsfrontend/src/lib/timeline/url-state.test.tsfrontend/src/components/devices/DeviceTimelineRecordingEvents.tsxfrontend/src/components/devices/DeviceTimelineChart.tsxfrontend/src/pages/DeviceTimelinePage.tsxfrontend/src/pages/DeviceTimelinePage.test.tsx
Frontend (modified files)
frontend/src/api/devices.ts— addgetDeviceTimeseriesfrontend/src/App.tsx— register/devices/:id/timelineroute + lazy importfrontend/src/pages/DevicesPage.tsx— add "Timeline →" link in each rowfrontend/src/lib/errors.ts— add 3 cap patterns + ENGLISH_LITERAL_FOR_KEYfrontend/src/lib/errors.test.ts— cover the 3 patternsfrontend/src/i18n/index.ts— EN+KO entries for the 3 keysfrontend-app/src/lib/errors.ts— mirror patternsfrontend-app/src/lib/__tests__/errors-locale-aware.test.ts— cover patternsfrontend-app/src/i18n/index.ts— mirror EN+KO entries
Phase 1 — Backend foundation
Task 1: DTO definitions
Files:
- Create: crates/xylolabs-core/src/dto/device_timeline.rs
- Modify: crates/xylolabs-core/src/dto/mod.rs
- [ ] Step 1: Add module to dto/mod.rs
Open crates/xylolabs-core/src/dto/mod.rs. The file has a flat list of pub mod X; lines (one per dto, sorted alphabetically). Insert pub mod device_timeline; immediately after pub mod device;.
- [ ] Step 2: Write the DTO file
Create crates/xylolabs-core/src/dto/device_timeline.rs with this content:
//! DTOs for the device timeline endpoint.
//!
//! Cycle 6 2026-05-22: `GET /api/v1/devices/{id}/timeseries` joins
//! chunks across sessions for a single device, grouped by stream name,
//! into a Grafana-style multi-panel timeline. See
//! `docs/superpowers/specs/2026-05-22-device-timeline-design.md`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Query parameters for `GET /api/v1/devices/{id}/timeseries`.
#[derive(Debug, Deserialize)]
pub struct DeviceTimeseriesQuery {
/// Inclusive lower bound in microseconds since epoch. Defaults to
/// `now - 24h` when omitted.
pub start_us: Option<i64>,
/// Inclusive upper bound in microseconds since epoch. Defaults to
/// `now` when omitted.
pub end_us: Option<i64>,
/// Comma-separated list of stream names to include. When omitted,
/// every non-bytes stream the device has emitted is returned.
pub streams: Option<String>,
/// LTTB target point count per stream. Defaults to 1000.
pub downsample: Option<i32>,
}
/// Per-session metadata included in the response so the frontend can
/// draw session-boundary reference lines.
#[derive(Debug, Clone, Serialize)]
pub struct SessionBoundary {
pub session_id: Uuid,
pub start_us: i64,
pub end_us: i64,
pub status: String,
}
/// One sample. `s` is the session UUID the sample came from; the
/// frontend uses it to break the line at session boundaries even when
/// the sample-rate-based gap detector would not.
#[derive(Debug, Clone, Serialize)]
pub struct TimelinePoint {
pub t_us: i64,
pub v: serde_json::Value,
pub s: Uuid,
}
/// One stream group (samples from every session that has a stream with
/// this name).
#[derive(Debug, Clone, Serialize)]
pub struct TimelineStream {
pub name: String,
pub value_type: String,
pub unit: Option<String>,
pub sample_rate_hz: Option<f32>,
pub points: Vec<TimelinePoint>,
}
/// Audio recording entries (bytes-typed streams). Surfaced as dots on
/// the dedicated event track instead of as a stitched waveform.
#[derive(Debug, Clone, Serialize)]
pub struct RecordingEvent {
pub session_id: Uuid,
pub stream_name: String,
pub start_us: i64,
pub end_us: i64,
pub sample_count: i32,
}
#[derive(Debug, Serialize)]
pub struct DeviceTimeseriesResponse {
pub device_id: Uuid,
pub device_label: String,
pub facility_id: Uuid,
pub start_us: i64,
pub end_us: i64,
pub session_count: i32,
pub session_boundaries: Vec<SessionBoundary>,
pub streams: Vec<TimelineStream>,
pub recording_events: Vec<RecordingEvent>,
}
- [ ] Step 3: Verify cargo check
Run: cargo check -p xylolabs-core
Expected: clean compile (no warnings about unused imports or fields).
- [ ] Step 4: Commit
git add crates/xylolabs-core/src/dto/device_timeline.rs crates/xylolabs-core/src/dto/mod.rs
git commit -S -m "feat(core/dto): ✨ add device timeline DTOs"
Task 2: Route module skeleton + registration
Files:
- Create: crates/xylolabs-server/src/routes/device_timeline.rs
- Modify: crates/xylolabs-server/src/routes/mod.rs
- Modify: crates/xylolabs-server/src/router.rs
- [ ] Step 1: Add
pub mod device_timeline;in routes/mod.rs
Open crates/xylolabs-server/src/routes/mod.rs. Insert pub mod device_timeline; immediately after pub mod devices;.
- [ ] Step 2: Create the skeleton handler
Create crates/xylolabs-server/src/routes/device_timeline.rs:
//! `GET /api/v1/devices/{id}/timeseries` — cross-session multi-stream
//! timeline for a single device. See
//! `docs/superpowers/specs/2026-05-22-device-timeline-design.md`.
use axum::{
Json,
extract::{Extension, Path, Query, State},
};
use uuid::Uuid;
use xylolabs_core::dto::device_timeline::{
DeviceTimeseriesQuery, DeviceTimeseriesResponse,
};
use xylolabs_core::models::Role;
use xylolabs_db::repo;
use crate::{
error::AppError,
extractors::AuthenticatedUser,
middleware::rbac::{require_facility_access, require_role},
state::AppState,
};
const DEFAULT_RANGE_US: i64 = 24 * 60 * 60 * 1_000_000;
const DEFAULT_DOWNSAMPLE: i32 = 1000;
pub async fn query_device_timeseries(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(device_id): Path<Uuid>,
Query(_query): Query<DeviceTimeseriesQuery>,
) -> Result<Json<DeviceTimeseriesResponse>, AppError> {
require_role(&user, Role::User)?;
let device = repo::device::find_by_id(&state.db, device_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("device {device_id} not found")))?;
require_facility_access(&user, device.facility_id)?;
// Phase placeholder — replaced incrementally by tasks 3-6 below.
Ok(Json(DeviceTimeseriesResponse {
device_id: device.id,
device_label: device.alias.clone().unwrap_or_else(|| device.name.clone()),
facility_id: device.facility_id,
start_us: 0,
end_us: 0,
session_count: 0,
session_boundaries: Vec::new(),
streams: Vec::new(),
recording_events: Vec::new(),
}))
}
- [ ] Step 3: Wire the route in router.rs
Open crates/xylolabs-server/src/router.rs. Find line 32 (the comma-separated use of route modules). Insert device_timeline, after devices, so the line reads:
inference, inference_proxy, ingest, metadata_query, push, streams, tags, transcode,
remains the same; locate the earlier line that imports devices, and add device_timeline, there. (Search for devices, in the route-module import block.)
Then locate the existing metadata_query route registration block (around line 403). Right after the .route("/metadata/sessions/{id}/live", get(metadata_query::live_sse)) registration, insert:
.route(
"/devices/{id}/timeseries",
get(device_timeline::query_device_timeseries),
)
- [ ] Step 4: Verify cargo check
Run: cargo check -p xylolabs-server
Expected: clean compile.
- [ ] Step 5: Commit
git add crates/xylolabs-server/src/routes/device_timeline.rs crates/xylolabs-server/src/routes/mod.rs crates/xylolabs-server/src/router.rs
git commit -S -m "feat(server): 🚧 device timeline route skeleton"
Task 3: Integration test fixture + happy path
Files:
- Create: crates/xylolabs-server/tests/api_device_timeline.rs
- [ ] Step 1: Inspect existing test fixtures
Read these files to understand the patterns. Do not invent helpers — copy what api_metadata_query.rs already does.
crates/xylolabs-server/tests/common/mod.rs—TestApp::setup()constructor;auth_header(),facility_auth_header(),request_json(),request(),response_json(),api_key_raw.-
crates/xylolabs-server/tests/api_metadata_query.rs— top of file definessetup_session_with_data(app, device_uid, sample_count)which creates an ingest session, sendsXmbpBatchdata, and closes the session. Every test in that file marks itself#[ignore]because the suite needs Docker (postgres + minio) running. -
[ ] Step 2: Write the failing happy-path test
Create crates/xylolabs-server/tests/api_device_timeline.rs. Reuse setup_session_with_data by copying it verbatim from api_metadata_query.rs into this file (it is private to that crate's test binary). Then the test:
#![allow(clippy::manual_div_ceil)]
//! Integration tests for `GET /api/v1/devices/{id}/timeseries`.
//! Cycle 6 2026-05-22. Each test requires Docker infrastructure
//! (postgres + minio) and is therefore `#[ignore]`d. Run with
//! `cargo test --test api_device_timeline -p xylolabs-server --
//! --ignored`.
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use xylolabs_core::models::{
MetadataSample, MetadataValue, MetadataValueType, StreamBlock, XmbpBatch,
};
use xylolabs_core::protocol::{XMBP_VERSION, encode_batch};
// COPY-PASTE this helper from api_metadata_query.rs verbatim — it is
// duplicated rather than shared because the existing `common` module
// only exposes raw `TestApp`. Pulling the helper into `common/` is a
// separate refactor and out of scope for this task.
async fn setup_session_with_data(
app: &common::TestApp,
device_uid: u32,
stream_name: &str,
sample_count: usize,
) -> String {
let body = serde_json::json!({
"device_uid": device_uid,
"name": "Timeline Test Session",
"streams": [
{"stream_index": 0, "name": stream_name, "value_type": "f64", "unit": "celsius", "sample_rate_hz": 100.0}
]
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/ingest/sessions")
.header("content-type", "application/json")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let (status, session_json) = app.request_json(req).await;
assert_eq!(status, StatusCode::CREATED);
let session_id = session_json["id"].as_str().unwrap().to_string();
let samples: Vec<MetadataSample> = (0..sample_count)
.map(|i| MetadataSample {
timestamp_us: (i as u64) * 100_000,
value: MetadataValue::F64(20.0 + (i as f64) * 0.01),
})
.collect();
let batch = XmbpBatch {
version: XMBP_VERSION,
flags: 0,
batch_seq: 0,
device_id: None,
stream_blocks: vec![StreamBlock {
stream_id: 0,
value_type: MetadataValueType::F64,
samples,
}],
};
let encoded = encode_batch(&batch).unwrap();
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/data"))
.header("content-type", "application/octet-stream")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(encoded))
.unwrap();
let (status, _) = app.request_json(req).await;
assert_eq!(status, StatusCode::OK);
let close_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/close"))
.header("X-Api-Key", &app.api_key_raw)
.body(Body::empty())
.unwrap();
let (close_status, _) = app.request_json(close_req).await;
assert_eq!(close_status, StatusCode::OK);
session_id
}
#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn returns_streams_grouped_by_name_across_two_sessions() {
let app = common::TestApp::setup().await;
// Two sessions on the SAME device_uid, each with a `temperature` stream.
let _s1 = setup_session_with_data(&app, 100, "temperature", 50).await;
let _s2 = setup_session_with_data(&app, 100, "temperature", 50).await;
// Look up the device id from the ingest-session list response so we can
// hit the timeline endpoint.
let list_req = Request::builder()
.uri("/api/v1/metadata/sessions")
.header("Authorization", app.facility_auth_header())
.body(Body::empty())
.unwrap();
let (status, list_json) = app.request_json(list_req).await;
assert_eq!(status, StatusCode::OK);
let device_id = list_json["items"][0]["device_id"].as_str().unwrap().to_string();
let req = Request::builder()
.uri(format!("/api/v1/devices/{device_id}/timeseries?start_us=0&end_us=9999999999999999"))
.header("Authorization", app.facility_auth_header())
.body(Body::empty())
.unwrap();
let (status, body) = app.request_json(req).await;
assert_eq!(status, StatusCode::OK, "got: {body}");
assert_eq!(body["device_id"].as_str().unwrap(), device_id);
assert!(body["session_count"].as_i64().unwrap() >= 2);
let streams = body["streams"].as_array().unwrap();
assert_eq!(streams.len(), 1);
assert_eq!(streams[0]["name"].as_str().unwrap(), "temperature");
let points = streams[0]["points"].as_array().unwrap();
assert!(points.len() >= 2, "must keep samples from both sessions");
}
- [ ] Step 3: Run the test, confirm it fails
Run: cargo test --test api_device_timeline -p xylolabs-server returns_streams_grouped_by_name_across_two_sessions -- --ignored --nocapture
Expected: FAIL. The handler currently returns an empty streams array, so streams.len() == 1 is false.
- [ ] Step 4: Skip commit here
Test is intentionally failing pending implementation in tasks 4–6.
Task 4: Session lookup + stream grouping
Files:
- Modify: crates/xylolabs-server/src/routes/device_timeline.rs
- [ ] Step 1: Add session + stream lookup
Replace the body of query_device_timeseries (after the RBAC + device-load preamble) with:
let now_us = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or_default()) as i64;
let end_us = _query.end_us.unwrap_or(now_us);
let start_us = _query
.start_us
.unwrap_or_else(|| end_us.saturating_sub(DEFAULT_RANGE_US));
if start_us > end_us {
return Err(AppError::BadRequest(
"start_us must be <= end_us".to_string(),
));
}
const MAX_TIMELINE_SESSIONS: i64 = 200;
let sessions = repo::ingest_session::list_by_device_in_range(
&state.db,
device_id,
start_us,
end_us,
MAX_TIMELINE_SESSIONS + 1,
)
.await?;
if sessions.len() as i64 > MAX_TIMELINE_SESSIONS {
return Err(AppError::BadRequest(format!(
"timeline window matched more than {MAX_TIMELINE_SESSIONS} sessions; narrow the time range"
)));
}
let session_ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
let stream_filter: Option<std::collections::HashSet<String>> = _query
.streams
.as_deref()
.map(|s| s.split(',').map(|t| t.trim().to_string()).collect());
let all_streams = repo::metadata_stream::list_by_sessions(&state.db, &session_ids).await?;
// Group streams by `name`. For each name we collect the stream rows
// from every session — these are the units that produce a single
// returned `TimelineStream`.
use std::collections::BTreeMap;
let mut streams_by_name: BTreeMap<String, Vec<xylolabs_core::models::MetadataStream>> =
BTreeMap::new();
for stream in all_streams {
if let Some(filter) = stream_filter.as_ref()
&& !filter.contains(&stream.name)
{
continue;
}
streams_by_name.entry(stream.name.clone()).or_default().push(stream);
}
// Placeholders, replaced in tasks 5-6.
let response_streams: Vec<xylolabs_core::dto::device_timeline::TimelineStream> = Vec::new();
let recording_events: Vec<xylolabs_core::dto::device_timeline::RecordingEvent> = Vec::new();
let session_boundaries: Vec<xylolabs_core::dto::device_timeline::SessionBoundary> = sessions
.iter()
.map(|s| xylolabs_core::dto::device_timeline::SessionBoundary {
session_id: s.id,
start_us: s.started_at.timestamp_micros(),
end_us: s
.closed_at
.map(|c| c.timestamp_micros())
.unwrap_or(end_us),
status: s.status.to_string(),
})
.collect();
Ok(Json(DeviceTimeseriesResponse {
device_id: device.id,
device_label: device.alias.clone().unwrap_or_else(|| device.name.clone()),
facility_id: device.facility_id,
start_us,
end_us,
session_count: streams_by_name.len() as i32, // refined in task 6
session_boundaries,
streams: response_streams,
recording_events,
}))
Rename _query to query (drop the leading underscore now that it's used).
- [ ] Step 2: Add the missing repo helper
The IngestSession model exposes started_at: DateTime<Utc> and
closed_at: Option<DateTime<Utc>> — there is no _us suffix.
Convert i64 microseconds to DateTime<Utc> in the handler via
chrono::DateTime::<Utc>::from_timestamp_micros(us), then pass
DateTime<Utc> into the repo.
Open crates/xylolabs-db/src/repo/ingest_session.rs. Add this function (next to existing list_by_facility helper — match the surrounding style):
/// Sessions for a device that overlap the half-open range
/// `[start, end]`. Returns up to `limit` rows; caller checks
/// `len() > expected_max` to detect over-cap conditions. Cycle 6
/// 2026-05-22 (device timeline endpoint).
pub async fn list_by_device_in_range(
pool: &PgPool,
device_id: Uuid,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
limit: i64,
) -> Result<Vec<IngestSession>, sqlx::Error> {
sqlx::query_as::<_, IngestSession>(
r#"
SELECT * FROM ingest_sessions
WHERE device_id = $1
AND started_at <= $3
AND (closed_at IS NULL OR closed_at >= $2)
ORDER BY started_at ASC
LIMIT $4
"#,
)
.bind(device_id)
.bind(start)
.bind(end)
.bind(limit)
.fetch_all(pool)
.await
}
In the handler, convert before calling:
let start_dt = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(start_us)
.ok_or_else(|| AppError::BadRequest("start_us out of range".to_string()))?;
let end_dt = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(end_us)
.ok_or_else(|| AppError::BadRequest("end_us out of range".to_string()))?;
let sessions = repo::ingest_session::list_by_device_in_range(
&state.db,
device_id,
start_dt,
end_dt,
MAX_TIMELINE_SESSIONS + 1,
)
.await?;
- [ ] Step 3: cargo check
Run: cargo check -p xylolabs-server
Expected: clean compile.
- [ ] Step 4: Commit
git add crates/xylolabs-server/src/routes/device_timeline.rs crates/xylolabs-db/src/repo/ingest_session.rs
git commit -S -m "feat(server): 🚧 device timeline session + stream grouping"
Task 5: Chunk fetch + cap enforcement + downsample
Files:
- Modify: crates/xylolabs-server/src/routes/device_timeline.rs
- [ ] Step 1: Add cap constants + chunk-fetch loop
Inside query_device_timeseries, replace the placeholder response_streams / recording_events initialization (right after the streams-by-name grouping) with the following block. Keep the existing imports; add what's missing at the top of the file:
use futures::stream::{self as fstream, StreamExt};
use xylolabs_core::chunk_format::decode_chunk_compressed;
use xylolabs_core::downsample::downsample_samples;
use xylolabs_core::models::{MetadataSample, MetadataValue};
use xylolabs_core::dto::device_timeline::{
RecordingEvent, TimelinePoint, TimelineStream,
};
Then within the handler:
const MAX_TIMELINE_CHUNKS: i64 = 500;
const MAX_TIMELINE_SAMPLES: usize = 2_000_000;
const MAX_CONCURRENT_CHUNK_DOWNLOADS: usize = 8;
let downsample_target = query.downsample.unwrap_or(DEFAULT_DOWNSAMPLE).max(0) as usize;
let mut total_chunks_used: i64 = 0;
let mut response_streams: Vec<TimelineStream> = Vec::new();
let mut recording_events: Vec<RecordingEvent> = Vec::new();
// Sessions that contributed at least one returned point. Drives
// session_count and session_boundaries filtering downstream.
let mut contributing_sessions: std::collections::HashSet<Uuid> = Default::default();
// Lookup map session_id → started_at_us for the per-session clock
// anchor used in task 6.
let session_started_at_us: std::collections::HashMap<Uuid, i64> = sessions
.iter()
.map(|s| (s.id, s.started_at.timestamp_micros()))
.collect();
for (name, stream_rows) in streams_by_name {
// Bytes streams become recording events only.
let value_type = stream_rows
.first()
.map(|s| s.value_type.clone())
.unwrap_or_default();
if value_type == "bytes" {
for stream in &stream_rows {
let chunks = repo::metadata_chunk::list_by_stream_in_range(
&state.db,
stream.id,
start_us,
end_us,
)
.await?;
for chunk in chunks {
recording_events.push(RecordingEvent {
session_id: stream.session_id,
stream_name: name.clone(),
start_us: chunk.start_ts_us,
end_us: chunk.end_ts_us,
sample_count: chunk.sample_count,
});
contributing_sessions.insert(stream.session_id);
}
}
continue;
}
// Numeric / array streams: pull chunks bounded by remaining cap.
let mut numeric_samples_with_session: Vec<(MetadataSample, Uuid)> = Vec::new();
for stream in &stream_rows {
let remaining = (MAX_TIMELINE_CHUNKS - total_chunks_used).max(0);
if remaining == 0 {
return Err(AppError::BadRequest(format!(
"timeline query matched more than {MAX_TIMELINE_CHUNKS} chunks; narrow the time range"
)));
}
let result = repo::metadata_chunk::list_by_stream_in_range_paginated(
&state.db,
stream.id,
start_us,
end_us,
remaining,
)
.await?;
if result.has_more {
return Err(AppError::BadRequest(format!(
"timeline query matched more than {MAX_TIMELINE_CHUNKS} chunks; narrow the time range"
)));
}
total_chunks_used += result.chunks.len() as i64;
// Download + decode with bounded concurrency.
let download_futures = result.chunks.iter().map(|chunk| {
let storage = state.storage.clone();
let s3_key = chunk.s3_key.clone();
let session_id = stream.session_id;
async move {
let compressed = match storage.download(&s3_key).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(s3_key = %s3_key, error = %e, "device-timeline chunk download failed, skipping");
return (session_id, Vec::new());
}
};
let samples = match decode_chunk_compressed(&compressed) {
Ok((_vt, s)) => s,
Err(e) => {
tracing::warn!(s3_key = %s3_key, error = %e, "device-timeline chunk decode failed, skipping");
return (session_id, Vec::new());
}
};
(session_id, samples)
}
});
let downloaded: Vec<(Uuid, Vec<MetadataSample>)> = fstream::iter(download_futures)
.buffered(MAX_CONCURRENT_CHUNK_DOWNLOADS)
.collect()
.await;
for (session_id, mut samples) in downloaded {
samples.retain(|s| {
let ts = s.timestamp_us as i64;
ts >= start_us && ts <= end_us
});
for sample in samples {
numeric_samples_with_session.push((sample, session_id));
}
if numeric_samples_with_session.len() > MAX_TIMELINE_SAMPLES {
return Err(AppError::BadRequest(format!(
"timeline samples exceed {MAX_TIMELINE_SAMPLES}; narrow the time range or request stronger downsampling"
)));
}
}
}
// Skip the stream group entirely if no points came back.
if numeric_samples_with_session.is_empty() {
continue;
}
// Sort by (t, session_id) so dedupe is deterministic.
numeric_samples_with_session
.sort_by_key(|(s, sid)| (s.timestamp_us as i64, *sid));
numeric_samples_with_session
.dedup_by_key(|(s, sid)| (s.timestamp_us as i64, *sid));
// Apply per-session clock anchor: shift each session's points so
// its first point aligns with the session's started_at_us. Without
// this, devices with bad RTCs spread their points across the chart
// unpredictably. Cap the tail at min(now, sessionStart + 1h) so
// long device-clock drift can't push points into the future.
let mut anchored: Vec<(MetadataSample, Uuid)> = Vec::with_capacity(numeric_samples_with_session.len());
let mut per_session_first: std::collections::HashMap<Uuid, i64> = Default::default();
for (sample, session_id) in &numeric_samples_with_session {
per_session_first
.entry(*session_id)
.or_insert(sample.timestamp_us as i64);
}
for (sample, session_id) in numeric_samples_with_session {
let first_ts = per_session_first.get(&session_id).copied().unwrap_or(0);
let session_start = session_started_at_us
.get(&session_id)
.copied()
.unwrap_or(first_ts);
let shifted_ts = (sample.timestamp_us as i64) + (session_start - first_ts);
let cap_us = (session_start + 3_600 * 1_000_000).min(now_us);
let final_ts = shifted_ts.min(cap_us);
anchored.push((
MetadataSample {
timestamp_us: final_ts as u64,
value: sample.value,
},
session_id,
));
}
// LTTB downsample on the numeric subset only (skip bytes — we
// already filtered them out above).
let samples_only: Vec<MetadataSample> =
anchored.iter().map(|(s, _)| s.clone()).collect();
let downsampled_samples = if downsample_target > 0
&& samples_only.len() > downsample_target
&& samples_only
.first()
.map(|s| s.value.as_f64().is_some())
.unwrap_or(false)
{
downsample_samples(&samples_only, downsample_target)
.unwrap_or(samples_only)
} else {
samples_only
};
// Re-attach session_id to each downsampled point by exact-ts match
// (LTTB picks original points, so timestamps are preserved).
let ts_to_session: std::collections::HashMap<u64, Uuid> = anchored
.iter()
.map(|(s, sid)| (s.timestamp_us, *sid))
.collect();
let first_stream = stream_rows.first().expect("stream_rows non-empty");
let points: Vec<TimelinePoint> = downsampled_samples
.into_iter()
.filter_map(|s| {
let session_id = ts_to_session.get(&s.timestamp_us).copied()?;
contributing_sessions.insert(session_id);
Some(TimelinePoint {
t_us: s.timestamp_us as i64,
v: metadata_value_to_json(&s.value, first_stream.scale, first_stream.offset),
s: session_id,
})
})
.collect();
if points.is_empty() {
continue;
}
response_streams.push(TimelineStream {
name,
value_type,
unit: first_stream.unit.clone(),
sample_rate_hz: first_stream.sample_rate_hz,
points,
});
}
// Filter session_boundaries to contributing sessions only.
let session_boundaries: Vec<xylolabs_core::dto::device_timeline::SessionBoundary> = sessions
.iter()
.filter(|s| contributing_sessions.contains(&s.id))
.map(|s| xylolabs_core::dto::device_timeline::SessionBoundary {
session_id: s.id,
start_us: s.started_at.timestamp_micros(),
end_us: s
.closed_at
.map(|c| c.timestamp_micros())
.unwrap_or(end_us),
status: s.status.to_string(),
})
.collect();
let session_count = contributing_sessions.len() as i32;
Then update the final Ok(Json(DeviceTimeseriesResponse { … })) so it uses these new locals (session_boundaries, session_count, response_streams, recording_events).
- [ ] Step 2: Add
metadata_value_to_jsonhelper
MetadataValue::to_json() already exists in xylolabs-core/src/models/metadata.rs and handles every variant (numeric, string, array, bytes via base64, JSON). The helper here only needs the scale/offset application path:
At the bottom of routes/device_timeline.rs, add:
/// Convert a `MetadataValue` into the JSON shape the frontend expects.
/// Applies `scale * v + offset` for scalar numeric values; delegates
/// every other variant to `MetadataValue::to_json` (which handles
/// arrays, bytes/base64, strings, etc.).
fn metadata_value_to_json(
value: &MetadataValue,
scale: Option<f64>,
offset: Option<f64>,
) -> serde_json::Value {
if let Some(f) = value.as_f64() {
let scaled = f * scale.unwrap_or(1.0) + offset.unwrap_or(0.0);
serde_json::json!(scaled)
} else {
value.to_json()
}
}
No extra imports needed beyond the use xylolabs_core::models::{MetadataSample, MetadataValue}; already in the file.
- [ ] Step 3: cargo check
Run: cargo check -p xylolabs-server
Expected: clean.
- [ ] Step 4: Run the integration test from task 3
Run: cargo test --test api_device_timeline -p xylolabs-server returns_streams_grouped_by_name_across_two_sessions -- --nocapture
Expected: PASS now that real data is returned.
- [ ] Step 5: Commit
git add crates/xylolabs-server/src/routes/device_timeline.rs
git commit -S -m "feat(server): ✨ device timeline chunk fetch, caps, downsample"
Task 6: Cap-error integration tests + filter test
Files:
- Modify: crates/xylolabs-server/tests/api_device_timeline.rs
- [ ] Step 1: Add failing tests for the
streams=filter and the chunk cap
Append to tests/api_device_timeline.rs:
#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn streams_filter_excludes_other_names() {
let app = common::TestApp::setup().await;
// Single session with two streams (temperature + humidity). Reuse the
// helper above to create the temperature stream/data, then add a
// humidity stream via the same ingest API surface.
let body = serde_json::json!({
"device_uid": 200,
"name": "Filter Test",
"streams": [
{"stream_index": 0, "name": "temperature", "value_type": "f64", "unit": "celsius", "sample_rate_hz": 100.0},
{"stream_index": 1, "name": "humidity", "value_type": "f64", "unit": "%", "sample_rate_hz": 100.0}
]
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/ingest/sessions")
.header("content-type", "application/json")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let (status, session_json) = app.request_json(req).await;
assert_eq!(status, StatusCode::CREATED);
let session_id = session_json["id"].as_str().unwrap().to_string();
let device_id = session_json["device_id"].as_str().unwrap().to_string();
// Send 50 samples for each stream.
for stream_id in 0u16..2 {
let samples: Vec<MetadataSample> = (0..50)
.map(|i| MetadataSample {
timestamp_us: (i as u64) * 100_000,
value: MetadataValue::F64(i as f64),
})
.collect();
let batch = XmbpBatch {
version: XMBP_VERSION,
flags: 0,
batch_seq: stream_id,
device_id: None,
stream_blocks: vec![StreamBlock {
stream_id,
value_type: MetadataValueType::F64,
samples,
}],
};
let encoded = encode_batch(&batch).unwrap();
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/data"))
.header("content-type", "application/octet-stream")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(encoded))
.unwrap();
let (status, _) = app.request_json(req).await;
assert_eq!(status, StatusCode::OK);
}
let close_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/close"))
.header("X-Api-Key", &app.api_key_raw)
.body(Body::empty())
.unwrap();
let _ = app.request_json(close_req).await;
let req = Request::builder()
.uri(format!("/api/v1/devices/{device_id}/timeseries?start_us=0&end_us=9999999999999999&streams=temperature"))
.header("Authorization", app.facility_auth_header())
.body(Body::empty())
.unwrap();
let (status, body) = app.request_json(req).await;
assert_eq!(status, StatusCode::OK, "got: {body}");
let streams = body["streams"].as_array().unwrap();
assert_eq!(streams.len(), 1);
assert_eq!(streams[0]["name"].as_str().unwrap(), "temperature");
}
#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn cap_too_many_chunks_returns_specific_error_literal() {
let app = common::TestApp::setup().await;
// Trigger the chunk cap by ingesting samples that force the chunker to
// emit many chunks. The simplest path: 5001 samples across 5001 chunks
// by sending one-sample batches. We send 501 explicit chunks via 501
// close-and-reopen cycles to comfortably overshoot the 500 cap.
// (Sister test in api_metadata_query.rs uses the same pattern at the
// MAX_QUERY_CHUNKS=100 boundary.)
let mut device_id_out = String::new();
for i in 0..505 {
let body = serde_json::json!({
"device_uid": 300,
"name": format!("Cap test {i}"),
"streams": [
{"stream_index": 0, "name": "temperature", "value_type": "f64", "unit": "celsius", "sample_rate_hz": 100.0}
]
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/ingest/sessions")
.header("content-type", "application/json")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap();
let (_status, session_json) = app.request_json(req).await;
let session_id = session_json["id"].as_str().unwrap().to_string();
if device_id_out.is_empty() {
device_id_out = session_json["device_id"].as_str().unwrap().to_string();
}
let samples = vec![MetadataSample {
timestamp_us: (i as u64) * 1_000_000,
value: MetadataValue::F64(i as f64),
}];
let batch = XmbpBatch {
version: XMBP_VERSION,
flags: 0,
batch_seq: i as u16,
device_id: None,
stream_blocks: vec![StreamBlock {
stream_id: 0,
value_type: MetadataValueType::F64,
samples,
}],
};
let encoded = encode_batch(&batch).unwrap();
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/data"))
.header("content-type", "application/octet-stream")
.header("X-Api-Key", &app.api_key_raw)
.body(Body::from(encoded))
.unwrap();
let _ = app.request_json(req).await;
let close_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/ingest/sessions/{session_id}/close"))
.header("X-Api-Key", &app.api_key_raw)
.body(Body::empty())
.unwrap();
let _ = app.request_json(close_req).await;
}
let req = Request::builder()
.uri(format!("/api/v1/devices/{device_id_out}/timeseries?start_us=0&end_us=9999999999999999"))
.header("Authorization", app.facility_auth_header())
.body(Body::empty())
.unwrap();
let (status, body) = app.request_json(req).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// Either the session cap (200) or the chunk cap (500) fires first.
let err = body["error"].as_str().unwrap();
assert!(
err.contains("timeline window matched more than 200 sessions")
|| err.contains("timeline query matched more than 500 chunks"),
"expected timeline cap message, got: {err}"
);
}
- [ ] Step 2: Run new tests
Run: cargo test --test api_device_timeline -p xylolabs-server -- --ignored --nocapture
Expected: all three tests pass.
- [ ] Step 3: Commit
git add crates/xylolabs-server/tests/api_device_timeline.rs
git commit -S -m "test(server): 🧪 device timeline filter + cap + RBAC tests"
Phase 2 — Frontend error mapping (mirrors cycle 5 deslop)
Task 7: Add 3 cap-error patterns + i18n
Files:
- Modify: frontend/src/lib/errors.ts
- Modify: frontend/src/lib/errors.test.ts
- Modify: frontend/src/i18n/index.ts
- Modify: frontend-app/src/lib/errors.ts
- Modify: frontend-app/src/lib/__tests__/errors-locale-aware.test.ts
- Modify: frontend-app/src/i18n/index.ts
- [ ] Step 1: Add patterns to
frontend/src/lib/errors.ts
In resolveErrorKey, immediately after the existing if (/query exceeds \d+ total samples/i.test(raw)) return 'errors.tooManySamples' line, insert:
if (/timeline window matched more than \d+ sessions/i.test(raw)) return 'errors.tooManyTimelineSessions'
if (/timeline query matched more than \d+ chunks/i.test(raw)) return 'errors.tooManyTimelineChunks'
if (/timeline samples exceed \d+/i.test(raw)) return 'errors.tooManyTimelineSamples'
In the ENGLISH_LITERAL_FOR_KEY map, immediately after the existing 'errors.tooManySamples': '...' line, insert:
'errors.tooManyTimelineSessions': 'Too many sessions in this range. Narrow the time range.',
'errors.tooManyTimelineChunks': 'Too many data chunks in this range. Narrow the time range.',
'errors.tooManyTimelineSamples': 'Too many samples in this range. Narrow the time range or downsample more.',
- [ ] Step 2: Mirror to
frontend-app/src/lib/errors.ts
Same two insertions, same lines, same content.
- [ ] Step 3: Add i18n EN+KO entries to
frontend/src/i18n/index.ts
Locate the errors.tooManySamples line in the EN block (around line 138, look for the comment "Metadata/audio query errors"). Insert immediately after:
'errors.tooManyTimelineSessions': 'Too many sessions in this range. Narrow the time range.',
'errors.tooManyTimelineChunks': 'Too many data chunks in this range. Narrow the time range.',
'errors.tooManyTimelineSamples': 'Too many samples in this range. Narrow the time range or downsample more.',
Then locate the matching KO line and insert:
'errors.tooManyTimelineSessions': '이 구간의 세션 수가 너무 많아요. 시간 범위를 좁혀 주세요.',
'errors.tooManyTimelineChunks': '이 구간의 데이터 청크가 너무 많아요. 시간 범위를 좁혀 주세요.',
'errors.tooManyTimelineSamples': '이 구간의 샘플 수가 너무 많아요. 시간 범위를 좁히거나 다운샘플링을 강화해 주세요.',
- [ ] Step 4: Mirror EN+KO to
frontend-app/src/i18n/index.ts
Same insertions in both the EN block and the KO block at the matching locations.
- [ ] Step 5: Append tests in
frontend/src/lib/errors.test.ts
Inside the existing describe('frontend errors.ts ...') block, add a new it:
it('friendlyErrorMessage: maps timeline cap literals (cycle 6)', () => {
expect(
friendlyErrorMessage(
{ error: 'timeline window matched more than 200 sessions; narrow the time range' },
'en',
),
).toBe('Too many sessions in this range. Narrow the time range.')
expect(
friendlyErrorMessage(
{ error: 'timeline query matched more than 500 chunks; narrow the time range' },
'ko',
),
).toBe('이 구간의 데이터 청크가 너무 많아요. 시간 범위를 좁혀 주세요.')
expect(
friendlyErrorMessage(
{ error: 'timeline samples exceed 2000000; narrow the time range or request stronger downsampling' },
'en',
),
).toBe('Too many samples in this range. Narrow the time range or downsample more.')
})
- [ ] Step 6: Append matching tests in
frontend-app/src/lib/__tests__/errors-locale-aware.test.ts
Inside the existing describe('P612 cycle 20 ...') block, add an it with the same three assertions.
- [ ] Step 7: Run tests
Run: cd frontend && npx vitest run src/lib/errors.test.ts && cd ../frontend-app && npx vitest run src/lib/__tests__/errors-locale-aware.test.ts
Expected: all green.
- [ ] Step 8: Commit
git add frontend/src/lib/errors.ts frontend/src/lib/errors.test.ts frontend/src/i18n/index.ts frontend-app/src/lib/errors.ts frontend-app/src/lib/__tests__/errors-locale-aware.test.ts frontend-app/src/i18n/index.ts
git commit -S -m "feat(app): 🌐 map device timeline cap errors to i18n"
Phase 3 — Frontend helpers
Task 8: gap-detection.ts
Files:
- Create: frontend/src/lib/timeline/gap-detection.ts
- Create: frontend/src/lib/timeline/gap-detection.test.ts
- [ ] Step 1: Write the failing test
Create frontend/src/lib/timeline/gap-detection.test.ts:
import { describe, it, expect } from 'vitest'
import { insertGapBreakpoints, type TimelinePoint } from './gap-detection'
const p = (t_us: number, v: number, s: string): TimelinePoint => ({ t_us, v, s })
describe('insertGapBreakpoints', () => {
it('returns the input untouched when there are no gaps', () => {
const input = [p(0, 1, 'A'), p(1_000_000, 2, 'A'), p(2_000_000, 3, 'A')]
const out = insertGapBreakpoints(input, 1)
expect(out).toEqual(input)
})
it('inserts a null breakpoint when Δt > 3 × expected interval', () => {
// sample_rate_hz = 1 → expected interval 1 s, threshold 3 s.
const input = [p(0, 1, 'A'), p(5_000_000, 2, 'A')]
const out = insertGapBreakpoints(input, 1)
expect(out.length).toBe(3)
expect(out[1].v).toBe(null)
expect(out[1].t_us).toBe(1) // just after the prior point
})
it('falls back to 30 s floor when sample_rate_hz is null', () => {
const input = [p(0, 1, 'A'), p(40_000_000, 2, 'A')] // 40 s gap
const out = insertGapBreakpoints(input, null)
expect(out.length).toBe(3)
expect(out[1].v).toBe(null)
})
it('breaks at session boundaries regardless of Δt', () => {
const input = [p(0, 1, 'A'), p(100_000, 2, 'B')] // 100 ms — would not gap by time
const out = insertGapBreakpoints(input, 1)
expect(out.length).toBe(3)
expect(out[1].v).toBe(null)
})
it('handles empty and single-point input', () => {
expect(insertGapBreakpoints([], 1)).toEqual([])
const single = [p(0, 1, 'A')]
expect(insertGapBreakpoints(single, 1)).toEqual(single)
})
})
- [ ] Step 2: Run, confirm fail
Run: cd frontend && npx vitest run src/lib/timeline/gap-detection.test.ts
Expected: FAIL (module not found).
- [ ] Step 3: Write the implementation
Create frontend/src/lib/timeline/gap-detection.ts:
/**
* Gap-detection helper for the device timeline view.
*
* Walks a sorted-by-`t_us` array of points and inserts a synthetic
* `{ t_us, v: null, s: null }` entry whenever (a) the gap to the
* previous point exceeds `3 × (1e6 / sample_rate_hz)` microseconds
* (with a 30 s floor when sample_rate_hz is null or below 1/30 Hz),
* or (b) the session id changes. Recharts breaks the line at every
* null value, which is what we want.
*
* Pure / unit-testable.
*/
export interface TimelinePoint {
t_us: number
v: number | number[] | string | null
s: string | null
}
const FLOOR_GAP_US = 30 * 1_000_000
export function insertGapBreakpoints(
points: TimelinePoint[],
sampleRateHz: number | null,
): TimelinePoint[] {
if (points.length < 2) return points.slice()
const expectedUs =
sampleRateHz && sampleRateHz > 0 ? 1_000_000 / sampleRateHz : null
const thresholdUs =
expectedUs != null ? Math.max(expectedUs * 3, FLOOR_GAP_US) : FLOOR_GAP_US
const out: TimelinePoint[] = []
for (let i = 0; i < points.length; i++) {
if (i > 0) {
const prev = points[i - 1]
const cur = points[i]
const sessionChanged = prev.s !== null && cur.s !== null && prev.s !== cur.s
if (sessionChanged || cur.t_us - prev.t_us > thresholdUs) {
out.push({ t_us: prev.t_us + 1, v: null, s: null })
}
}
out.push(points[i])
}
return out
}
- [ ] Step 4: Run tests, confirm pass
Run: npx vitest run src/lib/timeline/gap-detection.test.ts
Expected: 5 tests pass.
- [ ] Step 5: Commit
git add frontend/src/lib/timeline/gap-detection.ts frontend/src/lib/timeline/gap-detection.test.ts
git commit -S -m "feat(app): ✨ device timeline gap-detection helper"
Task 9: session-boundary.ts
Files:
- Create: frontend/src/lib/timeline/session-boundary.ts
- Create: frontend/src/lib/timeline/session-boundary.test.ts
- [ ] Step 1: Write the failing test
Create frontend/src/lib/timeline/session-boundary.test.ts:
import { describe, it, expect } from 'vitest'
import { toReferenceLineProps, type SessionBoundary } from './session-boundary'
const b = (start: number, end: number): SessionBoundary => ({
session_id: 'uuid',
start_us: start,
end_us: end,
status: 'closed',
})
describe('toReferenceLineProps', () => {
it('emits one ReferenceLine prop per session start, clamped to the visible range', () => {
const out = toReferenceLineProps([b(100, 200), b(300, 400)], 0, 500)
expect(out.length).toBe(2)
expect(out[0].x).toBe(100)
expect(out[1].x).toBe(300)
})
it('drops sessions whose start is outside [windowStart, windowEnd]', () => {
const out = toReferenceLineProps([b(50, 100), b(300, 400)], 200, 500)
expect(out.length).toBe(1)
expect(out[0].x).toBe(300)
})
it('returns [] for empty input', () => {
expect(toReferenceLineProps([], 0, 100)).toEqual([])
})
})
- [ ] Step 2: Run, confirm fail
Run: npx vitest run src/lib/timeline/session-boundary.test.ts
Expected: FAIL.
- [ ] Step 3: Write implementation
Create frontend/src/lib/timeline/session-boundary.ts:
/**
* Convert API `session_boundaries` into Recharts `ReferenceLine` props.
* Pure / unit-testable.
*/
export interface SessionBoundary {
session_id: string
start_us: number
end_us: number
status: string
}
export interface ReferenceLineProp {
x: number
sessionId: string
status: string
}
export function toReferenceLineProps(
boundaries: SessionBoundary[],
windowStart: number,
windowEnd: number,
): ReferenceLineProp[] {
const out: ReferenceLineProp[] = []
for (const b of boundaries) {
if (b.start_us >= windowStart && b.start_us <= windowEnd) {
out.push({ x: b.start_us, sessionId: b.session_id, status: b.status })
}
}
return out
}
- [ ] Step 4: Run tests, confirm pass
Run: npx vitest run src/lib/timeline/session-boundary.test.ts
Expected: 3 tests pass.
- [ ] Step 5: Commit
git add frontend/src/lib/timeline/session-boundary.ts frontend/src/lib/timeline/session-boundary.test.ts
git commit -S -m "feat(app): ✨ device timeline session-boundary helper"
Task 10: url-state.ts
Files:
- Create: frontend/src/lib/timeline/url-state.ts
- Create: frontend/src/lib/timeline/url-state.test.ts
- [ ] Step 1: Write the failing test
Create frontend/src/lib/timeline/url-state.test.ts:
import { describe, it, expect } from 'vitest'
import { parseTimelineUrlState, serializeTimelineUrlState } from './url-state'
describe('parseTimelineUrlState', () => {
it('returns last-24h defaults when no params are set', () => {
const before = Date.now()
const out = parseTimelineUrlState(new URLSearchParams())
const after = Date.now()
expect(out.endUs / 1000).toBeGreaterThanOrEqual(before)
expect(out.endUs / 1000).toBeLessThanOrEqual(after)
expect(out.endUs - out.startUs).toBe(24 * 3_600 * 1_000_000)
expect(out.streams).toBe(null)
})
it('parses from/to ISO strings into microseconds', () => {
const out = parseTimelineUrlState(
new URLSearchParams({
from: '2026-05-20T00:00:00Z',
to: '2026-05-21T00:00:00Z',
}),
)
expect(out.startUs).toBe(Date.parse('2026-05-20T00:00:00Z') * 1000)
expect(out.endUs).toBe(Date.parse('2026-05-21T00:00:00Z') * 1000)
})
it('parses streams as a sorted unique list', () => {
const out = parseTimelineUrlState(
new URLSearchParams({ streams: 'humidity,temperature,humidity' }),
)
expect(out.streams).toEqual(['humidity', 'temperature'])
})
it('clamps inverted ranges by swapping', () => {
const out = parseTimelineUrlState(
new URLSearchParams({
from: '2026-05-21T00:00:00Z',
to: '2026-05-20T00:00:00Z',
}),
)
expect(out.startUs).toBeLessThan(out.endUs)
})
})
describe('serializeTimelineUrlState', () => {
it('round-trips through parseTimelineUrlState', () => {
const original = {
startUs: Date.parse('2026-05-20T00:00:00Z') * 1000,
endUs: Date.parse('2026-05-21T00:00:00Z') * 1000,
streams: ['humidity', 'temperature'],
}
const params = serializeTimelineUrlState(original)
const round = parseTimelineUrlState(params)
expect(round.startUs).toBe(original.startUs)
expect(round.endUs).toBe(original.endUs)
expect(round.streams).toEqual(original.streams)
})
it('omits streams when null', () => {
const params = serializeTimelineUrlState({ startUs: 0, endUs: 1, streams: null })
expect(params.get('streams')).toBe(null)
})
})
- [ ] Step 2: Run, confirm fail
Run: npx vitest run src/lib/timeline/url-state.test.ts
Expected: FAIL.
- [ ] Step 3: Write the implementation
Create frontend/src/lib/timeline/url-state.ts:
/**
* URL ↔ device-timeline state adapter. Pure / unit-testable.
*
* Encodes `from` and `to` as ISO strings (so URLs stay human-readable)
* but exposes microseconds-since-epoch to consumers — the API contract
* is in microseconds.
*/
const TWENTY_FOUR_HOURS_US = 24 * 3_600 * 1_000_000
export interface TimelineUrlState {
startUs: number
endUs: number
/** `null` means "all non-bytes streams". Otherwise a sorted, unique list. */
streams: string[] | null
}
export function parseTimelineUrlState(params: URLSearchParams): TimelineUrlState {
const fromRaw = params.get('from')
const toRaw = params.get('to')
const nowUs = Date.now() * 1000
let endUs = toRaw ? Date.parse(toRaw) * 1000 : nowUs
let startUs = fromRaw ? Date.parse(fromRaw) * 1000 : endUs - TWENTY_FOUR_HOURS_US
if (!Number.isFinite(endUs)) endUs = nowUs
if (!Number.isFinite(startUs)) startUs = endUs - TWENTY_FOUR_HOURS_US
if (startUs > endUs) {
const tmp = startUs
startUs = endUs
endUs = tmp
}
const streamsRaw = params.get('streams')
let streams: string[] | null = null
if (streamsRaw) {
const unique = Array.from(
new Set(streamsRaw.split(',').map((s) => s.trim()).filter(Boolean)),
)
unique.sort()
streams = unique
}
return { startUs, endUs, streams }
}
export function serializeTimelineUrlState(state: TimelineUrlState): URLSearchParams {
const params = new URLSearchParams()
params.set('from', new Date(state.startUs / 1000).toISOString())
params.set('to', new Date(state.endUs / 1000).toISOString())
if (state.streams && state.streams.length > 0) {
params.set('streams', state.streams.join(','))
}
return params
}
- [ ] Step 4: Run tests, confirm pass
Run: npx vitest run src/lib/timeline/url-state.test.ts
Expected: 5 tests pass.
- [ ] Step 5: Commit
git add frontend/src/lib/timeline/url-state.ts frontend/src/lib/timeline/url-state.test.ts
git commit -S -m "feat(app): ✨ device timeline URL state adapter"
Phase 4 — Frontend API client
Task 11: getDeviceTimeseries in api/devices.ts
Files:
- Modify: frontend/src/api/devices.ts
- [ ] Step 1: Add types + function
Open frontend/src/api/devices.ts. Append to the end of the file:
// ---------------------------------------------------------------------------
// Device timeline (cycle 6 2026-05-22). See
// docs/superpowers/specs/2026-05-22-device-timeline-design.md.
// ---------------------------------------------------------------------------
export interface TimelinePoint {
t_us: number
v: number | number[] | string | null
s: string
}
export interface TimelineStream {
name: string
value_type: string
unit: string | null
sample_rate_hz: number | null
points: TimelinePoint[]
}
export interface SessionBoundary {
session_id: string
start_us: number
end_us: number
status: string
}
export interface RecordingEvent {
session_id: string
stream_name: string
start_us: number
end_us: number
sample_count: number
}
export interface DeviceTimeseriesResponse {
device_id: string
device_label: string
facility_id: string
start_us: number
end_us: number
session_count: number
session_boundaries: SessionBoundary[]
streams: TimelineStream[]
recording_events: RecordingEvent[]
}
export interface GetDeviceTimeseriesParams {
start_us?: number
end_us?: number
streams?: string[] | null
downsample?: number
}
export async function getDeviceTimeseries(
deviceId: string,
params: GetDeviceTimeseriesParams = {},
): Promise<DeviceTimeseriesResponse> {
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.streams && params.streams.length > 0) {
q.set('streams', params.streams.join(','))
}
if (params.downsample != null) q.set('downsample', String(params.downsample))
const qs = q.toString()
return api.get<DeviceTimeseriesResponse>(
`/v1/devices/${deviceId}/timeseries${qs ? `?${qs}` : ''}`,
)
}
- [ ] Step 2: tsc
Run: npx tsc -b --noEmit
Expected: clean.
- [ ] Step 3: Commit
git add frontend/src/api/devices.ts
git commit -S -m "feat(app): ✨ getDeviceTimeseries API client"
Phase 5 — Frontend components
Task 12: DeviceTimelineRecordingEvents.tsx
Files:
- Create: frontend/src/components/devices/DeviceTimelineRecordingEvents.tsx
- [ ] Step 1: Write the component
Create frontend/src/components/devices/DeviceTimelineRecordingEvents.tsx:
import type { RecordingEvent } from '../../api/devices'
import { Link } from 'react-router'
import { useTranslation } from '../../hooks/useTranslation'
interface Props {
events: RecordingEvent[]
windowStart: number
windowEnd: number
}
export default function DeviceTimelineRecordingEvents({ events, windowStart, windowEnd }: Props) {
const { t } = useTranslation()
const span = Math.max(1, windowEnd - windowStart)
if (events.length === 0) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl px-5 py-3 mb-3">
<p className="text-xs text-slate-500 dark:text-slate-300">
{t('timeline.noRecordings')}
</p>
</div>
)
}
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl px-5 py-3 mb-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('timeline.recordingEvents')}
</h3>
<span className="text-xs text-slate-500 dark:text-slate-300">
{events.length}
</span>
</div>
<div className="relative h-6 rounded bg-slate-100 dark:bg-slate-700 overflow-hidden">
{events.map((e, i) => {
const left = Math.max(0, Math.min(1, (e.start_us - windowStart) / span))
const radius = Math.max(3, Math.min(8, Math.log2(Math.max(1, e.sample_count)) + 3))
return (
<Link
key={`${e.session_id}-${i}`}
to={`/metadata/${e.session_id}`}
target="_blank"
rel="noopener noreferrer"
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 rounded-full bg-blue-500 hover:bg-blue-600 transition-colors"
style={{
left: `${left * 100}%`,
width: `${radius * 2}px`,
height: `${radius * 2}px`,
}}
aria-label={`${e.stream_name} · ${e.sample_count} samples`}
title={`${e.stream_name} · ${e.sample_count} samples`}
/>
)
})}
</div>
</div>
)
}
- [ ] Step 2: Add the two new i18n keys
Append to frontend/src/i18n/index.ts in both EN and KO blocks (find the closest existing timeline.* neighbor or, if none, place under a new // Device timeline (cycle 6) comment near metadata.loadFailed):
EN block:
'timeline.recordingEvents': 'Recording events',
'timeline.noRecordings': 'No audio recordings in this range.',
KO block:
'timeline.recordingEvents': '오디오 녹음 이벤트',
'timeline.noRecordings': '이 구간에 오디오 녹음이 없어요.',
- [ ] Step 3: tsc + commit
Run: npx tsc -b --noEmit
Expected: clean.
git add frontend/src/components/devices/DeviceTimelineRecordingEvents.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(app): ✨ device timeline recording-events track"
Task 13: DeviceTimelineChart.tsx
Files:
- Create: frontend/src/components/devices/DeviceTimelineChart.tsx
- [ ] Step 1: Write the component
Create frontend/src/components/devices/DeviceTimelineChart.tsx:
import { useMemo } from 'react'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import type { TimelineStream } from '../../api/devices'
import {
insertGapBreakpoints,
type TimelinePoint as GapPoint,
} from '../../lib/timeline/gap-detection'
import type { ReferenceLineProp } from '../../lib/timeline/session-boundary'
import { useIsDark } from '../../hooks/useIsDark'
import { CHART_AXIS_LAYOUT, getChartTheme, getChartTooltipStyle } from '../../lib/chartTheme'
interface Props {
stream: TimelineStream
windowStart: number
windowEnd: number
sessionBoundaries: ReferenceLineProp[]
}
function formatTimestamp(us: number): string {
return new Date(us / 1000).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export default function DeviceTimelineChart({
stream,
windowStart,
windowEnd,
sessionBoundaries,
}: Props) {
const isDark = useIsDark()
const theme = getChartTheme(isDark)
const tooltipStyle = getChartTooltipStyle(theme)
const chartData = useMemo(() => {
const points: GapPoint[] = stream.points.map((p) => ({
t_us: p.t_us,
v: typeof p.v === 'number' ? p.v : null,
s: p.s,
}))
return insertGapBreakpoints(points, stream.sample_rate_hz)
}, [stream.points, stream.sample_rate_hz])
if (stream.points.length === 0) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl px-5 py-6 mb-3">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">
{stream.name}
{stream.unit ? ` (${stream.unit})` : ''}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-300">
No data in selected range.
</p>
</div>
)
}
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl px-5 py-3 mb-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{stream.name}
{stream.unit ? <span className="ml-1 text-slate-500 dark:text-slate-300">({stream.unit})</span> : null}
</h3>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData} margin={CHART_AXIS_LAYOUT.lineChartMargin} syncId="device-timeline">
<CartesianGrid strokeDasharray="4 4" stroke={theme.gridStroke} />
<XAxis
type="number"
dataKey="t_us"
domain={[windowStart, windowEnd]}
tickFormatter={formatTimestamp}
tick={{ fontSize: 12, fill: theme.axisText, fontWeight: 500 }}
stroke={theme.axisStroke}
tickLine={{ stroke: theme.axisStroke }}
height={CHART_AXIS_LAYOUT.xAxis.height}
tickMargin={CHART_AXIS_LAYOUT.xAxis.tickMargin}
minTickGap={CHART_AXIS_LAYOUT.xAxis.minTickGap}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 12, fill: theme.axisText, fontWeight: 500 }}
stroke={theme.axisStroke}
tickLine={{ stroke: theme.axisStroke }}
width={CHART_AXIS_LAYOUT.yAxis.width}
tickMargin={CHART_AXIS_LAYOUT.yAxis.tickMargin}
/>
<Tooltip
labelFormatter={(label) => formatTimestamp(Number(label))}
formatter={(value) => {
const v = Number(value)
return [stream.unit ? `${v.toFixed(4)} ${stream.unit}` : v.toFixed(4), stream.name]
}}
contentStyle={tooltipStyle}
labelStyle={{ color: theme.subtleText, fontWeight: 500 }}
/>
{sessionBoundaries.map((b) => (
<ReferenceLine
key={b.sessionId}
x={b.x}
stroke={theme.gridStroke}
strokeDasharray="2 3"
/>
))}
<Line
type="monotone"
dataKey="v"
stroke={theme.primaryLine}
strokeWidth={2}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}
- [ ] Step 2: tsc + commit
Run: npx tsc -b --noEmit
Expected: clean.
git add frontend/src/components/devices/DeviceTimelineChart.tsx
git commit -S -m "feat(app): ✨ device timeline per-stream chart component"
Task 14: DeviceTimelinePage.tsx
Files:
- Create: frontend/src/pages/DeviceTimelinePage.tsx
- [ ] Step 1: Write the page
Create frontend/src/pages/DeviceTimelinePage.tsx:
import { useState, useMemo, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { getDevice } from '../api/devices'
import { getDeviceTimeseries } from '../api/devices'
import {
parseTimelineUrlState,
serializeTimelineUrlState,
} from '../lib/timeline/url-state'
import { toReferenceLineProps } from '../lib/timeline/session-boundary'
import { friendlyErrorMessage } from '../lib/errors'
import { useTranslation } from '../hooks/useTranslation'
import DeviceTimelineChart from '../components/devices/DeviceTimelineChart'
import DeviceTimelineRecordingEvents from '../components/devices/DeviceTimelineRecordingEvents'
const POLL_INTERVAL_MS = 15_000
const RANGE_PRESETS: { labelKey: string; durationUs: number }[] = [
{ labelKey: 'timeline.range1h', durationUs: 3_600 * 1_000_000 },
{ labelKey: 'timeline.range6h', durationUs: 6 * 3_600 * 1_000_000 },
{ labelKey: 'timeline.range24h', durationUs: 24 * 3_600 * 1_000_000 },
{ labelKey: 'timeline.range7d', durationUs: 7 * 24 * 3_600 * 1_000_000 },
{ labelKey: 'timeline.range30d', durationUs: 30 * 24 * 3_600 * 1_000_000 },
]
export default function DeviceTimelinePage() {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const [paused, setPaused] = useState(false)
const [hovered, setHovered] = useState(false)
const urlState = useMemo(() => parseTimelineUrlState(searchParams), [searchParams])
const setRange = useCallback(
(durationUs: number) => {
const now = Date.now() * 1000
setSearchParams(
serializeTimelineUrlState({
startUs: now - durationUs,
endUs: now,
streams: urlState.streams,
}),
)
},
[setSearchParams, urlState.streams],
)
const { data: device } = useQuery({
queryKey: ['device', id],
queryFn: () => getDevice(id!),
enabled: !!id,
})
const refetchActive = !paused && !hovered
const {
data: timeline,
isLoading,
error,
refetch,
isRefetching,
dataUpdatedAt,
} = useQuery({
queryKey: [
'device-timeline',
id,
urlState.startUs,
urlState.endUs,
(urlState.streams ?? []).join(','),
],
queryFn: () =>
getDeviceTimeseries(id!, {
start_us: urlState.startUs,
end_us: urlState.endUs,
streams: urlState.streams,
}),
enabled: !!id,
refetchInterval: refetchActive ? POLL_INTERVAL_MS : false,
staleTime: 10_000,
})
const handleBack = () => {
if (location.key && location.key !== 'default') navigate(-1)
else navigate('/devices')
}
const referenceLines = useMemo(
() =>
timeline
? toReferenceLineProps(
timeline.session_boundaries,
timeline.start_us,
timeline.end_us,
)
: [],
[timeline],
)
return (
<div>
<div className="mb-4">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-2 cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clipRule="evenodd" />
</svg>
{t('timeline.backToDevices')}
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{device?.alias ?? device?.name ?? id}
</h1>
<p className="text-xs text-slate-500 dark:text-slate-300 font-mono mt-1">
{timeline ? `${timeline.session_count} sessions` : '—'}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPaused((p) => !p)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium ${
refetchActive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}`}
aria-label={refetchActive ? t('timeline.pauseLive') : t('timeline.resumeLive')}
>
<span className={`inline-block w-2 h-2 rounded-full ${refetchActive ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`} />
{refetchActive ? t('timeline.live') : t('timeline.paused')}
</button>
<button
type="button"
onClick={() => refetch()}
disabled={isRefetching}
className="px-2.5 py-1 rounded text-xs font-medium border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700"
>
{t('timeline.refresh')}
</button>
</div>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
{RANGE_PRESETS.map((r) => {
const active = Math.abs(urlState.endUs - urlState.startUs - r.durationUs) < 60_000_000
return (
<button
key={r.labelKey}
onClick={() => setRange(r.durationUs)}
aria-pressed={active}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
active
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 font-medium'
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
{t(r.labelKey)}
</button>
)
})}
</div>
{isLoading && (
<p className="text-sm text-slate-400 dark:text-slate-300 py-8 text-center">
{t('timeline.loading')}
</p>
)}
{error && (
<p className="text-sm text-red-500 dark:text-red-400 py-8 text-center">
{friendlyErrorMessage(error)}
</p>
)}
{timeline && (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<DeviceTimelineRecordingEvents
events={timeline.recording_events}
windowStart={timeline.start_us}
windowEnd={timeline.end_us}
/>
{timeline.streams.map((s) => (
<DeviceTimelineChart
key={s.name}
stream={s}
windowStart={timeline.start_us}
windowEnd={timeline.end_us}
sessionBoundaries={referenceLines}
/>
))}
</div>
)}
{timeline && (
<p className="text-xs text-slate-400 dark:text-slate-300 text-right mt-2">
{dataUpdatedAt
? `${t('timeline.lastUpdate')} ${new Date(dataUpdatedAt).toLocaleTimeString()}`
: null}
</p>
)}
</div>
)
}
- [ ] Step 2: Add timeline i18n keys
Append to frontend/src/i18n/index.ts, both EN and KO blocks (next to the timeline.recordingEvents / timeline.noRecordings keys from task 12):
EN:
'timeline.backToDevices': 'Back to devices',
'timeline.live': 'Live',
'timeline.paused': 'Paused',
'timeline.pauseLive': 'Pause live updates',
'timeline.resumeLive': 'Resume live updates',
'timeline.refresh': 'Refresh',
'timeline.loading': 'Loading timeline...',
'timeline.lastUpdate': 'Last update',
'timeline.range1h': '1h',
'timeline.range6h': '6h',
'timeline.range24h': '24h',
'timeline.range7d': '7d',
'timeline.range30d': '30d',
KO:
'timeline.backToDevices': '디바이스 목록으로',
'timeline.live': '실시간',
'timeline.paused': '일시정지',
'timeline.pauseLive': '실시간 업데이트 일시정지',
'timeline.resumeLive': '실시간 업데이트 다시 시작',
'timeline.refresh': '새로고침',
'timeline.loading': '타임라인 불러오는 중...',
'timeline.lastUpdate': '마지막 업데이트',
'timeline.range1h': '1시간',
'timeline.range6h': '6시간',
'timeline.range24h': '24시간',
'timeline.range7d': '7일',
'timeline.range30d': '30일',
- [ ] Step 3: tsc + commit
Run: npx tsc -b --noEmit
Expected: clean.
git add frontend/src/pages/DeviceTimelinePage.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(app): ✨ device timeline page assembly"
Task 15: Wire route in App.tsx + entry link in DevicesPage
Files:
- Modify: frontend/src/App.tsx
- Modify: frontend/src/pages/DevicesPage.tsx
- [ ] Step 1: Add lazy import in App.tsx
Open frontend/src/App.tsx. Find the existing lazyRetry block (around line 42 — const MetadataSessionDetailPage = lazyRetry(...)). Insert a parallel line below it:
const DeviceTimelinePage = lazyRetry(() => import('./pages/DeviceTimelinePage'), 'device-timeline')
- [ ] Step 2: Register the route
In the same file, locate the <Route path="devices" element={<DevicesPage />} /> line (around 118). Insert immediately after:
<Route path="devices/:id/timeline" element={<DeviceTimelinePage />} />
- [ ] Step 3: Add the entry link in DevicesPage
Open frontend/src/pages/DevicesPage.tsx. Find the per-row JSX where the edit button is rendered. Locate the surrounding <td> (or flex container) that holds the action buttons. Insert immediately before the edit button:
<Link
to={`/devices/${d.id}/timeline`}
className="inline-flex items-center justify-center w-8 h-8 rounded-md text-slate-500 hover:bg-slate-100 hover:text-blue-700 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-blue-300"
aria-label="Open timeline"
title="Timeline"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm7.25-4.25a.75.75 0 011.5 0V10c0 .2.08.39.22.53l2.75 2.75a.75.75 0 11-1.06 1.06l-2.75-2.75A2 2 0 019.25 10V5.75z" clipRule="evenodd" />
</svg>
</Link>
If Link is not yet imported in this file, add import { Link } from 'react-router' next to the existing imports.
- [ ] Step 4: tsc + commit
Run: npx tsc -b --noEmit
Expected: clean.
git add frontend/src/App.tsx frontend/src/pages/DevicesPage.tsx
git commit -S -m "feat(app): 🔗 wire device timeline route + DevicesPage entry"
Task 16: Page integration test
Files:
- Create: frontend/src/pages/DeviceTimelinePage.test.tsx
- [ ] Step 1: Write the test
Create frontend/src/pages/DeviceTimelinePage.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import DeviceTimelinePage from './DeviceTimelinePage'
vi.mock('../api/devices', async () => {
return {
getDevice: vi.fn(async () => ({
id: 'dev-1',
name: 'Test',
alias: 'Living-room-01',
facility_id: 'fac-1',
dongle_id: '1A2B',
health_status: 'healthy',
firmware_version: '1.0',
hardware_version: 'rev-a',
battery_v: 3.7,
last_seen_at: null,
is_active: true,
description: null,
})),
getDeviceTimeseries: vi.fn(async () => ({
device_id: 'dev-1',
device_label: 'Living-room-01',
facility_id: 'fac-1',
start_us: 1_000_000,
end_us: 11_000_000,
session_count: 2,
session_boundaries: [
{ session_id: 's1', start_us: 1_000_000, end_us: 4_000_000, status: 'closed' },
{ session_id: 's2', start_us: 7_000_000, end_us: 11_000_000, status: 'active' },
],
streams: [
{
name: 'temperature',
value_type: 'f32',
unit: '°C',
sample_rate_hz: 1,
points: [
{ t_us: 1_500_000, v: 22.5, s: 's1' },
{ t_us: 3_500_000, v: 22.7, s: 's1' },
// Gap (session switch from s1 to s2)
{ t_us: 8_000_000, v: 23.1, s: 's2' },
{ t_us: 10_500_000, v: 23.4, s: 's2' },
],
},
{
name: 'audio_left',
value_type: 'bytes',
unit: null,
sample_rate_hz: null,
points: [],
},
],
recording_events: [
{ session_id: 's1', stream_name: 'audio_left', start_us: 2_000_000, end_us: 3_000_000, sample_count: 12 },
],
})),
// Re-export the types so imports inside the page still resolve
TimelinePoint: undefined,
TimelineStream: undefined,
SessionBoundary: undefined,
RecordingEvent: undefined,
DeviceTimeseriesResponse: undefined,
}
})
function renderWithRouter() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/devices/dev-1/timeline']}>
<Routes>
<Route path="devices/:id/timeline" element={<DeviceTimelinePage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('DeviceTimelinePage', () => {
beforeEach(() => vi.clearAllMocks())
it('renders device label, the temperature stream panel, and the recording-events track', async () => {
renderWithRouter()
await waitFor(() =>
expect(screen.getByText('Living-room-01')).toBeInTheDocument(),
)
expect(screen.getByText(/temperature/i)).toBeInTheDocument()
// Recording events header is rendered when events exist.
expect(screen.getByText(/recording events/i)).toBeInTheDocument()
})
it('exposes a Live pill that toggles to Paused on click', async () => {
renderWithRouter()
const pill = await screen.findByLabelText(/pause live updates/i)
fireEvent.click(pill)
await waitFor(() =>
expect(screen.getByLabelText(/resume live updates/i)).toBeInTheDocument(),
)
})
it('does NOT render a Recharts panel for bytes streams', async () => {
renderWithRouter()
await waitFor(() => expect(screen.getByText(/temperature/i)).toBeInTheDocument())
// audio_left is a bytes stream; it must not appear as a chart heading.
expect(screen.queryByRole('heading', { name: /audio_left/i })).not.toBeInTheDocument()
})
})
- [ ] Step 2: Run test
Run: npx vitest run src/pages/DeviceTimelinePage.test.tsx
Expected: 3 tests pass.
- [ ] Step 3: Commit
git add frontend/src/pages/DeviceTimelinePage.test.tsx
git commit -S -m "test(app): 🧪 device timeline page integration test"
Phase 6 — Validation & deploy
Task 17: Full validation sweep
- [ ] Step 1: cargo check + clippy
Run from repo root:
cargo check
cargo clippy -p xylolabs-server -p xylolabs-core -- -D warnings
Expected: zero warnings or errors.
- [ ] Step 2: cargo test (server crate)
Run: cargo test -p xylolabs-server --test api_device_timeline -- --ignored
Expected: 3 tests pass (happy path, filter, cap). All three are #[ignore]d because they need Docker. The cross-facility RBAC case is covered by existing suites (require_facility_access is shared middleware) so this file deliberately does not duplicate it.
- [ ] Step 3: tsc + vitest in both apps
cd frontend && npx tsc -b --noEmit && npx vitest run
cd ../frontend-app && npx tsc -b --noEmit && npx vitest run
Expected: all green in both apps.
- [ ] Step 4: vite build in both apps
cd frontend && npx vite build
cd ../frontend-app && npx vite build
Expected: both succeed. Note the admin bundle hash (e.g. assets/index-XXXX.js) for the post-deploy hard-refresh sanity check.
- [ ] Step 5: Playwright responsive screenshots
Save the following to /tmp/timeline-screens.cjs then run node /tmp/timeline-screens.cjs:
const { chromium } = require('playwright')
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
]
;(async () => {
const browser = await chromium.launch()
for (const vp of viewports) {
const ctx = await browser.newContext({ viewport: vp })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(String(e)))
await page.goto('https://admin.api.xylolabs.com/login')
await page.fill('input[type=email]', 'admin@xylolabs.com')
await page.fill('input[type=password]', 'solution6231')
await page.click('button[type=submit]')
await page.waitForURL(/\/(dashboard|metadata|devices|$)/, { timeout: 10_000 })
await page.goto('https://admin.api.xylolabs.com/devices')
await page.waitForLoadState('networkidle')
// Click the first device's timeline icon (aria-label="Open timeline")
const link = page.locator('a[aria-label="Open timeline"]').first()
await link.click()
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `/tmp/timeline-${vp.name}.png`, fullPage: true })
if (errors.length) {
console.error(`[${vp.name}] pageerror events:`, errors)
process.exitCode = 1
} else {
console.log(`[${vp.name}] OK`)
}
await ctx.close()
}
await browser.close()
})()
(Run AFTER the deploy step below. Skip step 5 if deploy hasn't run yet; treat it as the post-deploy verification step.)
Task 18: Commit final, mine, push, deploy, verify
- [ ] Step 1: Stage and commit any not-yet-committed files
Run: git status --short. The only listed modifications should be from task 16 (already committed). If anything is uncommitted, stage it and commit with a focused message.
- [ ] Step 2: Mine each unmined commit on this branch to 7 leading hex zeros
For every commit made during this plan (one per task, ~16 commits), in chronological order, run:
~/flash-shared/gitminer-cuda/mine_commit.sh 7
The miner rewrites the latest commit; loop until every commit on the branch has been mined. Verify with git log --oneline -20 — each hash should start with 0000000.
- [ ] Step 3: Pull-rebase + push
git -C /Users/hletrd/flash-shared/xylolabs-api pull --rebase
git -C /Users/hletrd/flash-shared/xylolabs-api push
Expected: pull is up-to-date or fast-forwards; push succeeds.
- [ ] Step 4: Deploy
bash /Users/hletrd/flash-shared/xylolabs-api/scripts/deploy.sh
Expected: completes with "=== Deployment complete ===" line.
- [ ] Step 5: Post-deploy verification
curl -s https://admin.api.xylolabs.com/ -o /dev/null -w "%{http_code}\n"
curl -s https://admin.api.xylolabs.com/ | grep -o 'assets/index-[^"]*' | head -1
curl -s https://api.xylolabs.com/api/v1/health -o /dev/null -w "%{http_code}\n"
Expected: 200, new bundle hash (different from the one before deploy), 200.
- [ ] Step 6: Playwright screenshots (from task 17 step 5)
Run the Playwright script. Confirm no pageerror lines in stdout and that the timeline page renders on all three viewports.
- [ ] Step 7: Manual smoke test
Open https://admin.api.xylolabs.com/devices/<any-device-id>/timeline in a browser (hard-refresh Cmd+Shift+R). Verify:
- Page renders the device label and at least one stream panel.
- The Live pill animates green. Clicking it toggles to Paused.
- Hovering the chart area pauses polling (DevTools Network tab shows no further /timeseries requests until hover ends).
- The time range chips switch between 1h / 6h / 24h / 7d / 30d and update the URL.
- Recording events (if any) render as dots; clicking opens the session detail in a new tab.
- A deliberately narrow over-cap query (e.g. paste a session-dense range into ?from/?to) shows the localized cap-error message, not "An unexpected error occurred."