use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use super::sessions::ErrorResponse;
use crate::web::state::WebState;
#[derive(Deserialize)]
pub struct AnalyticsQuery {
pub from: Option<String>,
pub to: Option<String>,
pub timezone: Option<String>,
pub project: Option<String>,
pub machine: Option<String>,
pub agent: Option<String>,
pub min_user_messages: Option<i64>,
pub include_one_shot: Option<String>,
pub granularity: Option<String>,
pub metric: Option<String>,
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
pub async fn summary(
State(state): State<WebState>,
Query(params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let include_one_shot = params.include_one_shot.as_deref() == Some("true");
let stats = state
.db
.get_stats(include_one_shot)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(serde_json::json!({
"total_sessions": stats.session_count,
"total_messages": stats.message_count
})))
}
pub async fn activity(
State(state): State<WebState>,
Query(params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let granularity = params.granularity.as_deref().unwrap_or("day");
let pool = state.db.pool();
let rows = sqlx::query(
"SELECT date(ended_at) as date, COUNT(*) as sessions, \
SUM(message_count) as messages \
FROM sessions WHERE deleted_at IS NULL AND ended_at IS NOT NULL \
GROUP BY date(ended_at) ORDER BY date ASC",
)
.fetch_all(pool)
.await
.map_err(|e| internal_error(&e.to_string()))?;
let series: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
use sqlx::Row;
serde_json::json!({
"date": r.get::<String, _>("date"),
"sessions": r.get::<i64, _>("sessions"),
"messages": r.get::<i64, _>("messages"),
})
})
.collect();
Ok(Json(serde_json::json!({
"granularity": granularity,
"series": series
})))
}
pub async fn heatmap(
State(state): State<WebState>,
Query(params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let metric = params.metric.as_deref().unwrap_or("messages");
let pool = state.db.pool();
let value_col = if metric == "sessions" {
"COUNT(*)"
} else {
"SUM(message_count)"
};
let sql = format!(
"SELECT date(ended_at) as date, {} as value \
FROM sessions WHERE deleted_at IS NULL AND ended_at IS NOT NULL \
GROUP BY date(ended_at) ORDER BY date ASC LIMIT 366",
value_col
);
let rows = sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(|e| internal_error(&e.to_string()))?;
use sqlx::Row;
let max_val: i64 = rows
.iter()
.map(|r| r.get::<i64, _>("value"))
.max()
.unwrap_or(0);
let (l1, l2, l3, l4) = if max_val > 0 {
let q = max_val as f64 / 4.0;
(
(q * 0.25).ceil() as i64,
(q * 1.0).ceil() as i64,
(q * 2.0).ceil() as i64,
(q * 3.0).ceil() as i64,
)
} else {
(1, 5, 12, 30)
};
let entries: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let val: i64 = r.get("value");
let level = if val == 0 {
0
} else if val <= l1 {
1
} else if val <= l2 {
2
} else if val <= l3 {
3
} else {
4
};
serde_json::json!({
"date": r.get::<String, _>("date"),
"value": val,
"level": level
})
})
.collect();
let entries_from = entries
.first()
.and_then(|e| e.get("date"))
.and_then(|d| d.as_str())
.unwrap_or("");
Ok(Json(serde_json::json!({
"metric": metric,
"entries_from": entries_from,
"levels": { "l1": l1, "l2": l2, "l3": l3, "l4": l4 },
"entries": entries
})))
}
pub async fn projects(
State(state): State<WebState>,
Query(_params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let pool = state.db.pool();
let rows = sqlx::query(
"SELECT project as name, COUNT(*) as sessions, \
SUM(message_count) as messages, \
MIN(started_at) as first_session, MAX(ended_at) as last_session, \
AVG(message_count) as avg_messages \
FROM sessions WHERE deleted_at IS NULL \
GROUP BY project ORDER BY messages DESC",
)
.fetch_all(pool)
.await
.map_err(|e| internal_error(&e.to_string()))?;
use sqlx::Row;
let projects: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"name": r.get::<String, _>("name"),
"sessions": r.get::<i64, _>("sessions"),
"messages": r.get::<i64, _>("messages"),
"first_session": r.get::<Option<String>, _>("first_session"),
"last_session": r.get::<Option<String>, _>("last_session"),
"avg_messages": r.get::<f64, _>("avg_messages"),
"median_messages": 0,
"agents": {},
"daily_trend": 0.0
})
})
.collect();
Ok(Json(serde_json::json!({ "projects": projects })))
}
pub async fn hour_of_week(
State(state): State<WebState>,
Query(_params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let pool = state.db.pool();
let rows = sqlx::query(
"SELECT CAST(strftime('%w', ended_at) AS INTEGER) as dow, \
CAST(strftime('%H', ended_at) AS INTEGER) as hour, \
SUM(message_count) as messages \
FROM sessions WHERE deleted_at IS NULL AND ended_at IS NOT NULL \
GROUP BY dow, hour",
)
.fetch_all(pool)
.await
.map_err(|e| internal_error(&e.to_string()))?;
use sqlx::Row;
let cells: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let sqlite_dow: i64 = r.get("dow");
let day_of_week = if sqlite_dow == 0 { 6 } else { sqlite_dow - 1 };
serde_json::json!({
"day_of_week": day_of_week,
"hour": r.get::<i64, _>("hour"),
"messages": r.get::<i64, _>("messages")
})
})
.collect();
Ok(Json(serde_json::json!({ "cells": cells })))
}
pub async fn top_sessions(
State(state): State<WebState>,
Query(params): Query<AnalyticsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let metric = params.metric.as_deref().unwrap_or("messages");
let pool = state.db.pool();
let order_col = if metric == "duration" {
"(julianday(ended_at) - julianday(started_at)) * 24 * 60"
} else {
"message_count"
};
let sql = format!(
"SELECT id, project, first_message, message_count, \
CAST((julianday(ended_at) - julianday(started_at)) * 24 * 60 AS REAL) as duration_min \
FROM sessions WHERE deleted_at IS NULL AND started_at IS NOT NULL AND ended_at IS NOT NULL \
ORDER BY {} DESC LIMIT 10",
order_col
);
let rows = sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(|e| internal_error(&e.to_string()))?;
use sqlx::Row;
let sessions: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.get::<String, _>("id"),
"project": r.get::<String, _>("project"),
"first_message": r.get::<Option<String>, _>("first_message"),
"message_count": r.get::<i64, _>("message_count"),
"duration_min": r.get::<f64, _>("duration_min")
})
})
.collect();
Ok(Json(serde_json::json!({
"metric": metric,
"sessions": sessions
})))
}