Skip to main content

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}