use crate::client::upgrade::Tier;
use crate::dashboard::gauge::TokenGauge;
use crate::dashboard::{DailyBucket, DashboardData};
use crate::server::{AppError, AppState, AuthUser};
use axum::{
extract::{Query, State},
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct DaysParam {
#[serde(default = "default_days")]
pub days: i64,
}
fn default_days() -> i64 {
30
}
#[derive(Serialize)]
pub struct CommandStat {
pub program: String,
pub tokens_sent: i64,
pub tokens_saved: i64,
pub savings_pct: f64,
pub run_count: i64,
}
pub async fn commands(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Query(params): Query<DaysParam>,
) -> Result<Json<Vec<CommandStat>>, AppError> {
let window = format!("-{} days", params.days);
let conn = state.db.conn();
let mut stmt = conn
.prepare(
"SELECT skill,
COALESCE(SUM(tokens_sent), 0),
COALESCE(SUM(tokens_saved), 0),
COUNT(*) as runs
FROM usage
WHERE user_id=?1 AND skill!='' AND recorded_at >= datetime('now',?2)
GROUP BY skill
ORDER BY SUM(tokens_saved) DESC
LIMIT 20",
)
.map_err(|e| AppError(anyhow::anyhow!(e)))?;
let stats: Vec<CommandStat> = stmt
.query_map(rusqlite::params![user_id, window], |row| {
let ts: i64 = row.get(1)?;
let sv: i64 = row.get(2)?;
let pct = if ts > 0 {
sv as f64 / ts as f64 * 100.0
} else {
0.0
};
Ok(CommandStat {
program: row.get(0)?,
tokens_sent: ts,
tokens_saved: sv,
savings_pct: pct,
run_count: row.get(3)?,
})
})
.map_err(|e| AppError(anyhow::anyhow!(e)))?
.filter_map(|r| r.ok())
.collect();
Ok(Json(stats))
}
pub async fn summary(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> Result<Json<DashboardData>, AppError> {
let (sent, saved) = state.db.usage_summary(&user_id, 30);
let cost = saved as f64 * 0.000_015;
let daily_raw = state.db.usage_by_day(&user_id, 30);
let daily_buckets: Vec<DailyBucket> = daily_raw
.into_iter()
.map(|(date, s, sv)| DailyBucket {
date,
tokens_sent: s as u64,
tokens_saved: sv as u64,
cost_usd_avoided: sv as f64 * 0.000_015,
skills_used: Default::default(),
top_lens: "clarity".to_string(),
})
.collect();
let skill_raw = state.db.skill_breakdown(&user_id, 30);
let skill_usage: std::collections::HashMap<String, u64> =
skill_raw.into_iter().map(|(k, v)| (k, v as u64)).collect();
let top_lens = skill_usage
.iter()
.max_by_key(|(_, v)| *v)
.map(|(k, _)| k.clone())
.unwrap_or_else(|| "clarity".to_string());
Ok(Json(DashboardData {
tokens_used: sent as u64,
tokens_saved: saved as u64,
cost_usd_avoided: cost,
period_days: 30,
daily_buckets,
project_breakdown: vec![],
skill_usage,
top_lens,
}))
}
pub async fn gauge(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> Result<Json<TokenGauge>, AppError> {
let user = state.db.get_user(&user_id);
let tier = user
.as_ref()
.map(|u| Tier::parse_tier(&u.tier))
.unwrap_or(Tier::Free);
let (used, _) = state.db.usage_summary(&user_id, 30);
let quota = tier.cloud_token_quota().unwrap_or(0);
Ok(Json(TokenGauge {
used_tokens: used as u64,
quota_tokens: quota,
reset_at: next_month_reset(),
overage_allowed: matches!(tier, Tier::Enterprise),
tier: format!("{tier:?}").to_lowercase(),
}))
}
pub async fn history(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Query(params): Query<DaysParam>,
) -> Result<Json<Vec<DailyBucket>>, AppError> {
let raw = state.db.usage_by_day(&user_id, params.days);
let buckets: Vec<DailyBucket> = raw
.into_iter()
.map(|(date, sent, saved)| DailyBucket {
date,
tokens_sent: sent as u64,
tokens_saved: saved as u64,
cost_usd_avoided: saved as f64 * 0.000_015,
skills_used: Default::default(),
top_lens: "clarity".to_string(),
})
.collect();
Ok(Json(buckets))
}
fn next_month_reset() -> String {
use chrono::Datelike;
let now = chrono::Utc::now();
let next = if now.month() == 12 {
chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap()
} else {
chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap()
};
next.format("%Y-%m-%d").to_string()
}