diff --git a/src/analysis/calendar.rs b/src/analysis/calendar.rs new file mode 100644 index 0000000..4ea7988 --- /dev/null +++ b/src/analysis/calendar.rs @@ -0,0 +1,37 @@ +//! Time worked per date, laid out like a wall calendar: one column per week, +//! one row per weekday. The Insights screen colors each cell by hours. + +use std::collections::HashMap; + +use jiff::civil::{Date, Weekday}; +use jiff::{SignedDuration, ToSpan}; + +use crate::domain::WorkDay; +use crate::time::DateRange; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalendarHeat { + /// One entry per week, oldest first, each holding its 7 dates starting on + /// the week start. A cell is `None` for dates after today. + pub weeks: Vec<[Option<(Date, SignedDuration)>; 7]>, +} + +/// The last `week_count` weeks, ending with the week of `today`. +pub fn calendar(days: &[WorkDay], today: Date, week_start: Weekday, week_count: usize) -> CalendarHeat { + let worked: HashMap = days.iter().map(|day| (day.date(), day.worked())).collect(); + let this_week = DateRange::week_of(today, week_start); + + let weeks = (0..week_count) + .rev() + .map(|weeks_ago| { + let first = this_week.first().saturating_sub((7 * weeks_ago as i64).days()); + std::array::from_fn(|offset| { + let date = first.saturating_add((offset as i64).days()); + let time = worked.get(&date).copied().unwrap_or(SignedDuration::ZERO); + (date <= today).then_some((date, time)) + }) + }) + .collect(); + + CalendarHeat { weeks } +} diff --git a/src/analysis/focus.rs b/src/analysis/focus.rs new file mode 100644 index 0000000..9e66bbe --- /dev/null +++ b/src/analysis/focus.rs @@ -0,0 +1,75 @@ +//! How long you stay on, and how you take breaks. +//! +//! A "session" is one segment: from pressing Start to pressing Pause. + +use jiff::SignedDuration; +use jiff::civil::Date; +use jiff::tz::TimeZone; + +use super::{days_in, median}; +use crate::domain::WorkDay; +use crate::time::DateRange; + +/// Session lengths are counted in these buckets: (label, upper limit in minutes). +pub const SESSION_BUCKETS: [(&str, i64); 6] = + [("<15m", 15), ("15-30m", 30), ("30m-1h", 60), ("1-2h", 120), ("2-3h", 180), ("3h+", i64::MAX)]; + +#[derive(Debug, Clone, PartialEq)] +pub struct Focus { + pub sessions: usize, + /// How many sessions fall in each of [`SESSION_BUCKETS`]. + pub session_counts: [usize; SESSION_BUCKETS.len()], + pub median_session: Option, + pub longest_session: Option<(Date, SignedDuration)>, + /// Sessions that lasted at least the "long session" setting: long + /// stretches without a break (hyperfocus). + pub long_sessions: usize, + pub days_worked: usize, + pub breaks: usize, + pub median_break: Option, + /// Work days where work went on past midnight. + pub past_midnight: usize, +} + +impl Focus { + pub fn sessions_per_day(&self) -> f64 { + if self.days_worked == 0 { 0.0 } else { self.sessions as f64 / self.days_worked as f64 } + } +} + +pub fn focus(days: &[WorkDay], range: DateRange, zone: &TimeZone, long_session: SignedDuration) -> Focus { + let worked_days: Vec<&WorkDay> = days_in(days, range).filter(|day| !day.is_empty()).collect(); + + let sessions: Vec<(Date, SignedDuration)> = worked_days + .iter() + .flat_map(|day| day.segments().iter().map(|segment| (day.date(), segment.duration()))) + .collect(); + let breaks: Vec = worked_days + .iter() + .flat_map(|day| day.breaks().map(|(end, next_start)| next_start.duration_since(end))) + .collect(); + + let mut session_counts = [0; SESSION_BUCKETS.len()]; + for (_, length) in &sessions { + let minutes = length.as_mins(); + let bucket = SESSION_BUCKETS.iter().position(|(_, limit)| minutes < *limit).unwrap_or(0); + session_counts[bucket] += 1; + } + + let past_midnight = worked_days + .iter() + .filter(|day| day.last_end().is_some_and(|end| end.to_zoned(zone.clone()).date() > day.date())) + .count(); + + Focus { + sessions: sessions.len(), + session_counts, + median_session: median(sessions.iter().map(|(_, length)| *length).collect()), + longest_session: sessions.iter().copied().max_by_key(|(_, length)| *length), + long_sessions: sessions.iter().filter(|(_, length)| *length >= long_session).count(), + days_worked: worked_days.len(), + breaks: breaks.len(), + median_break: median(breaks), + past_midnight, + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..911c8ed --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,82 @@ +//! Numbers about your work, computed from the work days. +//! +//! Every analysis takes the list of work days (from `Journal::snapshot`, so +//! the session in progress counts too) and returns plain data. The Insights +//! screen draws that data; nothing in here knows about screens. +//! +//! - [`overview`]: week and month totals against your targets, balance, trends. +//! - [`calendar`]: time worked per date, for the calendar heatmap. +//! - [`rhythm`]: when you work, by weekday and hour of the day. +//! - [`focus`]: how long your sessions and breaks last. + +pub mod calendar; +pub mod focus; +pub mod overview; +pub mod rhythm; + +use jiff::ToSpan; +use jiff::civil::Date; + +use crate::domain::WorkDay; +use crate::time::DateRange; + +/// How far back the Insights screen looks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Period { + Weeks4, + #[default] + Weeks12, + Months6, + Year, + AllTime, +} + +impl Period { + pub const ALL: [Period; 5] = + [Period::Weeks4, Period::Weeks12, Period::Months6, Period::Year, Period::AllTime]; + + pub fn label(self) -> &'static str { + match self { + Period::Weeks4 => "4 weeks", + Period::Weeks12 => "12 weeks", + Period::Months6 => "6 months", + Period::Year => "1 year", + Period::AllTime => "all time", + } + } + + /// The dates covered, ending today. "All time" starts at the first work day. + pub fn range(self, today: Date, days: &[WorkDay]) -> DateRange { + let first = match self { + Period::Weeks4 => today.saturating_sub(27.days()), + Period::Weeks12 => today.saturating_sub(83.days()), + Period::Months6 => today.saturating_sub(6.months()).saturating_add(1.day()), + Period::Year => today.saturating_sub(1.year()).saturating_add(1.day()), + Period::AllTime => days.first().map_or(today, WorkDay::date), + }; + DateRange::new(first, today) + } + + /// One step longer (all time stays all time). + pub fn longer(self) -> Period { + let index = Period::ALL.iter().position(|p| *p == self).unwrap_or(0); + Period::ALL[(index + 1).min(Period::ALL.len() - 1)] + } + + /// One step shorter (4 weeks stays 4 weeks). + pub fn shorter(self) -> Period { + let index = Period::ALL.iter().position(|p| *p == self).unwrap_or(0); + Period::ALL[index.saturating_sub(1)] + } +} + +/// The work days whose date falls in `range`. +pub fn days_in(days: &[WorkDay], range: DateRange) -> impl Iterator { + days.iter().filter(move |day| range.contains(day.date())) +} + +/// The middle value of a list (the lower middle for an even count). +fn median(mut values: Vec) -> Option { + values.sort(); + values.get(values.len().saturating_sub(1) / 2).copied() +} diff --git a/src/analysis/overview.rs b/src/analysis/overview.rs new file mode 100644 index 0000000..488cdaf --- /dev/null +++ b/src/analysis/overview.rs @@ -0,0 +1,142 @@ +//! 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); + } +} diff --git a/src/analysis/rhythm.rs b/src/analysis/rhythm.rs new file mode 100644 index 0000000..1c0ca7a --- /dev/null +++ b/src/analysis/rhythm.rs @@ -0,0 +1,88 @@ +//! When you work: time worked in each hour of each weekday. +//! +//! Segments are cut at every hour boundary on the clock, so a shift from +//! 22:30 to 01:00 adds 30 minutes to 22h and 60 minutes to 23h on its first +//! weekday, and 60 minutes to 00h on the next one. + +use jiff::civil::Weekday; +use jiff::tz::TimeZone; +use jiff::{RoundMode, SignedDuration, Unit, ZonedRound}; + +use super::days_in; +use crate::domain::WorkDay; +use crate::time::DateRange; + +#[derive(Debug, Clone, PartialEq)] +pub struct Rhythm { + /// The weekday of each row, starting with the week start. + pub weekdays: [Weekday; 7], + /// Seconds worked: `seconds[row][hour]`. + pub seconds: [[i64; 24]; 7], + /// How many weeks the data covers (to turn totals into averages). + pub weeks: f64, +} + +impl Rhythm { + /// The busiest hours of the day (all weekdays together), busiest first. + pub fn peak_hours(&self, count: usize) -> Vec { + let mut hours: Vec<(i8, i64)> = (0..24i8) + .map(|hour| (hour, self.seconds.iter().map(|row| row[hour as usize]).sum())) + .filter(|(_, seconds)| *seconds > 0) + .collect(); + hours.sort_by_key(|(hour, seconds)| (std::cmp::Reverse(*seconds), *hour)); + hours.into_iter().take(count).map(|(hour, _)| hour).collect() + } + + /// The largest cell, to scale colors against. + pub fn busiest_cell(&self) -> i64 { + self.seconds.iter().flatten().copied().max().unwrap_or(0) + } +} + +pub fn rhythm(days: &[WorkDay], range: DateRange, week_start: Weekday, zone: &TimeZone) -> Rhythm { + let weekdays: [Weekday; 7] = std::array::from_fn(|offset| week_start.wrapping_add(offset as i64)); + let mut seconds = [[0i64; 24]; 7]; + + for segment in days_in(days, range).flat_map(|day| day.segments()) { + let mut cursor = segment.start().to_zoned(zone.clone()); + let end = segment.end(); + while cursor.timestamp() < end { + let hour_start = cursor + .round(ZonedRound::new().smallest(Unit::Hour).mode(RoundMode::Trunc)) + .unwrap_or_else(|_| cursor.clone()); + let next_hour = hour_start.timestamp() + SignedDuration::from_hours(1); + let piece_end = next_hour.min(end); + + let row = cursor.weekday().since(week_start) as usize; + seconds[row][cursor.hour() as usize] += piece_end.duration_since(cursor.timestamp()).as_secs(); + cursor = piece_end.to_zoned(zone.clone()); + } + } + + Rhythm { weekdays, seconds, weeks: f64::from(range.day_count()) / 7.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Journal, Segment, ShiftState}; + use jiff::civil::date; + + #[test] + fn night_shifts_split_across_hours_and_weekdays() { + let zone = TimeZone::UTC; + let start = date(2026, 9, 24).at(22, 30, 0, 0).to_zoned(zone.clone()).unwrap(); // Thursday + let end = date(2026, 9, 25).at(1, 0, 0, 0).to_zoned(zone.clone()).unwrap(); // Friday + let segment = Segment::new(start.timestamp(), end.timestamp()).unwrap(); + let journal = + Journal::from_parts(vec![(date(2026, 9, 24), vec![segment])], ShiftState::Idle).unwrap(); + let days = journal.snapshot(end.timestamp()); + + let result = rhythm(&days, DateRange::last_days(date(2026, 9, 25), 7), Weekday::Monday, &zone); + let (thursday, friday) = (3, 4); + assert_eq!(result.seconds[thursday][22], 30 * 60); + assert_eq!(result.seconds[thursday][23], 60 * 60); + assert_eq!(result.seconds[friday][0], 60 * 60); + assert_eq!(result.peak_hours(1), vec![0]); + } +} diff --git a/src/main.rs b/src/main.rs index 0b0825b..0b7f45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! then this only declares the modules written so far. #![allow(dead_code)] +mod analysis; mod domain; mod time;