//! Time tracking session types. //! //! A `TimeSession` records a period of work on a task. Sessions are created //! when a timer is started and closed when it's stopped. At most one session //! per user can be active (ended_at IS NULL) at any time. use std::num::NonZeroU32; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::error::CoreError; use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId}; /// A validated, strictly-positive minute count for a manual time entry. /// /// Constructing this is the single gate that rejects zero/negative durations: /// a negative value would make `ended_at < started_at` and drive a task's /// cached `actual_minutes` negative, and zero records an empty session. Because /// the repository takes `PositiveMinutes` (not a raw `i32`), that whole class is /// unrepresentable below the command layer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PositiveMinutes(NonZeroU32); impl PositiveMinutes { /// Validate a raw minute count. Returns a `Validation` error for anything /// less than 1 (including negatives, which fail the `u32` conversion) or /// greater than a single day — a manual session is one work period, and an /// unbounded value would inflate the task's cached `actual_minutes` /// arbitrarily. pub fn try_new(minutes: i32) -> crate::Result { if minutes > crate::constants::MAX_SCHEDULED_DURATION_MINUTES { return Err(CoreError::validation( "minutes", format!( "Minutes must be at most {}", crate::constants::MAX_SCHEDULED_DURATION_MINUTES ), )); } u32::try_from(minutes) .ok() .and_then(NonZeroU32::new) .map(Self) .ok_or_else(|| CoreError::validation("minutes", "Minutes must be at least 1")) } /// The validated count as an `i32` for binding into SQL. pub fn as_i32(self) -> i32 { self.0.get() as i32 } } /// A single time tracking session on a task. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimeSession { pub id: TimeSessionId, pub task_id: TaskId, pub user_id: UserId, pub started_at: DateTime, pub ended_at: Option>, pub duration_minutes: Option, pub created_at: DateTime, } impl TimeSession { /// Returns true if this session is still running (no end time). pub fn is_active(&self) -> bool { self.ended_at.is_none() } /// Returns elapsed minutes from start to now (if active) or to ended_at. pub fn elapsed_minutes(&self) -> i32 { let end = self.ended_at.unwrap_or_else(Utc::now); let diff = end.signed_duration_since(self.started_at); diff.num_minutes().max(0) as i32 } } /// Aggregated time tracking data grouped by project and date. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimeTrackingSummary { pub project_id: Option, pub project_name: Option, pub date: String, pub total_minutes: i32, pub session_count: i32, } /// One project's row in the Day view's weekly time-summary panel. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimeSummaryProject { /// Display label; the "No Project" bucket for sessions on unassigned tasks. pub name: String, pub total_minutes: i32, /// Share of the week's total tracked time, rounded to a whole percent. /// Rows sum to ~100 (rounding aside). pub percent: i32, /// Bar-fill width relative to the largest project, 0-100. The top project is /// always 100; this drives the CSS bar chart. pub bar_percent: i32, } /// Pre-computed time-summary panel for the Day view: today's tracked total plus /// the week's per-project breakdown, already aggregated and sorted so the /// frontend only renders. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimeSummaryPanel { pub today_minutes: i32, pub projects: Vec, } /// Roll up per-project/per-date summary rows into the Day view's time-summary /// panel: today's tracked total and the week's per-project breakdown, sorted by /// minutes descending (ties keep first-seen order, matching the old JS Map + /// stable sort). `today` is a `YYYY-MM-DD` string matched against each row's /// `date`. Sessions on unassigned tasks collapse into a single "No Project" /// bucket. Pure and total: empty input yields a zeroed, empty panel. pub fn roll_up_time_summary(rows: &[TimeTrackingSummary], today: &str) -> TimeSummaryPanel { use std::collections::HashMap; let today_minutes = rows .iter() .filter(|r| r.date == today) .map(|r| r.total_minutes) .sum(); // Aggregate across the week by project. A `None` project id collapses to one // "No Project" bucket. `order` preserves first-seen order so the later stable // sort reproduces the old JS tie-breaking. let mut order: Vec> = Vec::new(); let mut acc: HashMap, (String, i32)> = HashMap::new(); for r in rows { let entry = acc.entry(r.project_id).or_insert_with(|| { order.push(r.project_id); let name = r .project_name .clone() .unwrap_or_else(|| "No Project".to_string()); (name, 0) }); entry.1 += r.total_minutes; } let total: i32 = acc.values().map(|(_, m)| *m).sum(); let max: i32 = acc.values().map(|(_, m)| *m).max().unwrap_or(0); let mut projects: Vec = order .into_iter() .map(|key| { let (name, minutes) = acc[&key].clone(); let percent = if total > 0 { ((f64::from(minutes) / f64::from(total)) * 100.0).round() as i32 } else { 0 }; let bar_percent = if max > 0 { ((f64::from(minutes) / f64::from(max)) * 100.0).round() as i32 } else { 0 }; TimeSummaryProject { name, total_minutes: minutes, percent, bar_percent, } }) .collect(); // Stable descending sort by minutes (matches the old JS `.sort((a,b) => b-a)`). projects.sort_by_key(|p| std::cmp::Reverse(p.total_minutes)); TimeSummaryPanel { today_minutes, projects, } } #[cfg(test)] mod tests { use super::*; fn make_session(started: DateTime, ended: Option>) -> TimeSession { TimeSession { id: TimeSessionId::new(), task_id: TaskId::new(), user_id: UserId::new(), started_at: started, ended_at: ended, duration_minutes: ended.map(|e| (e - started).num_minutes() as i32), created_at: started, } } #[test] fn is_active_when_no_end() { let session = make_session(Utc::now(), None); assert!(session.is_active()); } #[test] fn is_not_active_when_ended() { let start = Utc::now() - chrono::Duration::minutes(30); let end = Utc::now(); let session = make_session(start, Some(end)); assert!(!session.is_active()); } #[test] fn elapsed_minutes_for_ended_session() { let start = Utc::now() - chrono::Duration::minutes(45); let end = Utc::now(); let session = make_session(start, Some(end)); assert_eq!(session.elapsed_minutes(), 45); } #[test] fn elapsed_minutes_active_session_positive() { let start = Utc::now() - chrono::Duration::minutes(10); let session = make_session(start, None); assert!(session.elapsed_minutes() >= 9); } #[test] fn positive_minutes_accepts_positive_values() { assert_eq!(PositiveMinutes::try_new(1).unwrap().as_i32(), 1); assert_eq!(PositiveMinutes::try_new(30).unwrap().as_i32(), 30); } #[test] fn positive_minutes_rejects_zero_and_negatives() { assert!(PositiveMinutes::try_new(0).is_err()); assert!(PositiveMinutes::try_new(-1).is_err()); assert!(PositiveMinutes::try_new(-30).is_err()); } fn summary_row( project_id: Option, name: Option<&str>, date: &str, minutes: i32, sessions: i32, ) -> TimeTrackingSummary { TimeTrackingSummary { project_id, project_name: name.map(String::from), date: date.to_string(), total_minutes: minutes, session_count: sessions, } } #[test] fn roll_up_empty_yields_zeroed_empty_panel() { let panel = roll_up_time_summary(&[], "2026-07-04"); assert_eq!(panel.today_minutes, 0); assert!(panel.projects.is_empty()); } #[test] fn roll_up_aggregates_totals_percentages_and_sort_order() { let alpha = ProjectId::new(); let beta = ProjectId::new(); // Alpha: 60 + 30 across two days = 90. Beta: 30. Week total = 120. let rows = vec![ summary_row(Some(beta), Some("Beta"), "2026-07-01", 30, 1), summary_row(Some(alpha), Some("Alpha"), "2026-07-01", 60, 2), summary_row(Some(alpha), Some("Alpha"), "2026-07-04", 30, 1), ]; let panel = roll_up_time_summary(&rows, "2026-07-04"); // "Today" only counts the 2026-07-04 Alpha row. assert_eq!(panel.today_minutes, 30); // Two project buckets, sorted by minutes descending: Alpha (90), Beta (30). assert_eq!(panel.projects.len(), 2); assert_eq!(panel.projects[0].name, "Alpha"); assert_eq!(panel.projects[0].total_minutes, 90); assert_eq!(panel.projects[1].name, "Beta"); assert_eq!(panel.projects[1].total_minutes, 30); // Percent = share of the 120-minute week total: 75 + 25 = 100. assert_eq!(panel.projects[0].percent, 75); assert_eq!(panel.projects[1].percent, 25); let percent_sum: i32 = panel.projects.iter().map(|p| p.percent).sum(); assert!((99..=101).contains(&percent_sum), "percents sum to ~100: {percent_sum}"); // Bar percent is relative to the largest project (Alpha = 100). assert_eq!(panel.projects[0].bar_percent, 100); assert_eq!(panel.projects[1].bar_percent, 33); } #[test] fn roll_up_collapses_unassigned_into_no_project_bucket() { let rows = vec![ summary_row(None, None, "2026-07-02", 15, 1), summary_row(None, None, "2026-07-03", 45, 1), ]; let panel = roll_up_time_summary(&rows, "2026-07-04"); assert_eq!(panel.projects.len(), 1); assert_eq!(panel.projects[0].name, "No Project"); assert_eq!(panel.projects[0].total_minutes, 60); assert_eq!(panel.projects[0].percent, 100); assert_eq!(panel.projects[0].bar_percent, 100); assert_eq!(panel.today_minutes, 0); } }