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]>
This commit is contained in:
1 parent
3fdd635af0
commit
a646337339
6 files changed
+425
No files matched your search
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user