mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user