From fe5e2d9a7f46398d10297f2d9d05cd610ff7406f Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 25 Sep 2026 00:02:43 -0400 Subject: [PATCH] Accept date shortcuts in the Work day field Adding work to a day you forgot to track meant typing its full date. The Work day field now also takes 09-14 (this year), today, yesterday, -3 (three days ago) and weekday names like mon or friday (the most recent one). The editor shows the date it understood next to the field. The parsing lives in time::format::parse_date, next to parse_time. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++ src/app.rs | 5 +++ src/time/format.rs | 64 +++++++++++++++++++++++++++++++-- src/ui/popups/segment_editor.rs | 17 +++++---- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 368d579..9ec6956 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ settings, or with `w`. - Times you type (`22:30`, `2230`, `22h30`, `10:30pm`, `10p`) are read in both 12h and 24h styles. An end time before the start time means the next day. +- To add work to a day you didn't track, press `o` and change the **Work + day** field: `2026-09-14`, `09-14`, `yesterday`, `-3` (3 days ago) or a + weekday like `mon` all work. - Times are recorded to the minute, so what you see always adds up. ## Your files diff --git a/src/app.rs b/src/app.rs index 0296365..ec28696 100644 --- a/src/app.rs +++ b/src/app.rs @@ -904,6 +904,11 @@ mod tests { let other_day = app.model.clock.now().date().saturating_sub(jiff::ToSpan::days(10)); press(&mut app, &format!("o18:0019:00{}{other_day}", "".repeat(10))); assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]); + + // The same with a shortcut: "-10" is ten days ago. + let mut app = sample_app("add-shortcut-day"); + press(&mut app, &format!("o18:0019:00{}-10", "".repeat(10))); + assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]); } #[test] diff --git a/src/time/format.rs b/src/time/format.rs index d249b17..6e19674 100644 --- a/src/time/format.rs +++ b/src/time/format.rs @@ -6,8 +6,8 @@ use std::fmt; -use jiff::civil::{Date, Time}; -use jiff::{SignedDuration, Zoned}; +use jiff::civil::{Date, Time, Weekday}; +use jiff::{SignedDuration, ToSpan, Zoned}; /// How clock times are written. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -284,9 +284,69 @@ fn split_am_pm(text: &str) -> (&str, Option) { } } +/// Reads a date typed by the user, with shortcuts for recent days: +/// +/// `2026-09-14`, `09-14` or `9-14` (this year), `today`, `yesterday`, +/// `-3` (3 days ago), or a weekday like `mon` or `friday` (the most recent +/// one, today included). +pub fn parse_date(input: &str, today: Date) -> Result { + let text = input.trim().to_ascii_lowercase(); + let error = || format!("\"{}\" isn't a date; try 2026-09-14, 09-14, yesterday, -3 or mon", input.trim()); + + match text.as_str() { + "today" => return Ok(today), + "yesterday" => return Ok(today.saturating_sub(1.day())), + _ => {} + } + if let Some(days) = text.strip_prefix('-').and_then(|days| days.parse::().ok()) { + return Ok(today.saturating_sub(days.days())); + } + // A weekday name, or its start ("mon", "thu"): the most recent one. + const WEEKDAYS: [(&str, Weekday); 7] = [ + ("monday", Weekday::Monday), + ("tuesday", Weekday::Tuesday), + ("wednesday", Weekday::Wednesday), + ("thursday", Weekday::Thursday), + ("friday", Weekday::Friday), + ("saturday", Weekday::Saturday), + ("sunday", Weekday::Sunday), + ]; + if text.len() >= 3 + && let Some((_, weekday)) = WEEKDAYS.iter().find(|(name, _)| name.starts_with(&text)) + { + return Ok(today.saturating_sub(today.weekday().since(*weekday).days())); + } + // "09-14" or "9-14": month and day of this year. + let numbers: Vec<&str> = text.split('-').collect(); + if let [month, day] = numbers.as_slice() { + let month: i8 = month.parse().map_err(|_| error())?; + let day: i8 = day.parse().map_err(|_| error())?; + return Date::new(today.year(), month, day).map_err(|_| error()); + } + text.parse().map_err(|_| error()) +} + #[cfg(test)] mod tests { use super::*; + use jiff::civil::date; + + #[test] + fn reads_dates_and_shortcuts() { + let today = date(2026, 9, 24); // a Thursday + assert_eq!(parse_date("2026-09-14", today), Ok(date(2026, 9, 14))); + assert_eq!(parse_date("09-14", today), Ok(date(2026, 9, 14))); + assert_eq!(parse_date("9-4", today), Ok(date(2026, 9, 4))); + assert_eq!(parse_date("Today", today), Ok(today)); + assert_eq!(parse_date("yesterday", today), Ok(date(2026, 9, 23))); + assert_eq!(parse_date("-3", today), Ok(date(2026, 9, 21))); + assert_eq!(parse_date("mon", today), Ok(date(2026, 9, 21))); + assert_eq!(parse_date("thursday", today), Ok(today)); + assert_eq!(parse_date("fri", today), Ok(date(2026, 9, 18))); + assert!(parse_date("th", today).is_err()); + assert!(parse_date("13-40", today).is_err()); + assert!(parse_date("someday", today).is_err()); + } fn time(hour: i8, minute: i8) -> Time { Time::new(hour, minute, 0, 0).unwrap() diff --git a/src/ui/popups/segment_editor.rs b/src/ui/popups/segment_editor.rs index 55ed925..32dff7c 100644 --- a/src/ui/popups/segment_editor.rs +++ b/src/ui/popups/segment_editor.rs @@ -16,7 +16,7 @@ use ratatui::widgets::Paragraph; use super::{field_line, message_line, window}; use crate::action::Action; use crate::domain::{Segment, WorkDay}; -use crate::time::format::parse_time; +use crate::time::format::{parse_date, parse_time}; use crate::time::resolve::{nearest, next_after}; use crate::ui::keymap::{self, Binding, Command}; use crate::ui::widgets::text_input::{InputEvent, TextInput}; @@ -88,11 +88,12 @@ impl SegmentEditor { } } - /// The work day the segment is filed under (the Work day field). - fn work_day(&self) -> Result { + /// The work day the segment is filed under (the Work day field, which + /// also takes shortcuts like "yesterday", "-3" or "mon"). + fn work_day(&self, ctx: &Context) -> Result { match self.target { Target::Running { day, .. } => Ok(day), - _ => self.day.text().trim().parse().map_err(|_| "work day: write it like 2026-09-24".to_string()), + _ => parse_date(self.day.text(), ctx.now.date()).map_err(|error| format!("work day: {error}")), } } @@ -122,7 +123,7 @@ impl SegmentEditor { } fn resolve(&self, ctx: &Context) -> Result { - let day = self.work_day()?; + let day = self.work_day(ctx)?; let start_time = parse_time(self.start.text()).map_err(|error| format!("start: {error}"))?; let start = nearest(start_time, &self.reference(day, ctx)); let end = match self.end.text().trim() { @@ -221,7 +222,11 @@ impl Component for SegmentEditor { ), ]; if !self.is_running() { - let note = Span::styled("the day it counts for", ctx.theme.muted()); + // Which date the field means ("yesterday" → Wed Sep 23). + let note = match self.work_day(ctx) { + Ok(day) => Span::styled(ctx.fmt.date_short(day), ctx.theme.accent()), + Err(_) => Span::styled("a date, yesterday, -3, mon...", ctx.theme.muted()), + }; lines.push(field_line("Work day", &self.day, self.field == 2, self.inserting, note, ctx.theme)); } lines.push(Line::default());