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]>
290 lines
11 KiB
Rust
290 lines
11 KiB
Rust
//! Adding or fixing a segment by typing its times.
|
|
//!
|
|
//! You type clock times ("22:00", "1:30am"); the editor shows right away
|
|
//! which exact moments they mean (see `time::resolve`), so a night shift
|
|
//! "22:00 to 01:30" visibly ends the next day.
|
|
|
|
use jiff::civil::Date;
|
|
use jiff::{Timestamp, ToSpan, Zoned};
|
|
use ratatui::Frame;
|
|
use ratatui::crossterm::event::KeyEvent;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
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_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};
|
|
use crate::ui::{Component, Context};
|
|
|
|
/// What is being edited.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Target {
|
|
/// A new segment for the work day `day`, placed after (or before) its
|
|
/// existing segments.
|
|
New { day: Date, after: bool },
|
|
/// Segment number `index` of the work day `day`.
|
|
Existing { day: Date, index: usize, original: Segment },
|
|
/// The session in progress. Leaving the end empty keeps it running.
|
|
Running { day: Date, since: Timestamp },
|
|
}
|
|
|
|
pub struct SegmentEditor {
|
|
target: Target,
|
|
start: TextInput,
|
|
end: TextInput,
|
|
day: TextInput,
|
|
/// The field with the focus: 0 = start, 1 = end, 2 = work day.
|
|
field: usize,
|
|
inserting: bool,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// The typed fields, turned into exact moments.
|
|
struct Resolved {
|
|
start: Zoned,
|
|
end: Option<Zoned>,
|
|
day: Date,
|
|
}
|
|
|
|
impl SegmentEditor {
|
|
pub fn new(target: Target, ctx: &Context) -> SegmentEditor {
|
|
let (start, end, day) = match target {
|
|
Target::New { day, .. } => (String::new(), String::new(), day),
|
|
Target::Existing { day, original, .. } => {
|
|
(ctx.time(original.start()), ctx.time(original.end()), day)
|
|
}
|
|
Target::Running { day, since } => (ctx.time(since), String::new(), day),
|
|
};
|
|
SegmentEditor {
|
|
target,
|
|
start: TextInput::new(start),
|
|
end: TextInput::new(end),
|
|
day: TextInput::new(day.to_string()),
|
|
field: 0,
|
|
inserting: true,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
fn is_running(&self) -> bool {
|
|
matches!(self.target, Target::Running { .. })
|
|
}
|
|
|
|
fn field_count(&self) -> usize {
|
|
if self.is_running() { 2 } else { 3 }
|
|
}
|
|
|
|
fn input(&mut self) -> &mut TextInput {
|
|
match self.field {
|
|
0 => &mut self.start,
|
|
1 => &mut self.end,
|
|
_ => &mut self.day,
|
|
}
|
|
}
|
|
|
|
/// 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<Date, String> {
|
|
match self.target {
|
|
Target::Running { day, .. } => Ok(day),
|
|
_ => parse_date(self.day.text(), ctx.now.date()).map_err(|error| format!("work day: {error}")),
|
|
}
|
|
}
|
|
|
|
/// Where typed start times are placed: close to this moment, on the
|
|
/// work day `day` (the one in the Work day field).
|
|
fn reference(&self, day: Date, ctx: &Context) -> Zoned {
|
|
match self.target {
|
|
// Next to the day's existing work, or around noon on an empty day.
|
|
Target::New { after, .. } => {
|
|
let work_day = ctx.day(day);
|
|
let edge = if after {
|
|
work_day.and_then(WorkDay::last_end)
|
|
} else {
|
|
work_day.and_then(WorkDay::first_start)
|
|
};
|
|
edge.map_or_else(|| ctx.noon(day), |moment| ctx.local(moment))
|
|
}
|
|
// Moving a segment to another work day moves its times with it,
|
|
// keeping the same clock times.
|
|
Target::Existing { day: original_day, original, .. } => {
|
|
let moved_days = day.duration_since(original_day).as_hours() / 24;
|
|
let start = ctx.local(original.start());
|
|
start.checked_add(moved_days.days()).unwrap_or(start)
|
|
}
|
|
Target::Running { since, .. } => ctx.local(since),
|
|
}
|
|
}
|
|
|
|
fn resolve(&self, ctx: &Context) -> Result<Resolved, String> {
|
|
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() {
|
|
"" if self.is_running() => None,
|
|
"" => return Err("end: when did it end?".into()),
|
|
text => {
|
|
let end_time = parse_time(text).map_err(|error| format!("end: {error}"))?;
|
|
Some(next_after(end_time, &start))
|
|
}
|
|
};
|
|
Ok(Resolved { start, end, day })
|
|
}
|
|
|
|
fn submit(&mut self, ctx: &Context) -> Option<Action> {
|
|
let result = self.resolve(ctx).and_then(|resolved| self.action(resolved));
|
|
match result {
|
|
Ok(action) => Some(action),
|
|
Err(error) => {
|
|
self.error = Some(error);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn action(&self, resolved: Resolved) -> Result<Action, String> {
|
|
let start = resolved.start.timestamp();
|
|
let end = resolved.end.map(|end| end.timestamp());
|
|
if let Target::Running { .. } = self.target {
|
|
return Ok(Action::FixRunning { since: start, stopped: end });
|
|
}
|
|
let segment =
|
|
Segment::new(start, end.ok_or("end: when did it end?")?).map_err(|error| error.to_string())?;
|
|
Ok(match self.target {
|
|
Target::Existing { day, index, .. } => {
|
|
Action::ReplaceSegment { day, index, segment, new_day: resolved.day }
|
|
}
|
|
_ => Action::AddSegment { day: resolved.day, segment },
|
|
})
|
|
}
|
|
|
|
fn title(&self, ctx: &Context) -> String {
|
|
match self.target {
|
|
Target::New { day, .. } => format!("Add work · {}", ctx.fmt.date_short(day)),
|
|
Target::Existing { day, index, .. } => {
|
|
format!("Segment {} · {}", index + 1, ctx.fmt.date_short(day))
|
|
}
|
|
Target::Running { .. } => "The session in progress".to_string(),
|
|
}
|
|
}
|
|
|
|
/// The exact moment a field means, next to it.
|
|
fn note(moment: Option<&Zoned>, empty_note: &str, ctx: &Context) -> Span<'static> {
|
|
match moment {
|
|
Some(moment) => Span::styled(
|
|
format!("{} {}", ctx.fmt.date_short(moment.date()), ctx.fmt.time(moment.time())),
|
|
ctx.theme.accent(),
|
|
),
|
|
None => Span::styled(empty_note.to_string(), ctx.theme.muted()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Component for SegmentEditor {
|
|
fn keys(&self) -> &'static [&'static [Binding]] {
|
|
&[keymap::POPUP]
|
|
}
|
|
|
|
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
|
let title = self.title(ctx);
|
|
let inner =
|
|
window(frame, area, 76, 11, &title, "Enter save · Tab next field · Esc cancel", ctx.theme);
|
|
let resolved = self.resolve(ctx);
|
|
let (start, end) = match &resolved {
|
|
Ok(resolved) => (Some(&resolved.start), resolved.end.as_ref()),
|
|
Err(_) => (None, None),
|
|
};
|
|
let end_note = if self.is_running() { "empty = still working" } else { "" };
|
|
|
|
let mut lines = vec![
|
|
Line::default(),
|
|
field_line(
|
|
"Start",
|
|
&self.start,
|
|
self.field == 0,
|
|
self.inserting,
|
|
Self::note(start, "", ctx),
|
|
ctx.theme,
|
|
),
|
|
field_line(
|
|
"End",
|
|
&self.end,
|
|
self.field == 1,
|
|
self.inserting,
|
|
Self::note(end, end_note, ctx),
|
|
ctx.theme,
|
|
),
|
|
];
|
|
if !self.is_running() {
|
|
// 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());
|
|
let length = match (start, end) {
|
|
(Some(start), Some(end)) => ctx.fmt.duration(end.duration_since(start)),
|
|
(Some(start), None) if self.is_running() => {
|
|
ctx.fmt.duration(ctx.now.duration_since(start)) + " so far"
|
|
}
|
|
_ => String::new(),
|
|
};
|
|
lines.push(Line::from(vec![
|
|
Span::raw(" Length "),
|
|
Span::styled(length, Style::new().add_modifier(Modifier::BOLD)),
|
|
]));
|
|
let tip = "22:30, 2230 or 10:30pm all work. An end before the start is the next day.";
|
|
lines.push(message_line(self.error.as_deref(), tip, ctx.theme));
|
|
frame.render_widget(Paragraph::new(lines), inner);
|
|
}
|
|
|
|
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
|
|
match command {
|
|
Command::Confirm => return self.submit(ctx),
|
|
Command::Close => return Some(Action::ClosePopup),
|
|
Command::Down => self.field = (self.field + 1) % self.field_count(),
|
|
Command::Up => self.field = (self.field + self.field_count() - 1) % self.field_count(),
|
|
Command::Top => self.field = 0,
|
|
Command::Bottom => self.field = self.field_count() - 1,
|
|
Command::Insert => {
|
|
self.input().cursor_to_end();
|
|
self.inserting = true;
|
|
}
|
|
Command::InsertAtStart => {
|
|
self.input().cursor_to_start();
|
|
self.inserting = true;
|
|
}
|
|
Command::Retype => {
|
|
self.input().clear();
|
|
self.inserting = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn inserting(&self) -> bool {
|
|
self.inserting
|
|
}
|
|
|
|
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
|
|
self.error = None;
|
|
match self.input().insert_key(key) {
|
|
InputEvent::Submit => return self.submit(ctx),
|
|
InputEvent::Exit => self.inserting = false,
|
|
InputEvent::Next => self.field = (self.field + 1) % self.field_count(),
|
|
InputEvent::Previous => self.field = (self.field + self.field_count() - 1) % self.field_count(),
|
|
InputEvent::Edited => {}
|
|
}
|
|
None
|
|
}
|
|
}
|