| 783 |
783 |
|
assert_eq!(days[5].events.len(), 0, "Saturday should have 0 events");
|
| 784 |
784 |
|
assert_eq!(days[6].events.len(), 0, "Sunday should have 0 events");
|
| 785 |
785 |
|
}
|
|
786 |
+ |
|
|
787 |
+ |
/// Creates a minimal task with the given status. `completed_at` only
|
|
788 |
+ |
/// survives for Completed, matching what the repositories return.
|
|
789 |
+ |
fn make_task(status: TaskStatus, created_at: DateTime<Utc>) -> Task {
|
|
790 |
+ |
let mut task = make_completed_task(created_at, created_at);
|
|
791 |
+ |
task.status = status;
|
|
792 |
+ |
if task.status != TaskStatus::Completed {
|
|
793 |
+ |
task.completed_at = None;
|
|
794 |
+ |
}
|
|
795 |
+ |
task
|
|
796 |
+ |
}
|
|
797 |
+ |
|
|
798 |
+ |
fn make_project(name: &str) -> crate::models::Project {
|
|
799 |
+ |
crate::models::Project {
|
|
800 |
+ |
id: ProjectId::new(),
|
|
801 |
+ |
name: name.to_string(),
|
|
802 |
+ |
description: String::new(),
|
|
803 |
+ |
project_type: crate::models::ProjectType::SideProject,
|
|
804 |
+ |
status: crate::models::ProjectStatus::Active,
|
|
805 |
+ |
created_at: Utc::now(),
|
|
806 |
+ |
group_id: None,
|
|
807 |
+ |
}
|
|
808 |
+ |
}
|
|
809 |
+ |
|
|
810 |
+ |
fn utc(y: i32, m: u32, d: u32) -> DateTime<Utc> {
|
|
811 |
+ |
NaiveDate::from_ymd_opt(y, m, d)
|
|
812 |
+ |
.unwrap()
|
|
813 |
+ |
.and_hms_opt(12, 0, 0)
|
|
814 |
+ |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
|
|
815 |
+ |
.unwrap()
|
|
816 |
+ |
}
|
|
817 |
+ |
|
|
818 |
+ |
#[test]
|
|
819 |
+ |
fn test_parse_week_start_snaps_to_monday() {
|
|
820 |
+ |
let monday = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
|
|
821 |
+ |
// Any day in the week snaps back to its Monday.
|
|
822 |
+ |
assert_eq!(parse_week_start("2026-02-09"), Some(monday));
|
|
823 |
+ |
assert_eq!(parse_week_start("2026-02-12"), Some(monday));
|
|
824 |
+ |
assert_eq!(parse_week_start("2026-02-15"), Some(monday)); // Sunday
|
|
825 |
+ |
// The next Monday is its own week, not the previous one.
|
|
826 |
+ |
assert_eq!(
|
|
827 |
+ |
parse_week_start("2026-02-16"),
|
|
828 |
+ |
NaiveDate::from_ymd_opt(2026, 2, 16)
|
|
829 |
+ |
);
|
|
830 |
+ |
assert_eq!(parse_week_start("not-a-date"), None);
|
|
831 |
+ |
assert_eq!(parse_week_start("2026-02-30"), None);
|
|
832 |
+ |
}
|
|
833 |
+ |
|
|
834 |
+ |
#[test]
|
|
835 |
+ |
fn test_compute_project_health_status_bands() {
|
|
836 |
+ |
let healthy = make_project("healthy");
|
|
837 |
+ |
let warning = make_project("warning");
|
|
838 |
+ |
let danger = make_project("danger");
|
|
839 |
+ |
let untouched = make_project("untouched");
|
|
840 |
+ |
|
|
841 |
+ |
let past = Utc::now() - Duration::days(1);
|
|
842 |
+ |
let mut tasks = vec![];
|
|
843 |
+ |
|
|
844 |
+ |
// healthy: one pending, nothing overdue.
|
|
845 |
+ |
let mut t = make_task(TaskStatus::Pending, utc(2026, 2, 2));
|
|
846 |
+ |
t.project_id = Some(healthy.id);
|
|
847 |
+ |
tasks.push(t);
|
|
848 |
+ |
|
|
849 |
+ |
// warning: one overdue out of two.
|
|
850 |
+ |
for overdue in [true, false] {
|
|
851 |
+ |
let mut t = make_task(TaskStatus::Started, utc(2026, 2, 2));
|
|
852 |
+ |
t.project_id = Some(warning.id);
|
|
853 |
+ |
if overdue {
|
|
854 |
+ |
t.due = Some(past);
|
|
855 |
+ |
}
|
|
856 |
+ |
tasks.push(t);
|
|
857 |
+ |
}
|
|
858 |
+ |
|
|
859 |
+ |
// danger: three overdue, plus a deleted task that must not be counted.
|
|
860 |
+ |
for _ in 0..3 {
|
|
861 |
+ |
let mut t = make_task(TaskStatus::Pending, utc(2026, 2, 2));
|
|
862 |
+ |
t.project_id = Some(danger.id);
|
|
863 |
+ |
t.due = Some(past);
|
|
864 |
+ |
tasks.push(t);
|
|
865 |
+ |
}
|
|
866 |
+ |
let mut deleted = make_task(TaskStatus::Deleted, utc(2026, 2, 2));
|
|
867 |
+ |
deleted.project_id = Some(danger.id);
|
|
868 |
+ |
deleted.due = Some(past);
|
|
869 |
+ |
tasks.push(deleted);
|
|
870 |
+ |
|
|
871 |
+ |
let health = compute_project_health(
|
|
872 |
+ |
&[
|
|
873 |
+ |
healthy.clone(),
|
|
874 |
+ |
warning.clone(),
|
|
875 |
+ |
danger.clone(),
|
|
876 |
+ |
untouched.clone(),
|
|
877 |
+ |
],
|
|
878 |
+ |
&tasks,
|
|
879 |
+ |
);
|
|
880 |
+ |
|
|
881 |
+ |
// A project with no tasks is dropped entirely, not reported as healthy.
|
|
882 |
+ |
assert_eq!(health.len(), 3);
|
|
883 |
+ |
assert!(health.iter().all(|h| h.id != untouched.id));
|
|
884 |
+ |
|
|
885 |
+ |
let by_name = |name: &str| health.iter().find(|h| h.name == name).unwrap();
|
|
886 |
+ |
let h = by_name("healthy");
|
|
887 |
+ |
assert_eq!((h.active_count, h.overdue_count, h.total_count), (1, 0, 1));
|
|
888 |
+ |
assert_eq!(h.status, "healthy");
|
|
889 |
+ |
|
|
890 |
+ |
let w = by_name("warning");
|
|
891 |
+ |
assert_eq!((w.active_count, w.overdue_count, w.total_count), (2, 1, 2));
|
|
892 |
+ |
assert_eq!(w.status, "warning");
|
|
893 |
+ |
|
|
894 |
+ |
let d = by_name("danger");
|
|
895 |
+ |
assert_eq!(
|
|
896 |
+ |
(d.active_count, d.overdue_count, d.total_count),
|
|
897 |
+ |
(3, 3, 3),
|
|
898 |
+ |
"the deleted task must be excluded from every count"
|
|
899 |
+ |
);
|
|
900 |
+ |
assert_eq!(d.status, "danger");
|
|
901 |
+ |
}
|
|
902 |
+ |
|
|
903 |
+ |
#[test]
|
|
904 |
+ |
fn test_compute_focused_projects_groups_by_project() {
|
|
905 |
+ |
let alpha = ProjectId::new();
|
|
906 |
+ |
let beta = ProjectId::new();
|
|
907 |
+ |
|
|
908 |
+ |
let mut a1 = make_task(TaskStatus::Pending, utc(2026, 2, 9));
|
|
909 |
+ |
a1.project_id = Some(alpha);
|
|
910 |
+ |
a1.project_name = Some("Alpha".to_string());
|
|
911 |
+ |
let mut a2 = make_task(TaskStatus::Started, utc(2026, 2, 9));
|
|
912 |
+ |
a2.project_id = Some(alpha);
|
|
913 |
+ |
a2.project_name = Some("Alpha".to_string());
|
|
914 |
+ |
// No project_name from the query: falls back to "Unknown".
|
|
915 |
+ |
let mut b1 = make_task(TaskStatus::Pending, utc(2026, 2, 9));
|
|
916 |
+ |
b1.project_id = Some(beta);
|
|
917 |
+ |
b1.project_name = None;
|
|
918 |
+ |
// Projectless focus tasks contribute nothing.
|
|
919 |
+ |
let loose = make_task(TaskStatus::Pending, utc(2026, 2, 9));
|
|
920 |
+ |
|
|
921 |
+ |
let projects = compute_focused_projects(&[a1, a2, b1, loose]);
|
|
922 |
+ |
assert_eq!(projects.len(), 2);
|
|
923 |
+ |
|
|
924 |
+ |
let a = projects.iter().find(|p| p.id == alpha).unwrap();
|
|
925 |
+ |
assert_eq!(a.name, "Alpha");
|
|
926 |
+ |
assert_eq!(a.focused_task_count, 2);
|
|
927 |
+ |
|
|
928 |
+ |
let b = projects.iter().find(|p| p.id == beta).unwrap();
|
|
929 |
+ |
assert_eq!(b.name, "Unknown");
|
|
930 |
+ |
assert_eq!(b.focused_task_count, 1);
|
|
931 |
+ |
}
|
|
932 |
+ |
|
|
933 |
+ |
#[test]
|
|
934 |
+ |
fn test_compute_weekly_review_counts_and_carryover() {
|
|
935 |
+ |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
|
|
936 |
+ |
let project = make_project("Alpha");
|
|
937 |
+ |
|
|
938 |
+ |
// Pending work older than the week is carry-over; pending work created
|
|
939 |
+ |
// inside the week is not.
|
|
940 |
+ |
let mut carried = make_task(TaskStatus::Pending, utc(2026, 1, 20));
|
|
941 |
+ |
carried.project_id = Some(project.id);
|
|
942 |
+ |
let fresh = make_task(TaskStatus::Started, utc(2026, 2, 10));
|
|
943 |
+ |
// Completed and deleted tasks never carry over.
|
|
944 |
+ |
let done = make_task(TaskStatus::Completed, utc(2026, 1, 20));
|
|
945 |
+ |
let deleted = make_task(TaskStatus::Deleted, utc(2026, 1, 20));
|
|
946 |
+ |
|
|
947 |
+ |
let mut focused = make_task(TaskStatus::Pending, utc(2026, 2, 10));
|
|
948 |
+ |
focused.project_id = Some(project.id);
|
|
949 |
+ |
focused.project_name = Some("Alpha".to_string());
|
|
950 |
+ |
focused.is_focus = true;
|
|
951 |
+ |
|
|
952 |
+ |
let occurred = make_event("standup", utc(2026, 2, 10));
|
|
953 |
+ |
|
|
954 |
+ |
let review = WeeklyReview {
|
|
955 |
+ |
id: WeeklyReviewId::new(),
|
|
956 |
+ |
user_id: UserId::new(),
|
|
957 |
+ |
week_start_date: week_start,
|
|
958 |
+ |
notes: "shipped the installer".to_string(),
|
|
959 |
+ |
completed_at: utc(2026, 2, 15),
|
|
960 |
+ |
vacation_days: vec![5, 6],
|
|
961 |
+ |
};
|
|
962 |
+ |
|
|
963 |
+ |
let data = compute_weekly_review(WeeklyReviewInput {
|
|
964 |
+ |
week_start,
|
|
965 |
+ |
review: Some(review.clone()),
|
|
966 |
+ |
tasks_completed: vec![done.clone()],
|
|
967 |
+ |
tasks_overdue: vec![carried.clone()],
|
|
968 |
+ |
events_occurred: vec![occurred],
|
|
969 |
+ |
upcoming_events: vec![],
|
|
970 |
+ |
tasks_due_next_week: vec![fresh.clone()],
|
|
971 |
+ |
tasks_already_overdue: vec![carried.clone()],
|
|
972 |
+ |
all_tasks: vec![carried.clone(), fresh.clone(), done, deleted],
|
|
973 |
+ |
focused_tasks: vec![focused],
|
|
974 |
+ |
available_for_focus: vec![],
|
|
975 |
+ |
projects: vec![project.clone()],
|
|
976 |
+ |
tz: Tz::UTC,
|
|
977 |
+ |
});
|
|
978 |
+ |
|
|
979 |
+ |
assert_eq!(data.week_start_date, "2026-02-09");
|
|
980 |
+ |
assert_eq!(data.week_end_date, "2026-02-15");
|
|
981 |
+ |
assert_eq!(data.week_display, "Feb 09 - Feb 15");
|
|
982 |
+ |
|
|
983 |
+ |
// Review fields come straight off the stored row.
|
|
984 |
+ |
assert!(data.is_completed);
|
|
985 |
+ |
assert_eq!(data.completed_at, Some(review.completed_at));
|
|
986 |
+ |
assert_eq!(data.notes, "shipped the installer");
|
|
987 |
+ |
assert_eq!(data.vacation_days, vec![5, 6]);
|
|
988 |
+ |
assert!(!data.show_nudge, "a completed review never nudges");
|
|
989 |
+ |
|
|
990 |
+ |
// Counts mirror the pre-fetched lists.
|
|
991 |
+ |
assert_eq!(data.tasks_completed_count, 1);
|
|
992 |
+ |
assert_eq!(data.tasks_overdue_count, 1);
|
|
993 |
+ |
assert_eq!(data.tasks_due_next_week_count, 1);
|
|
994 |
+ |
assert_eq!(data.tasks_already_overdue_count, 1);
|
|
995 |
+ |
assert_eq!(data.events_occurred_count, 1);
|
|
996 |
+ |
assert_eq!(data.events_occurred.len(), 1);
|
|
997 |
+ |
assert_eq!(data.events_occurred[0].title, "standup");
|
|
998 |
+ |
assert_eq!(data.events_occurred[0].formatted_time, "Tue 12:00");
|
|
999 |
+ |
|
|
1000 |
+ |
// Pending = Pending + Started; carry-over is the pre-week subset.
|
|
1001 |
+ |
assert_eq!(data.tasks_pending_count, 2);
|
|
1002 |
+ |
assert_eq!(data.carried_over_count, 1);
|
|
1003 |
+ |
assert_eq!(data.carried_over_tasks.len(), 1);
|
|
1004 |
+ |
assert_eq!(data.carried_over_tasks[0].id, carried.id);
|
|
1005 |
+ |
|
|
1006 |
+ |
assert_eq!(data.timeline_days.len(), 7);
|
|
1007 |
+ |
assert!(data.timeline_days[5].is_vacation);
|
|
1008 |
+ |
assert!(data.timeline_days[6].is_vacation);
|
|
1009 |
+ |
assert!(!data.timeline_days[0].is_vacation);
|
|
1010 |
+ |
|
|
1011 |
+ |
// Derived from the focus list, not fetched.
|
|
1012 |
+ |
assert_eq!(data.focused_projects.len(), 1);
|
|
1013 |
+ |
assert_eq!(data.focused_projects[0].name, "Alpha");
|
|
1014 |
+ |
assert_eq!(data.focused_projects[0].focused_task_count, 1);
|
|
1015 |
+ |
|
|
1016 |
+ |
assert_eq!(data.project_health.len(), 1);
|
|
1017 |
+ |
assert_eq!(data.project_health[0].name, "Alpha");
|
|
1018 |
+ |
}
|
|
1019 |
+ |
|
|
1020 |
+ |
#[test]
|
|
1021 |
+ |
fn test_compute_weekly_review_without_stored_review() {
|
|
1022 |
+ |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
|
|
1023 |
+ |
let data = compute_weekly_review(WeeklyReviewInput {
|
|
1024 |
+ |
week_start,
|
|
1025 |
+ |
review: None,
|
|
1026 |
+ |
tasks_completed: vec![],
|
|
1027 |
+ |
tasks_overdue: vec![],
|
|
1028 |
+ |
events_occurred: vec![],
|
|
1029 |
+ |
upcoming_events: vec![],
|
|
1030 |
+ |
tasks_due_next_week: vec![],
|
|
1031 |
+ |
tasks_already_overdue: vec![],
|
|
1032 |
+ |
all_tasks: vec![],
|
|
1033 |
+ |
focused_tasks: vec![],
|
|
1034 |
+ |
available_for_focus: vec![],
|
|
1035 |
+ |
projects: vec![],
|
|
1036 |
+ |
tz: Tz::UTC,
|
|
1037 |
+ |
});
|
|
1038 |
+ |
|
|
1039 |
+ |
assert!(!data.is_completed);
|
|
1040 |
+ |
assert_eq!(data.completed_at, None);
|
|
1041 |
+ |
assert_eq!(data.notes, "");
|
|
1042 |
+ |
assert!(data.vacation_days.is_empty());
|
|
1043 |
+ |
assert!(data.timeline_days.iter().all(|d| !d.is_vacation));
|
|
1044 |
+ |
assert_eq!(data.tasks_pending_count, 0);
|
|
1045 |
+ |
assert_eq!(data.carried_over_count, 0);
|
|
1046 |
+ |
assert!(data.project_health.is_empty());
|
|
1047 |
+ |
assert!(data.focused_projects.is_empty());
|
|
1048 |
+ |
}
|
| 786 |
1049 |
|
}
|