Add the domain: segments, work days and the journal

The rules, with no files and no UI:

- Segment: one stretch of work; it can only be built with end > start.
- WorkDay: segments filed under one date, even past midnight.
- Journal: every work day plus what's happening now (Idle, OnBreak,
  Working). Start opens a work day that stays open until End day; a
  break longer than a setting opens a new day (the safety net for a
  forgotten End day). Segments never overlap, even across days.
- snapshot(): the days with the running session included, so analysis
  and export never special-case it.

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 7bbc1ee0fa
commit 3fdd635af0
5 files changed
+636

No files matched your search

+436
View File
@@ -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<Date> {
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<Date, WorkDay>,
state: ShiftState,
}
impl Journal {
/// Rebuilds a journal from stored data, checking every rule.
pub fn from_parts(days: Vec<(Date, Vec<Segment>)>, state: ShiftState) -> Result<Journal, DomainError> {
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<Item = &WorkDay> {
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<SignedDuration> {
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<StartOutcome, DomainError> {
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<Option<Segment>, 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<Date, DomainError> {
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<Segment, DomainError> {
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<WorkDay> {
let mut days: Vec<WorkDay> = 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<Timestamp> {
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<Date> {
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);
}
}
+58
View File
@@ -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 {}
+65
View File
@@ -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<Segment, DomainError> {
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, DomainError> {
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()));
}
}
+76
View File
@@ -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<Segment>,
}
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<Timestamp> {
self.segments.first().map(Segment::start)
}
/// When the last segment ended ("out").
pub fn last_end(&self) -> Option<Timestamp> {
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<Item = (Timestamp, Timestamp)> + '_ {
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<Segment> {
(index < self.segments.len()).then(|| self.segments.remove(index))
}
}
+1
View File
@@ -4,6 +4,7 @@
//! then this only declares the modules written so far.
#![allow(dead_code)]
mod domain;
mod time;
fn main() {}