diff --git a/src/domain/journal.rs b/src/domain/journal.rs new file mode 100644 index 0000000..a49a538 --- /dev/null +++ b/src/domain/journal.rs @@ -0,0 +1,436 @@ +//! The journal: every work day, plus what's happening right now. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; + +use jiff::civil::Date; +use jiff::{SignedDuration, Timestamp, Zoned}; + +use super::{DomainError, Segment, WorkDay}; + +/// What you're doing right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShiftState { + /// No work day is open. The next Start opens one. + #[default] + Idle, + /// A work day is open, but you're on a break. + OnBreak { day: Date }, + /// You're working. The session in progress started at `since` and + /// belongs to the work day `day`. + Working { day: Date, since: Timestamp }, +} + +impl ShiftState { + /// The open work day, if there is one. + pub fn open_day(self) -> Option { + match self { + ShiftState::Idle => None, + ShiftState::OnBreak { day } | ShiftState::Working { day, .. } => Some(day), + } + } + + pub fn is_working(self) -> bool { + matches!(self, ShiftState::Working { .. }) + } +} + +/// What pressing Start did, so the app can tell you. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartOutcome { + /// Opened a new work day. + Opened(Date), + /// Reopened a work day you had ended earlier (same date). + Reopened(Date), + /// Back from a break, same work day. + Resumed(Date), + /// The break lasted longer than the "new day after" setting, so the old + /// work day was closed and a new one opened. You probably forgot to + /// press End day. + NewDayAfterLongBreak { closed: Date, opened: Date }, +} + +impl StartOutcome { + /// The work day you're now working on. + pub fn day(self) -> Date { + match self { + StartOutcome::Opened(day) | StartOutcome::Reopened(day) | StartOutcome::Resumed(day) => day, + StartOutcome::NewDayAfterLongBreak { opened, .. } => opened, + } + } +} + +/// Every work day, plus what's happening right now. +/// +/// All changes go through methods that check the rules, so a `Journal` is +/// always valid: +/// - segments never overlap, not even across different work days; +/// - nothing is recorded after the session in progress started; +/// - the open work day always exists. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Journal { + days: BTreeMap, + state: ShiftState, +} + +impl Journal { + /// Rebuilds a journal from stored data, checking every rule. + pub fn from_parts(days: Vec<(Date, Vec)>, state: ShiftState) -> Result { + let mut journal = Journal::default(); + for (date, segments) in days { + if journal.days.contains_key(&date) { + return Err(DomainError::DuplicateDay(date)); + } + let mut day = WorkDay::new(date); + for segment in segments { + day.insert(segment); + } + journal.days.insert(date, day); + } + journal.check_no_overlaps()?; + + if let Some(open_day) = state.open_day() { + journal.day_entry(open_day); + } + if let ShiftState::Working { since, .. } = state + && journal.latest_end().is_some_and(|end| end > since) + { + return Err(DomainError::OverlapsRunning); + } + journal.state = state; + Ok(journal) + } + + pub fn state(&self) -> ShiftState { + self.state + } + + pub fn day(&self, date: Date) -> Option<&WorkDay> { + self.days.get(&date) + } + + /// Every work day, oldest first. + pub fn days(&self) -> impl DoubleEndedIterator { + self.days.values() + } + + /// How long the session in progress has lasted (zero when not working). + pub fn running_for(&self, now: Timestamp) -> SignedDuration { + match self.state { + ShiftState::Working { since, .. } => now.duration_since(since).max(SignedDuration::ZERO), + _ => SignedDuration::ZERO, + } + } + + /// How long the current break has lasted, when you're on one. + pub fn on_break_for(&self, now: Timestamp) -> Option { + let ShiftState::OnBreak { day } = self.state else { return None }; + let last_end = self.days.get(&day)?.last_end()?; + Some(now.duration_since(last_end)) + } + + /// Starts working at `now` (or comes back from a break). + /// + /// - With no open work day, opens one for today's date (or reopens it, + /// if you already ended a work day with today's date). + /// - On a break, resumes the open work day. Safety net: if the break + /// lasted at least `new_day_after`, you probably forgot End day, so the + /// old day is closed and one is opened for today instead. A zero + /// `new_day_after` turns the safety net off. + pub fn start(&mut self, now: &Zoned, new_day_after: SignedDuration) -> Result { + let at = now.timestamp(); + if self.state.is_working() { + return Err(DomainError::AlreadyWorking); + } + if self.latest_end().is_some_and(|end| end > at) { + // Something is recorded later than "now": the clock moved back, + // or the data file was edited by hand. + return Err(DomainError::InFuture); + } + + let today = now.date(); + let outcome = match self.state { + ShiftState::OnBreak { day } + if day != today && self.break_is_longer_than(day, at, new_day_after) => + { + self.remove_if_empty(day); + StartOutcome::NewDayAfterLongBreak { closed: day, opened: self.open(today).day() } + } + ShiftState::OnBreak { day } => StartOutcome::Resumed(day), + _ => self.open(today), + }; + self.state = ShiftState::Working { day: outcome.day(), since: at }; + Ok(outcome) + } + + /// Stops working at `now` and records the finished segment. Returns + /// `None` if the session lasted no time at all (nothing is recorded). + pub fn pause(&mut self, now: Timestamp) -> Result, DomainError> { + let ShiftState::Working { day, since } = self.state else { + return Err(DomainError::NotWorking); + }; + if now < since { + return Err(DomainError::EndNotAfterStart); + } + let segment = Segment::new(since, now).ok(); + if let Some(segment) = segment { + self.day_entry(day).insert(segment); + } + self.state = ShiftState::OnBreak { day }; + Ok(segment) + } + + /// Closes the open work day, stopping first if you're working. + pub fn end_day(&mut self, now: Timestamp) -> Result { + let day = self.state.open_day().ok_or(DomainError::NoOpenDay)?; + if self.state.is_working() { + self.pause(now)?; + } + self.state = ShiftState::Idle; + self.remove_if_empty(day); + Ok(day) + } + + /// Moves the start of the session in progress ("I actually started 10 + /// minutes ago"). + pub fn set_running_start(&mut self, since: Timestamp, now: Timestamp) -> Result<(), DomainError> { + let ShiftState::Working { day, .. } = self.state else { + return Err(DomainError::NotWorking); + }; + if since > now { + return Err(DomainError::InFuture); + } + if let Some(conflict) = self.find_overlap(since, now, None) { + return Err(DomainError::Overlaps(conflict)); + } + self.state = ShiftState::Working { day, since }; + Ok(()) + } + + /// Records a segment you forgot to track, under the work day `date`. + pub fn add_segment(&mut self, date: Date, segment: Segment, now: Timestamp) -> Result<(), DomainError> { + self.check_fits(segment, None, now)?; + self.day_entry(date).insert(segment); + Ok(()) + } + + /// Replaces segment number `index` of the work day `date`. The new + /// segment is filed under `new_date`, which can be a different work day. + pub fn replace_segment( + &mut self, + date: Date, + index: usize, + segment: Segment, + new_date: Date, + now: Timestamp, + ) -> Result<(), DomainError> { + if self.days.get(&date).and_then(|day| day.segments().get(index)).is_none() { + return Err(DomainError::NoSuchSegment); + } + self.check_fits(segment, Some((date, index)), now)?; + self.remove_segment(date, index)?; + self.day_entry(new_date).insert(segment); + Ok(()) + } + + /// Deletes segment number `index` of the work day `date`. + pub fn remove_segment(&mut self, date: Date, index: usize) -> Result { + let segment = + self.days.get_mut(&date).and_then(|day| day.remove(index)).ok_or(DomainError::NoSuchSegment)?; + self.remove_if_empty(date); + Ok(segment) + } + + /// Every work day as it stands at `now`, oldest first, with the session + /// in progress included as a segment that ends at `now`. + /// + /// Analysis, export and the screens use this, so they never have to + /// think about the running session separately. + pub fn snapshot(&self, now: Timestamp) -> Vec { + let mut days: Vec = self.days.values().cloned().collect(); + if let ShiftState::Working { day, since } = self.state + && let Ok(running) = Segment::new(since, now) + && let Some(work_day) = days.iter_mut().find(|work_day| work_day.date() == day) + { + work_day.insert(running); + } + days + } + + // ----- Helpers ------------------------------------------------------- + + /// The work day for `date`, created if it doesn't exist yet. + fn day_entry(&mut self, date: Date) -> &mut WorkDay { + self.days.entry(date).or_insert_with(|| WorkDay::new(date)) + } + + /// Makes sure a work day exists for `date`, and says whether it's new. + fn open(&mut self, date: Date) -> StartOutcome { + match self.days.entry(date) { + Entry::Occupied(_) => StartOutcome::Reopened(date), + Entry::Vacant(slot) => { + slot.insert(WorkDay::new(date)); + StartOutcome::Opened(date) + } + } + } + + /// Drops a work day that ended up with no segments, unless it's open. + fn remove_if_empty(&mut self, date: Date) { + let is_open = self.state.open_day() == Some(date); + if !is_open && self.days.get(&date).is_some_and(WorkDay::is_empty) { + self.days.remove(&date); + } + } + + fn break_is_longer_than(&self, day: Date, now: Timestamp, limit: SignedDuration) -> bool { + let safety_net_on = limit > SignedDuration::ZERO; + let last_end = self.days.get(&day).and_then(WorkDay::last_end); + safety_net_on && last_end.is_some_and(|end| now.duration_since(end) >= limit) + } + + /// The end of the latest recorded segment. + fn latest_end(&self) -> Option { + self.days.values().filter_map(WorkDay::last_end).max() + } + + /// The work day of a recorded segment sharing time with `start..end`, + /// skipping the segment `ignore`. + fn find_overlap(&self, start: Timestamp, end: Timestamp, ignore: Option<(Date, usize)>) -> Option { + self.days.values().find_map(|day| { + let clashes = day.segments().iter().enumerate().any(|(index, other)| { + ignore != Some((day.date(), index)) && start < other.end() && other.start() < end + }); + clashes.then_some(day.date()) + }) + } + + /// Checks that `segment` can be recorded: not in the future, not + /// overlapping another segment (except the one it replaces) and not + /// overlapping the session in progress. + fn check_fits( + &self, + segment: Segment, + replacing: Option<(Date, usize)>, + now: Timestamp, + ) -> Result<(), DomainError> { + if segment.end() > now { + return Err(DomainError::InFuture); + } + if let ShiftState::Working { since, .. } = self.state + && segment.end() > since + { + return Err(DomainError::OverlapsRunning); + } + match self.find_overlap(segment.start(), segment.end(), replacing) { + Some(day) => Err(DomainError::Overlaps(day)), + None => Ok(()), + } + } + + /// No two segments may overlap, across all days. Sorting every segment by + /// start means only neighbors need comparing. + fn check_no_overlaps(&self) -> Result<(), DomainError> { + let mut all: Vec<(Segment, Date)> = self + .days + .values() + .flat_map(|day| day.segments().iter().map(move |segment| (*segment, day.date()))) + .collect(); + all.sort_by_key(|(segment, _)| segment.start()); + match all.windows(2).find(|pair| pair[0].0.overlaps(&pair[1].0)) { + Some(pair) => Err(DomainError::Overlaps(pair[1].1)), + None => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jiff::civil::date; + use jiff::tz::TimeZone; + + const NEW_DAY_AFTER: SignedDuration = SignedDuration::from_hours(8); + + /// 2026-09-`day` at `hour:minute`, UTC. + fn at(day: i8, hour: i8, minute: i8) -> Zoned { + date(2026, 9, day).at(hour, minute, 0, 0).to_zoned(TimeZone::UTC).unwrap() + } + + #[test] + fn a_night_shift_stays_on_the_day_it_started() { + let mut journal = Journal::default(); + assert_eq!(journal.start(&at(24, 22, 0), NEW_DAY_AFTER), Ok(StartOutcome::Opened(date(2026, 9, 24)))); + journal.pause(at(25, 1, 30).timestamp()).unwrap(); + assert_eq!(journal.start(&at(25, 2, 0), NEW_DAY_AFTER), Ok(StartOutcome::Resumed(date(2026, 9, 24)))); + assert_eq!(journal.end_day(at(25, 4, 10).timestamp()), Ok(date(2026, 9, 24))); + + let day = journal.day(date(2026, 9, 24)).unwrap(); + assert_eq!(day.segments().len(), 2); + assert_eq!(day.worked(), SignedDuration::from_mins(5 * 60 + 40)); + assert_eq!(day.break_time(), SignedDuration::from_mins(30)); + assert_eq!(journal.state(), ShiftState::Idle); + } + + #[test] + fn a_long_break_opens_a_new_day() { + let mut journal = Journal::default(); + journal.start(&at(24, 22, 0), NEW_DAY_AFTER).unwrap(); + journal.pause(at(25, 2, 0).timestamp()).unwrap(); + // Forgot to press End day; back 19 hours later. + let outcome = journal.start(&at(25, 21, 0), NEW_DAY_AFTER).unwrap(); + assert_eq!( + outcome, + StartOutcome::NewDayAfterLongBreak { closed: date(2026, 9, 24), opened: date(2026, 9, 25) } + ); + } + + #[test] + fn starting_again_on_an_ended_date_reopens_it() { + let mut journal = Journal::default(); + journal.start(&at(24, 9, 0), NEW_DAY_AFTER).unwrap(); + journal.end_day(at(24, 12, 0).timestamp()).unwrap(); + assert_eq!( + journal.start(&at(24, 13, 0), NEW_DAY_AFTER), + Ok(StartOutcome::Reopened(date(2026, 9, 24))) + ); + assert_eq!(journal.days().count(), 1); + } + + #[test] + fn overlapping_segments_are_refused_even_across_days() { + let mut journal = Journal::default(); + let now = at(26, 12, 0).timestamp(); + let night = Segment::new(at(24, 22, 0).timestamp(), at(25, 2, 0).timestamp()).unwrap(); + journal.add_segment(date(2026, 9, 24), night, now).unwrap(); + + let early = Segment::new(at(25, 1, 0).timestamp(), at(25, 3, 0).timestamp()).unwrap(); + assert_eq!( + journal.add_segment(date(2026, 9, 25), early, now), + Err(DomainError::Overlaps(date(2026, 9, 24))) + ); + + let parts = vec![(date(2026, 9, 24), vec![night]), (date(2026, 9, 25), vec![early])]; + assert!(Journal::from_parts(parts, ShiftState::Idle).is_err()); + } + + #[test] + fn the_snapshot_includes_the_running_session() { + let mut journal = Journal::default(); + journal.start(&at(24, 9, 0), NEW_DAY_AFTER).unwrap(); + let days = journal.snapshot(at(24, 10, 30).timestamp()); + assert_eq!(days[0].worked(), SignedDuration::from_mins(90)); + // The journal itself is unchanged: still one open session, no segments. + assert!(journal.day(date(2026, 9, 24)).unwrap().is_empty()); + } + + #[test] + fn empty_days_disappear_when_closed() { + let mut journal = Journal::default(); + journal.start(&at(24, 9, 0), NEW_DAY_AFTER).unwrap(); + // Paused in the same minute: nothing recorded. + assert_eq!(journal.pause(at(24, 9, 0).timestamp()), Ok(None)); + journal.end_day(at(24, 9, 1).timestamp()).unwrap(); + assert_eq!(journal.days().count(), 0); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..47cc5e0 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,58 @@ +//! The rules of skyshift, with no files and no screens involved. +//! +//! - A [`Segment`] is one uninterrupted stretch of work (start -> end). +//! - A [`WorkDay`] groups segments under one date, even past midnight. +//! - The [`Journal`] holds every work day and knows whether you're working +//! right now. It's the only way to change recorded work, and it checks the +//! rules on every change, so the data can't end up in an impossible state. + +mod journal; +mod segment; +mod workday; + +use std::fmt; + +use jiff::civil::Date; + +pub use journal::{Journal, ShiftState, StartOutcome}; +pub use segment::Segment; +pub use workday::WorkDay; + +/// Why a change to the journal was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DomainError { + /// A segment must end after it starts. + EndNotAfterStart, + /// Two segments can't cover the same time. Holds the work day of the + /// segment that's in the way. + Overlaps(Date), + /// Recorded work can't overlap the session in progress. + OverlapsRunning, + /// Work can't be recorded in the future. + InFuture, + AlreadyWorking, + NotWorking, + NoOpenDay, + /// The segment was removed or changed in the meantime. + NoSuchSegment, + /// The same work day appears twice (only possible in a hand-edited file). + DuplicateDay(Date), +} + +impl fmt::Display for DomainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DomainError::EndNotAfterStart => write!(f, "the end must be after the start"), + DomainError::Overlaps(day) => write!(f, "that overlaps a segment of the work day {day}"), + DomainError::OverlapsRunning => write!(f, "that overlaps the session in progress"), + DomainError::InFuture => write!(f, "that's in the future"), + DomainError::AlreadyWorking => write!(f, "you're already working"), + DomainError::NotWorking => write!(f, "you're not working right now"), + DomainError::NoOpenDay => write!(f, "no work day is open"), + DomainError::NoSuchSegment => write!(f, "that segment doesn't exist anymore"), + DomainError::DuplicateDay(day) => write!(f, "the work day {day} appears twice"), + } + } +} + +impl std::error::Error for DomainError {} diff --git a/src/domain/segment.rs b/src/domain/segment.rs new file mode 100644 index 0000000..f1dc3b2 --- /dev/null +++ b/src/domain/segment.rs @@ -0,0 +1,65 @@ +//! One uninterrupted stretch of work. + +use jiff::{SignedDuration, Timestamp}; + +use super::DomainError; + +/// One uninterrupted stretch of work, from `start` to `end`. +/// +/// A segment always ends after it starts: the fields are private and +/// [`Segment::new`] is the only way to make one, so every `Segment` in the +/// program is valid and nobody has to check again. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Segment { + start: Timestamp, + end: Timestamp, +} + +impl Segment { + pub fn new(start: Timestamp, end: Timestamp) -> Result { + if end > start { Ok(Segment { start, end }) } else { Err(DomainError::EndNotAfterStart) } + } + + pub fn start(&self) -> Timestamp { + self.start + } + + pub fn end(&self) -> Timestamp { + self.end + } + + /// How long this segment lasted (always positive). + pub fn duration(&self) -> SignedDuration { + self.end.duration_since(self.start) + } + + /// Whether the two segments share any time. Touching ends (one ends at + /// 12:00, the other starts at 12:00) don't count as overlapping. + pub fn overlaps(&self, other: &Segment) -> bool { + self.start < other.end && other.start < self.end + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seg(start: i64, end: i64) -> Result { + Segment::new(Timestamp::from_second(start).unwrap(), Timestamp::from_second(end).unwrap()) + } + + #[test] + fn must_end_after_start() { + assert!(seg(0, 60).is_ok()); + assert_eq!(seg(60, 60), Err(DomainError::EndNotAfterStart)); + assert_eq!(seg(60, 0), Err(DomainError::EndNotAfterStart)); + } + + #[test] + fn touching_is_not_overlapping() { + let morning = seg(0, 100).unwrap(); + assert!(!morning.overlaps(&seg(100, 200).unwrap())); + assert!(morning.overlaps(&seg(99, 200).unwrap())); + assert!(morning.overlaps(&seg(10, 20).unwrap())); + } +} diff --git a/src/domain/workday.rs b/src/domain/workday.rs new file mode 100644 index 0000000..8172259 --- /dev/null +++ b/src/domain/workday.rs @@ -0,0 +1,76 @@ +//! A work day: every segment worked under one date. + +use jiff::civil::Date; +use jiff::{SignedDuration, Timestamp}; + +use super::Segment; + +/// Everything worked under one date. +/// +/// A work day can run past midnight: `date` is the day it counts for (the +/// day it was opened), not the calendar date of each segment. Its segments +/// are always sorted by start time and never overlap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkDay { + date: Date, + segments: Vec, +} + +impl WorkDay { + pub fn new(date: Date) -> WorkDay { + WorkDay { date, segments: Vec::new() } + } + + pub fn date(&self) -> Date { + self.date + } + + /// The segments, earliest first. + pub fn segments(&self) -> &[Segment] { + &self.segments + } + + pub fn is_empty(&self) -> bool { + self.segments.is_empty() + } + + /// Total time worked. + pub fn worked(&self) -> SignedDuration { + self.segments.iter().map(Segment::duration).sum() + } + + /// When the first segment started ("in"). + pub fn first_start(&self) -> Option { + self.segments.first().map(Segment::start) + } + + /// When the last segment ended ("out"). + pub fn last_end(&self) -> Option { + self.segments.last().map(Segment::end) + } + + /// Each break between two segments, as (end of one, start of the next). + /// Segments that touch (no time between them) aren't breaks. + pub fn breaks(&self) -> impl Iterator + '_ { + self.segments + .windows(2) + .map(|pair| (pair[0].end(), pair[1].start())) + .filter(|(end, next_start)| next_start > end) + } + + /// Total time between the first start and the last end that wasn't worked. + pub fn break_time(&self) -> SignedDuration { + self.breaks().map(|(end, next_start)| next_start.duration_since(end)).sum() + } + + /// Adds a segment, keeping them sorted. The journal has already checked + /// that it doesn't overlap anything (that's a journal-wide rule). + pub(super) fn insert(&mut self, segment: Segment) { + let index = self.segments.partition_point(|s| s.start() < segment.start()); + self.segments.insert(index, segment); + } + + pub(super) fn remove(&mut self, index: usize) -> Option { + (index < self.segments.len()).then(|| self.segments.remove(index)) + } +} diff --git a/src/main.rs b/src/main.rs index b91bde6..0b0825b 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 domain; mod time; fn main() {}