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:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:21 -04:00
1 parent 3fdd635af0
commit a646337339
6 files changed
+425

No files matched your search

+37
View File
@@ -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<Date, SignedDuration> = 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 }
}
+75
View File
@@ -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<SignedDuration>,
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<SignedDuration>,
/// 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<SignedDuration> = 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,
}
}
+82
View File
@@ -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<Item = &WorkDay> {
days.iter().filter(move |day| range.contains(day.date()))
}
/// The middle value of a list (the lower middle for an even count).
fn median<T: Ord + Copy>(mut values: Vec<T>) -> Option<T> {
values.sort();
values.get(values.len().saturating_sub(1) / 2).copied()
}
+142
View File
@@ -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);
}
}
+88
View File
@@ -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<i8> {
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]);
}
}
+1
View File
@@ -4,6 +4,7 @@
//! then this only declares the modules written so far.
#![allow(dead_code)]
mod analysis;
mod domain;
mod time;