use serde::Serialize;
use crate::contracts::QueueFile;
use crate::productivity::{self, ProductivityStats};
use crate::timeutil;
use super::burndown::BurndownReport;
use super::history::HistoryReport;
use super::shared::print_json;
use super::stats::StatsReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SectionStatus {
Ok,
Unavailable,
}
#[derive(Debug, Clone, Serialize)]
pub struct SectionResult<T> {
pub status: SectionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
impl<T> SectionResult<T> {
pub fn ok(data: T) -> Self {
Self {
status: SectionStatus::Ok,
data: Some(data),
error_message: None,
}
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self {
status: SectionStatus::Unavailable,
data: None,
error_message: Some(message.into()),
}
}
}
#[derive(Debug, Serialize)]
pub struct DashboardSections {
pub productivity_summary: SectionResult<productivity::ProductivitySummaryReport>,
pub productivity_velocity: SectionResult<productivity::ProductivityVelocityReport>,
pub burndown: SectionResult<BurndownReport>,
pub queue_stats: SectionResult<StatsReport>,
pub history: SectionResult<HistoryReport>,
}
#[derive(Debug, Serialize)]
pub struct DashboardReport {
pub window_days: u32,
pub generated_at: String,
pub sections: DashboardSections,
}
pub fn build_dashboard_report(
queue: &QueueFile,
done: Option<&QueueFile>,
stats: Option<&ProductivityStats>,
days: u32,
) -> DashboardReport {
let generated_at = timeutil::now_utc_rfc3339_or_fallback();
let productivity_summary = match stats {
Some(s) => {
let report = productivity::build_summary_report(s, 5);
SectionResult::ok(report)
}
None => SectionResult::unavailable("productivity stats not available"),
};
let productivity_velocity = match stats {
Some(s) => {
let report = productivity::build_velocity_report(s, days);
SectionResult::ok(report)
}
None => SectionResult::unavailable("productivity stats not available"),
};
let burndown_report = super::burndown::build_burndown_report(queue, done, days);
let burndown = SectionResult::ok(burndown_report);
let stats_report = super::stats::build_stats_report(queue, done, &[]);
let queue_stats = SectionResult::ok(stats_report);
let history_report = super::history::build_history_report(queue, done, days);
let history = SectionResult::ok(history_report);
DashboardReport {
window_days: days,
generated_at,
sections: DashboardSections {
productivity_summary,
productivity_velocity,
burndown,
queue_stats,
history,
},
}
}
pub(crate) fn print_dashboard(report: &DashboardReport) -> anyhow::Result<()> {
print_json(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::{Task, TaskPriority, TaskStatus};
use std::collections::HashMap;
fn empty_queue() -> QueueFile {
QueueFile {
version: 1,
tasks: vec![],
}
}
fn task_with_status(id: &str, status: TaskStatus) -> Task {
Task {
id: id.to_string(),
status,
title: "Test task".to_string(),
description: None,
priority: TaskPriority::Medium,
tags: vec![],
scope: vec![],
evidence: vec![],
plan: vec![],
notes: vec![],
request: None,
agent: None,
created_at: None,
updated_at: None,
completed_at: None,
started_at: None,
scheduled_start: None,
estimated_minutes: None,
actual_minutes: None,
depends_on: vec![],
blocks: vec![],
relates_to: vec![],
duplicates: None,
custom_fields: HashMap::new(),
parent_id: None,
}
}
#[test]
fn test_dashboard_report_serializes() {
let queue = empty_queue();
let report = build_dashboard_report(&queue, None, None, 30);
let json = serde_json::to_string_pretty(&report).unwrap();
assert!(json.contains("\"window_days\": 30"));
assert!(json.contains("\"productivity_summary\""));
assert!(json.contains("\"status\": \"unavailable\""));
}
#[test]
fn test_dashboard_report_with_productivity_stats() {
let stats = ProductivityStats::default();
let queue = empty_queue();
let report = build_dashboard_report(&queue, None, Some(&stats), 7);
assert_eq!(report.window_days, 7);
assert_eq!(
report.sections.productivity_summary.status,
SectionStatus::Ok
);
assert!(report.sections.productivity_summary.data.is_some());
}
#[test]
fn test_dashboard_report_with_tasks() {
let mut queue = empty_queue();
queue
.tasks
.push(task_with_status("RQ-0001", TaskStatus::Todo));
queue
.tasks
.push(task_with_status("RQ-0002", TaskStatus::Done));
let report = build_dashboard_report(&queue, None, None, 7);
assert_eq!(report.sections.queue_stats.status, SectionStatus::Ok);
let stats_data = report.sections.queue_stats.data.as_ref().unwrap();
assert_eq!(stats_data.summary.total, 2);
}
#[test]
fn test_section_result_ok() {
let result: SectionResult<String> = SectionResult::ok("test".to_string());
assert_eq!(result.status, SectionStatus::Ok);
assert_eq!(result.data, Some("test".to_string()));
assert!(result.error_message.is_none());
}
#[test]
fn test_section_result_unavailable() {
let result: SectionResult<String> = SectionResult::unavailable("not found");
assert_eq!(result.status, SectionStatus::Unavailable);
assert!(result.data.is_none());
assert_eq!(result.error_message, Some("not found".to_string()));
}
#[test]
fn test_section_result_serializes_correctly() {
let result: SectionResult<i32> = SectionResult::ok(42);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"status\":\"ok\""));
assert!(json.contains("\"data\":42"));
assert!(!json.contains("error_message"));
}
#[test]
fn test_dashboard_sections_burndown_history_are_ok_without_productivity_stats() {
let queue = empty_queue();
let report = build_dashboard_report(&queue, None, None, 7);
assert_eq!(
report.sections.productivity_summary.status,
SectionStatus::Unavailable
);
assert_eq!(
report.sections.productivity_velocity.status,
SectionStatus::Unavailable
);
assert_eq!(report.sections.burndown.status, SectionStatus::Ok);
assert_eq!(report.sections.history.status, SectionStatus::Ok);
assert_eq!(report.sections.queue_stats.status, SectionStatus::Ok);
}
#[test]
fn test_dashboard_includes_done_archive_in_queue_stats() {
let queue = empty_queue();
let done = QueueFile {
version: 1,
tasks: vec![task_with_status("RQ-0001", TaskStatus::Done)],
};
let report = build_dashboard_report(&queue, Some(&done), None, 7);
assert_eq!(report.sections.queue_stats.status, SectionStatus::Ok);
let stats_data = report.sections.queue_stats.data.as_ref().unwrap();
assert_eq!(stats_data.summary.total, 1);
assert_eq!(stats_data.summary.done, 1);
}
}