Skip to main content

mdvault_core/
report.rs

1//! Dashboard report data types and builder.
2//!
3//! Provides a unified JSON-serialisable schema consumed by:
4//! - CLI (`mdv report --json`)
5//! - TUI dashboard (`mdv dashboard`)
6//! - MCP tools (via the MCP server)
7//! - PNG chart generation
8
9use chrono::{Datelike, Duration, NaiveDate, Utc};
10use serde::Serialize;
11use std::collections::HashMap;
12
13use crate::index::{IndexDb, IndexedNote, NoteQuery, NoteType};
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Schema types
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Top-level dashboard report. Can be vault-wide or scoped to a single project.
20#[derive(Debug, Serialize)]
21pub struct DashboardReport {
22    pub generated_at: String,
23    pub scope: ReportScope,
24    pub summary: VaultSummary,
25    pub projects: Vec<ProjectReport>,
26    pub activity: ActivityReport,
27    pub overdue: Vec<FlaggedTask>,
28    pub high_priority: Vec<FlaggedTask>,
29    pub upcoming_deadlines: Vec<FlaggedTask>,
30}
31
32/// Whether this report covers the whole vault or a single project.
33#[derive(Debug, Serialize)]
34#[serde(tag = "type")]
35pub enum ReportScope {
36    #[serde(rename = "vault")]
37    Vault,
38    #[serde(rename = "project")]
39    Project { id: String, title: String },
40}
41
42/// High-level vault/scope summary.
43#[derive(Debug, Serialize)]
44pub struct VaultSummary {
45    pub total_notes: usize,
46    pub notes_by_type: HashMap<String, usize>,
47    pub total_tasks: usize,
48    pub tasks_by_status: HashMap<String, usize>,
49    pub total_projects: usize,
50    pub active_projects: usize,
51}
52
53/// Per-project breakdown.
54#[derive(Debug, Serialize)]
55pub struct ProjectReport {
56    pub id: String,
57    pub title: String,
58    pub kind: String,
59    pub status: String,
60    pub tasks: TaskCounts,
61    pub progress_percent: f64,
62    pub velocity: Velocity,
63    pub recent_completions: Vec<CompletedTask>,
64}
65
66/// Task counts grouped by status.
67#[derive(Debug, Default, Serialize)]
68pub struct TaskCounts {
69    pub total: usize,
70    pub todo: usize,
71    pub in_progress: usize,
72    pub blocked: usize,
73    pub done: usize,
74    pub cancelled: usize,
75}
76
77/// Velocity metrics over sliding windows.
78#[derive(Debug, Serialize)]
79pub struct Velocity {
80    pub tasks_per_week_4w: f64,
81    pub tasks_per_week_2w: f64,
82    pub created_last_7d: usize,
83    pub completed_last_7d: usize,
84}
85
86/// A completed task reference.
87#[derive(Debug, Serialize)]
88pub struct CompletedTask {
89    pub id: String,
90    pub title: String,
91    pub completed_at: String,
92    pub project: String,
93}
94
95/// Activity over time — burndown, heatmap, staleness.
96#[derive(Debug, Serialize)]
97pub struct ActivityReport {
98    pub period_days: u32,
99    pub daily_activity: Vec<DayActivity>,
100    pub stale_notes: Vec<StaleNote>,
101}
102
103/// Activity for a single day (tasks completed + created).
104#[derive(Debug, Serialize)]
105pub struct DayActivity {
106    pub date: String,
107    pub weekday: String,
108    pub tasks_completed: usize,
109    pub tasks_created: usize,
110    pub notes_modified: usize,
111}
112
113/// A note flagged as stale.
114#[derive(Debug, Serialize)]
115pub struct StaleNote {
116    pub title: String,
117    pub path: String,
118    pub note_type: String,
119    pub staleness_score: f64,
120    pub last_seen: Option<String>,
121}
122
123/// A task flagged for attention (overdue, high priority, or upcoming deadline).
124#[derive(Debug, Serialize)]
125pub struct FlaggedTask {
126    pub id: String,
127    pub title: String,
128    pub project: String,
129    pub due_date: Option<String>,
130    pub priority: Option<String>,
131    pub status: String,
132    pub days_overdue: Option<i64>,
133}
134
135// ─────────────────────────────────────────────────────────────────────────────
136// Builder
137// ─────────────────────────────────────────────────────────────────────────────
138
139/// Options for building a dashboard report.
140pub struct DashboardOptions {
141    /// Scope to a single project (by ID or folder name). None = vault-wide.
142    pub project: Option<String>,
143    /// Number of days of activity history to include (default: 30).
144    pub activity_days: u32,
145    /// Maximum stale notes to include (default: 10).
146    pub stale_limit: u32,
147    /// Minimum staleness score to flag (default: 0.5).
148    pub stale_threshold: f64,
149}
150
151impl Default for DashboardOptions {
152    fn default() -> Self {
153        Self { project: None, activity_days: 30, stale_limit: 10, stale_threshold: 0.5 }
154    }
155}
156
157/// Build a dashboard report from the index.
158pub fn build_dashboard(
159    db: &IndexDb,
160    options: &DashboardOptions,
161) -> Result<DashboardReport, String> {
162    let all_notes = db
163        .query_notes(&NoteQuery::default())
164        .map_err(|e| format!("Failed to query notes: {e}"))?;
165
166    let tasks: Vec<&IndexedNote> =
167        all_notes.iter().filter(|n| n.note_type == NoteType::Task).collect();
168
169    let projects: Vec<&IndexedNote> =
170        all_notes.iter().filter(|n| n.note_type == NoteType::Project).collect();
171
172    // Determine scope
173    let (scope, target_projects) = if let Some(ref project_filter) = options.project {
174        let matched = projects
175            .iter()
176            .find(|p| {
177                let (id, _, _) = extract_project_info(p);
178                let folder = p.path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
179                id.eq_ignore_ascii_case(project_filter)
180                    || folder.eq_ignore_ascii_case(project_filter)
181            })
182            .ok_or_else(|| format!("Project not found: {project_filter}"))?;
183
184        let (id, _, _) = extract_project_info(matched);
185        let title =
186            if matched.title.is_empty() { id.clone() } else { matched.title.clone() };
187
188        (ReportScope::Project { id: id.clone(), title }, vec![*matched])
189    } else {
190        (ReportScope::Vault, projects.to_vec())
191    };
192
193    // Build per-project reports
194    let project_reports: Vec<ProjectReport> =
195        target_projects.iter().map(|p| build_project_report(p, &tasks)).collect();
196
197    // Resolve project filter to folder name for consistent matching
198    let resolved_project_folder: Option<String> = if options.project.is_some() {
199        target_projects
200            .first()
201            .and_then(|p| p.path.file_stem().and_then(|s| s.to_str()).map(String::from))
202    } else {
203        None
204    };
205
206    // Vault summary
207    let summary =
208        build_vault_summary(&all_notes, &tasks, &projects, &resolved_project_folder);
209
210    // Activity report
211    let activity = build_activity_report(db, &all_notes, &tasks, options)?;
212
213    // Build actionable task lists
214    let today = chrono::Local::now().date_naive();
215    let (overdue, high_priority, upcoming_deadlines) =
216        build_flagged_tasks(&tasks, &target_projects, today);
217
218    Ok(DashboardReport {
219        generated_at: Utc::now().to_rfc3339(),
220        scope,
221        summary,
222        projects: project_reports,
223        activity,
224        overdue,
225        high_priority,
226        upcoming_deadlines,
227    })
228}
229
230// ─────────────────────────────────────────────────────────────────────────────
231// Internal builders
232// ─────────────────────────────────────────────────────────────────────────────
233
234fn build_vault_summary(
235    all_notes: &[IndexedNote],
236    tasks: &[&IndexedNote],
237    projects: &[&IndexedNote],
238    project_filter: &Option<String>,
239) -> VaultSummary {
240    let mut notes_by_type: HashMap<String, usize> = HashMap::new();
241    for note in all_notes {
242        *notes_by_type.entry(note.note_type.as_str().to_string()).or_default() += 1;
243    }
244
245    // If scoped to a project, filter tasks to that project
246    let scoped_tasks: Vec<&&IndexedNote> = if let Some(pf) = project_filter {
247        tasks.iter().filter(|t| task_matches_project(t, pf)).collect()
248    } else {
249        tasks.iter().collect()
250    };
251
252    let mut tasks_by_status: HashMap<String, usize> = HashMap::new();
253    for task in &scoped_tasks {
254        let status =
255            get_frontmatter_str(task, "status").unwrap_or_else(|| "todo".to_string());
256        let normalised = normalise_status(&status);
257        *tasks_by_status.entry(normalised).or_default() += 1;
258    }
259
260    let active_projects = projects
261        .iter()
262        .filter(|p| {
263            let (_, status, _) = extract_project_info(p);
264            !matches!(status.as_str(), "archived" | "done" | "completed")
265        })
266        .count();
267
268    VaultSummary {
269        total_notes: all_notes.len(),
270        notes_by_type,
271        total_tasks: scoped_tasks.len(),
272        tasks_by_status,
273        total_projects: projects.len(),
274        active_projects,
275    }
276}
277
278fn build_project_report(
279    project: &IndexedNote,
280    all_tasks: &[&IndexedNote],
281) -> ProjectReport {
282    let (id, status, kind) = extract_project_info(project);
283    let project_folder = project.path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
284    let title = if project.title.is_empty() {
285        project_folder.to_string()
286    } else {
287        project.title.clone()
288    };
289
290    let project_tasks: Vec<&&IndexedNote> =
291        all_tasks.iter().filter(|t| task_matches_project(t, project_folder)).collect();
292
293    let mut counts = TaskCounts::default();
294    for task in &project_tasks {
295        let s = get_frontmatter_str(task, "status").unwrap_or_else(|| "todo".to_string());
296        match normalise_status(&s).as_str() {
297            "todo" => counts.todo += 1,
298            "in_progress" => counts.in_progress += 1,
299            "blocked" => counts.blocked += 1,
300            "done" => counts.done += 1,
301            "cancelled" => counts.cancelled += 1,
302            _ => counts.todo += 1,
303        }
304    }
305    counts.total = project_tasks.len();
306
307    let active_total = counts.total - counts.cancelled;
308    let progress_percent = if active_total > 0 {
309        (counts.done as f64 / active_total as f64) * 100.0
310    } else {
311        0.0
312    };
313
314    let velocity = calculate_velocity(&project_tasks);
315    let recent_completions = recent_completions(&project_tasks, &id, 7, 10);
316
317    ProjectReport {
318        id,
319        title,
320        kind,
321        status,
322        tasks: counts,
323        progress_percent,
324        velocity,
325        recent_completions,
326    }
327}
328
329fn build_activity_report(
330    db: &IndexDb,
331    all_notes: &[IndexedNote],
332    tasks: &[&IndexedNote],
333    options: &DashboardOptions,
334) -> Result<ActivityReport, String> {
335    let today = chrono::Local::now().date_naive();
336    let start = today - Duration::days(options.activity_days as i64);
337
338    // Build daily activity
339    let mut daily_activity: Vec<DayActivity> = Vec::new();
340    let mut current = start;
341    while current <= today {
342        let date_str = current.format("%Y-%m-%d").to_string();
343
344        let completed = tasks
345            .iter()
346            .filter(|t| get_frontmatter_date(t, "completed_at") == Some(current))
347            .count();
348
349        let created = tasks
350            .iter()
351            .filter(|t| get_frontmatter_date(t, "created_at") == Some(current))
352            .count();
353
354        let modified =
355            all_notes.iter().filter(|n| n.modified.date_naive() == current).count();
356
357        daily_activity.push(DayActivity {
358            date: date_str,
359            weekday: format!("{:?}", current.weekday()),
360            tasks_completed: completed,
361            tasks_created: created,
362            notes_modified: modified,
363        });
364
365        current += Duration::days(1);
366    }
367
368    // Stale notes — only tasks and projects are actionable, so exclude
369    // timeless note types like zettels, contacts, and daily/weekly notes.
370    let stale_notes = db
371        .get_stale_notes(options.stale_threshold, None, Some(options.stale_limit))
372        .map_err(|e| format!("Failed to query stale notes: {e}"))?
373        .into_iter()
374        .filter(|(note, _)| matches!(note.note_type, NoteType::Task | NoteType::Project))
375        .map(|(note, score)| {
376            let last_seen = db
377                .get_activity_summary(note.id.unwrap_or(0))
378                .ok()
379                .flatten()
380                .and_then(|s| s.last_seen.map(|d| d.format("%Y-%m-%d").to_string()));
381
382            StaleNote {
383                title: note.title.clone(),
384                path: note.path.to_string_lossy().to_string(),
385                note_type: note.note_type.as_str().to_string(),
386                staleness_score: score,
387                last_seen,
388            }
389        })
390        .collect();
391
392    Ok(ActivityReport { period_days: options.activity_days, daily_activity, stale_notes })
393}
394
395fn calculate_velocity(tasks: &[&&IndexedNote]) -> Velocity {
396    let now = Utc::now();
397    let two_weeks_ago = (now - Duration::weeks(2)).date_naive();
398    let four_weeks_ago = (now - Duration::weeks(4)).date_naive();
399    let seven_days_ago = (now - Duration::days(7)).date_naive();
400
401    let completed_4w = tasks
402        .iter()
403        .filter(|t| {
404            get_frontmatter_date(t, "completed_at")
405                .map(|d| d >= four_weeks_ago)
406                .unwrap_or(false)
407        })
408        .count();
409
410    let completed_2w = tasks
411        .iter()
412        .filter(|t| {
413            get_frontmatter_date(t, "completed_at")
414                .map(|d| d >= two_weeks_ago)
415                .unwrap_or(false)
416        })
417        .count();
418
419    let completed_7d = tasks
420        .iter()
421        .filter(|t| {
422            get_frontmatter_date(t, "completed_at")
423                .map(|d| d >= seven_days_ago)
424                .unwrap_or(false)
425        })
426        .count();
427
428    let created_7d = tasks
429        .iter()
430        .filter(|t| {
431            get_frontmatter_date(t, "created_at")
432                .map(|d| d >= seven_days_ago)
433                .unwrap_or(false)
434        })
435        .count();
436
437    Velocity {
438        tasks_per_week_4w: completed_4w as f64 / 4.0,
439        tasks_per_week_2w: completed_2w as f64 / 2.0,
440        created_last_7d: created_7d,
441        completed_last_7d: completed_7d,
442    }
443}
444
445fn recent_completions(
446    tasks: &[&&IndexedNote],
447    project_id: &str,
448    days: i64,
449    limit: usize,
450) -> Vec<CompletedTask> {
451    let cutoff = (Utc::now() - Duration::days(days)).date_naive();
452
453    let mut completions: Vec<CompletedTask> = tasks
454        .iter()
455        .filter_map(|t| {
456            let completed_at = get_frontmatter_date(t, "completed_at")?;
457            if completed_at < cutoff {
458                return None;
459            }
460            Some(CompletedTask {
461                id: get_frontmatter_str(t, "task-id").unwrap_or_default(),
462                title: t.title.clone(),
463                completed_at: completed_at.format("%Y-%m-%d").to_string(),
464                project: project_id.to_string(),
465            })
466        })
467        .collect();
468
469    completions.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
470    completions.truncate(limit);
471    completions
472}
473
474fn build_flagged_tasks(
475    tasks: &[&IndexedNote],
476    target_projects: &[&IndexedNote],
477    today: NaiveDate,
478) -> (Vec<FlaggedTask>, Vec<FlaggedTask>, Vec<FlaggedTask>) {
479    // Build folder-name → project-id map
480    let folder_to_id: HashMap<String, String> = target_projects
481        .iter()
482        .filter_map(|p| {
483            let folder = p.path.file_stem().and_then(|s| s.to_str())?.to_string();
484            let (id, _, _) = extract_project_info(p);
485            Some((folder, id))
486        })
487        .collect();
488
489    let resolve_project = |task: &IndexedNote| -> String {
490        let raw = get_frontmatter_str(task, "project").unwrap_or_default();
491        folder_to_id.get(&raw).cloned().unwrap_or(raw)
492    };
493
494    let is_open =
495        |status: &str| !matches!(normalise_status(status).as_str(), "done" | "cancelled");
496
497    // Overdue
498    let mut overdue: Vec<FlaggedTask> = tasks
499        .iter()
500        .filter_map(|t| {
501            let status = get_frontmatter_str(t, "status").unwrap_or_default();
502            if !is_open(&status) {
503                return None;
504            }
505            let due = get_frontmatter_date(t, "due_date")?;
506            if due >= today {
507                return None;
508            }
509            Some(FlaggedTask {
510                id: get_frontmatter_str(t, "task-id").unwrap_or_default(),
511                title: t.title.clone(),
512                project: resolve_project(t),
513                due_date: Some(due.format("%Y-%m-%d").to_string()),
514                priority: get_frontmatter_str(t, "priority"),
515                status: normalise_status(&status),
516                days_overdue: Some((today - due).num_days()),
517            })
518        })
519        .collect();
520    overdue.sort_by(|a, b| b.days_overdue.cmp(&a.days_overdue));
521
522    // High priority
523    let mut high_priority: Vec<FlaggedTask> = tasks
524        .iter()
525        .filter_map(|t| {
526            let status = get_frontmatter_str(t, "status").unwrap_or_default();
527            if !is_open(&status) {
528                return None;
529            }
530            let priority = get_frontmatter_str(t, "priority")?;
531            if priority != "high" {
532                return None;
533            }
534            Some(FlaggedTask {
535                id: get_frontmatter_str(t, "task-id").unwrap_or_default(),
536                title: t.title.clone(),
537                project: resolve_project(t),
538                due_date: get_frontmatter_date(t, "due_date")
539                    .map(|d| d.format("%Y-%m-%d").to_string()),
540                priority: Some(priority),
541                status: normalise_status(&status),
542                days_overdue: None,
543            })
544        })
545        .collect();
546    high_priority.truncate(10);
547
548    // Upcoming deadlines (next 14 days)
549    let deadline_horizon = today + Duration::days(14);
550    let mut upcoming_deadlines: Vec<FlaggedTask> = tasks
551        .iter()
552        .filter_map(|t| {
553            let status = get_frontmatter_str(t, "status").unwrap_or_default();
554            if !is_open(&status) {
555                return None;
556            }
557            let due = get_frontmatter_date(t, "due_date")?;
558            if due < today || due > deadline_horizon {
559                return None;
560            }
561            Some(FlaggedTask {
562                id: get_frontmatter_str(t, "task-id").unwrap_or_default(),
563                title: t.title.clone(),
564                project: resolve_project(t),
565                due_date: Some(due.format("%Y-%m-%d").to_string()),
566                priority: get_frontmatter_str(t, "priority"),
567                status: normalise_status(&status),
568                days_overdue: None,
569            })
570        })
571        .collect();
572    upcoming_deadlines.sort_by(|a, b| a.due_date.cmp(&b.due_date));
573
574    (overdue, high_priority, upcoming_deadlines)
575}
576
577// ─────────────────────────────────────────────────────────────────────────────
578// Frontmatter helpers
579// ─────────────────────────────────────────────────────────────────────────────
580
581fn get_frontmatter_str(note: &IndexedNote, key: &str) -> Option<String> {
582    note.frontmatter_json
583        .as_ref()
584        .and_then(|fm| serde_json::from_str::<serde_json::Value>(fm).ok())
585        .and_then(|fm| fm.get(key).and_then(|v| v.as_str()).map(String::from))
586}
587
588fn get_frontmatter_date(note: &IndexedNote, key: &str) -> Option<NaiveDate> {
589    let date_str = get_frontmatter_str(note, key)?;
590    NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
591        .ok()
592        .or_else(|| {
593            chrono::DateTime::parse_from_rfc3339(&date_str).ok().map(|dt| dt.date_naive())
594        })
595        .or_else(|| {
596            // Handle "YYYY-MM-DDThh:mm:ss" without timezone
597            chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
598                .ok()
599                .map(|dt| dt.date())
600        })
601}
602
603fn extract_project_info(project: &IndexedNote) -> (String, String, String) {
604    let fm = project
605        .frontmatter_json
606        .as_ref()
607        .and_then(|fm| serde_json::from_str::<serde_json::Value>(fm).ok());
608
609    let id = fm
610        .as_ref()
611        .and_then(|fm| fm.get("project-id").and_then(|v| v.as_str()))
612        .map(String::from)
613        .unwrap_or_else(|| {
614            project.path.file_stem().and_then(|s| s.to_str()).unwrap_or("???").to_string()
615        });
616
617    let status = fm
618        .as_ref()
619        .and_then(|fm| fm.get("status").and_then(|v| v.as_str()))
620        .map(String::from)
621        .unwrap_or_else(|| "unknown".to_string());
622
623    let kind = fm
624        .as_ref()
625        .and_then(|fm| fm.get("kind").and_then(|v| v.as_str()))
626        .map(String::from)
627        .unwrap_or_else(|| "project".to_string());
628
629    (id, status, kind)
630}
631
632fn task_matches_project(task: &IndexedNote, project_folder: &str) -> bool {
633    // Check frontmatter project field
634    if let Some(project) = get_frontmatter_str(task, "project")
635        && project.eq_ignore_ascii_case(project_folder)
636    {
637        return true;
638    }
639
640    // Check path
641    let path_str = task.path.to_string_lossy();
642    crate::domain::task_belongs_to_project(&path_str, project_folder)
643}
644
645fn normalise_status(status: &str) -> String {
646    match status {
647        "todo" | "open" => "todo".to_string(),
648        "in-progress" | "in_progress" | "doing" => "in_progress".to_string(),
649        "blocked" | "waiting" => "blocked".to_string(),
650        "done" | "completed" => "done".to_string(),
651        "cancelled" | "canceled" => "cancelled".to_string(),
652        other => other.to_string(),
653    }
654}
655
656// ─────────────────────────────────────────────────────────────────────────────
657// Tests
658// ─────────────────────────────────────────────────────────────────────────────
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use chrono::{Duration, TimeZone, Utc};
664    use std::path::PathBuf;
665
666    /// Helper to create a minimal IndexedNote for testing.
667    fn make_note(
668        path: &str,
669        note_type: NoteType,
670        title: &str,
671        frontmatter: Option<&str>,
672    ) -> IndexedNote {
673        IndexedNote {
674            id: None,
675            path: PathBuf::from(path),
676            note_type,
677            title: title.to_string(),
678            created: Some(Utc::now()),
679            modified: Utc::now(),
680            frontmatter_json: frontmatter.map(String::from),
681            content_hash: "test".to_string(),
682        }
683    }
684
685    fn make_project(folder: &str, id: &str, title: &str, status: &str) -> IndexedNote {
686        let fm = serde_json::json!({
687            "project-id": id,
688            "status": status,
689            "kind": "project",
690        });
691        make_note(
692            &format!("Projects/{folder}/{folder}.md"),
693            NoteType::Project,
694            title,
695            Some(&fm.to_string()),
696        )
697    }
698
699    fn make_task(
700        project_folder: &str,
701        task_id: &str,
702        title: &str,
703        status: &str,
704        created_at: Option<NaiveDate>,
705        completed_at: Option<NaiveDate>,
706    ) -> IndexedNote {
707        let mut fm = serde_json::json!({
708            "task-id": task_id,
709            "status": status,
710            "project": project_folder,
711        });
712        if let Some(d) = created_at {
713            fm["created_at"] =
714                serde_json::Value::String(d.format("%Y-%m-%d").to_string());
715        }
716        if let Some(d) = completed_at {
717            fm["completed_at"] =
718                serde_json::Value::String(d.format("%Y-%m-%d").to_string());
719        }
720        let modified = completed_at
721            .or(created_at)
722            .map(|d| Utc.from_utc_datetime(&d.and_hms_opt(12, 0, 0).unwrap()))
723            .unwrap_or_else(Utc::now);
724        IndexedNote {
725            modified,
726            ..make_note(
727                &format!("Projects/{project_folder}/Tasks/{task_id}.md"),
728                NoteType::Task,
729                title,
730                Some(&fm.to_string()),
731            )
732        }
733    }
734
735    // ── normalise_status ─────────────────────────────────────────────────
736
737    #[test]
738    fn normalise_status_canonical_values() {
739        assert_eq!(normalise_status("todo"), "todo");
740        assert_eq!(normalise_status("in_progress"), "in_progress");
741        assert_eq!(normalise_status("blocked"), "blocked");
742        assert_eq!(normalise_status("done"), "done");
743        assert_eq!(normalise_status("cancelled"), "cancelled");
744    }
745
746    #[test]
747    fn normalise_status_aliases() {
748        assert_eq!(normalise_status("open"), "todo");
749        assert_eq!(normalise_status("in-progress"), "in_progress");
750        assert_eq!(normalise_status("doing"), "in_progress");
751        assert_eq!(normalise_status("waiting"), "blocked");
752        assert_eq!(normalise_status("completed"), "done");
753        assert_eq!(normalise_status("canceled"), "cancelled");
754    }
755
756    #[test]
757    fn normalise_status_unknown_passes_through() {
758        assert_eq!(normalise_status("review"), "review");
759    }
760
761    // ── frontmatter helpers ──────────────────────────────────────────────
762
763    #[test]
764    fn get_frontmatter_str_returns_value() {
765        let note = make_note(
766            "test.md",
767            NoteType::Task,
768            "Test",
769            Some(r#"{"status": "done", "project": "my-project"}"#),
770        );
771        assert_eq!(get_frontmatter_str(&note, "status"), Some("done".into()));
772        assert_eq!(get_frontmatter_str(&note, "project"), Some("my-project".into()));
773    }
774
775    #[test]
776    fn get_frontmatter_str_missing_key_returns_none() {
777        let note =
778            make_note("test.md", NoteType::Task, "Test", Some(r#"{"status": "done"}"#));
779        assert_eq!(get_frontmatter_str(&note, "nonexistent"), None);
780    }
781
782    #[test]
783    fn get_frontmatter_str_no_frontmatter_returns_none() {
784        let note = make_note("test.md", NoteType::Task, "Test", None);
785        assert_eq!(get_frontmatter_str(&note, "status"), None);
786    }
787
788    #[test]
789    fn get_frontmatter_date_parses_ymd() {
790        let note = make_note(
791            "test.md",
792            NoteType::Task,
793            "Test",
794            Some(r#"{"completed_at": "2025-06-15"}"#),
795        );
796        let expected = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
797        assert_eq!(get_frontmatter_date(&note, "completed_at"), Some(expected));
798    }
799
800    #[test]
801    fn get_frontmatter_date_parses_rfc3339() {
802        let note = make_note(
803            "test.md",
804            NoteType::Task,
805            "Test",
806            Some(r#"{"completed_at": "2025-06-15T10:30:00+00:00"}"#),
807        );
808        let expected = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
809        assert_eq!(get_frontmatter_date(&note, "completed_at"), Some(expected));
810    }
811
812    #[test]
813    fn get_frontmatter_date_parses_datetime_no_tz() {
814        let note = make_note(
815            "test.md",
816            NoteType::Task,
817            "Test",
818            Some(r#"{"completed_at": "2025-06-15T10:30:00"}"#),
819        );
820        let expected = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
821        assert_eq!(get_frontmatter_date(&note, "completed_at"), Some(expected));
822    }
823
824    // ── extract_project_info ─────────────────────────────────────────────
825
826    #[test]
827    fn extract_project_info_reads_frontmatter() {
828        let project = make_project("my-proj", "MP", "My Project", "open");
829        let (id, status, kind) = extract_project_info(&project);
830        assert_eq!(id, "MP");
831        assert_eq!(status, "open");
832        assert_eq!(kind, "project");
833    }
834
835    #[test]
836    fn extract_project_info_defaults_without_frontmatter() {
837        let note = make_note(
838            "Projects/fallback-proj/fallback-proj.md",
839            NoteType::Project,
840            "Fallback",
841            None,
842        );
843        let (id, status, kind) = extract_project_info(&note);
844        assert_eq!(id, "fallback-proj");
845        assert_eq!(status, "unknown");
846        assert_eq!(kind, "project");
847    }
848
849    // ── task_matches_project ─────────────────────────────────────────────
850
851    #[test]
852    fn task_matches_project_via_frontmatter() {
853        let task = make_task("my-project", "T-1", "Task", "todo", None, None);
854        assert!(task_matches_project(&task, "my-project"));
855    }
856
857    #[test]
858    fn task_matches_project_via_path() {
859        let task = make_note(
860            "Projects/my-project/Tasks/T-1.md",
861            NoteType::Task,
862            "Task",
863            Some(r#"{"status": "todo"}"#),
864        );
865        assert!(task_matches_project(&task, "my-project"));
866    }
867
868    #[test]
869    fn task_does_not_match_wrong_project() {
870        let task = make_task("other-project", "T-1", "Task", "todo", None, None);
871        assert!(!task_matches_project(&task, "my-project"));
872    }
873
874    // ── calculate_velocity ───────────────────────────────────────────────
875
876    #[test]
877    fn velocity_with_no_tasks() {
878        let tasks: Vec<&IndexedNote> = vec![];
879        let refs: Vec<&&IndexedNote> = tasks.iter().collect();
880        let v = calculate_velocity(&refs);
881        assert_eq!(v.tasks_per_week_4w, 0.0);
882        assert_eq!(v.tasks_per_week_2w, 0.0);
883        assert_eq!(v.completed_last_7d, 0);
884        assert_eq!(v.created_last_7d, 0);
885    }
886
887    #[test]
888    fn velocity_counts_recent_completions() {
889        let today = chrono::Local::now().date_naive();
890        let yesterday = today - Duration::days(1);
891        let two_days_ago = today - Duration::days(2);
892
893        let t1 =
894            make_task("proj", "T-1", "A", "done", Some(two_days_ago), Some(yesterday));
895        let t2 = make_task("proj", "T-2", "B", "done", Some(two_days_ago), Some(today));
896
897        let tasks = [&t1, &t2];
898        let refs: Vec<&&IndexedNote> = tasks.iter().collect();
899        let v = calculate_velocity(&refs);
900
901        assert_eq!(v.completed_last_7d, 2);
902        assert_eq!(v.created_last_7d, 2);
903        assert!(v.tasks_per_week_2w > 0.0);
904        assert!(v.tasks_per_week_4w > 0.0);
905    }
906
907    #[test]
908    fn velocity_excludes_old_completions() {
909        let old_date = chrono::Local::now().date_naive() - Duration::days(60);
910        let t1 = make_task("proj", "T-1", "Old", "done", Some(old_date), Some(old_date));
911
912        let tasks = [&t1];
913        let refs: Vec<&&IndexedNote> = tasks.iter().collect();
914        let v = calculate_velocity(&refs);
915
916        assert_eq!(v.completed_last_7d, 0);
917        assert_eq!(v.tasks_per_week_4w, 0.0);
918    }
919
920    // ── recent_completions ───────────────────────────────────────────────
921
922    #[test]
923    fn recent_completions_filters_and_sorts() {
924        let today = chrono::Local::now().date_naive();
925        let yesterday = today - Duration::days(1);
926        let old = today - Duration::days(30);
927
928        let t1 = make_task("proj", "T-1", "Recent", "done", None, Some(yesterday));
929        let t2 = make_task("proj", "T-2", "Today", "done", None, Some(today));
930        let t3 = make_task("proj", "T-3", "Old", "done", None, Some(old));
931
932        let tasks = [&t1, &t2, &t3];
933        let refs: Vec<&&IndexedNote> = tasks.iter().collect();
934        let result = recent_completions(&refs, "PROJ", 7, 10);
935
936        assert_eq!(result.len(), 2); // excludes old
937        assert_eq!(result[0].id, "T-2"); // today first (sorted desc)
938        assert_eq!(result[1].id, "T-1");
939    }
940
941    #[test]
942    fn recent_completions_respects_limit() {
943        let today = chrono::Local::now().date_naive();
944
945        let t1 = make_task("proj", "T-1", "A", "done", None, Some(today));
946        let t2 = make_task("proj", "T-2", "B", "done", None, Some(today));
947        let t3 = make_task("proj", "T-3", "C", "done", None, Some(today));
948
949        let tasks = [&t1, &t2, &t3];
950        let refs: Vec<&&IndexedNote> = tasks.iter().collect();
951        let result = recent_completions(&refs, "PROJ", 7, 2);
952
953        assert_eq!(result.len(), 2);
954    }
955
956    // ── build_project_report ─────────────────────────────────────────────
957
958    #[test]
959    fn project_report_calculates_progress() {
960        let project = make_project("my-proj", "MP", "My Project", "open");
961        let t1 = make_task("my-proj", "MP-1", "Done task", "done", None, None);
962        let t2 = make_task("my-proj", "MP-2", "Todo task", "todo", None, None);
963        let t3 = make_task("my-proj", "MP-3", "Cancelled", "cancelled", None, None);
964
965        let all_tasks: Vec<&IndexedNote> = vec![&t1, &t2, &t3];
966        let report = build_project_report(&project, &all_tasks);
967
968        assert_eq!(report.id, "MP");
969        assert_eq!(report.title, "My Project");
970        assert_eq!(report.tasks.total, 3);
971        assert_eq!(report.tasks.done, 1);
972        assert_eq!(report.tasks.todo, 1);
973        assert_eq!(report.tasks.cancelled, 1);
974        // Progress excludes cancelled: 1 done / 2 active = 50%
975        assert!((report.progress_percent - 50.0).abs() < 0.01);
976    }
977
978    #[test]
979    fn project_report_zero_tasks() {
980        let project = make_project("empty", "EMP", "Empty", "open");
981        let all_tasks: Vec<&IndexedNote> = vec![];
982        let report = build_project_report(&project, &all_tasks);
983
984        assert_eq!(report.tasks.total, 0);
985        assert_eq!(report.progress_percent, 0.0);
986    }
987
988    #[test]
989    fn project_report_all_done() {
990        let project = make_project("done-proj", "DP", "Done Project", "done");
991        let t1 = make_task("done-proj", "DP-1", "A", "done", None, None);
992        let t2 = make_task("done-proj", "DP-2", "B", "done", None, None);
993
994        let all_tasks: Vec<&IndexedNote> = vec![&t1, &t2];
995        let report = build_project_report(&project, &all_tasks);
996
997        assert!((report.progress_percent - 100.0).abs() < 0.01);
998    }
999
1000    // ── build_vault_summary ──────────────────────────────────────────────
1001
1002    #[test]
1003    fn vault_summary_counts_correctly() {
1004        let project = make_project("proj", "P", "Proj", "open");
1005        let t1 = make_task("proj", "T-1", "A", "done", None, None);
1006        let t2 = make_task("proj", "T-2", "B", "todo", None, None);
1007        let daily = make_note("Journal/2025-01-01.md", NoteType::Daily, "Jan 1", None);
1008
1009        let all_notes = vec![project.clone(), t1.clone(), t2.clone(), daily];
1010        let tasks: Vec<&IndexedNote> = vec![&t1, &t2];
1011        let projects: Vec<&IndexedNote> = vec![&project];
1012
1013        let summary = build_vault_summary(&all_notes, &tasks, &projects, &None);
1014
1015        assert_eq!(summary.total_notes, 4);
1016        assert_eq!(summary.total_tasks, 2);
1017        assert_eq!(summary.total_projects, 1);
1018        assert_eq!(summary.active_projects, 1);
1019        assert_eq!(summary.tasks_by_status.get("done"), Some(&1));
1020        assert_eq!(summary.tasks_by_status.get("todo"), Some(&1));
1021    }
1022
1023    #[test]
1024    fn vault_summary_filters_by_project() {
1025        let p1 = make_project("proj-a", "PA", "A", "open");
1026        let p2 = make_project("proj-b", "PB", "B", "open");
1027        let t1 = make_task("proj-a", "T-1", "A task", "done", None, None);
1028        let t2 = make_task("proj-b", "T-2", "B task", "todo", None, None);
1029
1030        let all_notes = vec![p1.clone(), p2.clone(), t1.clone(), t2.clone()];
1031        let tasks: Vec<&IndexedNote> = vec![&t1, &t2];
1032        let projects: Vec<&IndexedNote> = vec![&p1, &p2];
1033
1034        let summary = build_vault_summary(
1035            &all_notes,
1036            &tasks,
1037            &projects,
1038            &Some("proj-a".to_string()),
1039        );
1040
1041        // Only proj-a tasks counted
1042        assert_eq!(summary.total_tasks, 1);
1043        assert_eq!(summary.tasks_by_status.get("done"), Some(&1));
1044        assert_eq!(summary.tasks_by_status.get("todo"), None);
1045    }
1046
1047    #[test]
1048    fn vault_summary_excludes_archived_from_active() {
1049        let active = make_project("active", "A", "Active", "open");
1050        let archived = make_project("archived", "B", "Archived", "archived");
1051        let done = make_project("done", "C", "Done", "done");
1052
1053        let all_notes = vec![active.clone(), archived.clone(), done.clone()];
1054        let tasks: Vec<&IndexedNote> = vec![];
1055        let projects: Vec<&IndexedNote> = vec![&active, &archived, &done];
1056
1057        let summary = build_vault_summary(&all_notes, &tasks, &projects, &None);
1058
1059        assert_eq!(summary.total_projects, 3);
1060        assert_eq!(summary.active_projects, 1);
1061    }
1062
1063    // ── build_dashboard (integration via IndexDb) ────────────────────────
1064
1065    #[test]
1066    fn build_dashboard_vault_wide() {
1067        let db = IndexDb::open_in_memory().unwrap();
1068
1069        let project = make_project("test-proj", "TP", "Test Project", "open");
1070        db.insert_note(&project).unwrap();
1071
1072        let t1 = make_task("test-proj", "TP-1", "Done task", "done", None, None);
1073        let t2 = make_task("test-proj", "TP-2", "Todo task", "todo", None, None);
1074        db.insert_note(&t1).unwrap();
1075        db.insert_note(&t2).unwrap();
1076
1077        let options = DashboardOptions::default();
1078        let report = build_dashboard(&db, &options).unwrap();
1079
1080        assert!(matches!(report.scope, ReportScope::Vault));
1081        assert_eq!(report.projects.len(), 1);
1082        assert_eq!(report.projects[0].id, "TP");
1083        assert_eq!(report.summary.total_tasks, 2);
1084    }
1085
1086    #[test]
1087    fn build_dashboard_scoped_to_project() {
1088        let db = IndexDb::open_in_memory().unwrap();
1089
1090        let p1 = make_project("proj-a", "PA", "Project A", "open");
1091        let p2 = make_project("proj-b", "PB", "Project B", "open");
1092        db.insert_note(&p1).unwrap();
1093        db.insert_note(&p2).unwrap();
1094
1095        let t1 = make_task("proj-a", "PA-1", "A task", "done", None, None);
1096        let t2 = make_task("proj-b", "PB-1", "B task", "todo", None, None);
1097        db.insert_note(&t1).unwrap();
1098        db.insert_note(&t2).unwrap();
1099
1100        let options =
1101            DashboardOptions { project: Some("PA".to_string()), ..Default::default() };
1102        let report = build_dashboard(&db, &options).unwrap();
1103
1104        assert!(matches!(report.scope, ReportScope::Project { .. }));
1105        assert_eq!(report.projects.len(), 1);
1106        assert_eq!(report.projects[0].id, "PA");
1107    }
1108
1109    #[test]
1110    fn build_dashboard_project_not_found() {
1111        let db = IndexDb::open_in_memory().unwrap();
1112
1113        let options = DashboardOptions {
1114            project: Some("NONEXISTENT".to_string()),
1115            ..Default::default()
1116        };
1117        let result = build_dashboard(&db, &options);
1118
1119        assert!(result.is_err());
1120        assert!(result.unwrap_err().contains("Project not found"));
1121    }
1122
1123    #[test]
1124    fn build_dashboard_empty_vault() {
1125        let db = IndexDb::open_in_memory().unwrap();
1126        let options = DashboardOptions::default();
1127        let report = build_dashboard(&db, &options).unwrap();
1128
1129        assert!(matches!(report.scope, ReportScope::Vault));
1130        assert_eq!(report.projects.len(), 0);
1131        assert_eq!(report.summary.total_notes, 0);
1132        assert_eq!(report.summary.total_tasks, 0);
1133    }
1134
1135    #[test]
1136    fn build_dashboard_activity_days_respected() {
1137        let db = IndexDb::open_in_memory().unwrap();
1138        let options = DashboardOptions { activity_days: 7, ..Default::default() };
1139        let report = build_dashboard(&db, &options).unwrap();
1140
1141        assert_eq!(report.activity.period_days, 7);
1142        assert_eq!(report.activity.daily_activity.len(), 8); // 7 days + today
1143    }
1144
1145    #[test]
1146    fn stale_notes_excludes_non_actionable_types() {
1147        let db = IndexDb::open_in_memory().unwrap();
1148
1149        // Insert notes of various types — all will have default staleness of 1.0
1150        // (no activity_summary row → COALESCE to 1.0)
1151        let task = make_task("proj", "T-1", "Stale task", "todo", None, None);
1152        let project = make_project("proj", "P", "Stale project", "open");
1153        let zettel = make_note("Zettelkasten/z1.md", NoteType::Zettel, "A zettel", None);
1154        let daily = make_note("Journal/2025-01-01.md", NoteType::Daily, "Jan 1", None);
1155
1156        db.insert_note(&task).unwrap();
1157        db.insert_note(&project).unwrap();
1158        db.insert_note(&zettel).unwrap();
1159        db.insert_note(&daily).unwrap();
1160
1161        let options = DashboardOptions {
1162            stale_threshold: 0.5,
1163            stale_limit: 50,
1164            ..Default::default()
1165        };
1166        let report = build_dashboard(&db, &options).unwrap();
1167
1168        let stale_types: Vec<&str> =
1169            report.activity.stale_notes.iter().map(|s| s.note_type.as_str()).collect();
1170
1171        assert!(stale_types.iter().all(|t| *t == "task" || *t == "project"));
1172        assert!(!stale_types.contains(&"zettel"));
1173        assert!(!stale_types.contains(&"daily"));
1174    }
1175}