1use chrono::{Datelike, Duration, NaiveDate, Utc};
10use serde::Serialize;
11use std::collections::HashMap;
12
13use crate::index::{IndexDb, IndexedNote, NoteQuery, NoteType};
14
15#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
135pub struct DashboardOptions {
141 pub project: Option<String>,
143 pub activity_days: u32,
145 pub stale_limit: u32,
147 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
157pub 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 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 let project_reports: Vec<ProjectReport> =
195 target_projects.iter().map(|p| build_project_report(p, &tasks)).collect();
196
197 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 let summary =
208 build_vault_summary(&all_notes, &tasks, &projects, &resolved_project_folder);
209
210 let activity = build_activity_report(db, &all_notes, &tasks, options)?;
212
213 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
230fn 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 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 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 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 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 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 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 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
577fn 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 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 if let Some(project) = get_frontmatter_str(task, "project")
635 && project.eq_ignore_ascii_case(project_folder)
636 {
637 return true;
638 }
639
640 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#[cfg(test)]
661mod tests {
662 use super::*;
663 use chrono::{Duration, TimeZone, Utc};
664 use std::path::PathBuf;
665
666 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 #[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 #[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(¬e, "status"), Some("done".into()));
772 assert_eq!(get_frontmatter_str(¬e, "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(¬e, "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(¬e, "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(¬e, "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(¬e, "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(¬e, "completed_at"), Some(expected));
822 }
823
824 #[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(¬e);
844 assert_eq!(id, "fallback-proj");
845 assert_eq!(status, "unknown");
846 assert_eq!(kind, "project");
847 }
848
849 #[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 #[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 #[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); assert_eq!(result[0].id, "T-2"); 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 #[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 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 #[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 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 #[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); }
1144
1145 #[test]
1146 fn stale_notes_excludes_non_actionable_types() {
1147 let db = IndexDb::open_in_memory().unwrap();
1148
1149 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}