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:
1 parent
7bbc1ee0fa
commit
3fdd635af0
5 files changed
+636
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! then this only declares the modules written so far.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod domain;
|
||||
mod time;
|
||||
|
||||
fn main() {}
|
||||
Reference in new issue
Block a user