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:
1 parent
9257181a77
commit
fe5e2d9a7f
4 files changed
+81
-8
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in new issue
Block a user