feat(usage): refine usage dashboard UI and date range picker (#2002)

* feat(usage): enhance usage stats backend and query hooks

* feat(usage): redesign calendar date range picker with auto-switch and simplified layout

* refactor(usage): streamline dashboard layout and stats components

* refactor(usage): compact request log table with merged cache/multiplier columns and centered layout

* feat(i18n): add cache short labels and usage stats translations for zh/en/ja

* Align usage dashboard stats with range boundaries

The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes.

This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes.

Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows
Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning
Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual UI validation in the Tauri app

* Preserve full-day usage filters at minute precision

The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters.

This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts.

Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity
Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI
Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual Tauri dashboard interaction

* refactor(usage): move range presets into date picker, single-row layout

- UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside
  popover top; clicking a preset applies immediately and closes popover
- UsageDashboard: collapse to single row (app filters + refresh + picker);
  remove standalone preset buttons and summary stats bar
- RequestLogTable: replace static Calendar badge with interactive
  UsageDateRangePicker via onRangeChange prop; single filter row

* Keep usage pagination regression coverage aligned with the rendered UI

The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments.

Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text
Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text
Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit

* refactor(usage): clean up dead code and polish date range picker

- Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS,
  timestampToLocalDatetime, and localDatetimeToTimestamp from
  usageRange.ts (replaced by the calendar picker)
- Deduplicate getPresetLabel from UsageDashboard and
  UsageDateRangePicker into shared getUsageRangePresetLabel helper
- Add aria-label, aria-current and aria-pressed to calendar day
  buttons so screen readers can disambiguate same-numbered days
  across adjacent months
- Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja);
  the request log table renders R/W prefixes inline
- Align customRangeHint copy with the removed 30-day limit by
  dropping "up to 30 days" wording (zh/en/ja)

* fix(usage): align rollup cutoff to local midnight to keep days complete

`rollup_and_prune` previously used `Utc::now() - retain_days * 86400`
as the cutoff. Because rollups are bucketed by *local* date and detail
rows below the cutoff are pruned, an unaligned cutoff left the youngest
rolled-up day half-rolled-up and half-pruned. Combined with the new
`compute_rollup_date_bounds` boundary trimming (which excludes any
rollup day not fully covered by the requested range), custom range
queries that touch that day silently under-count summary, trend,
provider, and model stats.

Fix the invariant at the source: snap the cutoff to the next local
midnight after `(now - retain_days)`. Every rollup row now reflects a
complete local day, so the boundary trimmer's all-or-nothing assumption
holds.

Includes unit tests for the cutoff math (typical case + already-on-
midnight case). DST gap is handled defensively by bumping forward by
an hour.

Addresses Codex P2 review finding on PR #2002.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Dex Miller
2026-04-16 17:00:28 +08:00
committed by GitHub
parent 507bf038a9
commit de23216e49
18 changed files with 2156 additions and 766 deletions
+10 -2
View File
@@ -35,18 +35,26 @@ pub fn get_usage_trends(
#[tauri::command]
pub fn get_provider_stats(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats(app_type.as_deref())
state
.db
.get_provider_stats(start_date, end_date, app_type.as_deref())
}
/// 获取模型统计
#[tauri::command]
pub fn get_model_stats(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats(app_type.as_deref())
state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
}
/// 获取请求日志列表
+90 -1
View File
@@ -4,13 +4,61 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use chrono::{Duration, Local, TimeZone};
/// Compute the rollup/prune cutoff aligned to a local-day boundary.
///
/// Anything strictly older than the returned timestamp will be aggregated into
/// `usage_daily_rollups` and deleted from `proxy_request_logs`. Aligning to the
/// next local midnight after `(now - retain_days)` guarantees that the youngest
/// rollup row always represents a *complete* local day. Without this alignment
/// the cutoff falls mid-day, leaving the day half-rolled-up and half-pruned —
/// which would silently under-count any range query that touches that day
/// after `compute_rollup_date_bounds` trims partial-coverage rollup days.
fn compute_local_midnight_cutoff(
now: chrono::DateTime<Local>,
retain_days: i64,
) -> Result<i64, AppError> {
let target_day = now
.checked_sub_signed(Duration::days(retain_days))
.ok_or_else(|| AppError::Database("rollup cutoff overflow".to_string()))?
.date_naive();
// Use the *next* day's midnight so anything before it has fully been bucketed.
let next_day = target_day
.succ_opt()
.ok_or_else(|| AppError::Database("rollup cutoff next-day overflow".to_string()))?;
let naive_midnight = next_day
.and_hms_opt(0, 0, 0)
.ok_or_else(|| AppError::Database("rollup cutoff midnight overflow".to_string()))?;
let local_dt = match Local.from_local_datetime(&naive_midnight) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => {
// DST gap: fall back to one hour later, which always exists.
let bumped = naive_midnight + Duration::hours(1);
match Local.from_local_datetime(&bumped) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => {
return Err(AppError::Database(
"rollup cutoff fell into DST gap".to_string(),
))
}
}
}
};
Ok(local_dt.timestamp())
}
impl Database {
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
/// then delete the aggregated detail rows.
/// Returns the number of deleted detail rows.
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
let cutoff = compute_local_midnight_cutoff(Local::now(), retain_days)?;
let conn = lock_conn!(self.conn);
// Check if there are any rows to process
@@ -110,8 +158,49 @@ impl Database {
#[cfg(test)]
mod tests {
use super::compute_local_midnight_cutoff;
use crate::database::Database;
use crate::error::AppError;
use chrono::{Local, TimeZone};
fn local_dt(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> chrono::DateTime<Local> {
match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => panic!("invalid local datetime in test fixture"),
}
}
#[test]
fn cutoff_is_aligned_to_local_midnight_after_target_day() -> Result<(), AppError> {
// now = 2026-04-16 14:32:17 local; retain_days = 30
// target day = 2026-03-17; cutoff should be 2026-03-18 00:00 local.
let now = local_dt(2026, 4, 16, 14, 32, 17);
let cutoff_ts = compute_local_midnight_cutoff(now, 30)?;
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
let expected = local_dt(2026, 3, 18, 0, 0, 0);
assert_eq!(cutoff_dt, expected);
Ok(())
}
#[test]
fn cutoff_at_local_midnight_now_still_lands_on_midnight() -> Result<(), AppError> {
// If `now` is itself local midnight, the math should not introduce drift.
let now = local_dt(2026, 4, 16, 0, 0, 0);
let cutoff_ts = compute_local_midnight_cutoff(now, 7)?;
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
// (2026-04-16 - 7d) = 2026-04-09; cutoff = 2026-04-10 00:00 local.
let expected = local_dt(2026, 4, 10, 0, 0, 0);
assert_eq!(cutoff_dt, expected);
Ok(())
}
#[test]
fn test_rollup_and_prune() -> Result<(), AppError> {
File diff suppressed because it is too large Load Diff