wsb 0.155.49

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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(),
        }),
    )
}

/// GET /api/v1/analytics/summary
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
    })))
}

/// GET /api/v1/analytics/activity
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");
    // Query sessions grouped by date
    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
    })))
}

/// GET /api/v1/analytics/heatmap
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
    })))
}

/// GET /api/v1/analytics/projects
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 })))
}

/// GET /api/v1/analytics/hour-of-week
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| {
            // SQLite %w: 0=Sunday. Agentsview uses 0=Monday.
            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 })))
}

/// GET /api/v1/analytics/top-sessions
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
    })))
}