//! The big picture: totals against your targets, balance, and trends. use jiff::civil::{Date, Time, Weekday}; use jiff::tz::TimeZone; use jiff::{SignedDuration, Timestamp, ToSpan}; use super::days_in; use crate::domain::WorkDay; use crate::time::DateRange; use crate::time::resolve::on_date; /// Time worked in some range of dates. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Totals { pub worked: SignedDuration, pub days_worked: usize, } impl Totals { fn of<'a>(days: impl Iterator) -> Totals { days.filter(|day| !day.is_empty()).fold(Totals::default(), |totals, day| Totals { worked: totals.worked + day.worked(), days_worked: totals.days_worked + 1, }) } /// Average time per day worked (not per calendar day). pub fn average_day(&self) -> Option { let days = i32::try_from(self.days_worked).ok().filter(|days| *days > 0)?; Some(self.worked / days) } } /// When a work day started and ended, in minutes after midnight of its date. /// A night shift ending at 04:10 the next morning ends at minute 1690 (28:10). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DaySpan { pub date: Date, pub start_minute: i64, pub end_minute: i64, } /// What the overview needs from the settings. #[derive(Debug, Clone, Copy)] pub struct Targets { pub daily: SignedDuration, pub week_start: Weekday, } #[derive(Debug, Clone, PartialEq)] pub struct Overview { pub this_week: Totals, pub last_week: Totals, pub this_month: Totals, /// Everything in the selected period. pub period: Totals, /// Over the selected period: for each day worked, time worked minus the /// daily target, added up. Positive means you worked more than planned. pub balance: SignedDuration, /// Time worked per week, oldest first, ending with this week: /// (first date of the week, total). pub weeks: Vec<(Date, SignedDuration)>, /// First start and last end of every day worked in the period. pub spans: Vec, } pub fn overview( days: &[WorkDay], today: Date, period: DateRange, targets: Targets, zone: &TimeZone, week_count: usize, ) -> Overview { let this_week = DateRange::week_of(today, targets.week_start); let period_totals = Totals::of(days_in(days, period)); let balance = days_in(days, period).filter(|day| !day.is_empty()).map(|day| day.worked() - targets.daily).sum(); let weeks = (0..week_count) .rev() .map(|weeks_ago| { let week_start = this_week.first().saturating_sub((7 * weeks_ago as i64).days()); let week = DateRange::week_of(week_start, targets.week_start); (week.first(), Totals::of(days_in(days, week)).worked) }) .collect(); let spans = days_in(days, period).filter_map(|day| day_span(day, zone)).collect(); Overview { this_week: Totals::of(days_in(days, this_week)), last_week: Totals::of(days_in(days, this_week.previous())), this_month: Totals::of(days_in(days, DateRange::month_of(today))), period: period_totals, balance, weeks, spans, } } fn day_span(day: &WorkDay, zone: &TimeZone) -> Option { let midnight = on_date(day.date(), Time::midnight(), zone).timestamp(); let minutes_after_midnight = |instant: Timestamp| instant.duration_since(midnight).as_mins(); Some(DaySpan { date: day.date(), start_minute: minutes_after_midnight(day.first_start()?), end_minute: minutes_after_midnight(day.last_end()?), }) } #[cfg(test)] mod tests { use super::*; use crate::domain::{Journal, ShiftState}; use jiff::Zoned; use jiff::civil::date; fn at(day: i8, hour: i8) -> Zoned { date(2026, 9, day).at(hour, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap() } #[test] fn night_shift_spans_go_past_24h() { let segment = crate::domain::Segment::new(at(24, 22).timestamp(), at(25, 4).timestamp()).unwrap(); let journal = Journal::from_parts(vec![(date(2026, 9, 24), vec![segment])], ShiftState::Idle).unwrap(); let days = journal.snapshot(at(26, 12).timestamp()); let targets = Targets { daily: SignedDuration::from_hours(8), week_start: Weekday::Monday }; let today = date(2026, 9, 26); let result = overview(&days, today, DateRange::last_days(today, 7), targets, &TimeZone::UTC, 4); assert_eq!( result.spans, vec![DaySpan { date: date(2026, 9, 24), start_minute: 22 * 60, end_minute: 28 * 60 }] ); assert_eq!(result.this_week.worked, SignedDuration::from_hours(6)); assert_eq!(result.balance, SignedDuration::from_hours(-2)); assert_eq!(result.weeks.len(), 4); } }