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,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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! then this only declares the modules written so far.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod analysis;
|
||||
mod domain;
|
||||
mod time;
|
||||
|
||||
|
||||
Reference in new issue
Block a user