diff --git a/crates/aether-admin/src/observability/analytics/dashboard_summary.rs b/crates/aether-admin/src/observability/analytics/dashboard_summary.rs index daa4b889b..1122ad3c0 100644 --- a/crates/aether-admin/src/observability/analytics/dashboard_summary.rs +++ b/crates/aether-admin/src/observability/analytics/dashboard_summary.rs @@ -9,6 +9,7 @@ pub fn dashboard_summary_value(snapshot: &StoredDashboardSummary) -> Value { "stats_since": snapshot.stats_since, "generated_at": snapshot.generated_at, "timezone": snapshot.timezone, + "activity_timezone": snapshot.activity_timezone, "today_from": snapshot.today_from, "window_seconds": snapshot.window_seconds, "today": metrics_value(&snapshot.today), @@ -46,7 +47,8 @@ fn metrics_value(metrics: &DashboardSummaryMetrics) -> Value { "active_users": metrics.active_users, "cache_read_tokens": tokens_available.then_some(metrics.cache_read_tokens), "cache_creation_tokens": tokens_available.then_some(metrics.cache_creation_tokens), - "cache_input_tokens": tokens_available.then_some(metrics.cache_input_tokens), + "cache_input_tokens": (tokens_available && metrics.cache_input_tokens >= metrics.cache_read_tokens) + .then_some(metrics.cache_input_tokens), "avg_first_byte_ms": average(metrics.first_byte_sum_ms, metrics.first_byte_sample_count), "avg_response_ms": average(metrics.response_sum_ms, metrics.response_sample_count), "stream_requests": metrics.stream_requests, @@ -99,6 +101,9 @@ mod tests { assert_eq!(value["total"]["cache_input_tokens"], 400); assert!(value["today"].get("latency_p95_ms").is_none()); + snapshot.total.cache_input_tokens = 100; + assert!(dashboard_summary_value(&snapshot)["total"]["cache_input_tokens"].is_null()); + snapshot.total.usage_available_count = 0; let unknown = dashboard_summary_value(&snapshot); assert!(unknown["total"].get("cache_read_tokens").unwrap().is_null()); diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_history.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_history.rs new file mode 100644 index 000000000..a255b202f --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_history.rs @@ -0,0 +1,96 @@ +//! Compatibility reads for installations whose retained daily totals predate +//! the incremental dashboard. Never replay or mutate the activation boundary. +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::usage::*, DataLayerError}; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{Postgres, Row, Transaction}; + +pub(super) struct DashboardHistory { + pub since: DateTime, + pub metrics: Value, + pub days: Vec, +} + +pub(super) async fn read_dashboard_history( + tx: &mut Transaction<'_, Postgres>, + activation: DateTime, + now: DateTime, +) -> Result, DataLayerError> { + // Both timestamp and integer dates exist in supported imported schemas. + let state = sqlx::query( + r#" + WITH summary AS ( + SELECT CASE WHEN pg_typeof(cutoff_date) IN ('bigint'::regtype,'integer'::regtype) + THEN to_timestamp(cutoff_date::text::double precision) + ELSE cutoff_date::text::timestamptz END AS cutoff + FROM stats_summary ORDER BY updated_at DESC, created_at DESC LIMIT 1 + ), daily AS ( + SELECT CASE WHEN pg_typeof(date) IN ('bigint'::regtype,'integer'::regtype) + THEN to_timestamp(date::text::double precision) + ELSE date::text::timestamptz END AS day + FROM stats_daily WHERE total_requests>0 + ) SELECT cutoff, (SELECT min(day) FROM daily WHERE day>, _>("since") + .map_postgres_err()? + else { + return Ok(None); + }; + let cutoff: DateTime = state.try_get("cutoff").map_postgres_err()?; + // Canonical fact views expand several joins. JIT compilation can cost more + // than the small recent range itself on upgraded installations. + sqlx::query("SET LOCAL jit=off") + .execute(&mut **tx) + .await + .map_postgres_err()?; + // The published summary cutoff is advanced atomically with daily rollups. + // Read that same snapshot and use disjoint [history, cutoff), [cutoff, now) + // ranges. This keeps working after raw history is pruned or the cutoff moves. + let row = sqlx::query(include_str!("queries/dashboard_history.sql")) + .bind(cutoff) + .bind(now) + .fetch_one(&mut **tx) + .await + .map_postgres_err()?; + let days: Value = row.try_get("days").map_postgres_err()?; + Ok(Some(DashboardHistory { + since, + metrics: row.try_get("metrics").map_postgres_err()?, + days: serde_json::from_value(days) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?, + })) +} + +pub(super) async fn complete_activation_day( + tx: &mut Transaction<'_, Postgres>, + today: &DashboardSummaryMetrics, + today_start: DateTime, + activation: DateTime, + now: DateTime, +) -> Result { + // Match dashboard_request_fact exactly for the missing part of today. + // The actor UNION deduplicates users present on both sides of activation. + let row = sqlx::query(include_str!("queries/dashboard_activation_day.sql")) + .bind(today_start) + .bind(activation) + .bind(now) + .bind( + serde_json::to_value(today) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?, + ) + .fetch_one(&mut **tx) + .await + .map_postgres_err()?; + row.try_get("metrics").map_postgres_err() +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_history_tests.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_history_tests.rs new file mode 100644 index 000000000..5eb66a89d --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_history_tests.rs @@ -0,0 +1,159 @@ +use super::SqlxUsageReadRepository; +use aether_data_contracts::repository::usage::*; +use chrono::{DateTime, Duration, Utc}; +use futures_util::FutureExt; +use std::{collections::BTreeMap, panic::AssertUnwindSafe}; + +#[tokio::test] +#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"] +async fn live_dashboard_restores_legacy_history_without_replaying_or_double_counting() { + let options = std::env::var("AETHER_TEST_DATABASE_URL") + .unwrap() + .parse::() + .unwrap(); + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options.clone()) + .await + .unwrap(); + let database = format!("dashboard_history_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(&format!("CREATE DATABASE {database}")) + .execute(&admin) + .await + .unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect_with(options.database(&database)) + .await + .unwrap(); + + let outcome = AssertUnwindSafe(async { + crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageDashboardAnalyticsQuery { + timezone: "Asia/Kathmandu".into(), + }; + let now: DateTime = sqlx::query_scalar("SELECT CURRENT_TIMESTAMP") + .fetch_one(&pool) + .await + .unwrap(); + let utc_today = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); + let local_today = query.today_start(now).unwrap(); + let activation = local_today + + Duration::microseconds((now - local_today).num_microseconds().unwrap() / 2); + let before_activation = local_today + + Duration::microseconds((activation - local_today).num_microseconds().unwrap() / 2); + let after_activation = activation + + Duration::microseconds((now - activation).num_microseconds().unwrap() / 2); + let oldest = utc_today - Duration::days(400); + let recent_history = utc_today - Duration::days(3); + let cutoff = utc_today - Duration::days(2); + sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1") + .bind(activation) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO users(id,username,email_verified,is_deleted) VALUES ('shared','shared',false,false),('before-only','before-only',false,false),('deleted','deleted',false,true)") + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO stats_summary(id,cutoff_date) VALUES('legacy',$1)") + .bind(cutoff).execute(&pool).await.unwrap(); + + // The oldest day's request rows have already expired. Its exact NUMERIC + // cost and lifetime activity must survive outside the visible heatmap. + for (id, date, requests, input, output, creation, read, cost) in [ + ("expired", oldest, 10, 1000_i64, 200_i64, 30_i64, 40_i64, "123456789.12345678"), + ("recent", recent_history, 3, 300, 60, 9, 12, "0.30000003"), + ("at-cutoff", cutoff, 1, 100, 20, 3, 4, "0.10000001"), + ] { + sqlx::query("INSERT INTO stats_daily(id,date,total_requests,input_tokens,output_tokens,cache_creation_tokens,cache_read_tokens,actual_total_cost) VALUES($1,$2,$3,$4,$5,$6,$7,$8::text::numeric)") + .bind(id).bind(date).bind(requests).bind(input).bind(output) + .bind(creation).bind(read).bind(cost).execute(&pool).await.unwrap(); + } + // Retained detail below the cutoff must not be counted a second time; + // a request exactly at the cutoff must still be included once. + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,cache_creation_input_tokens,cache_read_input_tokens,actual_total_cost_usd,created_at) VALUES('overlap','overlap','m','p','completed','settled',999,999,1998,0,0,999,$1),('boundary','boundary','m','p','completed','settled',100,20,127,3,4,0.10000001,$2)") + .bind(recent_history + Duration::hours(12)).bind(cutoff) + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,created_at) VALUES('pending','pending','m','p','pending','pending',999,999,1998,999,$1),('unknown','unknown','m','unknown','completed','settled',999,999,1998,999,$1)") + .bind(cutoff + Duration::hours(12)).execute(&pool).await.unwrap(); + for (id, user, at) in [ + ("before-shared", "shared", before_activation), + ("before-unique", "before-only", before_activation), + ("before-deleted", "deleted", before_activation), + ("after-shared", "shared", after_activation), + ] { + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,total_cost_usd,created_at,first_byte_time_ms,response_time_ms,upstream_is_stream) VALUES($1,$1,$2,'m','p','completed','settled',100,20,120,0.25,0.25,$3,100,800,true)") + .bind(id).bind(user).bind(at).execute(&pool).await.unwrap(); + } + // Today's missing prefix must use final settlement facts, just like + // the already-active projection, instead of stale capture counters. + sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_input_tokens,billing_effective_input_tokens,billing_output_tokens,billing_cache_read_tokens,billing_cache_creation_tokens,billing_total_input_context,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('before-shared','settled',100,100,30,40,10,150,0.12345678,0.12345678)") + .execute(&pool).await.unwrap(); + + let restored = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(restored.total.request_count, 18); + assert_eq!(restored.total.total_tokens, 2318); + assert_eq!(restored.total.billable_amount.as_deref(), Some("123456790.39691360")); + assert_eq!(restored.stats_since, oldest.to_rfc3339()); + assert_eq!(restored.timezone, query.timezone); + assert_eq!(restored.activity_timezone, "UTC"); + assert_eq!(restored.today_from, local_today.to_rfc3339()); + assert_eq!(restored.today.request_count, 4); + assert_eq!(restored.today.total_tokens, 540); + assert_eq!(restored.today.billable_amount.as_deref(), Some("0.87345678")); + assert_eq!(restored.today.active_users, 2, "users on both sides of activation count once; deleted users do not count"); + assert_eq!(restored.today.first_byte_sample_count, 4); + assert_eq!(restored.today.response_sample_count, 4); + assert_eq!(restored.today.response_sum_ms, 3200.0); + assert_eq!(restored.today.stream_requests, 4); + + let mut expected_days = BTreeMap::from([ + (recent_history.date_naive().to_string(), 3_u64), + (cutoff.date_naive().to_string(), 1), + ]); + *expected_days.entry(before_activation.date_naive().to_string()).or_default() += 3; + *expected_days.entry(after_activation.date_naive().to_string()).or_default() += 1; + assert_eq!(restored.active_days, expected_days.len() as u64 + 1); + assert_eq!(restored.activity_days.iter().map(|day| (day.date.clone(), day.requests)).collect::>(), expected_days); + assert_eq!(sqlx::query_scalar::<_, DateTime>("SELECT stats_since FROM dashboard_stats_state WHERE singleton") + .fetch_one(&pool).await.unwrap(), activation, "reading history must not change the projection activation boundary"); + assert_eq!(sqlx::query_scalar::<_, i64>("SELECT count(*) FROM dashboard_request_contributions") + .fetch_one(&pool).await.unwrap(), 1, "old requests are read, not replayed into projection ledgers"); + + // The daily aggregator advances its exclusive boundary over the same + // request. Existing daily rows above the old cutoff were not duplicates. + sqlx::query("UPDATE stats_summary SET cutoff_date=$1 WHERE id='legacy'") + .bind(cutoff + Duration::days(1)).execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM usage WHERE request_id='boundary'") + .execute(&pool).await.unwrap(); + let advanced = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(advanced.total.request_count, restored.total.request_count); + assert_eq!(advanced.total.total_tokens, restored.total.total_tokens); + assert_eq!(advanced.total.billable_amount, restored.total.billable_amount); + assert_eq!(advanced.today, restored.today); + assert_eq!(advanced.activity_days, restored.activity_days); + assert_eq!(advanced.active_days, restored.active_days); + + // A summary cutoff without legacy daily history must leave the normal + // future-only projection and its requested calendar unchanged. + sqlx::query("DELETE FROM stats_daily").execute(&pool).await.unwrap(); + let future_only = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(future_only.stats_since, activation.to_rfc3339()); + assert_eq!(future_only.activity_timezone, query.timezone); + assert_eq!(future_only.total.request_count, 1); + assert_eq!(future_only.today.request_count, 1); + assert_eq!(future_only.today_from, activation.to_rfc3339()); + }) + .catch_unwind() + .await; + + pool.close().await; + sqlx::query(&format!("DROP DATABASE {database} WITH (FORCE)")) + .execute(&admin) + .await + .unwrap(); + admin.close().await; + if let Err(error) = outcome { + std::panic::resume_unwind(error); + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs index 1884a1785..baffc4370 100644 --- a/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs @@ -1,3 +1,4 @@ +use super::dashboard_history::{complete_activation_day, read_dashboard_history}; use super::SqlxUsageReadRepository; use crate::error::SqlxResultExt; use aether_data_contracts::{repository::usage::*, DataLayerError}; @@ -46,8 +47,14 @@ impl SqlxUsageReadRepository { let since: DateTime = state.try_get("stats_since").map_postgres_err()?; let now: DateTime = state.try_get("now").map_postgres_err()?; let today_start = query.today_start(now)?; - let today_from = today_start.max(since); - let minute_from = today_from + let history = read_dashboard_history(&mut tx, since, now).await?; + let today_from = if history.is_some() { + today_start + } else { + today_start.max(since) + }; + let minute_from = today_start + .max(since) .with_second(0) .and_then(|v| v.with_nanosecond(0)) .expect("valid minute"); @@ -78,10 +85,21 @@ impl SqlxUsageReadRepository { } today.active_users = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT a.actor_user_id) FROM dashboard_actor_minute a JOIN users u ON u.id=a.actor_user_id AND NOT u.is_deleted WHERE a.bucket_start >= $1 AND a.bucket_start <= $2 AND a.request_count > 0") .bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64; + if history.is_some() && since > today_start { + today = decode_metrics( + complete_activation_day(&mut tx, &today, today_start, since, now).await?, + )?; + } // Most UTC hours belong to one local day. Only hours straddling a local // midnight (e.g. Kathmandu) need their minute counts read. Lifetime // activity therefore scans narrow hourly counters, not metric JSON. - let days = sqlx::query(r#"WITH hours AS MATERIALIZED ( + let activity_timezone = if history.is_some() { + "UTC" + } else { + query.timezone.as_str() + }; + let mut days = if history.is_none() { + sqlx::query(r#"WITH hours AS MATERIALIZED ( SELECT bucket_start,shard,request_count, (bucket_start AT TIME ZONE $1)::date AS day, ((bucket_start+INTERVAL '1 hour'-INTERVAL '1 microsecond') AT TIME ZONE $1)::date AS last_day @@ -105,11 +123,23 @@ impl SqlxUsageReadRepository { ) m ON TRUE WHERE h.day<>h.last_day ) SELECT day AS date, sum(requests)::bigint AS requests FROM daily GROUP BY day HAVING sum(requests)>0 ORDER BY day"#) - .bind(&query.timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?; + .bind(activity_timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()? + .into_iter().map(|row| Ok(DashboardActivityDay { + date: row.try_get::("date").map_postgres_err()?.to_string(), + requests: row.try_get::("requests").map_postgres_err()?.max(0) as u64, + })).collect::, DataLayerError>>()? + } else { + Vec::new() + }; + let mut display_since = since; + if let Some(history) = history { + display_since = history.since; + total = decode_metrics(history.metrics)?; + days = history.days; + } let local_today = now .with_timezone( - &query - .timezone + &activity_timezone .parse::() .map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?, ) @@ -118,7 +148,10 @@ impl SqlxUsageReadRepository { let active_days = days.len() as u64; let dates: Vec = days .iter() - .map(|row| row.try_get("date").map_postgres_err()) + .map(|row| { + NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string())) + }) .collect::>()?; let consecutive_active_days = dashboard_consecutive_active_days(dates.iter().copied(), local_today); @@ -127,16 +160,17 @@ impl SqlxUsageReadRepository { if date >= first_heatmap_day { activity_days.push(DashboardActivityDay { date: date.to_string(), - requests: row.try_get::("requests").map_postgres_err()?.max(0) as u64, + requests: row.requests, }); } } let events = sqlx::query("SELECT COALESCE(sum(created_count),0)::bigint AS created, COALESCE(sum(deleted_count),0)::bigint AS deleted FROM dashboard_user_events_minute WHERE bucket_start >= $1 AND bucket_start <= $2") .bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()?; let summary = StoredDashboardSummary { - stats_since: since.to_rfc3339(), + stats_since: display_since.to_rfc3339(), generated_at: now.to_rfc3339(), timezone: query.timezone.clone(), + activity_timezone: activity_timezone.to_string(), today_from: today_from.to_rfc3339(), window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0, today, diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 742e71604..1349778f1 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -63,6 +63,9 @@ mod analytics_tests; mod attribution; pub mod cleanup; mod dashboard; +mod dashboard_history; +#[cfg(test)] +mod dashboard_history_tests; mod dashboard_retention; mod dashboard_summary; #[cfg(test)] diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_activation_day.sql b/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_activation_day.sql new file mode 100644 index 000000000..80b78778b --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_activation_day.sql @@ -0,0 +1,43 @@ +WITH missing AS MATERIALIZED ( + SELECT f.actor_user_id, f.input_tokens, f.output_tokens, f.total_tokens, + f.usage_available, f.billable_amount, f.cache_read_input_tokens, + f.cache_creation_input_tokens, f.first_byte_time_ms, f.response_time_ms, + f.upstream_is_stream, b.total_input_context + FROM usage_analytics_facts_v1 f + JOIN usage_billing_facts b ON b.request_id=f.request_id + -- Bound both view inputs so the planner cannot scan historical billing rows. + AND b.created_at >= $1 AND b.created_at < $2 + WHERE f.created_at >= $1 AND f.created_at < $2 AND f.record_kind <> 'session' +), metrics AS ( + SELECT jsonb_build_object( + 'request_count', count(*), + 'input_tokens', COALESCE(sum(input_tokens),0), + 'output_tokens', COALESCE(sum(output_tokens),0), + 'total_tokens', COALESCE(sum(total_tokens),0), + 'usage_available_count', count(*) FILTER (WHERE usage_available), + 'pricing_available_count', count(billable_amount), + 'billable_amount', COALESCE(sum(billable_amount),0), + 'cache_read_tokens', COALESCE(sum(cache_read_input_tokens),0), + 'cache_creation_tokens', COALESCE(sum(cache_creation_input_tokens),0), + 'cache_input_tokens', COALESCE(sum(total_input_context) FILTER (WHERE usage_available),0), + 'first_byte_sum_ms', COALESCE(sum(first_byte_time_ms),0), + 'first_byte_sample_count', count(first_byte_time_ms), + 'response_sum_ms', COALESCE(sum(response_time_ms),0), + 'response_sample_count', count(response_time_ms), + 'stream_requests', count(*) FILTER (WHERE COALESCE(upstream_is_stream,false)), + 'standard_requests', count(*) FILTER (WHERE NOT COALESCE(upstream_is_stream,false)) + ) AS value FROM missing +), actors AS ( + SELECT actor_user_id FROM missing WHERE actor_user_id IS NOT NULL + UNION + SELECT actor_user_id FROM dashboard_actor_minute + WHERE bucket_start >= date_trunc('minute',$2::timestamptz) + AND bucket_start <= $3 AND request_count>0 +), combined AS ( + SELECT dashboard_metrics_add($4::jsonb, value) AS value FROM metrics +) +SELECT value || jsonb_build_object( + 'billable_amount', round((value->>'billable_amount')::numeric,8)::text, + 'active_users', (SELECT count(*) FROM actors a JOIN users u + ON u.id=a.actor_user_id AND NOT u.is_deleted) +) AS metrics FROM combined diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_history.sql b/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_history.sql new file mode 100644 index 000000000..3eb9a991f --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/queries/dashboard_history.sql @@ -0,0 +1,51 @@ +WITH daily AS ( + SELECT CASE WHEN pg_typeof(date) IN ('bigint'::regtype,'integer'::regtype) + THEN to_timestamp(date::text::double precision) + ELSE date::text::timestamptz END AS day, + total_requests AS request_count, input_tokens, output_tokens, + CASE WHEN effective_input_tokens=0 AND total_input_context=0 AND input_tokens>0 + THEN input_tokens ELSE effective_input_tokens END + + output_tokens + cache_creation_tokens + cache_read_tokens AS total_tokens, + cache_creation_tokens, cache_read_tokens, + CASE WHEN total_input_context=0 THEN + CASE WHEN effective_input_tokens=0 AND input_tokens>0 THEN input_tokens + ELSE effective_input_tokens END + cache_creation_tokens + cache_read_tokens + ELSE total_input_context END AS cache_input_tokens, + actual_total_cost::numeric AS billable_amount + FROM stats_daily +), facts AS MATERIALIZED ( + SELECT (day AT TIME ZONE 'UTC')::date AS day, request_count, + input_tokens, output_tokens, total_tokens, cache_creation_tokens, + cache_read_tokens, cache_input_tokens, billable_amount + FROM daily WHERE day<$1 + UNION ALL + SELECT (b.created_at AT TIME ZONE 'UTC')::date, 1::bigint, + b.input_tokens, b.output_tokens, b.total_tokens, b.cache_creation_input_tokens, + b.cache_read_input_tokens, b.total_input_context, + COALESCE(s.billing_actual_total_cost_usd::numeric,u.actual_total_cost_usd::numeric) + FROM usage_billing_facts b + JOIN usage u USING (request_id) + LEFT JOIN usage_settlement_snapshots s USING (request_id) + WHERE b.created_at >= $1 AND b.created_at < $2 + AND u.created_at >= $1 AND u.created_at < $2 + AND b.status NOT IN ('pending','streaming') + AND b.provider_name NOT IN ('unknown','pending') +), activity AS ( + SELECT day, sum(request_count)::bigint AS requests FROM facts + GROUP BY day HAVING sum(request_count)>0 +) +SELECT jsonb_build_object( + 'request_count', COALESCE(sum(request_count),0)::bigint, + 'input_tokens', COALESCE(sum(input_tokens),0)::bigint, + 'output_tokens', COALESCE(sum(output_tokens),0)::bigint, + 'total_tokens', COALESCE(sum(total_tokens),0)::bigint, + 'cache_creation_tokens', COALESCE(sum(cache_creation_tokens),0)::bigint, + 'cache_read_tokens', COALESCE(sum(cache_read_tokens),0)::bigint, + 'cache_input_tokens', COALESCE(sum(cache_input_tokens),0)::bigint, + 'usage_available_count', COALESCE(sum(request_count),0)::bigint, + 'pricing_available_count', COALESCE(sum(request_count) FILTER (WHERE billable_amount IS NOT NULL),0)::bigint, + 'billable_amount', round(COALESCE(sum(billable_amount),0),8)::text +) AS metrics, + (SELECT COALESCE(jsonb_agg(jsonb_build_object('date',day::text,'requests',requests) + ORDER BY day),'[]'::jsonb) FROM activity) AS days +FROM facts diff --git a/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs b/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs index 6ec7877cf..31912ad05 100644 --- a/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs +++ b/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs @@ -42,6 +42,9 @@ pub struct StoredDashboardSummary { pub stats_since: String, pub generated_at: String, pub timezone: String, + /// Historical daily imports retain their original UTC calendar boundaries. + #[serde(default)] + pub activity_timezone: String, pub today_from: String, pub window_seconds: f64, pub today: DashboardSummaryMetrics, diff --git a/crates/aether-data/runtime/schema/README.md b/crates/aether-data/runtime/schema/README.md index 77fb531d5..0d3f16c76 100644 --- a/crates/aether-data/runtime/schema/README.md +++ b/crates/aether-data/runtime/schema/README.md @@ -157,6 +157,22 @@ The statistics schema migrations above do not embed a historical data rebuild. The new dashboard's activation boundary is not moved by legacy backfills, so they do not restore pre-activation dashboard totals. +For upgraded installations that retain historical `stats_daily` rows and a +`stats_summary` cutoff, the homepage has a read-only compatibility path. Lifetime +totals and activity combine daily aggregates strictly before that cutoff with +recent usage at or after it, in one database snapshot. This preserves history +whose individual requests have already expired and avoids double counting when +daily aggregation advances the cutoff. These activity dates use UTC, matching +the historical aggregates; the API reports `activity_timezone` separately from +the timezone used for today's metrics. On the activation day, today's metrics +also include requests before activation, with active users deduplicated across +the boundary. The stored activation timestamp and source records are unchanged. + +This compatibility path retains the legacy daily-aggregation retention contract: +unaggregated recent usage must remain available until the daily cutoff advances. +Installations without historical daily aggregates continue to read the durable +incremental dashboard projection. + Deleted users are excluded from dashboard active-user reads even when an older version left orphan activity rows. The anonymization upgrade installs rules for future deletions without cleaning old rows during migration; those old activity diff --git a/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs b/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs index 28f8e2013..e94b66fd6 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs @@ -212,6 +212,7 @@ impl InMemoryUsageReadRepository { stats_since: projection.since.to_rfc3339(), generated_at: now.to_rfc3339(), timezone: query.timezone.clone(), + activity_timezone: query.timezone.clone(), today_from: today_from.to_rfc3339(), window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0, today, diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 3b0482237..5e2fe733d 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -114,6 +114,7 @@ export interface OverviewDashboardSummary { stats_since: string generated_at: string timezone: string + activity_timezone?: string today_from: string window_seconds: number today: { diff --git a/frontend/src/components/stats/ActivityHeatmap.vue b/frontend/src/components/stats/ActivityHeatmap.vue index a0d274baa..db593ab56 100644 --- a/frontend/src/components/stats/ActivityHeatmap.vue +++ b/frontend/src/components/stats/ActivityHeatmap.vue @@ -132,7 +132,7 @@ >
@@ -198,7 +198,8 @@ const heatmapScroller = ref(null) const heatmapWrapper = ref(null) const heatmapWidth = ref(0) const cellSize = ref(10) -const cellGap = ref(props.compact ? 2 : 4) +const wideScreen = ref(true) +const cellGap = computed(() => !props.compact && wideScreen.value ? 4 : 2) const tooltipRef = ref(null) const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({ day: null, @@ -236,16 +237,20 @@ const verticalGapStyle = computed(() => ({ })) const weekColumns = computed(() => { - if (!props.data || !props.data.days || props.data.days.length === 0) { + if (!props.data) { return [] } - const dayEntries: DayWithMeta[] = props.data.days.map(day => ({ + const dayEntries: DayWithMeta[] = [...props.data.days].sort((a, b) => a.date.localeCompare(b.date)).map(day => ({ ...day, dateObj: new Date(`${day.date}T00:00:00Z`) })) - const firstDay = dayEntries[0]?.dateObj + const firstDay = new Date(`${props.data.start_date}T00:00:00Z`) + const lastDay = new Date(`${props.data.end_date}T00:00:00Z`) + if (!Number.isFinite(firstDay.getTime()) || !Number.isFinite(lastDay.getTime()) || firstDay > lastDay) { + return [] + } const padding: (DayWithMeta | null)[] = [] if (firstDay) { const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6 @@ -254,7 +259,18 @@ const weekColumns = computed(() => { } } - const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries] + // Keep every date in the declared range, including inactive dates before the + // first request and after the latest one. + const daysByDate = new Map(dayEntries.map(day => [day.date, day])) + const paddedDays: (DayWithMeta | null)[] = [...padding] + if (firstDay && lastDay) { + const cursor = new Date(firstDay) + while (cursor <= lastDay) { + const date = cursor.toISOString().slice(0, 10) + paddedDays.push(daysByDate.get(date) ?? { date, requests: 0, dateObj: new Date(cursor) }) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + } const remainder = paddedDays.length % 7 if (remainder !== 0) { for (let i = remainder; i < 7; i++) { @@ -356,12 +372,15 @@ const recalcCellSize = () => { const totalGap = Math.max(columnCount - 1, 0) * cellGap.value const availableSpace = Math.max(heatmapWidth.value - totalGap, 0) const rawSize = availableSpace / columnCount - // 自适应尺寸,最小 6px - cellSize.value = Math.max(6, rawSize) + // Let a complete year fill the card, while short histories never grow cells + // beyond the size they would have in a full calendar. + const yearCellSize = (heatmapWidth.value - 52 * cellGap.value) / 53 + const maxSize = Math.max(props.compact ? 12 : 16, yearCellSize) + cellSize.value = Math.min(maxSize, Math.max(6, rawSize)) } watch( - [() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value], + [heatmapWidth, () => weekColumns.value.length, cellGap, () => props.compact], () => { recalcCellSize() }, @@ -394,8 +413,7 @@ onMounted(() => { } mediaQuery = window.matchMedia('(min-width: 640px)') const updateGap = () => { - cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2 - recalcCellSize() + wideScreen.value = mediaQuery?.matches ?? false } mediaQueryHandler = () => updateGap() updateGap() diff --git a/frontend/src/components/stats/__tests__/ActivityHeatmap.spec.ts b/frontend/src/components/stats/__tests__/ActivityHeatmap.spec.ts new file mode 100644 index 000000000..d22d40849 --- /dev/null +++ b/frontend/src/components/stats/__tests__/ActivityHeatmap.spec.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue' +import ActivityHeatmap from '../ActivityHeatmap.vue' +import type { ActivityHeatmap as HeatmapData } from '@/types/activity' + +const mounted: Array<{ app: App; root: HTMLElement }> = [] +const observers: ResizeObserverStub[] = [] + +class ResizeObserverStub { + target: Element | null = null + constructor(private callback: ResizeObserverCallback) { observers.push(this) } + observe(target: Element) { this.target = target } + unobserve() {} + disconnect() { this.target = null } + resize(width: number) { + if (!this.target) throw new Error('Heatmap has not registered its resize observer') + this.callback([{ target: this.target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver) + } +} + +beforeEach(() => { + observers.length = 0 + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))) +}) + +afterEach(() => { + for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() } + vi.unstubAllGlobals() +}) + +function heatmap(startDate: string, length: number): HeatmapData { + const start = new Date(`${startDate}T00:00:00Z`).getTime() + const days = Array.from({ length }, (_, index) => ({ + date: new Date(start + index * 86400000).toISOString().slice(0, 10), + requests: index === length - 1 ? 250 : 0, + })) + return { start_date: startDate, end_date: days[days.length - 1]!.date, total_days: length, max_requests: 250, days } +} + +async function settle() { await nextTick(); await nextTick() } + +async function mount(data: HeatmapData, compact = true) { + const model = ref(data) + const isCompact = ref(compact) + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(defineComponent({ + setup: () => () => h(ActivityHeatmap, { data: model.value, compact: isCompact.value, showHeader: false }), + })) + mounted.push({ app, root }) + app.mount(root) + await settle() + return { root, model, isCompact, observer: observers[observers.length - 1]! } +} + +function cells(root: HTMLElement) { + return Array.from(root.querySelectorAll('.cell-emerge')) +} + +function size(root: HTMLElement) { return Number.parseFloat(cells(root)[0]!.style.width) } + +describe('activity heatmap layout', () => { + it.each([ + { compact: true, length: 1, maximum: 18 }, + { compact: false, length: 1, maximum: 16 }, + { compact: true, length: 31, maximum: 18 }, + { compact: false, length: 31, maximum: 16 }, + ])('keeps $length days within $maximum px cells (compact: $compact)', async ({ compact, length, maximum }) => { + const { root, observer } = await mount(heatmap('2026-10-05', length), compact) + observer.resize(1040) + await settle() + expect(cells(root)).toHaveLength(length) + for (const cell of cells(root)) { + expect(Number.parseFloat(cell.style.width)).toBeLessThanOrEqual(maximum) + expect(Number.parseFloat(cell.style.width)).toBeGreaterThanOrEqual(6) + expect(cell.style.height).toBe(cell.style.width) + } + const firstWeek = cells(root)[0]!.parentElement!.parentElement! + expect(firstWeek.children).toHaveLength(7) + expect(firstWeek.children[0]!.querySelector('.cell-emerge')).toBeNull() + expect(firstWeek.children[1]!.querySelector('.cell-emerge')).toBe(cells(root)[0]) + }) + + it('keeps a full year legible in a narrow container and resizes when its width or data changes', async () => { + const { root, observer, model } = await mount(heatmap('2025-10-06', 365)) + observer.resize(280) + await settle() + expect(cells(root)).toHaveLength(365) + expect(size(root)).toBe(6) + observer.resize(640) + await settle() + expect(size(root)).toBeGreaterThan(6) + expect(size(root)).toBeLessThanOrEqual(12) + const yearCellSize = size(root) + model.value = heatmap('2026-10-05', 1) + await settle() + expect(cells(root)).toHaveLength(1) + expect(size(root)).toBeGreaterThan(yearCellSize) + expect(size(root)).toBeLessThanOrEqual(12) + model.value = heatmap('2025-10-06', 365) + await settle() + expect(size(root)).toBe(yearCellSize) + observer.resize(1400) + await settle() + expect(size(root) * 53 + 52 * 2).toBeCloseTo(1400) + expect(cells(root)[0]!.style.height).toBe(cells(root)[0]!.style.width) + }) + + it('updates the cell size and spacing when compact mode changes', async () => { + const { root, observer, isCompact } = await mount(heatmap('2026-10-05', 1), false) + observer.resize(1040) + await settle() + expect(size(root)).toBeLessThanOrEqual(16) + expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('4px') + isCompact.value = true + await settle() + expect(size(root)).toBeLessThanOrEqual(18) + expect(cells(root)[0]!.parentElement!.parentElement!.style.rowGap).toBe('2px') + }) + + it('preserves calendar gaps between activity dates without inventing token or cost data', async () => { + const data = heatmap('2026-10-05', 4) + data.days = [ + { date: '2026-10-05', requests: 250, total_tokens: 1000, total_cost: 1.25 }, + { date: '2026-10-08', requests: 650 }, + ] + data.max_requests = 650 + const { root } = await mount(data) + const days = cells(root) + expect(days).toHaveLength(4) + expect(days[0]!.title).toContain('250') + expect(days[0]!.title).toContain('$1.25') + expect(days[3]!.title).toContain('650') + for (const [index, date] of ['2026-10-06', '2026-10-07'].entries()) { + const cell = days[index + 1]! + expect(cell.title).toContain(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`))) + expect(cell.title.split(' · ')[1]).toMatch(/^0\D/) + expect(cell.title).not.toContain('tokens') + expect(cell.title).not.toContain('$') + } + const week = days[0]!.parentElement!.parentElement! + expect(week.children[1]!.querySelector('.cell-emerge')).toBe(days[0]) + expect(week.children[4]!.querySelector('.cell-emerge')).toBe(days[3]) + }) + + it.each([false, true])('renders the full declared year with sparse or empty activity (empty: %s)', async empty => { + const data = heatmap('2025-10-06', 365) + data.days = empty ? [] : [{ date: '2026-05-17', requests: 250 }] + const { root, observer } = await mount(data) + observer.resize(1040) + await settle() + const days = cells(root) + expect(days).toHaveLength(365) + expect(days[0]!.title).toContain('2025年10月6日') + expect(days[364]!.title).toContain('2026年10月5日') + expect(days[0]!.title.split(' · ')[1]).toMatch(/^0\D/) + expect(days[364]!.title.split(' · ')[1]).toMatch(/^0\D/) + expect(days.filter(day => day.title.split(' · ')[1]?.startsWith('250'))).toHaveLength(empty ? 0 : 1) + expect(size(root) * 53 + 52 * 2).toBeCloseTo(1040) + }) +}) diff --git a/frontend/src/views/shared/Dashboard.vue b/frontend/src/views/shared/Dashboard.vue index 68bdff7d6..36c61272c 100644 --- a/frontend/src/views/shared/Dashboard.vue +++ b/frontend/src/views/shared/Dashboard.vue @@ -341,7 +341,7 @@ :data="activityHeatmap" :consecutive-active-days="dashboardSnapshot?.consecutive_active_days ?? null" :active-days="dashboardSnapshot?.active_days ?? null" - :scope-hint="statisticsScope + ' · ' + t('展示近365天', 'Last 365 days shown')" + :scope-hint="activityScope" :loading="loading" :error="false" :timeline-data="isDemo ? demoTimeline : undefined" @@ -843,14 +843,29 @@ const todayStats = ref<{ const dashboardSnapshot = ref(null); const statsSince = computed(() => timestamp(dashboardSnapshot.value?.stats_since, dashboardSnapshot.value?.timezone)); const statisticsScope = computed(() => `${t('统计自', 'Statistics since')} ${statsSince.value} · ${t('更新于', 'Updated at')} ${timestamp(dashboardSnapshot.value?.generated_at, dashboardSnapshot.value?.timezone)}`); +const activityScope = computed(() => [ + statisticsScope.value, + t('展示近365天', 'Last 365 days shown'), + dashboardSnapshot.value?.activity_timezone ?? dashboardSnapshot.value?.timezone, +].filter(Boolean).join(' · ')); const activityHeatmap = computed(() => { const snapshot = dashboardSnapshot.value; if (!snapshot) return null; - const days = snapshot.activity_days; + const dateParts = new Intl.DateTimeFormat('en', { + timeZone: snapshot.activity_timezone ?? snapshot.timezone, + year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(new Date(snapshot.generated_at)); + const datePart = (type: Intl.DateTimeFormatPartTypes) => Number(dateParts.find(part => part.type === type)?.value); + const today = Date.UTC(datePart('year'), datePart('month') - 1, datePart('day')); + const requestsByDate = new Map(snapshot.activity_days.map(day => [day.date, day.requests])); + const days = Array.from({ length: 365 }, (_, index) => { + const date = new Date(today - (364 - index) * 86_400_000).toISOString().slice(0, 10); + return { date, requests: requestsByDate.get(date) ?? 0 }; + }); return { - start_date: days[0]?.date ?? snapshot.stats_since.slice(0, 10), - end_date: days[days.length - 1]?.date ?? snapshot.generated_at.slice(0, 10), - total_days: days.length, + start_date: new Date(today - 364 * 86_400_000).toISOString().slice(0, 10), + end_date: new Date(today).toISOString().slice(0, 10), + total_days: 365, max_requests: days.reduce((max, day) => Math.max(max, day.requests), 0), days, }; diff --git a/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts b/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts index 72d735834..35d574fdd 100644 --- a/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts +++ b/frontend/src/views/shared/__tests__/Dashboard.loading.spec.ts @@ -8,7 +8,25 @@ const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), das vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }) })) vi.mock('@/api/overview', () => ({ overviewApi: api })) vi.mock('@/api/dashboard', () => ({ dashboardApi: { getDailyStats: api.daily } })) -vi.mock('@/features/overview/dashboard/DashboardActivity.vue', () => ({ default: { render: () => null } })) +vi.mock('@/features/overview/dashboard/DashboardActivity.vue', async () => { + const { defineComponent, h } = await import('vue') + return { + default: defineComponent({ + props: { data: Object, scopeHint: String, consecutiveActiveDays: Number, activeDays: Number }, + setup: props => () => h('section', { + 'data-dashboard-activity': '', + title: props.scopeHint, + 'data-start-date': props.data?.start_date, + 'data-end-date': props.data?.end_date, + 'data-total-days': props.data?.total_days, + }, [ + h('span', `${props.consecutiveActiveDays} / ${props.activeDays}`), + ...((props.data?.days ?? []) as { date: string; requests: number }[]).map(day => + h('span', { 'data-activity-date': day.date }, String(day.requests))), + ]), + }), + } +}) vi.mock('@/features/overview/dashboard/DashboardAnnouncements.vue', () => ({ default: { render: () => null } })) vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } })) vi.mock('@/components/charts/DoughnutChart.vue', () => ({ default: { render: () => null } })) @@ -55,6 +73,99 @@ describe('dashboard snapshot loading', () => { expect(api.daily).toHaveBeenCalledTimes(1) }) + it('labels restored history with its UTC activity dates independently of the dashboard timezone', async () => { + const snapshot = dashboardSummary() + snapshot.timezone = 'Asia/Shanghai' + snapshot.activity_timezone = 'UTC' + snapshot.stats_since = '2026-05-20T00:00:00Z' + snapshot.activity_days = [{ date: '2026-05-20', requests: 1234 }, ...snapshot.activity_days] + api.dashboardSummary.mockResolvedValue(snapshot) + const root = mount() + await settle() + + const activity = root.querySelector('[data-dashboard-activity]') + expect(activity?.getAttribute('title')).toContain('展示近365天 · UTC') + expect(activity?.getAttribute('title')).toContain('统计自 2026/5/20 08:00') + expect(activity?.textContent).toContain('2 / 413') + expect(activity?.querySelector('[data-activity-date="2026-05-20"]')?.textContent).toBe('1234') + }) + + it('uses the dashboard timezone for activity when the optional activity timezone is absent', async () => { + const snapshot = dashboardSummary() + snapshot.timezone = 'Asia/Shanghai' + api.dashboardSummary.mockResolvedValue(snapshot) + const root = mount() + await settle() + + expect(root.querySelector('[data-dashboard-activity]')?.getAttribute('title')) + .toContain('展示近365天 · Asia/Shanghai') + }) + + it.each([ + { activityTimezone: 'UTC', start: '2025-09-20', end: '2026-09-19' }, + { activityTimezone: 'Asia/Shanghai', start: '2025-09-21', end: '2026-09-20' }, + { activityTimezone: undefined, start: '2025-09-21', end: '2026-09-20' }, + ])('ends the 365-day calendar on $end for activity timezone $activityTimezone', async ({ activityTimezone, start, end }) => { + const snapshot = dashboardSummary() + snapshot.timezone = 'Asia/Shanghai' + snapshot.activity_timezone = activityTimezone + snapshot.generated_at = '2026-09-19T16:30:00Z' + api.dashboardSummary.mockResolvedValue(snapshot) + const root = mount() + await settle() + + const activity = root.querySelector('[data-dashboard-activity]') + const days = activity?.querySelectorAll('[data-activity-date]') + expect(activity?.getAttribute('data-start-date')).toBe(start) + expect(activity?.getAttribute('data-end-date')).toBe(end) + expect(activity?.getAttribute('data-total-days')).toBe('365') + expect(days).toHaveLength(365) + expect(days?.[0]?.getAttribute('data-activity-date')).toBe(start) + expect(days?.[364]?.getAttribute('data-activity-date')).toBe(end) + }) + + it('includes empty days at both ends and between requests without changing lifetime active counts', async () => { + const snapshot = dashboardSummary() + snapshot.activity_days = [ + { date: '2025-09-19', requests: 9999 }, + { date: '2026-09-16', requests: 50 }, + { date: '2026-09-18', requests: 10 }, + { date: '2026-09-20', requests: 9999 }, + ] + api.dashboardSummary.mockResolvedValue(snapshot) + const root = mount() + await settle() + + const activity = root.querySelector('[data-dashboard-activity]') + expect(activity?.querySelectorAll('[data-activity-date]')).toHaveLength(365) + for (const date of ['2025-09-20', '2026-09-17', '2026-09-19']) { + expect(activity?.querySelector(`[data-activity-date="${date}"]`)?.textContent).toBe('0') + } + expect(activity?.querySelector('[data-activity-date="2026-09-16"]')?.textContent).toBe('50') + expect(activity?.querySelector('[data-activity-date="2026-09-18"]')?.textContent).toBe('10') + expect(activity?.querySelector('[data-activity-date="2025-09-19"]')).toBeNull() + expect(activity?.querySelector('[data-activity-date="2026-09-20"]')).toBeNull() + expect(activity?.textContent).toContain('2 / 413') + }) + + it('shows all 365 zero-request days when no usage history exists', async () => { + const snapshot = dashboardSummary() + snapshot.activity_days = [] + snapshot.active_days = 0 + snapshot.consecutive_active_days = 0 + api.dashboardSummary.mockResolvedValue(snapshot) + const root = mount() + await settle() + + const activity = root.querySelector('[data-dashboard-activity]') + const days = activity?.querySelectorAll('[data-activity-date]') + expect(days).toHaveLength(365) + expect(Array.from(days ?? []).every(day => day.textContent === '0')).toBe(true) + expect(activity?.getAttribute('data-start-date')).toBe('2025-09-20') + expect(activity?.getAttribute('data-end-date')).toBe('2026-09-19') + expect(activity?.textContent).toContain('0 / 0') + }) + it('does not loop requests with the real time picker when summary fails', async () => { const snapshot = deferred() api.dashboardSummary.mockReturnValue(snapshot.promise)