Skip to main content

max / goingson

11.0 KB · 315 lines History Blame Raw
1 //! Time tracking session types.
2 //!
3 //! A `TimeSession` records a period of work on a task. Sessions are created
4 //! when a timer is started and closed when it's stopped. At most one session
5 //! per user can be active (ended_at IS NULL) at any time.
6
7 use std::num::NonZeroU32;
8
9 use chrono::{DateTime, Utc};
10 use serde::{Deserialize, Serialize};
11 use crate::error::CoreError;
12 use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId};
13
14 /// A validated, strictly-positive minute count for a manual time entry.
15 ///
16 /// Constructing this is the single gate that rejects zero/negative durations:
17 /// a negative value would make `ended_at < started_at` and drive a task's
18 /// cached `actual_minutes` negative, and zero records an empty session. Because
19 /// the repository takes `PositiveMinutes` (not a raw `i32`), that whole class is
20 /// unrepresentable below the command layer.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 pub struct PositiveMinutes(NonZeroU32);
23
24 impl PositiveMinutes {
25 /// Validate a raw minute count. Returns a `Validation` error for anything
26 /// less than 1 (including negatives, which fail the `u32` conversion) or
27 /// greater than a single day — a manual session is one work period, and an
28 /// unbounded value would inflate the task's cached `actual_minutes`
29 /// arbitrarily.
30 pub fn try_new(minutes: i32) -> crate::Result<Self> {
31 if minutes > crate::constants::MAX_SCHEDULED_DURATION_MINUTES {
32 return Err(CoreError::validation(
33 "minutes",
34 format!(
35 "Minutes must be at most {}",
36 crate::constants::MAX_SCHEDULED_DURATION_MINUTES
37 ),
38 ));
39 }
40 u32::try_from(minutes)
41 .ok()
42 .and_then(NonZeroU32::new)
43 .map(Self)
44 .ok_or_else(|| CoreError::validation("minutes", "Minutes must be at least 1"))
45 }
46
47 /// The validated count as an `i32` for binding into SQL.
48 pub fn as_i32(self) -> i32 {
49 self.0.get() as i32
50 }
51 }
52
53 /// A single time tracking session on a task.
54 #[derive(Debug, Clone, Serialize, Deserialize)]
55 #[serde(rename_all = "camelCase")]
56 pub struct TimeSession {
57 pub id: TimeSessionId,
58 pub task_id: TaskId,
59 pub user_id: UserId,
60 pub started_at: DateTime<Utc>,
61 pub ended_at: Option<DateTime<Utc>>,
62 pub duration_minutes: Option<i32>,
63 pub created_at: DateTime<Utc>,
64 }
65
66 impl TimeSession {
67 /// Returns true if this session is still running (no end time).
68 pub fn is_active(&self) -> bool {
69 self.ended_at.is_none()
70 }
71
72 /// Returns elapsed minutes from start to now (if active) or to ended_at.
73 pub fn elapsed_minutes(&self) -> i32 {
74 let end = self.ended_at.unwrap_or_else(Utc::now);
75 let diff = end.signed_duration_since(self.started_at);
76 diff.num_minutes().max(0) as i32
77 }
78 }
79
80 /// Aggregated time tracking data grouped by project and date.
81 #[derive(Debug, Clone, Serialize, Deserialize)]
82 #[serde(rename_all = "camelCase")]
83 pub struct TimeTrackingSummary {
84 pub project_id: Option<ProjectId>,
85 pub project_name: Option<String>,
86 pub date: String,
87 pub total_minutes: i32,
88 pub session_count: i32,
89 }
90
91 /// One project's row in the Day view's weekly time-summary panel.
92 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93 #[serde(rename_all = "camelCase")]
94 pub struct TimeSummaryProject {
95 /// Display label; the "No Project" bucket for sessions on unassigned tasks.
96 pub name: String,
97 pub total_minutes: i32,
98 /// Share of the week's total tracked time, rounded to a whole percent.
99 /// Rows sum to ~100 (rounding aside).
100 pub percent: i32,
101 /// Bar-fill width relative to the largest project, 0-100. The top project is
102 /// always 100; this drives the CSS bar chart.
103 pub bar_percent: i32,
104 }
105
106 /// Pre-computed time-summary panel for the Day view: today's tracked total plus
107 /// the week's per-project breakdown, already aggregated and sorted so the
108 /// frontend only renders.
109 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110 #[serde(rename_all = "camelCase")]
111 pub struct TimeSummaryPanel {
112 pub today_minutes: i32,
113 pub projects: Vec<TimeSummaryProject>,
114 }
115
116 /// Roll up per-project/per-date summary rows into the Day view's time-summary
117 /// panel: today's tracked total and the week's per-project breakdown, sorted by
118 /// minutes descending (ties keep first-seen order, matching the old JS Map +
119 /// stable sort). `today` is a `YYYY-MM-DD` string matched against each row's
120 /// `date`. Sessions on unassigned tasks collapse into a single "No Project"
121 /// bucket. Pure and total: empty input yields a zeroed, empty panel.
122 pub fn roll_up_time_summary(rows: &[TimeTrackingSummary], today: &str) -> TimeSummaryPanel {
123 use std::collections::HashMap;
124
125 let today_minutes = rows
126 .iter()
127 .filter(|r| r.date == today)
128 .map(|r| r.total_minutes)
129 .sum();
130
131 // Aggregate across the week by project. A `None` project id collapses to one
132 // "No Project" bucket. `order` preserves first-seen order so the later stable
133 // sort reproduces the old JS tie-breaking.
134 let mut order: Vec<Option<ProjectId>> = Vec::new();
135 let mut acc: HashMap<Option<ProjectId>, (String, i32)> = HashMap::new();
136 for r in rows {
137 let entry = acc.entry(r.project_id).or_insert_with(|| {
138 order.push(r.project_id);
139 let name = r
140 .project_name
141 .clone()
142 .unwrap_or_else(|| "No Project".to_string());
143 (name, 0)
144 });
145 entry.1 += r.total_minutes;
146 }
147
148 let total: i32 = acc.values().map(|(_, m)| *m).sum();
149 let max: i32 = acc.values().map(|(_, m)| *m).max().unwrap_or(0);
150
151 let mut projects: Vec<TimeSummaryProject> = order
152 .into_iter()
153 .map(|key| {
154 let (name, minutes) = acc[&key].clone();
155 let percent = if total > 0 {
156 ((f64::from(minutes) / f64::from(total)) * 100.0).round() as i32
157 } else {
158 0
159 };
160 let bar_percent = if max > 0 {
161 ((f64::from(minutes) / f64::from(max)) * 100.0).round() as i32
162 } else {
163 0
164 };
165 TimeSummaryProject {
166 name,
167 total_minutes: minutes,
168 percent,
169 bar_percent,
170 }
171 })
172 .collect();
173
174 // Stable descending sort by minutes (matches the old JS `.sort((a,b) => b-a)`).
175 projects.sort_by_key(|p| std::cmp::Reverse(p.total_minutes));
176
177 TimeSummaryPanel {
178 today_minutes,
179 projects,
180 }
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186
187 fn make_session(started: DateTime<Utc>, ended: Option<DateTime<Utc>>) -> TimeSession {
188 TimeSession {
189 id: TimeSessionId::new(),
190 task_id: TaskId::new(),
191 user_id: UserId::new(),
192 started_at: started,
193 ended_at: ended,
194 duration_minutes: ended.map(|e| (e - started).num_minutes() as i32),
195 created_at: started,
196 }
197 }
198
199 #[test]
200 fn is_active_when_no_end() {
201 let session = make_session(Utc::now(), None);
202 assert!(session.is_active());
203 }
204
205 #[test]
206 fn is_not_active_when_ended() {
207 let start = Utc::now() - chrono::Duration::minutes(30);
208 let end = Utc::now();
209 let session = make_session(start, Some(end));
210 assert!(!session.is_active());
211 }
212
213 #[test]
214 fn elapsed_minutes_for_ended_session() {
215 let start = Utc::now() - chrono::Duration::minutes(45);
216 let end = Utc::now();
217 let session = make_session(start, Some(end));
218 assert_eq!(session.elapsed_minutes(), 45);
219 }
220
221 #[test]
222 fn elapsed_minutes_active_session_positive() {
223 let start = Utc::now() - chrono::Duration::minutes(10);
224 let session = make_session(start, None);
225 assert!(session.elapsed_minutes() >= 9);
226 }
227
228 #[test]
229 fn positive_minutes_accepts_positive_values() {
230 assert_eq!(PositiveMinutes::try_new(1).unwrap().as_i32(), 1);
231 assert_eq!(PositiveMinutes::try_new(30).unwrap().as_i32(), 30);
232 }
233
234 #[test]
235 fn positive_minutes_rejects_zero_and_negatives() {
236 assert!(PositiveMinutes::try_new(0).is_err());
237 assert!(PositiveMinutes::try_new(-1).is_err());
238 assert!(PositiveMinutes::try_new(-30).is_err());
239 }
240
241 fn summary_row(
242 project_id: Option<ProjectId>,
243 name: Option<&str>,
244 date: &str,
245 minutes: i32,
246 sessions: i32,
247 ) -> TimeTrackingSummary {
248 TimeTrackingSummary {
249 project_id,
250 project_name: name.map(String::from),
251 date: date.to_string(),
252 total_minutes: minutes,
253 session_count: sessions,
254 }
255 }
256
257 #[test]
258 fn roll_up_empty_yields_zeroed_empty_panel() {
259 let panel = roll_up_time_summary(&[], "2026-07-04");
260 assert_eq!(panel.today_minutes, 0);
261 assert!(panel.projects.is_empty());
262 }
263
264 #[test]
265 fn roll_up_aggregates_totals_percentages_and_sort_order() {
266 let alpha = ProjectId::new();
267 let beta = ProjectId::new();
268 // Alpha: 60 + 30 across two days = 90. Beta: 30. Week total = 120.
269 let rows = vec![
270 summary_row(Some(beta), Some("Beta"), "2026-07-01", 30, 1),
271 summary_row(Some(alpha), Some("Alpha"), "2026-07-01", 60, 2),
272 summary_row(Some(alpha), Some("Alpha"), "2026-07-04", 30, 1),
273 ];
274
275 let panel = roll_up_time_summary(&rows, "2026-07-04");
276
277 // "Today" only counts the 2026-07-04 Alpha row.
278 assert_eq!(panel.today_minutes, 30);
279
280 // Two project buckets, sorted by minutes descending: Alpha (90), Beta (30).
281 assert_eq!(panel.projects.len(), 2);
282 assert_eq!(panel.projects[0].name, "Alpha");
283 assert_eq!(panel.projects[0].total_minutes, 90);
284 assert_eq!(panel.projects[1].name, "Beta");
285 assert_eq!(panel.projects[1].total_minutes, 30);
286
287 // Percent = share of the 120-minute week total: 75 + 25 = 100.
288 assert_eq!(panel.projects[0].percent, 75);
289 assert_eq!(panel.projects[1].percent, 25);
290 let percent_sum: i32 = panel.projects.iter().map(|p| p.percent).sum();
291 assert!((99..=101).contains(&percent_sum), "percents sum to ~100: {percent_sum}");
292
293 // Bar percent is relative to the largest project (Alpha = 100).
294 assert_eq!(panel.projects[0].bar_percent, 100);
295 assert_eq!(panel.projects[1].bar_percent, 33);
296 }
297
298 #[test]
299 fn roll_up_collapses_unassigned_into_no_project_bucket() {
300 let rows = vec![
301 summary_row(None, None, "2026-07-02", 15, 1),
302 summary_row(None, None, "2026-07-03", 45, 1),
303 ];
304
305 let panel = roll_up_time_summary(&rows, "2026-07-04");
306
307 assert_eq!(panel.projects.len(), 1);
308 assert_eq!(panel.projects[0].name, "No Project");
309 assert_eq!(panel.projects[0].total_minutes, 60);
310 assert_eq!(panel.projects[0].percent, 100);
311 assert_eq!(panel.projects[0].bar_percent, 100);
312 assert_eq!(panel.today_minutes, 0);
313 }
314 }
315