Skip to content

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 DTOs
  • crates/xylolabs-server/src/routes/device_timeline.rs — handler
  • crates/xylolabs-server/tests/api_device_timeline.rs — integration tests

Backend (modified files)

  • crates/xylolabs-core/src/dto/mod.rs — add pub mod device_timeline;
  • crates/xylolabs-server/src/routes/mod.rs — add pub mod device_timeline;
  • crates/xylolabs-server/src/router.rs — register /devices/{id}/timeseries

Frontend (new files)

  • frontend/src/lib/timeline/gap-detection.ts
  • frontend/src/lib/timeline/gap-detection.test.ts
  • frontend/src/lib/timeline/session-boundary.ts
  • frontend/src/lib/timeline/session-boundary.test.ts
  • frontend/src/lib/timeline/url-state.ts
  • frontend/src/lib/timeline/url-state.test.ts
  • frontend/src/components/devices/DeviceTimelineRecordingEvents.tsx
  • frontend/src/components/devices/DeviceTimelineChart.tsx
  • frontend/src/pages/DeviceTimelinePage.tsx
  • frontend/src/pages/DeviceTimelinePage.test.tsx

Frontend (modified files)

  • frontend/src/api/devices.ts — add getDeviceTimeseries
  • frontend/src/App.tsx — register /devices/:id/timeline route + lazy import
  • frontend/src/pages/DevicesPage.tsx — add "Timeline →" link in each row
  • frontend/src/lib/errors.ts — add 3 cap patterns + ENGLISH_LITERAL_FOR_KEY
  • frontend/src/lib/errors.test.ts — cover the 3 patterns
  • frontend/src/i18n/index.ts — EN+KO entries for the 3 keys
  • frontend-app/src/lib/errors.ts — mirror patterns
  • frontend-app/src/lib/__tests__/errors-locale-aware.test.ts — cover patterns
  • frontend-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.rsTestApp::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 defines setup_session_with_data(app, device_uid, sample_count) which creates an ingest session, sends XmbpBatch data, 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_json helper

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."