Files
skyshift/src/analysis/overview.rs
T
skkeyeandClaude Opus 5.5 a646337339 Add analysis for the Insights views
Plain data computed from the work days, nothing about drawing:

- overview: week/month totals, balance against the daily target, weekly
  totals, and when days start and end (past 24h for night shifts).
- calendar: time worked per date, as weeks x weekdays.
- rhythm: time worked per weekday and hour, split at hour boundaries.
- focus: session lengths, long sessions (hyperfocus), breaks, days that
  went past midnight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:21 -04:00

143 lines
4.8 KiB
Rust

//! 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<Item = &'a WorkDay>) -> 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<SignedDuration> {
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<DaySpan>,
}
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<DaySpan> {
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);
}
}