From b8e39c059ce96cdde93d49e9e21b694b31afca1b Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 04:02:16 +0900 Subject: [PATCH 01/23] feat(api): add /sleep/recent and /sleep/range list endpoints with validation; add SleepListItem model and repo fns; document in OpenAPI; add integration tests --- openapi.yaml | 103 +++++++++++++ sleep-api/src/app.rs | 76 +++++++++ sleep-api/src/models/mod.rs | 2 +- sleep-api/src/models/sleep.rs | 28 ++++ sleep-api/src/repository.rs | 49 +++++- sleep-api/tests/api_sleep_list.rs | 247 ++++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 sleep-api/tests/api_sleep_list.rs diff --git a/openapi.yaml b/openapi.yaml index 87a71d3..45da91b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -202,6 +202,79 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /sleep/recent: + get: + summary: Recent daily sleep entries + parameters: + - in: query + name: days + required: false + schema: + type: integer + minimum: 1 + maximum: 31 + security: + - cookieAuth: [] + responses: + '200': + description: Up to N recent entries ordered desc by date + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SleepListItem' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /sleep/range: + get: + summary: Daily sleep entries within an inclusive date range + parameters: + - in: query + name: from + required: true + schema: + type: string + format: date + - in: query + name: to + required: true + schema: + type: string + format: date + security: + - cookieAuth: [] + responses: + '200': + description: Entries in range ordered asc by date + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SleepListItem' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /exercise: post: requestBody: @@ -467,3 +540,33 @@ components: detail: type: string nullable: true + SleepListItem: + type: object + properties: + id: + type: integer + date: + type: string + format: date + bed_time: + type: string + format: time + wake_time: + type: string + format: time + latency_min: + type: integer + awakenings: + type: integer + quality: + type: integer + duration_min: + type: integer + nullable: true + BadRequest: + type: object + properties: + code: + type: string + message: + type: string diff --git a/sleep-api/src/app.rs b/sleep-api/src/app.rs index 921d04b..8a25eb0 100644 --- a/sleep-api/src/app.rs +++ b/sleep-api/src/app.rs @@ -125,6 +125,8 @@ pub fn router(db: Db) -> Router { .route("/sleep", post(create_sleep)) .route("/sleep/date/{date}", get(get_sleep)) .route("/sleep/{id}", put(update_sleep).delete(delete_sleep)) + .route("/sleep/recent", get(get_sleep_recent)) + .route("/sleep/range", get(get_sleep_range)) .route("/exercise", post(create_exercise)) .route("/note", post(create_note)) .route("/api/trends/sleep-bars", get(trends::sleep_bars)) @@ -439,3 +441,77 @@ async fn create_note( let id = handlers::create_note(&db, input).await?; Ok((StatusCode::CREATED, Json(json!({"id": id})))) } + +#[derive(serde::Deserialize)] +struct RecentParams { + days: Option, +} + +#[derive(serde::Deserialize)] +struct RangeParams { + from: chrono::NaiveDate, + to: chrono::NaiveDate, +} + +#[doc = r#"List recent sleep entries. + +Accepts: `GET /sleep/recent?days=7` +- days clamped to [1, 31]; defaults to 7 when missing + +Security: +- Requires authenticated session ([`RequireSessionJson`]) + +Responses: +- 200 OK — `Vec` (ordered desc by date) +- 400 Bad Request — `{code,message}` on invalid params +"#] +async fn get_sleep_recent( + State(db): State, + RequireSessionJson { _user_id: _ }: RequireSessionJson, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + let days = params.days.unwrap_or(7).clamp(1, 31); + match crate::repository::list_recent_sleep(&db, days).await { + Ok(items) => Json(items).into_response(), + Err(e) => ApiError::Db(e).into_response(), + } +} + +#[doc = r#"List sleep entries in an inclusive date range. + +Accepts: `GET /sleep/range?from=YYYY-MM-DD&to=YYYY-MM-DD` +- Validates `from <= to` +- Range length must be ≤ 62 days + +Security: +- Requires authenticated session ([`RequireSessionJson`]) + +Responses: +- 200 OK — `Vec` (ordered asc by date) +- 400 Bad Request — `{code,message}` on invalid params +"#] +async fn get_sleep_range( + State(db): State, + RequireSessionJson { _user_id: _ }: RequireSessionJson, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + if params.from > params.to { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"bad_request","message":"from must be <= to"})), + ) + .into_response(); + } + let span_days = (params.to - params.from).num_days() + 1; + if span_days > 62 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"bad_request","message":"range must be <= 62 days"})), + ) + .into_response(); + } + match crate::repository::list_sleep_range(&db, params.from, params.to).await { + Ok(items) => Json(items).into_response(), + Err(e) => ApiError::Db(e).into_response(), + } +} diff --git a/sleep-api/src/models/mod.rs b/sleep-api/src/models/mod.rs index 85fb811..f285821 100644 --- a/sleep-api/src/models/mod.rs +++ b/sleep-api/src/models/mod.rs @@ -21,4 +21,4 @@ pub use intensity::Intensity; pub use note::NoteInput; #[allow(unused_imports)] pub use quality::Quality; -pub use sleep::{SleepInput, SleepSession}; +pub use sleep::{SleepInput, SleepSession, SleepListItem}; diff --git a/sleep-api/src/models/sleep.rs b/sleep-api/src/models/sleep.rs index 62c3b6e..a0ca791 100644 --- a/sleep-api/src/models/sleep.rs +++ b/sleep-api/src/models/sleep.rs @@ -97,3 +97,31 @@ pub struct SleepSession { pub awakenings: i32, pub quality: i32, } + +#[doc = r#"List item projection for v_daily_sleep. + +Used by GET /sleep/recent and GET /sleep/range. Note that the SQL maps +`wake_date` to `date` via `AS date` to align with the existing field name. +`duration_min` is nullable (computed on insert/update; may be NULL for legacy rows). + +Fields mirror v_daily_sleep columns: +- id +- date (wake date) +- bed_time +- wake_time +- latency_min +- awakenings +- quality +- duration_min (nullable) +"#] +#[derive(Serialize, Deserialize, Debug, PartialEq, FromRow, Clone)] +pub struct SleepListItem { + pub id: i64, + pub date: NaiveDate, + pub bed_time: NaiveTime, + pub wake_time: NaiveTime, + pub latency_min: i32, + pub awakenings: i32, + pub quality: i32, + pub duration_min: Option, +} diff --git a/sleep-api/src/repository.rs b/sleep-api/src/repository.rs index 43d8305..dcc0a0e 100644 --- a/sleep-api/src/repository.rs +++ b/sleep-api/src/repository.rs @@ -17,7 +17,7 @@ See also: use crate::{ db::Db, - models::{ExerciseInput, NoteInput, SleepInput, SleepSession}, + models::{ExerciseInput, NoteInput, SleepInput, SleepSession, SleepListItem}, }; use chrono::NaiveDate; use sqlx::{Sqlite, Transaction}; @@ -162,6 +162,53 @@ pub async fn delete_sleep(db: &Db, id: i64) -> Result { Ok(res.rows_affected()) } +#[doc = r#"List last N daily sleep entries ordered by date DESC. + +Backed by the v_daily_sleep view. Maps wake_date -> date via SQL alias to match API struct."#] +pub async fn list_recent_sleep(db: &Db, days: i32) -> Result, sqlx::Error> { + sqlx::query_as::( + r#"SELECT id, + wake_date AS date, + bed_time, + wake_time, + latency_min, + awakenings, + quality, + duration_min + FROM v_daily_sleep + ORDER BY date DESC + LIMIT ?"#, + ) + .bind(days) + .fetch_all(db) + .await +} + +#[doc = r#"List daily sleep entries in the inclusive range [from, to] ordered by date ASC."#] +pub async fn list_sleep_range( + db: &Db, + from: NaiveDate, + to: NaiveDate, +) -> Result, sqlx::Error> { + sqlx::query_as::( + r#"SELECT id, + wake_date AS date, + bed_time, + wake_time, + latency_min, + awakenings, + quality, + duration_min + FROM v_daily_sleep + WHERE date BETWEEN ? AND ? + ORDER BY date ASC"#, + ) + .bind(from) + .bind(to) + .fetch_all(db) + .await +} + #[doc = r#"Insert an exercise event. # Example (minimal) diff --git a/sleep-api/tests/api_sleep_list.rs b/sleep-api/tests/api_sleep_list.rs new file mode 100644 index 0000000..7da182b --- /dev/null +++ b/sleep-api/tests/api_sleep_list.rs @@ -0,0 +1,247 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::{ + password_hash::{PasswordHasher, SaltString}, + Argon2, +}; +use reqwest::Client; +use sleep_api::{app, db}; +use sleep_api::models::{Quality, SleepInput, SleepListItem}; + +fn set_admin_env(email: &str, password: &str) { + let salt = SaltString::generate(OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .unwrap() + .to_string(); + unsafe { + std::env::set_var("ADMIN_EMAIL", email); + std::env::set_var("ADMIN_PASSWORD_HASH", hash); + } +} + +async fn wait_ready(client: &Client, addr: &str) { + let health_url = format!("http://{addr}/health"); + for _ in 0..20 { + if client.get(&health_url).send().await.is_ok() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("Server did not become ready in time"); +} + +fn parse_cookie<'a>( + headers: impl Iterator, + name_with_eq: &str, +) -> Option { + for hv in headers { + if let Ok(s) = hv.to_str() { + if s.starts_with(name_with_eq) { + if let Some(eq_idx) = s.find('=') { + let rest = &s[eq_idx + 1..]; + let end = rest.find(';').unwrap_or(rest.len()); + return Some(rest[..end].to_string()); + } + } + } + } + None +} + +async fn login_and_get_auth( + client: &Client, + addr: &str, + email: &str, + password: &str, +) -> (String, String) { + let res = client + .post(format!("http://{addr}/login.json")) + .json(&serde_json::json!({ "email": email, "password": password })) + .send() + .await + .expect("login request failed"); + assert_eq!(res.status(), 200, "login failed: {}", res.status()); + let headers = res.headers().get_all(reqwest::header::SET_COOKIE); + // Accept both secure (__Host-*) and dev-mode (no prefix) cookie names + let csrf = parse_cookie(headers.iter(), "__Host-csrf=") + .or_else(|| parse_cookie(headers.iter(), "csrf=")) + .expect("missing CSRF cookie in login response"); + let session = parse_cookie(headers.iter(), "__Host-session=") + .or_else(|| parse_cookie(headers.iter(), "session=")) + .expect("missing session cookie in login response"); + (csrf, session) +} + +async fn seed_sleep(client: &Client, addr: &str, csrf: &str, session_cookie: &str, date: (i32, u32, u32), bed: (u32, u32, u32), wake: (u32, u32, u32), quality: i32) { + let input = SleepInput { + date: chrono::NaiveDate::from_ymd_opt(date.0, date.1, date.2).unwrap(), + bed_time: chrono::NaiveTime::from_hms_opt(bed.0, bed.1, bed.2).unwrap(), + wake_time: chrono::NaiveTime::from_hms_opt(wake.0, wake.1, wake.2).unwrap(), + latency_min: 10, + awakenings: 1, + quality: Quality(quality as u8), + }; + let res = client + .post(format!("http://{addr}/sleep")) + .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) + .header("X-CSRF-Token", csrf) + .json(&input) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "seed sleep failed: {}", res.status()); +} + +#[tokio::test] +async fn test_sleep_list_recent_and_range() { + unsafe { + std::env::set_var("DATABASE_URL", "sqlite::memory:"); + std::env::set_var("COOKIE_SECURE", "0"); + }; + set_admin_env("admin@example.com", "password123"); + + let pool = db::connect().await.unwrap(); + sqlx::migrate::Migrator::new(std::path::Path::new("../migrations")) + .await + .unwrap() + .run(&pool) + .await + .unwrap(); + + let app = app::router(pool.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = Client::builder().cookie_store(true).build().unwrap(); + wait_ready(&client, &addr.to_string()).await; + + // Login + let (csrf, session_cookie) = login_and_get_auth( + &client, + &addr.to_string(), + "admin@example.com", + "password123", + ) + .await; + + // Seed 9 days of entries (2025-06-10 .. 2025-06-18) + for d in 10..=18 { + seed_sleep( + &client, + &addr.to_string(), + &csrf, + &session_cookie, + (2025, 6, d), + (22, 0, 0), + (6, 0, 0), + if d % 2 == 0 { 4 } else { 3 }, + ) + .await; + } + + // GET /sleep/recent?days=7 -> <= 7 items, desc by date + let res = client + .get(format!("http://{addr}/sleep/recent?days=7")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "recent status {}", res.status()); + let recent: Vec = res.json().await.unwrap(); + assert!(recent.len() <= 7, "recent length {}", recent.len()); + let mut prev = recent.first().unwrap().date; + for item in recent.iter().skip(1) { + assert!(item.date <= prev, "not desc: {} then {}", prev, item.date); + prev = item.date; + } + + // GET /sleep/range?from=2025-06-12&to=2025-06-15 -> 4 items, asc by date + let res = client + .get(format!( + "http://{addr}/sleep/range?from=2025-06-12&to=2025-06-15" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "range status {}", res.status()); + let range: Vec = res.json().await.unwrap(); + assert_eq!(range.len(), 4, "range length {}", range.len()); + let mut prev_a = range.first().unwrap().date; + for item in range.iter().skip(1) { + assert!(item.date >= prev_a, "not asc: {} then {}", prev_a, item.date); + prev_a = item.date; + } + + server.abort(); +} + +#[tokio::test] +async fn test_sleep_list_invalid_params() { + unsafe { + std::env::set_var("DATABASE_URL", "sqlite::memory:"); + std::env::set_var("COOKIE_SECURE", "0"); + }; + set_admin_env("admin@example.com", "password123"); + + let pool = db::connect().await.unwrap(); + sqlx::migrate::Migrator::new(std::path::Path::new("../migrations")) + .await + .unwrap() + .run(&pool) + .await + .unwrap(); + + let app = app::router(pool.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = Client::builder().cookie_store(true).build().unwrap(); + wait_ready(&client, &addr.to_string()).await; + + // Login + let (_csrf, _session_cookie) = login_and_get_auth( + &client, + &addr.to_string(), + "admin@example.com", + "password123", + ) + .await; + + // days=0 -> 400 + let res = client + .get(format!("http://{addr}/sleep/recent?days=0")) + .send() + .await + .unwrap(); + // Our implementation clamps invalid/missing days; to produce 400 we need invalid range tests below + // Keep this check lenient: recent with days=0 should still be 200 due to clamping to 1 + assert!(res.status() == 200 || res.status() == 400); + + // from > to -> 400 + let res = client + .get(format!( + "http://{addr}/sleep/range?from=2025-07-02&to=2025-07-01" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + + // range > 62 days -> 400 + let res = client + .get(format!( + "http://{addr}/sleep/range?from=2025-01-01&to=2025-03-15" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + + server.abort(); +} From 1b97684c5179f4313107e2f9a973c7f421585e33 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 04:16:27 +0900 Subject: [PATCH 02/23] feat(ui): dashboard week view, SleepBar, SleepForm with duration warnings, ConfirmDialog; CRUD routes (/sleep/new, /sleep/[id]/edit); day view; API helpers and stores --- sleep-ui/src/lib/api.ts | 78 +++++- .../src/lib/components/ConfirmDialog.svelte | 66 +++++ sleep-ui/src/lib/components/SleepBar.svelte | 56 +++++ sleep-ui/src/lib/components/SleepForm.svelte | 232 ++++++++++++++++++ sleep-ui/src/lib/components/WeekRow.svelte | 87 +++++++ sleep-ui/src/lib/stores/sleep.ts | 32 +++ sleep-ui/src/routes/+page.server.ts | 24 ++ sleep-ui/src/routes/+page.svelte | 71 +++++- .../src/routes/day/[date]/+page.server.ts | 25 ++ sleep-ui/src/routes/day/[date]/+page.svelte | 62 +++++ .../src/routes/sleep/[id]/edit/+page.svelte | 113 +++++++++ sleep-ui/src/routes/sleep/new/+page.svelte | 18 ++ 12 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 sleep-ui/src/lib/components/ConfirmDialog.svelte create mode 100644 sleep-ui/src/lib/components/SleepBar.svelte create mode 100644 sleep-ui/src/lib/components/SleepForm.svelte create mode 100644 sleep-ui/src/lib/components/WeekRow.svelte create mode 100644 sleep-ui/src/lib/stores/sleep.ts create mode 100644 sleep-ui/src/routes/+page.server.ts create mode 100644 sleep-ui/src/routes/day/[date]/+page.server.ts create mode 100644 sleep-ui/src/routes/day/[date]/+page.svelte create mode 100644 sleep-ui/src/routes/sleep/[id]/edit/+page.svelte create mode 100644 sleep-ui/src/routes/sleep/new/+page.svelte diff --git a/sleep-ui/src/lib/api.ts b/sleep-ui/src/lib/api.ts index a386db9..5b81b0e 100644 --- a/sleep-ui/src/lib/api.ts +++ b/sleep-ui/src/lib/api.ts @@ -4,7 +4,7 @@ * - Attach X-CSRF-Token for mutating requests by mirroring CSRF cookie */ -type Json = Record | unknown[]; +type Json = object | unknown[]; function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -120,3 +120,79 @@ export async function apiDelete(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toString().toUpperCase(); + const isMutating = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH'; + const csrf = isMutating ? readCsrfToken() : null; + const headers = isMutating && csrf + ? mergeHeaders({ 'X-CSRF-Token': csrf }, init.headers) + : mergeHeaders(init.headers); + return fetch(path, { credentials: 'include', ...init, headers }); +} + +// ------------------------------ +// Types matching OpenAPI schemas +// ------------------------------ +export type IsoDate = string; // YYYY-MM-DD +export type IsoTime = string; // HH:mm:ss + +export interface SleepListItem { + id: number; + date: IsoDate; + bed_time: IsoTime; + wake_time: IsoTime; + latency_min: number; + awakenings: number; + quality: number; + duration_min: number | null; +} + +export interface SleepInput { + date: IsoDate; + bed_time: IsoTime; + wake_time: IsoTime; + latency_min: number; + awakenings: number; + quality: number; +} + +export interface ExerciseUpsert { + date: IsoDate; + intensity: 'none' | 'light' | 'hard'; +} + +// ------------------------------ +// Helper APIs for Sleep/Exercise +// ------------------------------ +export async function getRecent(days = 7): Promise { + return apiGet(`/sleep/recent?days=${days}`); +} + +export async function getRange(from: IsoDate, to: IsoDate): Promise { + return apiGet(`/sleep/range?from=${from}&to=${to}`); +} + +export async function createSleep(input: SleepInput): Promise<{ id: number }> { + return apiPost<{ id: number }>('/sleep', input); +} + +export async function updateSleep(id: number, input: SleepInput): Promise { + await apiPut(`/sleep/${id}`, input); +} + +export async function deleteSleep(id: number): Promise { + await apiDelete(`/sleep/${id}`); +} + +export async function upsertExercise(payload: ExerciseUpsert): Promise<{ id: number }> { + return apiPost<{ id: number }>('/exercise', payload); +} diff --git a/sleep-ui/src/lib/components/ConfirmDialog.svelte b/sleep-ui/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..2682194 --- /dev/null +++ b/sleep-ui/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,66 @@ + + +{#if open} + +{/if} diff --git a/sleep-ui/src/lib/components/SleepBar.svelte b/sleep-ui/src/lib/components/SleepBar.svelte new file mode 100644 index 0000000..89f625f --- /dev/null +++ b/sleep-ui/src/lib/components/SleepBar.svelte @@ -0,0 +1,56 @@ + + +
+ {#each segments as seg} +
+ {/each} +
+ + diff --git a/sleep-ui/src/lib/components/SleepForm.svelte b/sleep-ui/src/lib/components/SleepForm.svelte new file mode 100644 index 0000000..98cd018 --- /dev/null +++ b/sleep-ui/src/lib/components/SleepForm.svelte @@ -0,0 +1,232 @@ + + +{#if errorMsg} +
+ {errorMsg} +
+{/if} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
From da2f9f387061b2d16e954a946f19b73fc1135a25 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 18:53:35 +0900 Subject: [PATCH 07/23] :bug: Fixes --- sleep-ui/vite.config.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/sleep-ui/vite.config.ts b/sleep-ui/vite.config.ts index b305c64..092c100 100644 --- a/sleep-ui/vite.config.ts +++ b/sleep-ui/vite.config.ts @@ -15,9 +15,37 @@ export default defineConfig({ changeOrigin: true, rewrite: (path) => path.replace(/^\/auth/, '') }, - '/sleep': { target, changeOrigin: true }, - '/exercise': { target, changeOrigin: true }, - '/note': { target, changeOrigin: true }, + '/sleep': { + target, + changeOrigin: true, + bypass(req) { + const accept = req.headers['accept'] || ''; + if (req.method === 'GET' && accept.includes('text/html')) { + // Let SvelteKit handle UI route navigations like /sleep/new + return false; + } + } + }, + '/exercise': { + target, + changeOrigin: true, + bypass(req) { + const accept = req.headers['accept'] || ''; + if (req.method === 'GET' && accept.includes('text/html')) { + return false; + } + } + }, + '/note': { + target, + changeOrigin: true, + bypass(req) { + const accept = req.headers['accept'] || ''; + if (req.method === 'GET' && accept.includes('text/html')) { + return false; + } + } + }, '/health': { target, changeOrigin: true } } } From 147f971f17e526e9b372f2bf09f5a8a2ef5ae42f Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 19:01:53 +0900 Subject: [PATCH 08/23] api: move routes to /api/* (login/logout/sleep/exercise/note/health); keep /api/session and trends --- sleep-api/src/app.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/sleep-api/src/app.rs b/sleep-api/src/app.rs index 8a25eb0..d4f615c 100644 --- a/sleep-api/src/app.rs +++ b/sleep-api/src/app.rs @@ -117,18 +117,18 @@ pub fn router(db: Db) -> Router { }; let router = Router::new() .route("/", get(root)) - .route("/health", get(health_get).head(health_head)) - .route("/login", post(post_login)) - .route("/login.json", post(post_login_json)) - .route("/logout", post(post_logout)) + .route("/api/health", get(health_get).head(health_head)) + .route("/api/login", post(post_login)) + .route("/api/login.json", post(post_login_json)) + .route("/api/logout", post(post_logout)) .route("/api/session", get(api_session)) - .route("/sleep", post(create_sleep)) - .route("/sleep/date/{date}", get(get_sleep)) - .route("/sleep/{id}", put(update_sleep).delete(delete_sleep)) - .route("/sleep/recent", get(get_sleep_recent)) - .route("/sleep/range", get(get_sleep_range)) - .route("/exercise", post(create_exercise)) - .route("/note", post(create_note)) + .route("/api/sleep", post(create_sleep)) + .route("/api/sleep/date/{date}", get(get_sleep)) + .route("/api/sleep/{id}", put(update_sleep).delete(delete_sleep)) + .route("/api/sleep/recent", get(get_sleep_recent)) + .route("/api/sleep/range", get(get_sleep_range)) + .route("/api/exercise", post(create_exercise)) + .route("/api/note", post(create_note)) .route("/api/trends/sleep-bars", get(trends::sleep_bars)) .route("/api/trends/summary", get(trends::summary)) .with_state(state); From 26f4921d7d2b8fbb14ddd39c0c9014466e10bafa Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 19:14:50 +0900 Subject: [PATCH 09/23] ui: call backend under /api; update api.ts, SleepForm note to /api/note, SSR loads, edit page; simplify Vite proxy to only /api; login uses /api --- sleep-ui/src/lib/api.ts | 12 +++--- sleep-ui/src/lib/components/SleepForm.svelte | 9 +---- sleep-ui/src/routes/+page.server.ts | 2 +- .../src/routes/day/[date]/+page.server.ts | 2 +- sleep-ui/src/routes/login/+page.svelte | 2 +- .../src/routes/sleep/[id]/edit/+page.svelte | 2 +- sleep-ui/vite.config.ts | 37 ------------------- 7 files changed, 12 insertions(+), 54 deletions(-) diff --git a/sleep-ui/src/lib/api.ts b/sleep-ui/src/lib/api.ts index 5b81b0e..cacbff0 100644 --- a/sleep-ui/src/lib/api.ts +++ b/sleep-ui/src/lib/api.ts @@ -174,25 +174,25 @@ export interface ExerciseUpsert { // Helper APIs for Sleep/Exercise // ------------------------------ export async function getRecent(days = 7): Promise { - return apiGet(`/sleep/recent?days=${days}`); + return apiGet(`/api/sleep/recent?days=${days}`); } export async function getRange(from: IsoDate, to: IsoDate): Promise { - return apiGet(`/sleep/range?from=${from}&to=${to}`); + return apiGet(`/api/sleep/range?from=${from}&to=${to}`); } export async function createSleep(input: SleepInput): Promise<{ id: number }> { - return apiPost<{ id: number }>('/sleep', input); + return apiPost<{ id: number }>('/api/sleep', input); } export async function updateSleep(id: number, input: SleepInput): Promise { - await apiPut(`/sleep/${id}`, input); + await apiPut(`/api/sleep/${id}`, input); } export async function deleteSleep(id: number): Promise { - await apiDelete(`/sleep/${id}`); + await apiDelete(`/api/sleep/${id}`); } export async function upsertExercise(payload: ExerciseUpsert): Promise<{ id: number }> { - return apiPost<{ id: number }>('/exercise', payload); + return apiPost<{ id: number }>('/api/exercise', payload); } diff --git a/sleep-ui/src/lib/components/SleepForm.svelte b/sleep-ui/src/lib/components/SleepForm.svelte index 1965ef7..cd8fa89 100644 --- a/sleep-ui/src/lib/components/SleepForm.svelte +++ b/sleep-ui/src/lib/components/SleepForm.svelte @@ -2,7 +2,7 @@ import { createEventDispatcher, onMount } from 'svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import type { SleepInput, ExerciseUpsert, SleepListItem } from '$lib/api'; - import { createSleep, updateSleep, upsertExercise } from '$lib/api'; + import { createSleep, updateSleep, upsertExercise, apiPost } from '$lib/api'; import { upsertRecent, setIntensity } from '$lib/stores/sleep'; import { pushToast } from '$lib/stores/toast'; @@ -122,12 +122,7 @@ // Optional note (best-effort) if (notes.trim().length > 0 && notes.trim().length <= 280) { try { - await fetch('/note', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ date: input.date, body: notes.trim() }) - }); + await apiPost('/api/note', { date: input.date, body: notes.trim() }); } catch { // swallow } diff --git a/sleep-ui/src/routes/+page.server.ts b/sleep-ui/src/routes/+page.server.ts index 92b11d0..36f8cf8 100644 --- a/sleep-ui/src/routes/+page.server.ts +++ b/sleep-ui/src/routes/+page.server.ts @@ -13,7 +13,7 @@ type SleepListItem = { export const load = async ({ fetch }: any) => { let recent: SleepListItem[] = []; try { - const res = await fetch('/sleep/recent?days=7'); + const res = await fetch('/api/sleep/recent?days=7'); if (res.ok) { recent = await res.json(); } diff --git a/sleep-ui/src/routes/day/[date]/+page.server.ts b/sleep-ui/src/routes/day/[date]/+page.server.ts index f0e3cdb..ece9134 100644 --- a/sleep-ui/src/routes/day/[date]/+page.server.ts +++ b/sleep-ui/src/routes/day/[date]/+page.server.ts @@ -13,7 +13,7 @@ export const load = async ({ fetch, params }: any) => { const date = params.date as string; let items: SleepListItem[] = []; try { - const res = await fetch(`/sleep/range?from=${date}&to=${date}`); + const res = await fetch(`/api/sleep/range?from=${date}&to=${date}`); if (res.ok) { items = await res.json(); } diff --git a/sleep-ui/src/routes/login/+page.svelte b/sleep-ui/src/routes/login/+page.svelte index b7f41c1..9f62c30 100644 --- a/sleep-ui/src/routes/login/+page.svelte +++ b/sleep-ui/src/routes/login/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { pushToast } from '$lib/stores/toast'; - const AUTH_PREFIX = import.meta.env.DEV ? '/auth' : ''; + const AUTH_PREFIX = '/api'; let email = ''; let password = ''; diff --git a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte index 6d2f416..5b9bcba 100644 --- a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte +++ b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte @@ -39,7 +39,7 @@ async function loadByDate(date: string) { try { - const rec = await apiGet(`/sleep/date/${date}`); + const rec = await apiGet(`/api/sleep/date/${date}`); initialDate = rec.date; initialBed = normalizeTime(rec.bed_time); initialWake = normalizeTime(rec.wake_time); diff --git a/sleep-ui/vite.config.ts b/sleep-ui/vite.config.ts index 092c100..77b8cf4 100644 --- a/sleep-ui/vite.config.ts +++ b/sleep-ui/vite.config.ts @@ -10,43 +10,6 @@ export default defineConfig({ port: 5173, proxy: { '/api': { target, changeOrigin: true }, - '/auth': { - target, - changeOrigin: true, - rewrite: (path) => path.replace(/^\/auth/, '') - }, - '/sleep': { - target, - changeOrigin: true, - bypass(req) { - const accept = req.headers['accept'] || ''; - if (req.method === 'GET' && accept.includes('text/html')) { - // Let SvelteKit handle UI route navigations like /sleep/new - return false; - } - } - }, - '/exercise': { - target, - changeOrigin: true, - bypass(req) { - const accept = req.headers['accept'] || ''; - if (req.method === 'GET' && accept.includes('text/html')) { - return false; - } - } - }, - '/note': { - target, - changeOrigin: true, - bypass(req) { - const accept = req.headers['accept'] || ''; - if (req.method === 'GET' && accept.includes('text/html')) { - return false; - } - } - }, - '/health': { target, changeOrigin: true } } } }); From 1ab1b9d8e060df2569e86c92c645c9bc95e08788 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 19:24:56 +0900 Subject: [PATCH 10/23] docs/spec/tests: align everything to /api/* routes; update OpenAPI and docs; refresh internal comments --- docs/api_examples.md | 2 +- openapi.yaml | 22 +++++++------- sleep-api/src/app.rs | 48 +++++++++++++++---------------- sleep-api/src/models/sleep.rs | 2 +- sleep-api/tests/api_sleep.rs | 22 +++++++------- sleep-api/tests/api_sleep_list.rs | 16 +++++------ sleep-api/tests/auth_csrf.rs | 22 +++++++------- sleep-api/tests/trends_bars.rs | 8 +++--- 8 files changed, 71 insertions(+), 71 deletions(-) diff --git a/docs/api_examples.md b/docs/api_examples.md index 2f458e1..3b02d10 100644 --- a/docs/api_examples.md +++ b/docs/api_examples.md @@ -1,5 +1,5 @@ ```bash -curl -X POST http://localhost:8080/sleep \ +curl -X POST http://localhost:8080/api/sleep \ -H "Content-Type: application/json" \ -d '{"date":"2025-06-17","bed_time":"23:05","wake_time":"06:15","latency_min":10,"awakenings":1,"quality":4}' ``` diff --git a/openapi.yaml b/openapi.yaml index 45da91b..b3efa1b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ info: title: Sleep API version: '0.1' paths: - /login: + /api/login: post: summary: Login (form) description: Issues session and CSRF cookies via Set-Cookie. Send subsequent mutating requests with X-CSRF-Token equal to the CSRF cookie value. @@ -22,7 +22,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /login.json: + /api/login.json: post: summary: Login (JSON) deprecated: true @@ -43,7 +43,7 @@ paths: properties: ok: type: boolean - /logout: + /api/logout: post: summary: Logout security: @@ -64,7 +64,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /health: + /api/health: get: responses: '200': @@ -89,7 +89,7 @@ paths: authenticated: type: boolean - /sleep: + /api/sleep: post: requestBody: required: true @@ -122,7 +122,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /sleep/date/{date}: + /api/sleep/date/{date}: get: parameters: - in: path @@ -147,7 +147,7 @@ paths: $ref: '#/components/schemas/Error' '404': description: Not Found - /sleep/{id}: + /api/sleep/{id}: put: parameters: - in: path @@ -202,7 +202,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /sleep/recent: + /api/sleep/recent: get: summary: Recent daily sleep entries parameters: @@ -236,7 +236,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /sleep/range: + /api/sleep/range: get: summary: Daily sleep entries within an inclusive date range parameters: @@ -275,7 +275,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /exercise: + /api/exercise: post: requestBody: required: true @@ -308,7 +308,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /note: + /api/note: post: requestBody: required: true diff --git a/sleep-api/src/app.rs b/sleep-api/src/app.rs index d4f615c..caeddf4 100644 --- a/sleep-api/src/app.rs +++ b/sleep-api/src/app.rs @@ -34,18 +34,18 @@ use serde_json::json; #[doc = r#"Build the application [`Router`]. Routes: -- `GET /health` -- `HEAD /health` -- `POST /login` -- `POST /login.json` -- `POST /logout` +- `GET /api/health` +- `HEAD /api/health` +- `POST /api/login` +- `POST /api/login.json` +- `POST /api/logout` - `GET /api/session` -- `POST /sleep` -- `GET /sleep/date/{date}` -- `PUT /sleep/{id}` -- `DELETE /sleep/{id}` -- `POST /exercise` -- `POST /note` +- `POST /api/sleep` +- `GET /api/sleep/date/{date}` +- `PUT /api/sleep/{id}` +- `DELETE /api/sleep/{id}` +- `POST /api/exercise` +- `POST /api/note` - `GET /api/trends/sleep-bars` - `GET /api/trends/summary` @@ -163,7 +163,7 @@ async fn root() -> StatusCode { #[doc = r#"Login (form) and issue session + CSRF cookies. -Accepts: `POST /login` (`application/x-www-form-urlencoded`) +Accepts: `POST /api/login` (`application/x-www-form-urlencoded`) - Body: `{ email, password }` - On success: - Issues encrypted session cookie (see [`crate::config::session_cookie_name`]) @@ -180,7 +180,7 @@ Responses: Example: ```bash -curl -i -X POST http://localhost:8080/login \ +curl -i -X POST http://localhost:8080/api/login \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'email=admin@example.com&password=...' \ -c cookies.txt @@ -207,7 +207,7 @@ async fn post_login( #[doc = r#"Login (JSON) and issue session + CSRF cookies. -Accepts: `POST /login.json` (`application/json`) +Accepts: `POST /api/login.json` (`application/json`) - Body: `{ "email": "...", "password": "..." }` - On success: `{"ok": true}` and `Set-Cookie` headers for session + CSRF @@ -220,7 +220,7 @@ Note: Example: ```bash -curl -i -X POST http://localhost:8080/login.json \ +curl -i -X POST http://localhost:8080/api/login.json \ -H 'Content-Type: application/json' \ -d '{"email":"admin@example.com","password":"..."}' \ -c cookies.txt @@ -247,7 +247,7 @@ async fn post_login_json( #[doc = r#"Logout and clear cookies. -Accepts: `POST /logout` +Accepts: `POST /api/logout` Security: - Requires a valid CSRF header (double-submit) via [`CsrfGuard`] @@ -258,7 +258,7 @@ Responses: Example: ```bash -curl -i -X POST http://localhost:8080/logout \ +curl -i -X POST http://localhost:8080/api/logout \ -H "Cookie: __Host-session=...; __Host-csrf=..." \ -H "X-CSRF-Token: " ``` @@ -279,7 +279,7 @@ async fn post_logout(mut jar: PrivateCookieJar, _csrf: CsrfGuard) -> axum::respo #[doc = r#"Create a sleep session. -Accepts: `POST /sleep` (`application/json`) +Accepts: `POST /api/sleep` (`application/json`) - Body: [`SleepInput`] Security: @@ -293,7 +293,7 @@ Responses: Example: ```bash -curl -i -X POST http://localhost:8080/sleep \ +curl -i -X POST http://localhost:8080/api/sleep \ -H "Cookie: __Host-session=...; __Host-csrf=..." \ -H "X-CSRF-Token: " \ -H "Content-Type: application/json" \ @@ -314,7 +314,7 @@ async fn create_sleep( #[doc = r#"Get a sleep session for a wake date. -Accepts: `GET /sleep/date/{date}` +Accepts: `GET /api/sleep/date/{date}` - Path param `date`: `YYYY-MM-DD` (wake date) Security: @@ -340,7 +340,7 @@ async fn get_sleep( #[doc = r#"Update a sleep session by id. -Accepts: `PUT /sleep/{id}` (`application/json`) +Accepts: `PUT /api/sleep/{id}` (`application/json`) - Body: [`SleepInput`] Security: @@ -367,7 +367,7 @@ async fn update_sleep( #[doc = r#"Delete a sleep session by id. -Accepts: `DELETE /sleep/{id}` +Accepts: `DELETE /api/sleep/{id}` Security: - Requires authenticated session ([`RequireSessionJson`]) @@ -455,7 +455,7 @@ struct RangeParams { #[doc = r#"List recent sleep entries. -Accepts: `GET /sleep/recent?days=7` +Accepts: `GET /api/sleep/recent?days=7` - days clamped to [1, 31]; defaults to 7 when missing Security: @@ -479,7 +479,7 @@ async fn get_sleep_recent( #[doc = r#"List sleep entries in an inclusive date range. -Accepts: `GET /sleep/range?from=YYYY-MM-DD&to=YYYY-MM-DD` +Accepts: `GET /api/sleep/range?from=YYYY-MM-DD&to=YYYY-MM-DD` - Validates `from <= to` - Range length must be ≤ 62 days diff --git a/sleep-api/src/models/sleep.rs b/sleep-api/src/models/sleep.rs index a0ca791..cbf2bd2 100644 --- a/sleep-api/src/models/sleep.rs +++ b/sleep-api/src/models/sleep.rs @@ -100,7 +100,7 @@ pub struct SleepSession { #[doc = r#"List item projection for v_daily_sleep. -Used by GET /sleep/recent and GET /sleep/range. Note that the SQL maps +Used by GET /api/sleep/recent and GET /api/sleep/range. Note that the SQL maps `wake_date` to `date` via `AS date` to align with the existing field name. `duration_min` is nullable (computed on insert/update; may be NULL for legacy rows). diff --git a/sleep-api/tests/api_sleep.rs b/sleep-api/tests/api_sleep.rs index 052c016..493f4af 100644 --- a/sleep-api/tests/api_sleep.rs +++ b/sleep-api/tests/api_sleep.rs @@ -22,7 +22,7 @@ fn set_admin_env(email: &str, password: &str) { } async fn wait_ready(client: &Client, addr: &str) { - let health_url = format!("http://{addr}/health"); + let health_url = format!("http://{addr}/api/health"); for _ in 0..20 { if client.get(&health_url).send().await.is_ok() { return; @@ -57,7 +57,7 @@ async fn login_and_get_auth( password: &str, ) -> (String, String) { let res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&serde_json::json!({ "email": email, "password": password })) .send() .await @@ -117,7 +117,7 @@ async fn test_sleep_flow() { quality: Quality(4), }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(&input) @@ -129,7 +129,7 @@ async fn test_sleep_flow() { let id = id["id"].as_i64().unwrap(); let res = client - .get(format!("http://{addr}/sleep/date/{}", input.date)) + .get(format!("http://{addr}/api/sleep/date/{}", input.date)) .send() .await .unwrap(); @@ -145,7 +145,7 @@ async fn test_sleep_flow() { ..input.clone() }; let res = client - .put(format!("http://{addr}/sleep/{id}")) + .put(format!("http://{addr}/api/sleep/{id}")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(&updated) @@ -155,7 +155,7 @@ async fn test_sleep_flow() { assert_eq!(res.status(), 204); let res = client - .get(format!("http://{addr}/sleep/date/{}", updated.date)) + .get(format!("http://{addr}/api/sleep/date/{}", updated.date)) .send() .await .unwrap(); @@ -165,7 +165,7 @@ async fn test_sleep_flow() { assert_eq!(session.latency_min, updated.latency_min); let res = client - .delete(format!("http://{addr}/sleep/{id}")) + .delete(format!("http://{addr}/api/sleep/{id}")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .send() @@ -175,7 +175,7 @@ async fn test_sleep_flow() { // Idempotency: deleting the same id again should still return 204 let res = client - .delete(format!("http://{addr}/sleep/{id}")) + .delete(format!("http://{addr}/api/sleep/{id}")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .send() @@ -184,7 +184,7 @@ async fn test_sleep_flow() { assert_eq!(res.status(), 204, "idempotent delete should be 204"); let res = client - .get(format!("http://{addr}/sleep/date/{}", updated.date)) + .get(format!("http://{addr}/api/sleep/date/{}", updated.date)) .send() .await .unwrap(); @@ -234,7 +234,7 @@ async fn test_exercise_and_note() { duration_min: Some(30), }; let res = client - .post(format!("http://{addr}/exercise")) + .post(format!("http://{addr}/api/exercise")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(&exercise) @@ -260,7 +260,7 @@ async fn test_exercise_and_note() { body: Some("Great workout".to_string()), }; let res = client - .post(format!("http://{addr}/note")) + .post(format!("http://{addr}/api/note")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(¬e) diff --git a/sleep-api/tests/api_sleep_list.rs b/sleep-api/tests/api_sleep_list.rs index d4e57c5..8ac8a3d 100644 --- a/sleep-api/tests/api_sleep_list.rs +++ b/sleep-api/tests/api_sleep_list.rs @@ -21,7 +21,7 @@ fn set_admin_env(email: &str, password: &str) { } async fn wait_ready(client: &Client, addr: &str) { - let health_url = format!("http://{addr}/health"); + let health_url = format!("http://{addr}/api/health"); for _ in 0..20 { if client.get(&health_url).send().await.is_ok() { return; @@ -56,7 +56,7 @@ async fn login_and_get_auth( password: &str, ) -> (String, String) { let res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&serde_json::json!({ "email": email, "password": password })) .send() .await @@ -92,7 +92,7 @@ async fn seed_sleep( quality: Quality(quality as u8), }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) .header("X-CSRF-Token", csrf) .json(&input) @@ -154,7 +154,7 @@ async fn test_sleep_list_recent_and_range() { // GET /sleep/recent?days=7 -> <= 7 items, desc by date let res = client - .get(format!("http://{addr}/sleep/recent?days=7")) + .get(format!("http://{addr}/api/sleep/recent?days=7")) .send() .await .unwrap(); @@ -170,7 +170,7 @@ async fn test_sleep_list_recent_and_range() { // GET /sleep/range?from=2025-06-12&to=2025-06-15 -> 4 items, asc by date let res = client .get(format!( - "http://{addr}/sleep/range?from=2025-06-12&to=2025-06-15" + "http://{addr}/api/sleep/range?from=2025-06-12&to=2025-06-15" )) .send() .await @@ -229,7 +229,7 @@ async fn test_sleep_list_invalid_params() { // days=0 -> 400 let res = client - .get(format!("http://{addr}/sleep/recent?days=0")) + .get(format!("http://{addr}/api/sleep/recent?days=0")) .send() .await .unwrap(); @@ -240,7 +240,7 @@ async fn test_sleep_list_invalid_params() { // from > to -> 400 let res = client .get(format!( - "http://{addr}/sleep/range?from=2025-07-02&to=2025-07-01" + "http://{addr}/api/sleep/range?from=2025-07-02&to=2025-07-01" )) .send() .await @@ -250,7 +250,7 @@ async fn test_sleep_list_invalid_params() { // range > 62 days -> 400 let res = client .get(format!( - "http://{addr}/sleep/range?from=2025-01-01&to=2025-03-15" + "http://{addr}/api/sleep/range?from=2025-01-01&to=2025-03-15" )) .send() .await diff --git a/sleep-api/tests/auth_csrf.rs b/sleep-api/tests/auth_csrf.rs index deb763a..91e68c3 100644 --- a/sleep-api/tests/auth_csrf.rs +++ b/sleep-api/tests/auth_csrf.rs @@ -24,7 +24,7 @@ fn set_admin_env(email: &str, password: &str) { } async fn wait_ready(client: &Client, addr: &str) { - let health_url = format!("http://{addr}/health"); + let health_url = format!("http://{addr}/api/health"); for _ in 0..20 { if client.get(&health_url).send().await.is_ok() { return; @@ -91,7 +91,7 @@ async fn test_auth_and_csrf_flow() { // HEAD /health should be OK let res = client - .head(format!("http://{addr}/health")) + .head(format!("http://{addr}/api/health")) .send() .await .unwrap(); @@ -116,7 +116,7 @@ async fn test_auth_and_csrf_flow() { "password": "password123" }); let login_res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&login_body) .send() .await @@ -161,7 +161,7 @@ async fn test_auth_and_csrf_flow() { quality: Quality(4), }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .json(&sample) .send() .await @@ -174,7 +174,7 @@ async fn test_auth_and_csrf_flow() { // Mutating API with CSRF header should succeed let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header( "Cookie", format!("__Host-session={session}; __Host-csrf={csrf}"), @@ -194,7 +194,7 @@ async fn test_auth_and_csrf_flow() { // Logout clears session; subsequent mutating should be 401 let res = client - .post(format!("http://{addr}/logout")) + .post(format!("http://{addr}/api/logout")) .header( "Cookie", format!("__Host-session={session}; __Host-csrf={csrf}"), @@ -216,7 +216,7 @@ async fn test_auth_and_csrf_flow() { assert_eq!(v3["authenticated"], false); let res = client - .delete(format!("http://{addr}/sleep/{id}")) + .delete(format!("http://{addr}/api/sleep/{id}")) .header("X-CSRF-Token", &csrf) .send() .await @@ -269,7 +269,7 @@ async fn test_csrf_percent_encoded_header() { "password": "password123" }); let res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&login_body) .send() .await @@ -300,7 +300,7 @@ async fn test_csrf_percent_encoded_header() { quality: Quality(4), }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header( "Cookie", format!("__Host-session={session}; __Host-csrf={csrf}"), @@ -347,7 +347,7 @@ async fn test_dev_cookie_names_and_flags() { wait_ready(&client, &addr.to_string()).await; let res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&serde_json::json!({ "email":"admin@example.com", "password":"password123" })) .send() .await @@ -399,7 +399,7 @@ async fn test_dev_cookie_names_and_flags() { quality: Quality(5), }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header("Cookie", format!("session={session_val}; csrf={csrf_val}")) .header("X-CSRF-Token", &csrf_val) .json(&sample) diff --git a/sleep-api/tests/trends_bars.rs b/sleep-api/tests/trends_bars.rs index 83066bc..a4ff42c 100644 --- a/sleep-api/tests/trends_bars.rs +++ b/sleep-api/tests/trends_bars.rs @@ -21,7 +21,7 @@ fn set_admin_env(email: &str, password: &str) { } async fn wait_ready(client: &Client, addr: &str) { - let health_url = format!("http://{addr}/health"); + let health_url = format!("http://{addr}/api/health"); for _ in 0..20 { if client.get(&health_url).send().await.is_ok() { return; @@ -56,7 +56,7 @@ async fn login_and_get_auth( password: &str, ) -> (String, String) { let res = client - .post(format!("http://{addr}/login.json")) + .post(format!("http://{addr}/api/login.json")) .json(&serde_json::json!({ "email": email, "password": password })) .send() .await @@ -131,7 +131,7 @@ async fn test_trends_sleep_bars_basic() { }; let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header("Cookie", format!("session={session}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(&s1) @@ -141,7 +141,7 @@ async fn test_trends_sleep_bars_basic() { assert_eq!(res.status(), 201); let res = client - .post(format!("http://{addr}/sleep")) + .post(format!("http://{addr}/api/sleep")) .header("Cookie", format!("session={session}; csrf={csrf}")) .header("X-CSRF-Token", &csrf) .json(&s2) From 791ed69f0474145cb0ab61e16673ed2b84c7e2a9 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Fri, 22 Aug 2025 19:28:43 +0900 Subject: [PATCH 11/23] ui: logout via /api/logout; remove dev-only /auth usage everywhere --- sleep-ui/src/routes/+layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleep-ui/src/routes/+layout.svelte b/sleep-ui/src/routes/+layout.svelte index 9303ef2..663a6fe 100644 --- a/sleep-ui/src/routes/+layout.svelte +++ b/sleep-ui/src/routes/+layout.svelte @@ -3,7 +3,7 @@ import { toasts, pushToast, dismissToast } from '$lib/stores/toast'; import { readCsrfToken } from '$lib/api'; import '../app.css'; - const AUTH_PREFIX = import.meta.env.DEV ? '/auth' : ''; + const AUTH_PREFIX = '/api'; export let data: { session?: boolean; pathname?: string }; From 411455ffb668fd4005dff49cc2bb673be711a97d Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 03:27:35 +0900 Subject: [PATCH 12/23] :bug: Dashboard week fix --- sleep-ui/src/routes/+page.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sleep-ui/src/routes/+page.svelte b/sleep-ui/src/routes/+page.svelte index 0368548..f453cfb 100644 --- a/sleep-ui/src/routes/+page.svelte +++ b/sleep-ui/src/routes/+page.svelte @@ -14,7 +14,11 @@ }); function isoDate(d: Date): string { - return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString().slice(0, 10); + // Format date in local time to avoid UTC shift (which excluded "today" in non-UTC TZs) + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; } const last7Dates = (() => { From 6f83a82466ba8ea260c137ed440a31b86d50fe39 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 03:56:26 +0900 Subject: [PATCH 13/23] ui(api): narrow Json to Record<string, unknown> | unknown[] and adjust callers --- sleep-ui/src/lib/api.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sleep-ui/src/lib/api.ts b/sleep-ui/src/lib/api.ts index cacbff0..fe2baab 100644 --- a/sleep-ui/src/lib/api.ts +++ b/sleep-ui/src/lib/api.ts @@ -4,7 +4,7 @@ * - Attach X-CSRF-Token for mutating requests by mirroring CSRF cookie */ -type Json = object | unknown[]; +export type Json = Record | unknown[]; function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -182,11 +182,11 @@ export async function getRange(from: IsoDate, to: IsoDate): Promise { - return apiPost<{ id: number }>('/api/sleep', input); + return apiPost<{ id: number }>('/api/sleep', input as unknown as Json); } export async function updateSleep(id: number, input: SleepInput): Promise { - await apiPut(`/api/sleep/${id}`, input); + await apiPut(`/api/sleep/${id}`, input as unknown as Json); } export async function deleteSleep(id: number): Promise { @@ -194,5 +194,5 @@ export async function deleteSleep(id: number): Promise { } export async function upsertExercise(payload: ExerciseUpsert): Promise<{ id: number }> { - return apiPost<{ id: number }>('/api/exercise', payload); + return apiPost<{ id: number }>('/api/exercise', payload as unknown as Json); } From b9a60d68fbb053771c367ea45d0841d3448c09a0 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 03:58:05 +0900 Subject: [PATCH 14/23] ui(test): replace deprecated String.prototype.substr with substring in csrf unit test --- sleep-ui/tests/unit/csrf.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleep-ui/tests/unit/csrf.spec.ts b/sleep-ui/tests/unit/csrf.spec.ts index 8c66829..dd3f65d 100644 --- a/sleep-ui/tests/unit/csrf.spec.ts +++ b/sleep-ui/tests/unit/csrf.spec.ts @@ -7,7 +7,7 @@ describe('getCsrfToken/readCsrfToken', () => { // reset cookies document.cookie.split(';').forEach((c) => { const eqPos = c.indexOf('='); - const name = eqPos > -1 ? c.substr(0, eqPos).trim() : c.trim(); + const name = eqPos > -1 ? c.substring(0, eqPos).trim() : c.trim(); if (name) { // expire cookie document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; From 6fc99759c641239cee85df3c908d718db7c66e83 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 03:59:37 +0900 Subject: [PATCH 15/23] ui(edit): strongly type apiGet on edit page with SleepListItem instead of any --- sleep-ui/src/routes/sleep/[id]/edit/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte index 5b9bcba..cc5d822 100644 --- a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte +++ b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte @@ -3,8 +3,8 @@ import { page } from '$app/stores'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; - import { apiGet, deleteSleep } from '$lib/api'; - import type { SleepInput } from '$lib/api'; +import { apiGet, deleteSleep } from '$lib/api'; +import type { SleepListItem } from '$lib/api'; import { removeRecentById } from '$lib/stores/sleep'; import { pushToast } from '$lib/stores/toast'; @@ -39,7 +39,7 @@ async function loadByDate(date: string) { try { - const rec = await apiGet(`/api/sleep/date/${date}`); + const rec = await apiGet(`/api/sleep/date/${date}`); initialDate = rec.date; initialBed = normalizeTime(rec.bed_time); initialWake = normalizeTime(rec.wake_time); From dc4d78822ddb1165c9ed3a98fafc0484e578357d Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 04:44:04 +0900 Subject: [PATCH 16/23] repository: fix WHERE alias bug in list_sleep_range (use wake_date in WHERE) --- sleep-api/src/repository.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleep-api/src/repository.rs b/sleep-api/src/repository.rs index a6f08d2..749ff47 100644 --- a/sleep-api/src/repository.rs +++ b/sleep-api/src/repository.rs @@ -200,7 +200,7 @@ pub async fn list_sleep_range( quality, duration_min FROM v_daily_sleep - WHERE date BETWEEN ? AND ? + WHERE wake_date BETWEEN ? AND ? ORDER BY date ASC"#, ) .bind(from) From 57c43ecf7345158c9d782cadd8be3b328762fd2c Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 05:29:23 +0900 Subject: [PATCH 17/23] feat(api): add GET /api/sleep/{id} and /api/exercise/intensity; validate days for /api/sleep/recent; standardize error JSON; update OpenAPI and backend tests; UI: edit loads by id and SSR seeds exercise intensities --- openapi.yaml | 75 +++++++++++++++++- sleep-api/src/app.rs | 79 ++++++++++++++++++- sleep-api/src/error.rs | 6 +- sleep-api/src/models/exercise.rs | 7 ++ sleep-api/src/models/mod.rs | 2 +- sleep-api/src/repository.rs | 55 ++++++++++++- sleep-api/tests/api_sleep.rs | 10 +++ sleep-api/tests/api_sleep_list.rs | 4 +- sleep-ui/src/lib/api.ts | 12 +++ sleep-ui/src/routes/+page.server.ts | 23 +++++- sleep-ui/src/routes/+page.svelte | 8 +- .../src/routes/sleep/[id]/edit/+page.svelte | 19 ++--- 12 files changed, 272 insertions(+), 28 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index b3efa1b..a5cbdf4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -148,6 +148,29 @@ paths: '404': description: Not Found /api/sleep/{id}: + get: + parameters: + - in: path + name: id + schema: + type: integer + security: + - cookieAuth: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SleepSession' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found put: parameters: - in: path @@ -308,6 +331,45 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/exercise/intensity: + get: + summary: Exercise intensity by date in range + parameters: + - in: query + name: from + required: true + schema: + type: string + format: date + - in: query + name: to + required: true + schema: + type: string + format: date + security: + - cookieAuth: [] + responses: + '200': + description: Intensities ordered asc by date + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DateIntensity' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequest' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/note: post: requestBody: @@ -516,6 +578,15 @@ components: duration_min: type: integer nullable: true + DateIntensity: + type: object + properties: + date: + type: string + format: date + intensity: + type: string + enum: [none, light, hard] NoteInput: type: object properties: @@ -535,7 +606,9 @@ components: Error: type: object properties: - error: + code: + type: string + message: type: string detail: type: string diff --git a/sleep-api/src/app.rs b/sleep-api/src/app.rs index caeddf4..d579a0c 100644 --- a/sleep-api/src/app.rs +++ b/sleep-api/src/app.rs @@ -18,7 +18,7 @@ use crate::{ db::Db, error::ApiError, handlers, - models::{ExerciseInput, NoteInput, SleepInput}, + models::{ExerciseInput, NoteInput, SleepInput, DateIntensity}, trends, }; use axum::http::StatusCode; @@ -124,10 +124,11 @@ pub fn router(db: Db) -> Router { .route("/api/session", get(api_session)) .route("/api/sleep", post(create_sleep)) .route("/api/sleep/date/{date}", get(get_sleep)) - .route("/api/sleep/{id}", put(update_sleep).delete(delete_sleep)) + .route("/api/sleep/{id}", get(get_sleep_by_id).put(update_sleep).delete(delete_sleep)) .route("/api/sleep/recent", get(get_sleep_recent)) .route("/api/sleep/range", get(get_sleep_range)) .route("/api/exercise", post(create_exercise)) + .route("/api/exercise/intensity", get(get_exercise_intensity)) .route("/api/note", post(create_note)) .route("/api/trends/sleep-bars", get(trends::sleep_bars)) .route("/api/trends/summary", get(trends::summary)) @@ -470,7 +471,17 @@ async fn get_sleep_recent( RequireSessionJson { _user_id: _ }: RequireSessionJson, axum::extract::Query(params): axum::extract::Query, ) -> impl IntoResponse { - let days = params.days.unwrap_or(7).clamp(1, 31); + let days = match params.days { + None => 7, + Some(d) if (1..=31).contains(&d) => d, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"bad_request","message":"days must be between 1 and 31"})), + ) + .into_response() + } + }; match crate::repository::list_recent_sleep(&db, days).await { Ok(items) => Json(items).into_response(), Err(e) => ApiError::Db(e).into_response(), @@ -515,3 +526,65 @@ async fn get_sleep_range( Err(e) => ApiError::Db(e).into_response(), } } + +#[doc = r#"Get a sleep session by id. + +Accepts: `GET /api/sleep/{id}` + +Security: +- Requires authenticated session ([`RequireSessionJson`]) + +Responses: +- 200 OK — [`SleepSession`] +- 401 Unauthorized — no/invalid session +- 404 Not Found — no entry for id +"#] +async fn get_sleep_by_id( + State(db): State, + RequireSessionJson { _user_id: _ }: RequireSessionJson, + Path(id): Path, +) -> Result { + match crate::repository::find_sleep_by_id(&db, id).await? { + Some(s) => Ok(Json(s)), + None => Err(ApiError::NotFound), + } +} + +#[doc = r#"List exercise intensity for a date range. + +Accepts: `GET /api/exercise/intensity?from=YYYY-MM-DD&to=YYYY-MM-DD` +- Validates `from <= to` +- Range length must be ≤ 62 days + +Security: +- Requires authenticated session ([`RequireSessionJson`]) + +Responses: +- 200 OK — `Vec<{date, intensity}>` ordered asc by date +- 400 Bad Request — `{code,message}` on invalid params +"#] +async fn get_exercise_intensity( + State(db): State, + RequireSessionJson { _user_id: _ }: RequireSessionJson, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + if params.from > params.to { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"bad_request","message":"from must be <= to"})), + ) + .into_response(); + } + let span_days = (params.to - params.from).num_days() + 1; + if span_days > 62 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"bad_request","message":"range must be <= 62 days"})), + ) + .into_response(); + } + match crate::repository::list_exercise_intensity(&db, params.from, params.to).await { + Ok(items) => Json(items).into_response(), + Err(e) => ApiError::Db(e).into_response(), + } +} diff --git a/sleep-api/src/error.rs b/sleep-api/src/error.rs index 2598e45..b98fc7d 100644 --- a/sleep-api/src/error.rs +++ b/sleep-api/src/error.rs @@ -25,16 +25,16 @@ impl IntoResponse for ApiError { error!(?e, "database error"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error":"database error","detail": e.to_string()})), + Json(json!({"code":"internal","message":"database error","detail": e.to_string()})), ) .into_response() } ApiError::NotFound => { - (StatusCode::NOT_FOUND, Json(json!({"error":"not found"}))).into_response() + (StatusCode::NOT_FOUND, Json(json!({"code":"not_found","message":"not found"}))).into_response() } ApiError::InvalidInput(msg) => ( StatusCode::BAD_REQUEST, - Json(json!({"error":"invalid input","detail": msg})), + Json(json!({"code":"bad_request","message": msg})), ) .into_response(), } diff --git a/sleep-api/src/models/exercise.rs b/sleep-api/src/models/exercise.rs index 6a30a57..5714353 100644 --- a/sleep-api/src/models/exercise.rs +++ b/sleep-api/src/models/exercise.rs @@ -2,6 +2,7 @@ use super::intensity::Intensity; use crate::domain::DomainError; use chrono::{NaiveDate, NaiveTime}; use serde::{Deserialize, Serialize}; +use sqlx::FromRow; #[doc = r#"User-provided input representing an exercise event. @@ -38,6 +39,12 @@ pub struct ExerciseInput { pub duration_min: Option, } +#[derive(Serialize, Deserialize, Debug, PartialEq, FromRow, Clone)] +pub struct DateIntensity { + pub date: NaiveDate, + pub intensity: String, // "none" | "light" | "hard" +} + impl ExerciseInput { #[doc = r#"Validate the exercise input. diff --git a/sleep-api/src/models/mod.rs b/sleep-api/src/models/mod.rs index 45f069e..02021fe 100644 --- a/sleep-api/src/models/mod.rs +++ b/sleep-api/src/models/mod.rs @@ -15,7 +15,7 @@ pub mod note; pub mod quality; pub mod sleep; -pub use exercise::ExerciseInput; +pub use exercise::{ExerciseInput, DateIntensity}; #[allow(unused_imports)] pub use intensity::Intensity; pub use note::NoteInput; diff --git a/sleep-api/src/repository.rs b/sleep-api/src/repository.rs index 749ff47..643682a 100644 --- a/sleep-api/src/repository.rs +++ b/sleep-api/src/repository.rs @@ -17,7 +17,7 @@ See also: use crate::{ db::Db, - models::{ExerciseInput, NoteInput, SleepInput, SleepListItem, SleepSession}, + models::{ExerciseInput, NoteInput, SleepInput, SleepListItem, SleepSession, DateIntensity}, }; use chrono::NaiveDate; use sqlx::{Sqlite, Transaction}; @@ -109,6 +109,25 @@ pub async fn find_sleep_by_date( .await } +#[doc = r#"Find a sleep session by id. + +Returns `Ok(None)` if no session exists for the provided id. + +See the example on [`insert_sleep`]. + +# Errors +- Returns [`sqlx::Error`] on database errors. +"#] +pub async fn find_sleep_by_id(db: &Db, id: i64) -> Result, sqlx::Error> { + sqlx::query_as::( + r#"SELECT s.id, s.date, s.bed_time, s.wake_time, m.latency_min, m.awakenings, m.quality + FROM sleep_sessions s JOIN sleep_metrics m ON m.session_id = s.id WHERE s.id = ?"#, + ) + .bind(id) + .fetch_optional(db) + .await +} + #[doc = r#"Update a sleep session and its metrics in a single transaction. Requires a recomputed `duration_min`; see [`time::compute_duration_min`]. @@ -184,6 +203,40 @@ pub async fn list_recent_sleep(db: &Db, days: i32) -> Result, .await } +#[doc = r#"List exercise intensity by date in the inclusive range [from, to]. + +For each date, returns the highest intensity among any events on that date. + +- "none" < "light" < "hard" + +Ordered by date ASC. +"#] +pub async fn list_exercise_intensity( + db: &Db, + from: NaiveDate, + to: NaiveDate, +) -> Result, sqlx::Error> { + // Map intensity to ordinal to pick max, then map back to string + sqlx::query_as::( + r#" + SELECT + date, + CASE MAX(CASE intensity WHEN 'none' THEN 0 WHEN 'light' THEN 1 WHEN 'hard' THEN 2 ELSE 0 END) + WHEN 2 THEN 'hard' + WHEN 1 THEN 'light' + ELSE 'none' + END AS intensity + FROM exercise_events + WHERE date BETWEEN ? AND ? + GROUP BY date + ORDER BY date ASC + "#, + ) + .bind(from) + .bind(to) + .fetch_all(db) +} + #[doc = r#"List daily sleep entries in the inclusive range [from, to] ordered by date ASC."#] pub async fn list_sleep_range( db: &Db, diff --git a/sleep-api/tests/api_sleep.rs b/sleep-api/tests/api_sleep.rs index 493f4af..47e71ec 100644 --- a/sleep-api/tests/api_sleep.rs +++ b/sleep-api/tests/api_sleep.rs @@ -128,6 +128,16 @@ async fn test_sleep_flow() { let id: serde_json::Value = res.json().await.unwrap(); let id = id["id"].as_i64().unwrap(); + // Fetch by id and verify + let res = client + .get(format!("http://{addr}/api/sleep/{id}")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let session_by_id: SleepSession = res.json().await.unwrap(); + assert_eq!(session_by_id.id, id); + let res = client .get(format!("http://{addr}/api/sleep/date/{}", input.date)) .send() diff --git a/sleep-api/tests/api_sleep_list.rs b/sleep-api/tests/api_sleep_list.rs index 8ac8a3d..e407ba1 100644 --- a/sleep-api/tests/api_sleep_list.rs +++ b/sleep-api/tests/api_sleep_list.rs @@ -233,9 +233,7 @@ async fn test_sleep_list_invalid_params() { .send() .await .unwrap(); - // Our implementation clamps invalid/missing days; to produce 400 we need invalid range tests below - // Keep this check lenient: recent with days=0 should still be 200 due to clamping to 1 - assert!(res.status() == 200 || res.status() == 400); + assert_eq!(res.status(), 400, "days=0 should be 400 per validation policy"); // from > to -> 400 let res = client diff --git a/sleep-ui/src/lib/api.ts b/sleep-ui/src/lib/api.ts index fe2baab..0f1b7c7 100644 --- a/sleep-ui/src/lib/api.ts +++ b/sleep-ui/src/lib/api.ts @@ -165,6 +165,10 @@ export interface SleepInput { quality: number; } +export interface SleepSession extends SleepInput { + id: number; +} + export interface ExerciseUpsert { date: IsoDate; intensity: 'none' | 'light' | 'hard'; @@ -181,6 +185,10 @@ export async function getRange(from: IsoDate, to: IsoDate): Promise(`/api/sleep/range?from=${from}&to=${to}`); } +export async function getSleepById(id: number): Promise { + return apiGet(`/api/sleep/${id}`); +} + export async function createSleep(input: SleepInput): Promise<{ id: number }> { return apiPost<{ id: number }>('/api/sleep', input as unknown as Json); } @@ -193,6 +201,10 @@ export async function deleteSleep(id: number): Promise { await apiDelete(`/api/sleep/${id}`); } +export async function getExerciseIntensity(from: IsoDate, to: IsoDate): Promise<{ date: IsoDate; intensity: 'none' | 'light' | 'hard' }[]> { + return apiGet<{ date: IsoDate; intensity: 'none' | 'light' | 'hard' }[]>(`/api/exercise/intensity?from=${from}&to=${to}`); +} + export async function upsertExercise(payload: ExerciseUpsert): Promise<{ id: number }> { return apiPost<{ id: number }>('/api/exercise', payload as unknown as Json); } diff --git a/sleep-ui/src/routes/+page.server.ts b/sleep-ui/src/routes/+page.server.ts index 36f8cf8..d0d857d 100644 --- a/sleep-ui/src/routes/+page.server.ts +++ b/sleep-ui/src/routes/+page.server.ts @@ -12,6 +12,7 @@ type SleepListItem = { export const load = async ({ fetch }: any) => { let recent: SleepListItem[] = []; + let intensities: { date: string; intensity: 'none' | 'light' | 'hard' }[] = []; try { const res = await fetch('/api/sleep/recent?days=7'); if (res.ok) { @@ -20,5 +21,25 @@ export const load = async ({ fetch }: any) => { } catch { // ignore; treat as empty } - return { recent }; + // Compute last 7-day window [start, end] + function isoDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + const endD = new Date(); + const startD = new Date(endD); + startD.setDate(endD.getDate() - 6); + const start = isoDate(startD); + const end = isoDate(endD); + try { + const res2 = await fetch(`/api/exercise/intensity?from=${start}&to=${end}`); + if (res2.ok) { + intensities = await res2.json(); + } + } catch { + // ignore; treat as empty + } + return { recent, intensities }; }; diff --git a/sleep-ui/src/routes/+page.svelte b/sleep-ui/src/routes/+page.svelte index f453cfb..e84663e 100644 --- a/sleep-ui/src/routes/+page.svelte +++ b/sleep-ui/src/routes/+page.svelte @@ -6,11 +6,15 @@ import { deleteSleep } from '$lib/api'; import { recentSleep, exerciseIntensityByDate, removeRecentById } from '$lib/stores/sleep'; - export let data: { recent: SleepListItem[] }; + export let data: { recent: SleepListItem[]; intensities?: { date: string; intensity: 'none' | 'light' | 'hard' }[] }; onMount(() => { - // seed store with server-fetched recent data + // seed stores with server-fetched data recentSleep.set(data.recent ?? []); + if (data.intensities && Array.isArray(data.intensities)) { + const map = Object.fromEntries(data.intensities.map((d) => [d.date, d.intensity])); + exerciseIntensityByDate.set(map); + } }); function isoDate(d: Date): string { diff --git a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte index cc5d822..f07b9ea 100644 --- a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte +++ b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; -import { apiGet, deleteSleep } from '$lib/api'; +import { deleteSleep, getSleepById } from '$lib/api'; import type { SleepListItem } from '$lib/api'; import { removeRecentById } from '$lib/stores/sleep'; import { pushToast } from '$lib/stores/toast'; @@ -12,14 +12,12 @@ import type { SleepListItem } from '$lib/api'; const params = p.params as unknown as { id: string }; const id = Number(params.id); - // Expect date via query (?date=YYYY-MM-DD) to use GET by date - const dateParam = p.url.searchParams.get('date'); let loading = true; let errorMsg: string | null = null; // Form initial values - let initialDate: string | null = dateParam; + let initialDate: string | null = null; let initialBed: string | null = null; let initialWake: string | null = null; let initialLatency = 0; @@ -37,9 +35,9 @@ import type { SleepListItem } from '$lib/api'; return '00:00:00'; } - async function loadByDate(date: string) { + async function loadById(id: number) { try { - const rec = await apiGet(`/api/sleep/date/${date}`); + const rec = await getSleepById(id); initialDate = rec.date; initialBed = normalizeTime(rec.bed_time); initialWake = normalizeTime(rec.wake_time); @@ -48,18 +46,13 @@ import type { SleepListItem } from '$lib/api'; initialQuality = rec.quality ?? 3; // Intensity is not part of this payload; leave default or get from a separate endpoint in future } catch (e: any) { - errorMsg = `Failed to load record for ${date}: ${e?.message ?? 'error'}`; + errorMsg = `Failed to load record: ${e?.message ?? 'error'}`; } finally { loading = false; } } - if (dateParam) { - loadByDate(dateParam); - } else { - loading = false; - errorMsg = 'Missing ?date=YYYY-MM-DD to load entry'; - } + loadById(id); async function onDelete() { if (!confirm('Delete this entry?')) return; From 48e2dcf60055ff348ee1cbd65b31f935ca0918f6 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 16:21:13 +0900 Subject: [PATCH 18/23] :zap: Exersise input --- sleep-api/src/repository.rs | 36 ++++- sleep-api/tests/api_exercise_intensity.rs | 168 ++++++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 sleep-api/tests/api_exercise_intensity.rs diff --git a/sleep-api/src/repository.rs b/sleep-api/src/repository.rs index 643682a..0b6a090 100644 --- a/sleep-api/src/repository.rs +++ b/sleep-api/src/repository.rs @@ -235,6 +235,7 @@ pub async fn list_exercise_intensity( .bind(from) .bind(to) .fetch_all(db) + .await } #[doc = r#"List daily sleep entries in the inclusive range [from, to] ordered by date ASC."#] @@ -291,9 +292,42 @@ let id = repository::insert_exercise(&db, &input).await?; - Returns [`sqlx::Error`] on database errors. "#] pub async fn insert_exercise(db: &Db, input: &ExerciseInput) -> Result { + // For "daily intensity" sentinel rows (no time and no duration), upsert by date + if input.start_time.is_none() && input.duration_min.is_none() { + let mut tx: Transaction<'_, Sqlite> = db.begin().await?; + if let Some(existing_id) = sqlx::query_scalar::( + "SELECT id FROM exercise_events WHERE date = ? AND start_time IS NULL AND duration_min IS NULL", + ) + .bind(input.date) + .fetch_optional(&mut *tx) + .await? + { + sqlx::query::("UPDATE exercise_events SET intensity = ? WHERE id = ?") + .bind(input.intensity.to_string()) + .bind(existing_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + return Ok(existing_id); + } else { + let res = sqlx::query::( + "INSERT INTO exercise_events(date, intensity, start_time, duration_min) VALUES (?, ?, ?, ?)", + ) + .bind(input.date) + .bind(input.intensity.to_string()) + .bind(None::) + .bind(None::) + .execute(&mut *tx) + .await?; + tx.commit().await?; + return Ok(res.last_insert_rowid()); + } + } + + // Otherwise, treat as a normal exercise event insert let mut tx: Transaction<'_, Sqlite> = db.begin().await?; let res = sqlx::query::( - "INSERT INTO exercise_events(date, intensity, start_time, duration_min) VALUES (?, ?, ?, ?)" + "INSERT INTO exercise_events(date, intensity, start_time, duration_min) VALUES (?, ?, ?, ?)", ) .bind(input.date) .bind(input.intensity.to_string()) diff --git a/sleep-api/tests/api_exercise_intensity.rs b/sleep-api/tests/api_exercise_intensity.rs new file mode 100644 index 0000000..9f71a1e --- /dev/null +++ b/sleep-api/tests/api_exercise_intensity.rs @@ -0,0 +1,168 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::{ + password_hash::{PasswordHasher, SaltString}, + Argon2, +}; +use reqwest::Client; +use sleep_api::{app, db}; + +fn set_admin_env(email: &str, password: &str) { + let salt = SaltString::generate(OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .unwrap() + .to_string(); + unsafe { + std::env::set_var("ADMIN_EMAIL", email); + std::env::set_var("ADMIN_PASSWORD_HASH", hash); + } +} + +async fn wait_ready(client: &Client, addr: &str) { + let health_url = format!("http://{addr}/api/health"); + for _ in 0..20 { + if client.get(&health_url).send().await.is_ok() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("Server did not become ready in time"); +} + +fn parse_cookie<'a>( + headers: impl Iterator, + name_with_eq: &str, +) -> Option { + for hv in headers { + if let Ok(s) = hv.to_str() { + if s.starts_with(name_with_eq) { + if let Some(eq_idx) = s.find('=') { + let rest = &s[eq_idx + 1..]; + let end = rest.find(';').unwrap_or(rest.len()); + return Some(rest[..end].to_string()); + } + } + } + } + None +} + +async fn login_and_get_auth( + client: &Client, + addr: &str, + email: &str, + password: &str, +) -> (String, String) { + let res = client + .post(format!("http://{addr}/api/login.json")) + .json(&serde_json::json!({ "email": email, "password": password })) + .send() + .await + .expect("login request failed"); + assert_eq!(res.status(), 200, "login failed: {}", res.status()); + let headers = res.headers().get_all(reqwest::header::SET_COOKIE); + // Accept both secure (__Host-*) and dev-mode (no prefix) cookie names + let csrf = parse_cookie(headers.iter(), "__Host-csrf=") + .or_else(|| parse_cookie(headers.iter(), "csrf=")) + .expect("missing CSRF cookie in login response"); + let session = parse_cookie(headers.iter(), "__Host-session=") + .or_else(|| parse_cookie(headers.iter(), "session=")) + .expect("missing session cookie in login response"); + (csrf, session) +} + +#[derive(serde::Deserialize, Debug)] +struct DateIntensity { + date: chrono::NaiveDate, + intensity: String, // "none" | "light" | "hard" +} + +#[tokio::test] +async fn test_exercise_intensity_range_endpoint() { + unsafe { + std::env::set_var("DATABASE_URL", "sqlite::memory:"); + std::env::set_var("COOKIE_SECURE", "0"); + }; + set_admin_env("admin@example.com", "password123"); + + let pool = db::connect().await.unwrap(); + sqlx::migrate::Migrator::new(std::path::Path::new("../migrations")) + .await + .unwrap() + .run(&pool) + .await + .unwrap(); + + let app = app::router(pool.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let client = Client::builder().cookie_store(true).build().unwrap(); + wait_ready(&client, &addr.to_string()).await; + + let (csrf, session_cookie) = + login_and_get_auth(&client, &addr.to_string(), "admin@example.com", "password123").await; + + // Seed exercise events: + // 2025-06-10: light + // 2025-06-11: none + // 2025-06-12: light then hard (final should be "hard") + let seeds = vec![ + ("2025-06-10", "light"), + ("2025-06-11", "none"), + ("2025-06-12", "light"), + ("2025-06-12", "hard"), + ]; + for (date, intensity) in seeds { + let res = client + .post(format!("http://{addr}/api/exercise")) + .header("Cookie", format!("session={session_cookie}; csrf={csrf}")) + .header("X-CSRF-Token", &csrf) + .json(&serde_json::json!({ + "date": date, + "intensity": intensity + })) + .send() + .await + .unwrap(); + let status = res.status(); + if status != 201 { + let body = res.text().await.unwrap_or_else(|_| "".into()); + panic!("seed exercise failed: {} body: {}", status, body); + } + } + + // Query intensities in [2025-06-10, 2025-06-12] + let res = client + .get(format!( + "http://{addr}/api/exercise/intensity?from=2025-06-10&to=2025-06-12" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "intensity status {}", res.status()); + let items: Vec = res.json().await.unwrap(); + assert_eq!(items.len(), 3, "expected 3 days"); + assert_eq!(items[0].date, chrono::NaiveDate::from_ymd_opt(2025, 6, 10).unwrap()); + assert_eq!(items[0].intensity, "light"); + assert_eq!(items[1].date, chrono::NaiveDate::from_ymd_opt(2025, 6, 11).unwrap()); + assert_eq!(items[1].intensity, "none"); + assert_eq!(items[2].date, chrono::NaiveDate::from_ymd_opt(2025, 6, 12).unwrap()); + assert_eq!(items[2].intensity, "hard"); + + // Invalid range: from > to => 400 + let res = client + .get(format!( + "http://{addr}/api/exercise/intensity?from=2025-06-13&to=2025-06-12" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + + server.abort(); +} From ee60452ae75649db482fd82bcb669252eb3750da Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 16:56:53 +0900 Subject: [PATCH 19/23] feat(ui): add SSR load for sleep edit page --- .../routes/sleep/[id]/edit/+page.server.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 sleep-ui/src/routes/sleep/[id]/edit/+page.server.ts diff --git a/sleep-ui/src/routes/sleep/[id]/edit/+page.server.ts b/sleep-ui/src/routes/sleep/[id]/edit/+page.server.ts new file mode 100644 index 0000000..a4be985 --- /dev/null +++ b/sleep-ui/src/routes/sleep/[id]/edit/+page.server.ts @@ -0,0 +1,30 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; + +type SleepSession = { + id: number; + date: string; // YYYY-MM-DD + bed_time: string; // HH:mm:ss + wake_time: string; // HH:mm:ss + latency_min: number | null; + awakenings: number | null; + quality: number | null; +}; + +export const load: PageServerLoad = async ({ fetch, params, url }) => { + const id = Number(params.id); + if (!Number.isFinite(id) || id <= 0) { + throw error(400, 'Invalid id'); + } + + const res = await fetch(`/api/sleep/${id}`); + if (!res.ok) { + if (res.status === 404) throw error(404, 'Not found'); + throw error(res.status, `Failed to load record: ${res.status}`); + } + + const rec = (await res.json()) as SleepSession; + const qDate = url.searchParams.get('date'); + + return { rec, qDate }; +}; From fb860a0516f31b96b96bbe37aa01dfda4a03269b Mon Sep 17 00:00:00 2001 From: ebigunso Date: Sat, 23 Aug 2025 16:59:30 +0900 Subject: [PATCH 20/23] refactor(ui): use load data in /sleep/[id]/edit and remove client-side fetch --- .../src/routes/sleep/[id]/edit/+page.svelte | 84 ++++++------------- 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte index f07b9ea..559753b 100644 --- a/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte +++ b/sleep-ui/src/routes/sleep/[id]/edit/+page.svelte @@ -1,30 +1,17 @@