tix/app/stats_time.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The dimension the Time tab's summary breaks down by (WP05 T026).
5///
6/// The words are the CLI's own `--by` vocabulary (`all` is the explicit spelling of
7/// "no breakdown"), and each dimension's rows are the shared
8/// [`crate::time_tracking::aggregate::TimeTotal`]s [`TimeSnapshot::by_issue`] and
9/// friends return — this enum chooses a dimension, it never groups anything itself.
10///
11/// [`TimeSnapshot::by_issue`]: crate::time_tracking::aggregate::TimeSnapshot::by_issue
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
13pub enum TimeBreakdown {
14 /// The scope's one ungrouped total.
15 #[default]
16 All,
17 /// One total per issue the snapshot holds.
18 Issue,
19 /// One total per identity that recorded work.
20 Assignee,
21 /// One total per calendar date.
22 Date,
23}
24
25impl TimeBreakdown {
26 /// The dimensions in cycle order.
27 pub const ALL: [Self; 4] = [Self::All, Self::Issue, Self::Assignee, Self::Date];
28
29 /// The dimension's name, as `tix time total --by` spells it.
30 pub const fn label(self) -> &'static str {
31 match self {
32 Self::All => "all",
33 Self::Issue => "issue",
34 Self::Assignee => "assignee",
35 Self::Date => "date",
36 }
37 }
38
39 /// The next dimension in cycle order, wrapping.
40 pub fn next(self) -> Self {
41 let index = Self::ALL
42 .iter()
43 .position(|dimension| *dimension == self)
44 .unwrap_or(0);
45 Self::ALL[(index + 1) % Self::ALL.len()]
46 }
47}
48
49/// The `F7` Stats page's **Time tab** session state (WP05 T026).
50///
51/// Holds the tab's **one** read — the shared
52/// [`crate::time_tracking::aggregate::TimeSnapshot`] — rather than re-reading every
53/// issue per frame, exactly as the portfolio tabs cache their projections; the
54/// snapshot is rebuilt when the tab is opened and after every mutation, never during
55/// a render.
56///
57/// Nothing here is a total of its own. [`Self::total`] and [`Self::groups`] call the
58/// shared calculation, the filter is the shared `TimeFilter` (its validation is that
59/// type's own `problems`), and the entry rows are the snapshot's own index — so the
60/// tab, the CLI and the pane cannot disagree about a figure (C-005, C-006, NFR-003).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct StatsTimeState {
63 /// The current entries and retained time History over the project's active issues.
64 pub snapshot: crate::time_tracking::aggregate::TimeSnapshot,
65 /// The scope the reader is looking through: issue, assignee, period, or none.
66 pub filter: crate::time_tracking::filters::TimeFilter,
67 /// The dimension the summary breaks down by.
68 pub breakdown: TimeBreakdown,
69 /// Whether the audit view is on (the `H` toggle) — the CLI's `--audit`.
70 pub audit: bool,
71 /// Which entry row the mutations act on, as an index into [`Self::rows`].
72 pub cursor: usize,
73}
74
75impl StatsTimeState {
76 /// A fresh tab over `snapshot`, with the empty filter, no breakdown and no audit.
77 pub fn new(snapshot: crate::time_tracking::aggregate::TimeSnapshot) -> Self {
78 Self {
79 snapshot,
80 filter: crate::time_tracking::filters::TimeFilter::new(),
81 breakdown: TimeBreakdown::All,
82 audit: false,
83 cursor: 0,
84 }
85 }
86
87 /// The filter the tab evaluates: the reader's scope, with the deleted context
88 /// asked for when the audit view is on — the CLI's `--audit`, which is the same
89 /// request (`deleted_count` is `Some` only when it was asked for).
90 pub fn scope(&self) -> crate::time_tracking::filters::TimeFilter {
91 if self.audit {
92 self.filter.clone().including_deleted()
93 } else {
94 self.filter.clone()
95 }
96 }
97
98 /// The entry rows the cursor walks: the snapshot's current entries the filter
99 /// selects, in the snapshot's own deterministic order (issue, then the entry's
100 /// display order).
101 pub fn rows(&self) -> Vec<&crate::time_tracking::aggregate::IndexedEntry> {
102 self.snapshot
103 .entries()
104 .iter()
105 .filter(|indexed| self.filter.selects(indexed.issue, &indexed.entry))
106 .collect()
107 }
108
109 /// The row the `e`/`x` mutations act on.
110 pub fn selected(&self) -> Option<&crate::time_tracking::aggregate::IndexedEntry> {
111 self.rows().get(self.cursor).copied()
112 }
113
114 /// Keep the cursor inside the rows (called after every refresh, so a deletion
115 /// cannot leave it past the end).
116 pub fn clamp(&mut self) {
117 let len = self.rows().len();
118 self.cursor = if len == 0 {
119 0
120 } else {
121 self.cursor.min(len - 1)
122 };
123 }
124
125 /// Move the cursor `delta` rows, clamped into the rows the filter selects.
126 pub fn move_cursor(&mut self, delta: isize) {
127 let last = self.rows().len().saturating_sub(1);
128 let next = if delta.is_negative() {
129 self.cursor.saturating_sub(delta.unsigned_abs())
130 } else {
131 self.cursor.saturating_add(delta as usize)
132 };
133 self.cursor = next.min(last);
134 }
135
136 /// The scope's total, from the shared calculation.
137 pub fn total(&self) -> crate::time_tracking::aggregate::TimeTotal {
138 self.snapshot.total(&self.scope())
139 }
140
141 /// The breakdown's groups, from the shared calculation. Empty for
142 /// [`TimeBreakdown::All`], which is the scope's one ungrouped total already.
143 pub fn groups(&self) -> Vec<crate::time_tracking::aggregate::TimeTotal> {
144 let scope = self.scope();
145 match self.breakdown {
146 TimeBreakdown::All => Vec::new(),
147 TimeBreakdown::Issue => self.snapshot.by_issue(&scope),
148 TimeBreakdown::Assignee => self.snapshot.by_assignee(&scope),
149 TimeBreakdown::Date => self.snapshot.by_date(&scope),
150 }
151 }
152
153 /// The explicit audit view, from the shared calculation.
154 pub fn audit_view(&self) -> crate::time_tracking::aggregate::TimeAudit {
155 self.snapshot.audit(&self.scope())
156 }
157}
158
159/// Which mutation a [`TimeForm`] is collecting (WP05 T025).
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum TimeFormKind {
162 /// Add an entry to the issue the form's `issue` selector names.
163 Add,
164 /// Correct the entry `entry_id` on issue `issue`, naming only the fields that
165 /// were filled in.
166 Edit {
167 /// The issue the entry belongs to.
168 issue: u32,
169 /// The entry's stable id.
170 entry_id: String,
171 },
172 /// Remove the entry `entry_id` from issue `issue`'s current projection.
173 Delete {
174 /// The issue the entry belongs to.
175 issue: u32,
176 /// The entry's stable id.
177 entry_id: String,
178 },
179}
180
181/// The time-entry form's state (WP05 T025).
182///
183/// The fields are the CLI's own arguments — the issue selector, the minutes, the
184/// optional date, the author, the optional note, and the correction/deletion reason
185/// — so one form adds, corrects and deletes rather than teaching three key paths.
186///
187/// Nothing here writes. On submit the form builds the same
188/// [`crate::time_tracking::service::TimeMutationRequest`] `tix time add/edit/delete`
189/// builds and hands it to [`crate::time_tracking::service::apply`], the one seam that
190/// resolves the issue, validates the intent, stages the projection and the event and
191/// writes once. A refusal leaves the form open with the service's own message in
192/// [`Self::error`], so a refused submission cannot look like a silent one.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct TimeForm {
195 /// Which mutation this form will ask for.
196 pub kind: TimeFormKind,
197 /// The issue selector (`Add` only; edits and deletes already name their issue).
198 pub issue: String,
199 /// The duration in whole minutes.
200 pub minutes: String,
201 /// The optional calendar date, `YYYY-MM-DD`.
202 pub date: String,
203 /// Who did the work. Blank keeps the configured identity.
204 pub author: String,
205 /// Optional free text.
206 pub note: String,
207 /// Why: required for a correction and a deletion, refused by the service when
208 /// blank, exactly as the CLI's `--reason` is.
209 pub reason: String,
210 /// Whether the deletion was confirmed (`Delete` only; toggled with `space`).
211 pub confirm: bool,
212 /// Which field the cursor is in.
213 pub field: usize,
214 /// The last refusal, rendered in the form so the reader fixes the field rather
215 /// than losing what they typed.
216 pub error: Option<String>,
217}
218
219impl TimeForm {
220 /// An add form, with the issue selector pre-filled from the tab's filter when it
221 /// names one issue (the common case: filter to an issue, then add to it).
222 pub fn add(default_issue: String) -> Self {
223 Self {
224 kind: TimeFormKind::Add,
225 issue: default_issue,
226 minutes: String::new(),
227 date: String::new(),
228 author: crate::storage::actor_identity(),
229 note: String::new(),
230 reason: String::new(),
231 confirm: false,
232 field: 0,
233 error: None,
234 }
235 }
236
237 /// An edit form for one entry of one issue, pre-filled with the entry's **current**
238 /// values so the reader corrects a value rather than retyping it. Blank means
239 /// "leave this field alone" on submit, which is the same patch semantics the CLI's
240 /// optional flags have.
241 pub fn edit(issue: u32, entry: &crate::time_tracking::TimeEntry) -> Self {
242 Self {
243 kind: TimeFormKind::Edit {
244 issue,
245 entry_id: entry.id.clone(),
246 },
247 issue: issue.to_string(),
248 minutes: entry.minutes().map(|m| m.to_string()).unwrap_or_default(),
249 date: entry.date.clone().unwrap_or_default(),
250 author: entry.author.clone(),
251 note: entry.note.clone().unwrap_or_default(),
252 reason: String::new(),
253 confirm: false,
254 field: 0,
255 error: None,
256 }
257 }
258
259 /// A delete form for one entry of one issue. It names the value it will remove in
260 /// its own title, because the confirmation is about that value.
261 pub fn delete(issue: u32, entry: &crate::time_tracking::TimeEntry) -> Self {
262 Self {
263 kind: TimeFormKind::Delete {
264 issue,
265 entry_id: entry.id.clone(),
266 },
267 issue: issue.to_string(),
268 minutes: entry.minutes().map(|m| m.to_string()).unwrap_or_default(),
269 date: entry.date.clone().unwrap_or_default(),
270 author: entry.author.clone(),
271 note: entry.note.clone().unwrap_or_default(),
272 reason: String::new(),
273 confirm: false,
274 field: 0,
275 error: None,
276 }
277 }
278
279 /// How many fields `Tab` walks, by kind: the deletion's confirmation is one of
280 /// them, so it is reachable from the keyboard like every other choice.
281 pub const fn field_count(&self) -> usize {
282 match self.kind {
283 TimeFormKind::Add => 5,
284 TimeFormKind::Edit { .. } => 5,
285 TimeFormKind::Delete { .. } => 2,
286 }
287 }
288
289 /// The field the cursor is in, by name, for the form's own footer and the tests.
290 pub fn field_name(&self) -> &'static str {
291 if self.is_add() {
292 return match self.field {
293 0 => "issue",
294 1 => "minutes",
295 2 => "date",
296 3 => "author",
297 _ => "note",
298 };
299 }
300 if self.is_edit() {
301 return match self.field {
302 0 => "minutes",
303 1 => "date",
304 2 => "author",
305 3 => "note",
306 _ => "reason",
307 };
308 }
309 if self.field == 0 { "reason" } else { "confirm" }
310 }
311
312 /// Whether this form asks for a create.
313 pub const fn is_add(&self) -> bool {
314 matches!(self.kind, TimeFormKind::Add)
315 }
316
317 /// Whether this form asks for a correction.
318 pub const fn is_edit(&self) -> bool {
319 matches!(self.kind, TimeFormKind::Edit { .. })
320 }
321
322 /// Whether this form asks for a deletion.
323 pub const fn is_delete(&self) -> bool {
324 matches!(self.kind, TimeFormKind::Delete { .. })
325 }
326
327 /// The form's title, naming the entry a correction or a deletion is about.
328 pub fn title(&self) -> String {
329 match &self.kind {
330 TimeFormKind::Add => "Add a time entry".to_string(),
331 TimeFormKind::Edit { issue, entry_id } => {
332 format!("Correct {entry_id} on #{issue}")
333 }
334 TimeFormKind::Delete { issue, entry_id } => {
335 format!("Delete {entry_id} from #{issue}")
336 }
337 }
338 }
339
340 /// The typed text of the field the cursor is in, or `None` for a field that is
341 /// chosen rather than typed (a deletion's confirmation, which `space` toggles).
342 /// A reason may therefore contain a space: only the confirm field swallows it.
343 fn focused_text_mut(&mut self) -> Option<&mut String> {
344 if self.is_add() {
345 return Some(match self.field {
346 0 => &mut self.issue,
347 1 => &mut self.minutes,
348 2 => &mut self.date,
349 3 => &mut self.author,
350 _ => &mut self.note,
351 });
352 }
353 if self.is_edit() {
354 return Some(match self.field {
355 0 => &mut self.minutes,
356 1 => &mut self.date,
357 2 => &mut self.author,
358 3 => &mut self.note,
359 _ => &mut self.reason,
360 });
361 }
362 // Delete: field 0 is the reason, field 1 is the confirmation.
363 if self.field == 0 {
364 Some(&mut self.reason)
365 } else {
366 None
367 }
368 }
369}
370
371/// The Time tab's filter editor (WP05 T026).
372///
373/// Edits the **same** [`crate::time_tracking::filters::TimeFilter`] the CLI's
374/// `--issue/--assignee/--from/--to` builds and normalizes it through that type's own
375/// `problems`, so an unreadable bound is refused by the shared DTO — with its message
376/// and its remedy — rather than by a second filter grammar here. Nothing writes: a
377/// filter is read-only session state, and applying one only replaces the scope the tab
378/// is looking through.
379#[derive(Debug, Clone, Default, PartialEq, Eq)]
380pub struct TimeFilterForm {
381 /// The issue criterion, as typed (`184`).
382 pub issue: String,
383 /// The assignee criterion, as typed.
384 pub assignee: String,
385 /// The inclusive lower period bound, `YYYY-MM-DD`.
386 pub from: String,
387 /// The inclusive upper period bound, `YYYY-MM-DD`.
388 pub to: String,
389 /// Which field the cursor is in.
390 pub field: usize,
391 /// The last refusal from the shared filter validator, with its remedy.
392 pub error: Option<String>,
393}
394
395impl TimeFilterForm {
396 /// An editor pre-filled from the filter in force, so the reader edits rather than
397 /// retypes the scope.
398 pub fn from_filter(filter: &crate::time_tracking::filters::TimeFilter) -> Self {
399 Self {
400 issue: filter
401 .issue
402 .map(|number| number.to_string())
403 .unwrap_or_default(),
404 assignee: filter.assignee.clone().unwrap_or_default(),
405 from: filter.date_from.clone().unwrap_or_default(),
406 to: filter.date_to.clone().unwrap_or_default(),
407 field: 0,
408 error: None,
409 }
410 }
411
412 /// The fields `Tab` walks.
413 pub const FIELD_COUNT: usize = 4;
414
415 /// The field the cursor is in, by name.
416 pub fn field_name(&self) -> &'static str {
417 match self.field {
418 0 => "issue",
419 1 => "assignee",
420 2 => "from",
421 _ => "to",
422 }
423 }
424
425 /// The text of the focused field.
426 fn focused_text_mut(&mut self) -> &mut String {
427 match self.field {
428 0 => &mut self.issue,
429 1 => &mut self.assignee,
430 2 => &mut self.from,
431 _ => &mut self.to,
432 }
433 }
434
435 /// The filter this form describes, normalized through the shared type.
436 ///
437 /// The bounds and the identity go through [`TimeFilter::problems`], so the refusal
438 /// is the shared DTO's own (message and remedy); the only refusal raised here is
439 /// the issue criterion's *shape*, which the shared type does not have a variant for
440 /// because the CLI receives it as a number from `clap` — a TUI text field has to
441 /// say so itself.
442 ///
443 /// [`TimeFilter::problems`]: crate::time_tracking::filters::TimeFilter::problems
444 pub fn to_filter(
445 &self,
446 ) -> std::result::Result<crate::time_tracking::filters::TimeFilter, String> {
447 let mut filter = crate::time_tracking::filters::TimeFilter::new();
448 let issue = self.issue.trim();
449 if !issue.is_empty() {
450 let number: u32 = issue.parse().map_err(|_| {
451 format!(
452 "the issue criterion {issue:?} is not an issue number; write it as digits \
453 (e.g. `184`), or leave it empty for every issue"
454 )
455 })?;
456 filter = filter.for_issue(number);
457 }
458 let assignee = self.assignee.trim();
459 if !assignee.is_empty() {
460 filter = filter.for_assignee(assignee);
461 }
462 let from = self.from.trim();
463 let to = self.to.trim();
464 if !from.is_empty() {
465 filter = filter.since(from);
466 }
467 if !to.is_empty() {
468 filter = filter.until(to);
469 }
470 match filter.problems().into_iter().next() {
471 Some(problem) => Err(format!(
472 "{} ({}): {} — {}",
473 problem.kind(),
474 problem.field(),
475 problem.message(),
476 problem.remedy()
477 )),
478 None => Ok(filter),
479 }
480 }
481}
482
483impl App {
484 /// The Time tab's keys (WP05 T025–T027): walk the entry rows, add, correct and
485 /// delete through the shared service, edit the filter, cycle the breakdown,
486 /// toggle the audit view.
487 ///
488 /// Every action key opens a form (or a view) and writes nothing; the only writing
489 /// path is the form's `Enter`, which reaches
490 /// [`crate::time_tracking::service::apply`].
491 pub(in crate::app) fn handle_time_tab_key(&mut self, key: KeyEvent) -> Result<Action> {
492 if self.stats_time.is_none() {
493 // A path that reached the Time tab without the `Tab` key (a click on the
494 // strip) still reads before it acts.
495 self.refresh_stats_time();
496 }
497 if let Some(state) = self.stats_time.as_mut() {
498 state.clamp();
499 }
500 match key.code {
501 KeyCode::Up | KeyCode::Char('k') => {
502 if let Some(state) = self.stats_time.as_mut() {
503 state.move_cursor(-1);
504 }
505 }
506 KeyCode::Down | KeyCode::Char('j') => {
507 if let Some(state) = self.stats_time.as_mut() {
508 state.move_cursor(1);
509 }
510 }
511 KeyCode::Enter => {
512 // `Enter` opens the row's issue's detail, exactly as it does on the
513 // Health tab: the page is a reader, and the pane it opens is where the
514 // issue's own time block (and, from there, its health form) lives.
515 if let Some((number, _)) = self.selected_time_entry() {
516 match self.store.get(IssueSelector::Number(number)) {
517 Ok(issue) => self.open_detail_root(issue)?,
518 Err(error) => self.status_msg = Some(error.to_string()),
519 }
520 }
521 }
522 KeyCode::Char('a') => return self.open_time_add_form(),
523 KeyCode::Char('e') => return self.open_time_edit_form(),
524 KeyCode::Char('x') => return self.open_time_delete_form(),
525 KeyCode::Char('f') => return self.open_time_filter_form(),
526 KeyCode::Char('s') => {
527 if let Some(state) = self.stats_time.as_mut() {
528 state.breakdown = state.breakdown.next();
529 }
530 }
531 KeyCode::Char('H') => {
532 if let Some(state) = self.stats_time.as_mut() {
533 state.audit = !state.audit;
534 }
535 }
536 _ => {}
537 }
538 Ok(Action::None)
539 }
540
541 /// Take the Time tab's **one** read (WP05 T026).
542 ///
543 /// The same read `tix time total` performs — one
544 /// [`TimeSnapshot`](crate::time_tracking::aggregate::TimeSnapshot) over the
545 /// project's active issues — taken once per refresh rather than per frame, because
546 /// a render must not read the disk (the rule the health document follows). The
547 /// reader's filter, breakdown and audit toggle are session state and survive; only
548 /// the data is replaced, and the entry cursor is clamped so a deletion cannot leave
549 /// it past the end.
550 pub(in crate::app) fn refresh_stats_time(&mut self) {
551 let Ok(issues) = self.store.list() else {
552 // A read that failed must not be reported as "nothing recorded": keep what
553 // the tab already had, or leave it unread — which the tab says out loud.
554 return;
555 };
556 let snapshot = crate::time_tracking::aggregate::TimeSnapshot::of(&issues);
557 let mut state = match self.stats_time.take() {
558 Some(previous) => {
559 let mut state = StatsTimeState::new(snapshot);
560 state.filter = previous.filter;
561 state.breakdown = previous.breakdown;
562 state.audit = previous.audit;
563 state.cursor = previous.cursor;
564 state
565 }
566 None => StatsTimeState::new(snapshot),
567 };
568 state.clamp();
569 self.stats_time = Some(state);
570 }
571
572 /// The entry row the mutations act on, with its issue, cloned so the caller can
573 /// borrow `self` mutably afterwards.
574 pub(in crate::app) fn selected_time_entry(
575 &self,
576 ) -> Option<(u32, crate::time_tracking::TimeEntry)> {
577 self.stats_time
578 .as_ref()
579 .and_then(|state| state.selected())
580 .map(|indexed| (indexed.issue, indexed.entry.clone()))
581 }
582
583 /// Open the add form (WP05 T025). Its issue selector is pre-filled from the tab's
584 /// filter when that names one issue, which is the common case: filter to an issue,
585 /// then add to it.
586 pub(in crate::app) fn open_time_add_form(&mut self) -> Result<Action> {
587 let default_issue = self
588 .stats_time
589 .as_ref()
590 .and_then(|state| state.filter.issue)
591 .map(|number| number.to_string())
592 .unwrap_or_default();
593 self.time_return = Some(Mode::Stats);
594 self.time_form = Some(TimeForm::add(default_issue));
595 self.mode = Mode::TimeForm;
596 Ok(Action::None)
597 }
598
599 /// Open the correction form for the selected entry.
600 pub(in crate::app) fn open_time_edit_form(&mut self) -> Result<Action> {
601 let Some((issue, entry)) = self.selected_time_entry() else {
602 self.status_msg =
603 Some("select a time entry first: `j`/`k` walk the tab's entry rows".into());
604 return Ok(Action::None);
605 };
606 self.time_return = Some(Mode::Stats);
607 self.time_form = Some(TimeForm::edit(issue, &entry));
608 self.mode = Mode::TimeForm;
609 Ok(Action::None)
610 }
611
612 /// Open the deletion form for the selected entry. It asks for a reason and a
613 /// confirmation, exactly as `tix time delete --reason … --confirm` does.
614 pub(in crate::app) fn open_time_delete_form(&mut self) -> Result<Action> {
615 let Some((issue, entry)) = self.selected_time_entry() else {
616 self.status_msg =
617 Some("select a time entry first: `j`/`k` walk the tab's entry rows".into());
618 return Ok(Action::None);
619 };
620 self.time_return = Some(Mode::Stats);
621 self.time_form = Some(TimeForm::delete(issue, &entry));
622 self.mode = Mode::TimeForm;
623 Ok(Action::None)
624 }
625
626 /// Open the filter editor over the tab's current scope (WP05 T026).
627 pub(in crate::app) fn open_time_filter_form(&mut self) -> Result<Action> {
628 let filter = self
629 .stats_time
630 .as_ref()
631 .map(|state| state.filter.clone())
632 .unwrap_or_default();
633 self.time_filter_form = Some(TimeFilterForm::from_filter(&filter));
634 self.mode = Mode::TimeFilterForm;
635 Ok(Action::None)
636 }
637
638 /// Show a refusal in the form that raised it, without closing the form.
639 pub(in crate::app) fn time_form_error(&mut self, message: String) {
640 if let Some(form) = self.time_form.as_mut() {
641 form.error = Some(message.clone());
642 }
643 self.status_msg = Some(message);
644 }
645
646 /// The time form's keys: `Tab` walks the fields, `space` toggles the deletion's
647 /// confirmation (only in the confirm field, so a reason may contain a space),
648 /// `Enter` submits, `Esc` leaves without writing.
649 pub(in crate::app) fn handle_time_form_key(&mut self, key: KeyEvent) -> Result<Action> {
650 if !matches!(self.mode, Mode::TimeForm) {
651 return Ok(Action::None);
652 }
653 match key.code {
654 KeyCode::Esc => {
655 self.time_form = None;
656 self.mode = self.time_return.take().unwrap_or(Mode::Stats);
657 }
658 KeyCode::Tab => {
659 if let Some(form) = self.time_form.as_mut() {
660 let count = form.field_count();
661 form.field = (form.field + 1) % count;
662 }
663 }
664 KeyCode::BackTab => {
665 if let Some(form) = self.time_form.as_mut() {
666 let count = form.field_count();
667 form.field = (form.field + count - 1) % count;
668 }
669 }
670 KeyCode::Char(' ') => {
671 if let Some(form) = self.time_form.as_mut() {
672 if form.is_delete() && form.field != 0 {
673 form.confirm = !form.confirm;
674 } else if let Some(text) = form.focused_text_mut() {
675 text.push(' ');
676 }
677 }
678 }
679 KeyCode::Backspace => {
680 if let Some(form) = self.time_form.as_mut()
681 && let Some(text) = form.focused_text_mut()
682 {
683 text.pop();
684 }
685 }
686 KeyCode::Enter => return self.submit_time_form(),
687 KeyCode::Char(c) => {
688 if let Some(form) = self.time_form.as_mut()
689 && let Some(text) = form.focused_text_mut()
690 {
691 text.push(c);
692 }
693 }
694 _ => {}
695 }
696 Ok(Action::None)
697 }
698
699 /// Submit the time form through the shared service (WP05 T025).
700 ///
701 /// The request is the service's own —
702 /// [`TimeMutationRequest::create`](crate::time_tracking::service::TimeMutationRequest::create)
703 /// and its siblings — and [`crate::time_tracking::service::apply`] is the one seam
704 /// that validates, stages and writes. A refusal therefore writes nothing at all,
705 /// and the form **stays open** carrying the service's own message (and, for a
706 /// deletion, the remedy the CLI states), so a refused submission cannot look like a
707 /// silent one and the reader keeps what they typed.
708 pub(in crate::app) fn submit_time_form(&mut self) -> Result<Action> {
709 let Some(form) = self.time_form.clone() else {
710 self.mode = Mode::Stats;
711 return Ok(Action::None);
712 };
713 let request = match Self::time_request(&form) {
714 Ok(request) => request,
715 Err(message) => {
716 self.time_form_error(message);
717 return Ok(Action::None);
718 }
719 };
720 match crate::time_tracking::service::apply(&self.store, &request) {
721 Ok(applied) => {
722 self.time_form = None;
723 self.mode = self.time_return.take().unwrap_or(Mode::Stats);
724 // The write changed an issue, so both readers of it are re-derived: the
725 // tab's snapshot (which no other refresh would notice) and the detail
726 // pane, whose time block is built from the issue it holds.
727 self.refresh_stats_time();
728 self.refresh_detail()?;
729 let verb = match form.kind {
730 TimeFormKind::Add => "added",
731 TimeFormKind::Edit { .. } => "corrected",
732 TimeFormKind::Delete { .. } => "deleted",
733 };
734 self.status_msg = Some(if applied.outcome.changed {
735 format!(
736 "time: {verb} {} on #{}",
737 applied.outcome.entry_id, applied.issue.number
738 )
739 } else {
740 // The one no-op case: the edit named exactly what is recorded, so
741 // no event was appended and `updated` was not bumped.
742 format!(
743 "time: {} unchanged — the correction names the recorded value",
744 applied.outcome.entry_id
745 )
746 });
747 Ok(Action::None)
748 }
749 Err(error) => {
750 self.time_form_error(error.to_string());
751 Ok(Action::None)
752 }
753 }
754 }
755
756 /// The request a form describes, or the message to show when the form is not yet
757 /// answerable — a duration that is not a whole number, or an add that names no
758 /// issue. Everything else (a blank reason, an unknown entry, an unknown issue, a
759 /// negative duration, an unreadable date) is left to the service, so the refusal
760 /// has one owner and one wording.
761 ///
762 /// An associated function, not a method: nothing about the App decides the
763 /// request, and a reader should be able to see that at the signature.
764 pub(in crate::app) fn time_request(
765 form: &TimeForm,
766 ) -> std::result::Result<crate::time_tracking::service::TimeMutationRequest, String> {
767 match &form.kind {
768 TimeFormKind::Add => {
769 let issue = form.issue.trim();
770 if issue.is_empty() {
771 return Err(
772 "a time entry belongs to an issue: name one — its number (e.g. `184`) or \
773 its id"
774 .to_string(),
775 );
776 }
777 let minutes = feed_minutes(&form.minutes, "enter an integer at or above zero")?;
778 let author = if form.author.trim().is_empty() {
779 crate::storage::actor_identity()
780 } else {
781 form.author.trim().to_string()
782 };
783 let mut entry = crate::time_tracking::service::NewTimeEntry::new(minutes, author);
784 if !form.date.trim().is_empty() {
785 entry = entry.on(form.date.trim());
786 }
787 if !form.note.trim().is_empty() {
788 entry = entry.with_note(form.note.trim());
789 }
790 Ok(crate::time_tracking::service::TimeMutationRequest::create(
791 issue, entry,
792 ))
793 }
794 TimeFormKind::Edit { issue, entry_id } => {
795 let mut change = crate::time_tracking::service::TimeEntryEdit::new();
796 if !form.minutes.trim().is_empty() {
797 change = change.duration(feed_minutes(
798 &form.minutes,
799 "leave it empty to keep the recorded value",
800 )?);
801 }
802 if !form.date.trim().is_empty() {
803 change = change.date(form.date.trim());
804 }
805 if !form.author.trim().is_empty() {
806 change = change.author(form.author.trim());
807 }
808 if !form.note.trim().is_empty() {
809 change = change.note(form.note.trim());
810 }
811 Ok(crate::time_tracking::service::TimeMutationRequest::edit(
812 issue.to_string(),
813 entry_id.clone(),
814 change,
815 form.reason.trim().to_string(),
816 ))
817 }
818 TimeFormKind::Delete { issue, entry_id } => {
819 if !form.confirm {
820 return Err(
821 "deleting a time entry needs confirmation: it removes the entry from the \
822 issue's current entries, while its value and its event stay in History \
823 (the tab's `h` view reads them back) — press `space` in the confirm field"
824 .to_string(),
825 );
826 }
827 Ok(crate::time_tracking::service::TimeMutationRequest::delete(
828 issue.to_string(),
829 entry_id.clone(),
830 form.reason.trim().to_string(),
831 ))
832 }
833 }
834 }
835
836 /// The filter editor's keys, read raw: `Tab` walks the fields, `Enter` applies,
837 /// `Esc` leaves. All four fields are free text, so the letters are letters.
838 pub(in crate::app) fn handle_time_filter_form_key(&mut self, key: KeyEvent) -> Result<Action> {
839 if !matches!(self.mode, Mode::TimeFilterForm) {
840 return Ok(Action::None);
841 }
842 match key.code {
843 KeyCode::Esc => {
844 self.time_filter_form = None;
845 self.mode = Mode::Stats;
846 }
847 KeyCode::Tab => {
848 if let Some(form) = self.time_filter_form.as_mut() {
849 form.field = (form.field + 1) % TimeFilterForm::FIELD_COUNT;
850 }
851 }
852 KeyCode::BackTab => {
853 if let Some(form) = self.time_filter_form.as_mut() {
854 form.field = (form.field + TimeFilterForm::FIELD_COUNT - 1)
855 % TimeFilterForm::FIELD_COUNT;
856 }
857 }
858 KeyCode::Backspace => {
859 if let Some(form) = self.time_filter_form.as_mut() {
860 form.focused_text_mut().pop();
861 }
862 }
863 KeyCode::Enter => return self.submit_time_filter_form(),
864 KeyCode::Char(c) => {
865 if let Some(form) = self.time_filter_form.as_mut() {
866 form.focused_text_mut().push(c);
867 }
868 }
869 _ => {}
870 }
871 Ok(Action::None)
872 }
873
874 /// Apply the filter editor (WP05 T026).
875 ///
876 /// Nothing is written: a filter is session state, and applying one only replaces
877 /// the scope the tab reads through. An unreadable scope is refused by the shared
878 /// [`TimeFilter`](crate::time_tracking::filters::TimeFilter)'s own validator, whose
879 /// message **and remedy** the form shows while staying open.
880 pub(in crate::app) fn submit_time_filter_form(&mut self) -> Result<Action> {
881 let Some(form) = self.time_filter_form.clone() else {
882 self.mode = Mode::Stats;
883 return Ok(Action::None);
884 };
885 match form.to_filter() {
886 Ok(filter) => {
887 self.time_filter_form = None;
888 self.mode = Mode::Stats;
889 if let Some(state) = self.stats_time.as_mut() {
890 state.filter = filter.clone();
891 // The rows the cursor indexed may be gone or different, so the
892 // cursor resets to the first of the new scope.
893 state.cursor = 0;
894 state.clamp();
895 }
896 self.status_msg = Some(format!("time: scope {}", filter.describe()));
897 Ok(Action::None)
898 }
899 Err(message) => {
900 // The refusal is the shared validator's own sentence, remedy and all;
901 // the form keeps it so the reader can fix the field.
902 if let Some(open) = self.time_filter_form.as_mut() {
903 open.error = Some(message.clone());
904 }
905 self.status_msg = Some(message);
906 Ok(Action::None)
907 }
908 }
909 }
910}