mod aggregation;
mod helpers;
#[cfg(test)]
mod tests;
use chrono::Utc;
use serde::Serialize;
use std::collections::HashMap;
use crate::index::{IndexDb, IndexedNote, NoteQuery, NoteType};
use aggregation::{
build_activity_report, build_flagged_tasks, build_project_report, build_review_due,
build_vault_summary,
};
use helpers::extract_project_info;
#[derive(Debug, Serialize)]
pub struct DashboardReport {
pub generated_at: String,
pub scope: ReportScope,
pub summary: VaultSummary,
pub projects: Vec<ProjectReport>,
pub activity: ActivityReport,
pub overdue: Vec<FlaggedTask>,
pub high_priority: Vec<FlaggedTask>,
pub upcoming_deadlines: Vec<FlaggedTask>,
pub zombie: Vec<FlaggedTask>,
pub review_due: Vec<ReviewDueProject>,
}
#[derive(Debug, Serialize)]
pub struct ReviewDueProject {
pub id: String,
pub title: String,
pub kind: String,
pub review_interval: String,
pub last_reviewed: Option<String>,
pub days_since_review: i64,
pub days_overdue: i64,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum ReportScope {
#[serde(rename = "vault")]
Vault,
#[serde(rename = "project")]
Project { id: String, title: String },
}
#[derive(Debug, Serialize)]
pub struct VaultSummary {
pub total_notes: usize,
pub notes_by_type: HashMap<String, usize>,
pub total_tasks: usize,
pub tasks_by_status: HashMap<String, usize>,
pub total_projects: usize,
pub active_projects: usize,
}
#[derive(Debug, Serialize)]
pub struct ProjectReport {
pub id: String,
pub title: String,
pub kind: String,
pub status: String,
pub tasks: TaskCounts,
pub progress_percent: f64,
pub velocity: Velocity,
pub recent_completions: Vec<CompletedTask>,
}
#[derive(Debug, Default, Serialize)]
pub struct TaskCounts {
pub total: usize,
pub todo: usize,
pub in_progress: usize,
pub blocked: usize,
pub done: usize,
pub cancelled: usize,
}
#[derive(Debug, Serialize)]
pub struct Velocity {
pub tasks_per_week_4w: f64,
pub tasks_per_week_2w: f64,
pub created_last_7d: usize,
pub completed_last_7d: usize,
}
#[derive(Debug, Serialize)]
pub struct CompletedTask {
pub id: String,
pub title: String,
pub completed_at: String,
pub project: String,
}
#[derive(Debug, Serialize)]
pub struct ActivityReport {
pub period_days: u32,
pub daily_activity: Vec<DayActivity>,
pub stale_notes: Vec<StaleNote>,
}
#[derive(Debug, Serialize)]
pub struct DayActivity {
pub date: String,
pub weekday: String,
pub tasks_completed: usize,
pub tasks_created: usize,
pub notes_modified: usize,
}
#[derive(Debug, Serialize)]
pub struct StaleNote {
pub title: String,
pub path: String,
pub note_type: String,
pub staleness_score: f64,
pub last_seen: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct FlaggedTask {
pub id: String,
pub title: String,
pub project: String,
pub due_date: Option<String>,
pub priority: Option<String>,
pub status: String,
pub days_overdue: Option<i64>,
}
pub struct DashboardOptions {
pub project: Option<String>,
pub activity_days: u32,
pub stale_limit: u32,
pub stale_threshold: f64,
pub zombie_days: u32,
}
impl Default for DashboardOptions {
fn default() -> Self {
Self {
project: None,
activity_days: 30,
stale_limit: 10,
stale_threshold: 0.5,
zombie_days: 30,
}
}
}
pub fn build_dashboard(
db: &IndexDb,
options: &DashboardOptions,
) -> Result<DashboardReport, String> {
let all_notes = db
.query_notes(&NoteQuery::default())
.map_err(|e| format!("Failed to query notes: {e}"))?;
let tasks: Vec<&IndexedNote> =
all_notes.iter().filter(|n| n.note_type == NoteType::Task).collect();
let projects: Vec<&IndexedNote> =
all_notes.iter().filter(|n| n.note_type == NoteType::Project).collect();
let (scope, target_projects) = if let Some(ref project_filter) = options.project {
let matched = projects
.iter()
.find(|p| {
let (id, _, _) = extract_project_info(p);
let folder = p.path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
id.eq_ignore_ascii_case(project_filter)
|| folder.eq_ignore_ascii_case(project_filter)
})
.ok_or_else(|| format!("Project not found: {project_filter}"))?;
let (id, _, _) = extract_project_info(matched);
let title =
if matched.title.is_empty() { id.clone() } else { matched.title.clone() };
(ReportScope::Project { id: id.clone(), title }, vec![*matched])
} else {
(ReportScope::Vault, projects.to_vec())
};
let project_reports: Vec<ProjectReport> =
target_projects.iter().map(|p| build_project_report(p, &tasks)).collect();
let resolved_project_folder: Option<String> = if options.project.is_some() {
target_projects
.first()
.and_then(|p| p.path.file_stem().and_then(|s| s.to_str()).map(String::from))
} else {
None
};
let summary =
build_vault_summary(&all_notes, &tasks, &projects, &resolved_project_folder);
let activity = build_activity_report(db, &all_notes, &tasks, options)?;
let today = chrono::Local::now().date_naive();
let (overdue, high_priority, upcoming_deadlines, zombie) =
build_flagged_tasks(&tasks, &target_projects, today, options.zombie_days);
let review_due = build_review_due(&target_projects, today);
Ok(DashboardReport {
generated_at: Utc::now().to_rfc3339(),
scope,
summary,
projects: project_reports,
activity,
overdue,
high_priority,
upcoming_deadlines,
zombie,
review_due,
})
}