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 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-25 00:02:43 -04:00
1 parent 9257181a77
commit fe5e2d9a7f
4 files changed
+81 -8

No files matched your search

+3
View File
@@ -60,6 +60,9 @@ settings, or with `<Space>w`.
- Times you type (`22:30`, `2230`, `22h30`, `10:30pm`, `10p`) are read in - 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 both 12h and 24h styles. An end time before the start time means the next
day. 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. - Times are recorded to the minute, so what you see always adds up.
## Your files ## Your files
+5
View File
@@ -904,6 +904,11 @@ mod tests {
let other_day = app.model.clock.now().date().saturating_sub(jiff::ToSpan::days(10)); let other_day = app.model.clock.now().date().saturating_sub(jiff::ToSpan::days(10));
press(&mut app, &format!("o18:00<Tab>19:00<Tab>{}{other_day}<CR>", "<BS>".repeat(10))); press(&mut app, &format!("o18:00<Tab>19:00<Tab>{}{other_day}<CR>", "<BS>".repeat(10)));
assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]); 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:00<Tab>19:00<Tab>{}-10<CR>", "<BS>".repeat(10)));
assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]);
} }
#[test] #[test]
+62 -2
View File
@@ -6,8 +6,8 @@
use std::fmt; use std::fmt;
use jiff::civil::{Date, Time}; use jiff::civil::{Date, Time, Weekday};
use jiff::{SignedDuration, Zoned}; use jiff::{SignedDuration, ToSpan, Zoned};
/// How clock times are written. /// How clock times are written.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -284,9 +284,69 @@ fn split_am_pm(text: &str) -> (&str, Option<AmPm>) {
} }
} }
/// 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<Date, String> {
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::<i64>().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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 { fn time(hour: i8, minute: i8) -> Time {
Time::new(hour, minute, 0, 0).unwrap() Time::new(hour, minute, 0, 0).unwrap()
+11 -6
View File
@@ -16,7 +16,7 @@ use ratatui::widgets::Paragraph;
use super::{field_line, message_line, window}; use super::{field_line, message_line, window};
use crate::action::Action; use crate::action::Action;
use crate::domain::{Segment, WorkDay}; 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::time::resolve::{nearest, next_after};
use crate::ui::keymap::{self, Binding, Command}; use crate::ui::keymap::{self, Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput}; 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). /// The work day the segment is filed under (the Work day field, which
fn work_day(&self) -> Result<Date, String> { /// also takes shortcuts like "yesterday", "-3" or "mon").
fn work_day(&self, ctx: &Context) -> Result<Date, String> {
match self.target { match self.target {
Target::Running { day, .. } => Ok(day), 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<Resolved, String> { fn resolve(&self, ctx: &Context) -> Result<Resolved, String> {
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_time = parse_time(self.start.text()).map_err(|error| format!("start: {error}"))?;
let start = nearest(start_time, &self.reference(day, ctx)); let start = nearest(start_time, &self.reference(day, ctx));
let end = match self.end.text().trim() { let end = match self.end.text().trim() {
@@ -221,7 +222,11 @@ impl Component for SegmentEditor {
), ),
]; ];
if !self.is_running() { 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(field_line("Work day", &self.day, self.field == 2, self.inserting, note, ctx.theme));
} }
lines.push(Line::default()); lines.push(Line::default());