Skip to main content

tix/ui/
schedule.rs

1//! The `F8` Schedule page: the **Series** tab (tix #1593, mission
2//! `recurring-issues-01M3C7G7` WP07) and the **Cycle schedules** tab (mission
3//! `repeating-cycle-schedules-01M3C7W1` WP07, tix #181).
4//!
5//! # What the page is for
6//!
7//! The due warning ([`crate::ui::recurring_warning`]) answers "something is due —
8//! what now?" once. This page is where the series *live*: their identity, rule,
9//! lifecycle state, cursor and skipped dates, and the keys that act on them. It is
10//! a top-level mode like `F4`/`F5`/`F6`, not a modal, so the board's own state is
11//! not borrowed while a user manages a schedule.
12//!
13//! # Three tabs
14//!
15//! `F8` Schedule holds three tabs — Series (#175), Cycle schedules (#181, this one)
16//! and Capacity (#182, WP06 of mission `cycle-capacity-planning-01M3C85C`). Series
17//! ships with the recurring-issues mission, Cycle schedules with #181 and Capacity
18//! with the cycle-capacity mission; the strip draws exactly the tabs that exist
19//! rather than pretending others do.
20//!
21//! All three tabs are *renderers*: the page owns no registry, no evaluator and no
22//! generator. Every value comes from [`crate::app::App`], which reads the one
23//! service each figure belongs to — so a tab and `tix recurring …`/
24//! `tix cycle schedule …`/`tix capacity …` cannot disagree (C-008/NFR-003), and the
25//! page can hold no second evaluator, cursor or History grammar. The one thing the
26//! page *does* define is the two **forms** that collect a record's fields —
27//! [`ScheduleForm`] for a cycle schedule and [`SeriesForm`] for a recurring series —
28//! and each submit is the shared service's: the cycle registry's
29//! [`create`](crate::cycle_schedule::registry::create)/
30//! [`edit`](crate::cycle_schedule::registry::edit) — which the CLI's `--confirm` and
31//! the MCP tools also reach — and `tix recurring create`'s own `create_series`,
32//! which the CLI and this page's form share (no MCP tool creates a series).
33//!
34//! # The Capacity tab renders what the CLI exposes
35//!
36//! Each row is one configured scope ([`CapacityRow`]): the resolved display label
37//! `tix capacity list` prints, beside the shared calculation's compact values and
38//! unit. The detail pane carries the **same fields `tix capacity calculate` prints**
39//! (`print_calculation`'s order, verbatim), each finding through the shared
40//! [`crate::capacity::warning::CapacityWarning::render`] — its reason and its remedy
41//! — and, for a cycle scope, the immutable close evidence `tix capacity snapshot`
42//! reads back, labelled historical beside the live values (T033). Nothing is
43//! recomputed here: every number is a field of [`crate::capacity::calculate::CapacityCalculation`]
44//! or [`crate::capacity::snapshot::CapacitySnapshot`], and an absent value prints `-`
45//! rather than `0`.
46//!
47//! # The Cycle schedules tab renders what the CLI exposes
48//!
49//! Each schedule row carries the fields `tix cycle schedule list` prints; the detail
50//! pane carries the values `tix cycle schedule show` prints (identity, cadence,
51//! duration, start date, timezone, look-ahead and the derived cursor), the schedule's
52//! generated cycles (`id`, `sequence`, `name`, dates, `state`), its closed snapshots
53//! (`docs/cycle_snapshots/…`) and the findings [`crate::cycle_schedule::identity`]
54//! reported — plus the warnings `tix cycle schedule detect` evaluates. Nothing is a
55//! TUI-only number, and both empty states say how to stop being empty.
56//!
57//! # The keys act through the shared services
58//!
59//! Series tab: `n` defines a series (the same `tix recurring create` the CLI runs),
60//! `p`ause / `r`esume / `x` end change lifecycle state; `g`enerate,
61//! `s`kip, `d`efer and `i`gnore act on the series' **due candidate** as the shared
62//! detector reports it. Cycle schedules tab: `n` defines a schedule and `E`
63//! (`e`xtend owns the lowercase) edits the selected one through the form; `g`enerate
64//! runs WP04's confirmed generator; `p`ause/`r`esume/adjust-future (`a`)/`i`gnore act
65//! on the schedule, and `c`lose / `e`xtend / `s`kip act on its current instance —
66//! each as a WP05 confirmed lifecycle request. The four actions that record a reason
67//! collect one first, and every outcome is the service's own word
68//! (`created`/`no-op`/`rejected`, `applied`/`no-op`/`rejected`) rather than a private
69//! success/failure notion. A series or schedule with nothing to act on says so instead
70//! of inventing a target, and no answer is ever a refresh effect (C-003/C-004).
71//!
72//! `Tab`/`Shift+Tab` switch tabs. They are read raw by the page (the page's
73//! `[schedule]` table binds the Series actions), exactly as the
74//! Portfolio page's Roadmap tab reads its own command letters raw — and for the same
75//! reason `E`, `c`, `R`, `e` and `a` on the Cycle schedules tab are.
76
77use ratatui::Frame;
78use ratatui::layout::{Constraint, Direction, Layout, Rect};
79use ratatui::style::{Color, Modifier, Style};
80use ratatui::text::{Line, Span};
81use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
82
83use crate::app::App;
84use crate::cycle_schedule::config::CycleScheduleId;
85use crate::cycle_schedule::identity::GeneratedCycleId;
86use crate::cycle_schedule::lifecycle::LifecycleAction;
87use crate::keybindings::TuiAction;
88
89/// Which tab of the page is showing.
90///
91/// A `const ALL` in display order, like the Library and Portfolio pages' tabs: the
92/// strip, the hint line, the cursor clamp and the empty state all read this list,
93/// so a further tab (Capacity, #182) is additive here and nowhere else.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
95pub enum ScheduleTab {
96    /// The recurring series (mission `recurring-issues-01M3C7G7`, #175).
97    #[default]
98    Series,
99    /// The configured cycle schedules and the cycles they generated (#181).
100    Cycles,
101    /// The configured capacity scopes and their shared calculations (mission
102    /// `cycle-capacity-planning-01M3C85C`, WP06, #182).
103    Capacity,
104}
105
106impl ScheduleTab {
107    /// The tabs in display order.
108    pub const ALL: [Self; 3] = [Self::Series, Self::Cycles, Self::Capacity];
109
110    /// The tab's label in the strip.
111    pub const fn label(self) -> &'static str {
112        match self {
113            Self::Series => "Series",
114            Self::Cycles => "Cycle schedules",
115            Self::Capacity => "Capacity",
116        }
117    }
118
119    /// The next tab, wrapping. `Tab` moves forward.
120    pub const fn next(self) -> Self {
121        match self {
122            Self::Series => Self::Cycles,
123            Self::Cycles => Self::Capacity,
124            Self::Capacity => Self::Series,
125        }
126    }
127
128    /// The previous tab, wrapping. `Shift+Tab` moves backward.
129    ///
130    /// Spelled out rather than delegated to [`Self::next`]: with three tabs `next`
131    /// and `prev` are no longer inverses of one another, so sharing one body would
132    /// make `Shift+Tab` step forward.
133    pub const fn prev(self) -> Self {
134        match self {
135            Self::Series => Self::Capacity,
136            Self::Cycles => Self::Series,
137            Self::Capacity => Self::Cycles,
138        }
139    }
140
141    /// The keys this tab answers to, as its hint line.
142    pub const fn hint(self) -> &'static str {
143        match self {
144            Self::Series => SERIES_KEYS,
145            Self::Cycles => CYCLE_SCHEDULE_KEYS,
146            Self::Capacity => CAPACITY_KEYS,
147        }
148    }
149
150    /// What the tab says when there is nothing to show, including how to fill it.
151    ///
152    /// Kept here rather than in the app so the sentence is a property of the page a
153    /// test can assert, not a string literal inside a render function.
154    pub const fn empty_hint(self) -> &'static str {
155        match self {
156            Self::Series => {
157                "No recurring series yet. Create one with \
158                 `tix recurring create --title <TITLE> --rule daily --anchor <DATE>`, \
159                 or press `n` to create one here."
160            }
161            Self::Cycles => {
162                "No cycle schedules yet. Create one with `tix cycle schedule create --id <ID> \
163                 --name <NAME> --cadence <KIND> --duration <N> --duration-unit <UNIT> \
164                 --start-date <DATE> --timezone <TZ> --confirm`."
165            }
166            Self::Capacity => {
167                "No capacity configured. Configure one with `tix capacity set --scope \
168                 <cycle|team|assignee> --id <REFERENCE> --value <N> --unit <UNIT>`."
169            }
170        }
171    }
172}
173
174/// The page's own state: which tab is showing, which row the cursor is on, the reason
175/// prompt when one is open, and the create/edit form when one is.
176///
177/// Session-only: nothing here is persisted, and the page owns no value the services
178/// do not already own.
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct ScheduleState {
181    /// The tab in view.
182    pub tab: ScheduleTab,
183    /// The selected row within the active tab.
184    pub selected: usize,
185    /// The reason prompt, `Some` exactly while the page is collecting the reason a
186    /// lifecycle action records (T037).
187    pub prompt: Option<CycleReasonPrompt>,
188    /// The create/edit form, `Some` exactly while the page is defining a schedule
189    /// (T036).
190    pub form: Option<ScheduleForm>,
191    /// The Series tab's create form, `Some` exactly while the page is defining a
192    /// recurring series.
193    ///
194    /// A **separate** field from [`Self::form`] rather than a variant of it: the two
195    /// forms collect different fields (`title`/`rule`/`anchor` against the ten cycle
196    /// schedule fields) and submit to different services, and one `Option` per form
197    /// is what makes "which tab defined what" a property of the state rather than
198    /// something a render has to infer.
199    pub series_form: Option<SeriesForm>,
200}
201
202impl ScheduleState {
203    /// A fresh page, on the first tab with the cursor on the first row.
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    /// Move the cursor by `delta`, clamped to `rows` entries.
209    pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
210        let last = rows.saturating_sub(1);
211        let before = self.selected;
212        let next = if delta.is_negative() {
213            self.selected.saturating_sub(delta.unsigned_abs())
214        } else {
215            self.selected.saturating_add(delta as usize)
216        };
217        self.selected = next.min(last);
218        self.selected != before
219    }
220
221    /// Clamp the cursor into `rows` entries, for a registry that changed under the
222    /// page.
223    pub fn clamp_selected(&mut self, rows: usize) {
224        self.selected = self.selected.min(rows.saturating_sub(1));
225    }
226
227    /// Step to the next (`delta` ≥ 0) or previous tab, wrapping, and reset the row
228    /// cursor: it indexes the *active* tab's rows, so it must not carry over.
229    pub fn switch_tab(&mut self, delta: isize) {
230        self.tab = if delta.is_negative() {
231            self.tab.prev()
232        } else {
233            self.tab.next()
234        };
235        self.selected = 0;
236    }
237}
238
239/// One row of the Series tab, as display data: the widget never reads a registry.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct ScheduleEntry {
242    /// The series' display name.
243    pub title: String,
244    /// Its stable id, rule, state and cursor, as one dimmed tail.
245    pub subtitle: String,
246    /// Whether the series is currently due (its candidate has arrived).
247    pub due: bool,
248}
249
250/// One row of the **Cycle schedules** tab, as display data.
251///
252/// The twin of [`ScheduleEntry`] for the other tab; the widget still reads nothing.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct CycleScheduleEntry {
255    /// The schedule's display name.
256    pub title: String,
257    /// Its stable id, state, cadence, anchor and generated count, as one dimmed tail.
258    pub subtitle: String,
259    /// Whether the last evaluation reported a warning for it.
260    pub warned: bool,
261}
262
263/// The `key: value` lines of the Series tab's detail pane, plus its heading.
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct ScheduleDetail {
266    /// The heading, e.g. `Series`.
267    pub heading: String,
268    /// The lines, in a stable order.
269    pub lines: Vec<(String, String)>,
270}
271
272/// The Cycle schedules tab's detail pane: the schedule's own fields, then its
273/// generated cycles, closed snapshots, identity findings and evaluated warnings.
274///
275/// Every section is a list of already-rendered lines, so the widget composes text
276/// and computes nothing — the same rule the Series tab's [`ScheduleDetail`] follows.
277#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct CycleScheduleDetail {
279    /// The schedule record's own `key: value` facts.
280    pub fields: Vec<(String, String)>,
281    /// One line per generated cycle.
282    pub cycles: Vec<String>,
283    /// One line per closed snapshot.
284    pub snapshots: Vec<String>,
285    /// One line per identity finding.
286    pub findings: Vec<String>,
287    /// One line per evaluated warning.
288    pub warnings: Vec<String>,
289}
290
291/// One row of the **Capacity** tab, as display data (WP06 T030/T032).
292///
293/// Every figure it names is a field of the shared [`crate::capacity::calculate::CapacityCalculation`],
294/// already formatted by the caller: the widget composes text and computes nothing, the
295/// same rule the page's other tabs follow.
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub struct CapacityRow {
298    /// The scope's resolved display label — **which scope the figures belong to**, the
299    /// same `display` `tix capacity list`/`calculate` print.
300    pub title: String,
301    /// The compact `key: value` tail: the scope kind, the state, the values and the
302    /// unit, with an absent value as `-` (never `0`).
303    pub subtitle: String,
304    /// Whether the calculation carried any finding (an overflow, an uncountable
305    /// estimate, an unresolved reference).
306    pub warned: bool,
307    /// Whether the scope's committed work exceeds its ceiling
308    /// ([`crate::capacity::calculate::CapacityCalculation::is_over_capacity`]).
309    pub overflowing: bool,
310}
311
312/// A closed cycle's immutable capacity evidence, as the Capacity tab's detail draws it
313/// (WP06 T033).
314///
315/// The `label` is the explicit **current-vs-historical** statement; the fields are the
316/// ones `tix capacity snapshot <CYCLE>` prints, in that command's order.
317#[derive(Debug, Clone, Default, PartialEq, Eq)]
318pub struct CapacitySnapshotView {
319    /// The current-vs-historical label, and whether the cycle has been reopened or
320    /// edited since the close.
321    pub label: String,
322    /// The stored evidence's own fields, exactly as the close wrote them.
323    pub fields: Vec<(String, String)>,
324}
325
326/// The Capacity tab's detail pane (WP06 T030/T033): the selected scope's shared
327/// calculation, its findings with their remedies, and — for a cycle scope with a closed
328/// snapshot — the immutable close evidence beside the live values.
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct CapacityDetail {
331    /// The pane's heading: the scope's resolved display label.
332    pub heading: String,
333    /// The calculation's own fields, in `tix capacity calculate`'s order.
334    pub fields: Vec<(String, String)>,
335    /// Each finding, rendered through [`crate::capacity::warning::CapacityWarning::render`]
336    /// — its reason **and** its remedy, one string per line.
337    pub diagnostics: Vec<String>,
338    /// The immutable close evidence, `Some` exactly when the scope is a cycle with a
339    /// recorded snapshot.
340    pub snapshot: Option<CapacitySnapshotView>,
341}
342
343/// The capacity read the page renders (WP06): the configured scopes' rows and their
344/// details, plus the compact per-cycle cells the board's cycle group headers show.
345///
346/// Built once per refresh by [`crate::app::App`] from the **shared** DTOs — the same
347/// one-pass pair and the same `for_scope` the CLI's `tix capacity calculate` calls — so
348/// no value here is a TUI-only number (C-007/NFR-003).
349#[derive(Debug, Clone, Default, PartialEq, Eq)]
350pub struct CapacityView {
351    /// One row per configured scope, in declaration order.
352    pub rows: Vec<CapacityRow>,
353    /// The selected row's detail, by the same index as [`Self::rows`].
354    pub details: Vec<CapacityDetail>,
355    /// A compact cell per cycle scope, keyed by the reference a card names **and** by
356    /// the scope's resolved display label, for the board's grouped-by-cycle headers.
357    pub summaries: std::collections::BTreeMap<String, String>,
358}
359
360/// The reason a lifecycle action records, being typed.
361///
362/// The action and its target are captured when the prompt opens, so confirming it
363/// cannot drift onto another row: the prompt carries the schedule and the instance
364/// it is about.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct CycleReasonPrompt {
367    /// The schedule the action names.
368    pub schedule: CycleScheduleId,
369    /// The action that records the reason.
370    pub action: LifecycleAction,
371    /// The instance the action names, when it names one.
372    pub cycle: Option<GeneratedCycleId>,
373    /// The reason being typed.
374    pub buffer: String,
375}
376
377/// One row of the `create`/`edit` form (T036).
378///
379/// The spellings are the CLI's: the form collects text and the shared registry parses
380/// it, so "is `fortnightly` a cadence family" has one answer for every surface.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
382pub enum ScheduleField {
383    /// The stable id; the registry key, and create-only.
384    Id,
385    /// The display label.
386    Name,
387    /// The cadence family.
388    Cadence,
389    /// How many cadence units between starts.
390    Interval,
391    /// The unit an `interval` cadence counts in.
392    Unit,
393    /// How long one cycle lasts.
394    Duration,
395    /// The duration's unit.
396    DurationUnit,
397    /// The anchor date.
398    StartDate,
399    /// The fixed UTC offset.
400    Timezone,
401    /// How many future cycles to consider.
402    LookAhead,
403}
404
405impl ScheduleField {
406    /// Every field, in the form's row order.
407    pub const ALL: [Self; 10] = [
408        Self::Id,
409        Self::Name,
410        Self::Cadence,
411        Self::Interval,
412        Self::Unit,
413        Self::Duration,
414        Self::DurationUnit,
415        Self::StartDate,
416        Self::Timezone,
417        Self::LookAhead,
418    ];
419
420    /// The fields an **edit** shows: everything but the identity, which is the
421    /// registry key and does not change — a rename is a new `name`, not a new id.
422    pub const EDIT: [Self; 9] = [
423        Self::Name,
424        Self::Cadence,
425        Self::Interval,
426        Self::Unit,
427        Self::Duration,
428        Self::DurationUnit,
429        Self::StartDate,
430        Self::Timezone,
431        Self::LookAhead,
432    ];
433
434    /// The row's label.
435    pub const fn label(self) -> &'static str {
436        match self {
437            Self::Id => "id",
438            Self::Name => "name",
439            Self::Cadence => "cadence",
440            Self::Interval => "interval",
441            Self::Unit => "unit",
442            Self::Duration => "duration",
443            Self::DurationUnit => "duration-unit",
444            Self::StartDate => "start-date",
445            Self::Timezone => "timezone",
446            Self::LookAhead => "look-ahead",
447        }
448    }
449
450    /// What the field accepts, shown beside it. The spellings are the contract's.
451    pub const fn hint(self) -> &'static str {
452        match self {
453            Self::Id => "stable, never reused",
454            Self::Name => "display label",
455            Self::Cadence => "daily | weekly | monthly | interval",
456            Self::Interval => "1-1000 (interval cadence)",
457            Self::Unit => "days | weeks | months",
458            Self::Duration => "positive count",
459            Self::DurationUnit => "days | weeks | months",
460            Self::StartDate => "YYYY-MM-DD",
461            Self::Timezone => "UTC or ±HH:MM",
462            Self::LookAhead => "future cycles to consider",
463        }
464    }
465
466    /// Whether the field carries a calendar date, for the hint's sake.
467    pub const fn is_date(self) -> bool {
468        matches!(self, Self::StartDate)
469    }
470}
471
472/// The `create`/`edit` form the Cycle schedules tab shows (T036).
473///
474/// A **page-local** form: the page owns it, exactly as it owns the reason prompt, so
475/// defining a schedule needs no new mode, no new `InputContext` and no new binding.
476/// It collects text and nothing else — every rule about what the text may be is the
477/// shared registry's, and a refusal keeps the form open with the message on it rather
478/// than closing and losing what was typed.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct ScheduleForm {
481    /// The id being edited, or `None` when the form creates a new schedule.
482    pub editing: Option<String>,
483    /// The fields shown, in row order.
484    pub fields: Vec<ScheduleField>,
485    /// The typed value per shown field, in the same order.
486    pub values: Vec<String>,
487    /// The focused row.
488    pub focus: usize,
489    /// The refusal the last save produced, kept while the form stays open.
490    pub error: Option<String>,
491}
492
493impl ScheduleForm {
494    /// A form that creates a schedule: every field empty, the id focused first.
495    pub fn create() -> Self {
496        let fields = ScheduleField::ALL.to_vec();
497        let values = vec![String::new(); fields.len()];
498        Self {
499            editing: None,
500            fields,
501            values,
502            focus: 0,
503            error: None,
504        }
505    }
506
507    /// A form that edits `id`, pre-filled from `values` (in [`ScheduleField::EDIT`]
508    /// order).
509    ///
510    /// A short or long `values` is tolerated rather than panicking: the form is a
511    /// session value a test can build by hand, and a missing value is an empty field.
512    pub fn edit(id: String, values: Vec<String>) -> Self {
513        let fields = ScheduleField::EDIT.to_vec();
514        let mut filled = values;
515        filled.resize(fields.len(), String::new());
516        Self {
517            editing: Some(id),
518            fields,
519            values: filled,
520            focus: 0,
521            error: None,
522        }
523    }
524
525    /// The form's block title: what it is about to do, and to what.
526    pub fn title(&self) -> String {
527        match &self.editing {
528            Some(id) => format!("Edit cycle schedule `{id}` (enter: save, esc: cancel)"),
529            None => "New cycle schedule (enter: save, esc: cancel)".to_string(),
530        }
531    }
532
533    /// The focused field.
534    pub fn focused(&self) -> ScheduleField {
535        self.fields
536            .get(self.focus)
537            .copied()
538            .unwrap_or(ScheduleField::Id)
539    }
540
541    /// The typed value of `field` (`""` when the form does not show it).
542    pub fn value(&self, field: ScheduleField) -> &str {
543        self.fields
544            .iter()
545            .position(|shown| *shown == field)
546            .and_then(|index| self.values.get(index))
547            .map(String::as_str)
548            .unwrap_or("")
549    }
550
551    /// Step the focus forward, wrapping.
552    pub fn focus_next(&mut self) {
553        let len = self.fields.len().max(1);
554        self.focus = (self.focus + 1) % len;
555    }
556
557    /// Step the focus backward, wrapping.
558    pub fn focus_prev(&mut self) {
559        let len = self.fields.len().max(1);
560        self.focus = (self.focus + len - 1) % len;
561    }
562
563    /// Append a character to the focused field.
564    pub fn insert_char(&mut self, c: char) {
565        let focus = self.focus;
566        if let Some(value) = self.values.get_mut(focus) {
567            value.push(c);
568        }
569    }
570
571    /// Remove the focused field's last character.
572    pub fn delete_char(&mut self) {
573        let focus = self.focus;
574        if let Some(value) = self.values.get_mut(focus) {
575            value.pop();
576        }
577    }
578
579    /// Empty the focused field, for a value being replaced rather than extended.
580    pub fn clear_field(&mut self) {
581        let focus = self.focus;
582        if let Some(value) = self.values.get_mut(focus) {
583            value.clear();
584        }
585    }
586
587    /// Keep the refusal on the form, so the message and what was typed stay together.
588    pub fn set_error(&mut self, message: String) {
589        self.error = Some(message);
590    }
591}
592
593/// One row of the Series tab's create form.
594///
595/// The spellings are the CLI's: the form collects text and the shared create service
596/// parses it, so "is `fortnightly` a rule family" has one answer for every surface.
597/// Only the three fields a first series cannot do without are collected — the issue's
598/// `--body`/`--status`/`--priority`/… keep their CLI defaults, and `--name` defaults
599/// to the title, exactly as `tix recurring create` leaves them.
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
601pub enum SeriesField {
602    /// The series issue's title (and the default display name).
603    Title,
604    /// The rule family: what `--rule` accepts.
605    Rule,
606    /// The schedule origin, `YYYY-MM-DD`.
607    Anchor,
608}
609
610impl SeriesField {
611    /// Every field, in the form's row order.
612    pub const ALL: [Self; 3] = [Self::Title, Self::Rule, Self::Anchor];
613
614    /// The row's label.
615    pub const fn label(self) -> &'static str {
616        match self {
617            Self::Title => "title",
618            Self::Rule => "rule",
619            Self::Anchor => "anchor",
620        }
621    }
622
623    /// What the field accepts, shown beside it. The spellings are the contract's.
624    ///
625    /// The rule hint names only the families the form's three fields can complete:
626    /// `interval` needs a count and a unit and `cron-like` needs an expression, so
627    /// offering them here would advertise a choice the form always refuses. They are
628    /// named in [`SeriesForm::NOTE`] as the CLI's, not the form's.
629    pub const fn hint(self) -> &'static str {
630        match self {
631            Self::Title => "issue title (required)",
632            Self::Rule => "daily | weekly | monthly",
633            Self::Anchor => "YYYY-MM-DD",
634        }
635    }
636}
637
638/// The Series tab's create form.
639///
640/// A **page-local** form, the twin of [`ScheduleForm`]: the page owns it, so defining
641/// a series needs no new mode, no new `InputContext` and no new binding. It collects
642/// text and nothing else — every rule about what the text may be is the shared create
643/// service's, and a refusal keeps the form open with the message on it rather than
644/// closing and losing what was typed.
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct SeriesForm {
647    /// The fields shown, in row order.
648    pub fields: Vec<SeriesField>,
649    /// The typed value per shown field, in the same order.
650    pub values: Vec<String>,
651    /// The focused row.
652    pub focus: usize,
653    /// The refusal the last save produced, kept while the form stays open.
654    pub error: Option<String>,
655}
656
657impl SeriesForm {
658    /// Where the rule families the form cannot express are defined.
659    ///
660    /// The three fields complete `daily`, `weekly` and `monthly`. The other two
661    /// families need arguments the form does not collect, so rather than offer them
662    /// (and refuse), the form says where they live — the same service, the CLI's own
663    /// flags. Asserted against [`SeriesField::Rule::hint`] by a test, so the two can
664    /// not drift apart again.
665    pub const NOTE: &'static str = "`interval` and `cron-like` rules need the CLI: \
666                                    `tix recurring create --rule <FAMILY> --interval \
667                                    <N> --unit <UNIT>`.";
668
669    /// A form that creates a series: every field empty, the title focused first.
670    pub fn create() -> Self {
671        Self {
672            fields: SeriesField::ALL.to_vec(),
673            values: vec![String::new(); SeriesField::ALL.len()],
674            focus: 0,
675            error: None,
676        }
677    }
678
679    /// The form's block title: what it is about to do, and the keys it answers to.
680    pub fn title(&self) -> &'static str {
681        "New recurring series (enter: save, esc: cancel)"
682    }
683
684    /// The focused field.
685    pub fn focused(&self) -> SeriesField {
686        self.fields
687            .get(self.focus)
688            .copied()
689            .unwrap_or(SeriesField::Title)
690    }
691
692    /// The typed value of `field` (`""` when the form does not show it).
693    pub fn value(&self, field: SeriesField) -> &str {
694        self.fields
695            .iter()
696            .position(|shown| *shown == field)
697            .and_then(|index| self.values.get(index))
698            .map(String::as_str)
699            .unwrap_or("")
700    }
701
702    /// Step the focus forward, wrapping.
703    pub fn focus_next(&mut self) {
704        let len = self.fields.len().max(1);
705        self.focus = (self.focus + 1) % len;
706    }
707
708    /// Step the focus backward, wrapping.
709    pub fn focus_prev(&mut self) {
710        let len = self.fields.len().max(1);
711        self.focus = (self.focus + len - 1) % len;
712    }
713
714    /// Append a character to the focused field.
715    pub fn insert_char(&mut self, c: char) {
716        let focus = self.focus;
717        if let Some(value) = self.values.get_mut(focus) {
718            value.push(c);
719        }
720    }
721
722    /// Remove the focused field's last character.
723    pub fn delete_char(&mut self) {
724        let focus = self.focus;
725        if let Some(value) = self.values.get_mut(focus) {
726            value.pop();
727        }
728    }
729
730    /// Empty the focused field, for a value being replaced rather than extended.
731    pub fn clear_field(&mut self) {
732        let focus = self.focus;
733        if let Some(value) = self.values.get_mut(focus) {
734            value.clear();
735        }
736    }
737
738    /// Keep the refusal on the form, so the message and what was typed stay together.
739    pub fn set_error(&mut self, message: String) {
740        self.error = Some(message);
741    }
742}
743
744/// The rows the `?` overlay shows on the **Cycle schedules** tab, as
745/// `(action, description)` for the keys the page's binding section binds.
746///
747/// The Series tab is served by the keybinding table's own list and its own wording.
748/// This tab needs its own: the same letters act on the *schedules* rather than on a
749/// series, two bound Series keys (`x` end, `d` defer) have no meaning here and must
750/// not be advertised as if they did, and four of the tab's keys are read raw. Keeping
751/// the list here is what stops the overlay and the tab from describing different keys.
752pub const CYCLE_HELP_ACTIONS: &[(TuiAction, &str)] = &[
753    (TuiAction::MoveUp, "move the cursor up"),
754    (TuiAction::MoveDown, "move the cursor down"),
755    (
756        TuiAction::SeriesGenerate,
757        "generate the schedule's look-ahead",
758    ),
759    (TuiAction::SeriesSkip, "skip the current cycle"),
760    (TuiAction::SeriesPause, "pause the schedule"),
761    (TuiAction::SeriesResume, "resume the schedule"),
762    (
763        TuiAction::SeriesIgnore,
764        "close a warning without deciding anything",
765    ),
766    (TuiAction::Help, "this help"),
767    (TuiAction::Close, "back to the board"),
768];
769
770/// The keys the Cycle schedules tab answers to that **no** [`TuiAction`] names,
771/// because the page reads them raw: `(key, description)`.
772pub const CYCLE_HELP_RAW: &[(&str, &str)] = &[
773    ("tab / shift+tab", "switch tabs"),
774    ("n", "define a new schedule"),
775    ("E", "edit the selected schedule"),
776    ("c", "close the current cycle (asks why)"),
777    ("R", "reopen the closed cycle (asks why)"),
778    ("e", "extend the current cycle (asks why)"),
779    ("s", "skip the current cycle (asks why)"),
780    ("a", "re-derive future cycles (asks why)"),
781];
782
783/// The keys the **Series** tab answers to, as its hint line.
784pub const SERIES_KEYS: &str = "j/k: move  n: new  g: generate  p: pause  r: resume  x: end  \
785                               s: skip  d: defer  i: ignore  q: back to the board";
786
787/// The Series tab's hint, kept under its original name.
788pub const SCHEDULE_KEYS: &str = SERIES_KEYS;
789
790/// The keys the **Cycle schedules** tab answers to, as its hint line.
791pub const CYCLE_SCHEDULE_KEYS: &str = "j/k: move  Tab: tabs  n: new  E: edit  g: generate  \
792                                       c: close  R: reopen  e: extend  s: skip  p: pause  \
793                                       r: resume  a: adjust-future  i: ignore  q: back to the board";
794
795/// The keys the **Capacity** tab answers to, as its hint line.
796///
797/// The tab is read-only: it shows the shared calculation and acts on nothing, so
798/// `j`/`k`, the tab switch and the way back are all it names.
799pub const CAPACITY_KEYS: &str = "j/k: move  Tab: tabs  q: back to the board";
800
801/// The rows the `?` overlay shows on the **Capacity** tab.
802///
803/// Like the Cycle schedules tab, the tab has its own row set rather than the Series
804/// tab's: `g`enerate, `p`ause and the rest act on a series and mean nothing on this
805/// tab, so advertising them here would name keys that do nothing.
806pub const CAPACITY_HELP_ACTIONS: &[(TuiAction, &str)] = &[
807    (TuiAction::MoveUp, "move the cursor up"),
808    (TuiAction::MoveDown, "move the cursor down"),
809    (TuiAction::Help, "this help"),
810    (TuiAction::Close, "back to the board"),
811];
812
813/// The keys the Capacity tab answers to that **no** [`TuiAction`] names, because the
814/// page reads them raw: `(key, description)`.
815pub const CAPACITY_HELP_RAW: &[(&str, &str)] = &[("tab / shift+tab", "switch tabs")];
816
817fn dim() -> Style {
818    Style::default().fg(Color::DarkGray)
819}
820
821/// The tab strip: the page's name and every tab, the active one cyan and bold.
822fn tab_line(active: ScheduleTab) -> Line<'static> {
823    let mut spans = vec![
824        Span::styled("F8 Schedule", Style::default().add_modifier(Modifier::BOLD)),
825        Span::raw("  "),
826    ];
827    for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
828        if index > 0 {
829            spans.push(Span::raw("  "));
830        }
831        let style = if *tab == active {
832            Style::default()
833                .fg(Color::Cyan)
834                .add_modifier(Modifier::BOLD)
835        } else {
836            dim()
837        };
838        spans.push(Span::styled(format!("[{}]", tab.label()), style));
839    }
840    Line::from(spans)
841}
842
843/// The rect of every tab in the strip, in display order (WP06).
844///
845/// The strip is drawn by [`tab_line`] on the page's first row; this mirrors that
846/// line's own widths so a pointer click can address a tab. Kept beside the renderer
847/// so the two cannot drift: a label or a separator changed in one changes both.
848pub fn tab_rects(area: Rect) -> Vec<(ScheduleTab, Rect)> {
849    use unicode_width::UnicodeWidthStr;
850
851    // The page's name, then the two-space gap before the first tab.
852    let mut x = area.x + UnicodeWidthStr::width("F8 Schedule") as u16 + 2;
853    let mut rects = Vec::with_capacity(ScheduleTab::ALL.len());
854    for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
855        if index > 0 {
856            x += 2;
857        }
858        let label = format!("[{}]", tab.label());
859        let width = UnicodeWidthStr::width(label.as_str()) as u16;
860        rects.push((*tab, Rect::new(x, area.y, width, 1)));
861        x += width;
862    }
863    rects
864}
865
866/// The reason prompt's line: what is being decided, and what is being typed.
867fn prompt_line(prompt: &CycleReasonPrompt) -> Line<'static> {
868    let mut spans = vec![Span::styled(
869        prompt.action.as_str().to_string(),
870        Style::default().add_modifier(Modifier::BOLD),
871    )];
872    if let Some(cycle) = &prompt.cycle {
873        spans.push(Span::raw(format!(" {cycle}")));
874    }
875    spans.push(Span::styled("  reason> ", dim()));
876    spans.push(Span::raw(prompt.buffer.clone()));
877    spans.push(Span::styled(
878        "  (enter: apply  esc: cancel, nothing written)",
879        dim(),
880    ));
881    Line::from(spans)
882}
883
884/// Draw the page: strip and hints, then the active tab's body.
885pub fn render(app: &App, f: &mut Frame, area: Rect) {
886    let state = app.schedule_state();
887
888    let rows = Layout::default()
889        .direction(Direction::Vertical)
890        .constraints([
891            Constraint::Length(1),
892            Constraint::Length(1),
893            Constraint::Min(1),
894        ])
895        .split(area);
896    f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
897    // A reason prompt owns the hint row: the line under the strip is the page's one
898    // status line, and what the user is typing has to be visible on it. Otherwise a
899    // refusal or confirmation raised by an action is shown here rather than being
900    // written into `status_msg` where only the board could render it.
901    match state.prompt.as_ref() {
902        Some(prompt) => f.render_widget(Paragraph::new(prompt_line(prompt)), rows[1]),
903        None if app.status_msg.is_some() => {
904            let message = app.status_msg.as_deref().unwrap_or_default();
905            f.render_widget(
906                Paragraph::new(Span::styled(message, Style::default())),
907                rows[1],
908            );
909        }
910        None => f.render_widget(
911            Paragraph::new(Span::styled(state.tab.hint(), dim())),
912            rows[1],
913        ),
914    }
915
916    // The create/edit form takes the whole body: it is the page's only mode that is
917    // about one record rather than a list, and a half-drawn list beside it would
918    // invite the page's other keys while they are inert (the form owns every key).
919    // Only one of the two forms can be open — each `n` opens the one its tab owns.
920    if let Some(form) = state.series_form.as_ref() {
921        render_series_form(form, f, rows[2]);
922        return;
923    }
924    if let Some(form) = state.form.as_ref() {
925        render_form(form, f, rows[2]);
926        return;
927    }
928
929    match state.tab {
930        ScheduleTab::Series => render_series(app, f, rows[2], &state),
931        ScheduleTab::Cycles => render_cycles(app, f, rows[2], &state),
932        ScheduleTab::Capacity => render_capacity(app, f, rows[2], &state),
933    }
934}
935
936/// The create/edit form: one row per field, the focused one marked and highlighted,
937/// the keys and the last refusal under it.
938///
939/// It draws no value the page does not hold: the fields and the text are the state's,
940/// and the refusal is the shared registry's own rendered block.
941fn render_form(form: &ScheduleForm, f: &mut Frame, area: Rect) {
942    let rows: Vec<(String, String, String)> = form
943        .fields
944        .iter()
945        .map(|field| {
946            (
947                field.label().to_string(),
948                form.value(*field).to_string(),
949                field.hint().to_string(),
950            )
951        })
952        .collect();
953    render_form_fields(
954        f,
955        area,
956        &form.title(),
957        &rows,
958        form.focus,
959        form.error.as_deref(),
960        None,
961    );
962}
963
964/// The Series tab's create form: the same shape as [`render_form`], its own fields.
965fn render_series_form(form: &SeriesForm, f: &mut Frame, area: Rect) {
966    let rows: Vec<(String, String, String)> = form
967        .fields
968        .iter()
969        .map(|field| {
970            (
971                field.label().to_string(),
972                form.value(*field).to_string(),
973                field.hint().to_string(),
974            )
975        })
976        .collect();
977    render_form_fields(
978        f,
979        area,
980        form.title(),
981        &rows,
982        form.focus,
983        form.error.as_deref(),
984        Some(SeriesForm::NOTE),
985    );
986}
987
988/// Draw a form body: `(label, value, hint)` per row, the focused one marked and
989/// highlighted, the field keys and the last refusal under it.
990///
991/// One renderer for both the Cycle schedules form and the Series form, so a form key
992/// hint or a refusal block cannot drift between the page's two tabs.
993fn render_form_fields(
994    f: &mut Frame,
995    area: Rect,
996    title: &str,
997    rows: &[(String, String, String)],
998    focus: usize,
999    error: Option<&str>,
1000    note: Option<&str>,
1001) {
1002    let block = Block::default()
1003        .borders(Borders::ALL)
1004        .title(title.to_string());
1005    let inner = block.inner(area);
1006    f.render_widget(block, area);
1007
1008    let mut lines: Vec<Line<'static>> = Vec::new();
1009    for (index, (label, value, hint)) in rows.iter().enumerate() {
1010        let focused = index == focus;
1011        let mut spans = vec![Span::raw(if focused { "> " } else { "  " })];
1012        spans.push(Span::styled(
1013            format!("{label}: "),
1014            if focused {
1015                Style::default().add_modifier(Modifier::BOLD)
1016            } else {
1017                dim()
1018            },
1019        ));
1020        spans.push(Span::raw(value.clone()));
1021        if focused {
1022            spans.push(Span::styled("▏", Style::default().fg(Color::Cyan)));
1023        }
1024        spans.push(Span::styled(format!("  ({hint})"), dim()));
1025        lines.push(Line::from(spans));
1026    }
1027    lines.push(Line::from(""));
1028    if let Some(note) = note {
1029        lines.push(Line::from(Span::styled(note.to_string(), dim())));
1030        lines.push(Line::from(""));
1031    }
1032    lines.push(Line::from(Span::styled(
1033        "tab / shift+tab: field  enter: save  esc: cancel, nothing written",
1034        dim(),
1035    )));
1036    if let Some(error) = error {
1037        lines.push(Line::from(""));
1038        for line in error.lines() {
1039            lines.push(Line::from(Span::styled(
1040                line.to_string(),
1041                Style::default().fg(Color::Red),
1042            )));
1043        }
1044    }
1045    f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
1046}
1047
1048/// The Series tab: the list of series beside the selected one's detail.
1049fn render_series(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1050    let entries = app.schedule_entries();
1051    let detail = app.schedule_detail(state.selected);
1052
1053    let body = Layout::default()
1054        .direction(Direction::Horizontal)
1055        .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1056        .split(area);
1057
1058    let list = Block::default()
1059        .borders(Borders::ALL)
1060        .title(format!("Series ({})", entries.len()));
1061    if entries.is_empty() {
1062        f.render_widget(
1063            Paragraph::new(ScheduleTab::Series.empty_hint())
1064                .block(list)
1065                .wrap(Wrap { trim: true })
1066                .style(dim()),
1067            body[0],
1068        );
1069    } else {
1070        let mut lines: Vec<Line<'static>> = Vec::new();
1071        for (index, entry) in entries.iter().enumerate() {
1072            let selected = index == state.selected;
1073            let mut spans = vec![Span::raw(if selected { "> " } else { "  " })];
1074            spans.push(Span::styled(
1075                entry.title.clone(),
1076                if selected {
1077                    Style::default().add_modifier(Modifier::BOLD)
1078                } else {
1079                    Style::default()
1080                },
1081            ));
1082            if entry.due {
1083                spans.push(Span::styled("  [due]", Style::default().fg(Color::Cyan)));
1084            }
1085            lines.push(Line::from(spans));
1086            lines.push(Line::from(Span::styled(
1087                format!("    {}", entry.subtitle),
1088                dim(),
1089            )));
1090        }
1091        f.render_widget(Paragraph::new(lines).block(list), body[0]);
1092    }
1093
1094    let block = Block::default()
1095        .borders(Borders::ALL)
1096        .title("Series detail");
1097    match detail.as_ref() {
1098        Some(detail) => {
1099            let lines: Vec<Line<'static>> = detail
1100                .lines
1101                .iter()
1102                .map(|(key, value)| {
1103                    Line::from(vec![
1104                        Span::styled(format!("{key}: "), dim()),
1105                        Span::raw(value.clone()),
1106                    ])
1107                })
1108                .collect();
1109            f.render_widget(
1110                Paragraph::new(lines)
1111                    .block(block)
1112                    .wrap(Wrap { trim: false }),
1113                body[1],
1114            );
1115        }
1116        None => f.render_widget(
1117            Paragraph::new(Span::styled(
1118                "Nothing selected: a series has to exist before the page can describe it.",
1119                dim(),
1120            ))
1121            .block(block)
1122            .wrap(Wrap { trim: true }),
1123            body[1],
1124        ),
1125    }
1126}
1127
1128/// The Cycle schedules tab: the schedule list (with any finding that belongs to no
1129/// listed schedule under it) beside the selected schedule's detail.
1130fn render_cycles(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1131    let entries = app.cycle_schedule_entries();
1132    let detail = app.cycle_schedule_detail(state.selected);
1133    let unattached = app.cycle_schedule_unattached_findings();
1134
1135    let body = Layout::default()
1136        .direction(Direction::Horizontal)
1137        .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1138        .split(area);
1139
1140    // A dangling identity — a generated cycle naming a schedule that is not in the
1141    // registry — has no row of its own, so it gets its own block under the list
1142    // rather than being silently counted. Its height is the block it needs, capped
1143    // so a wall of findings cannot swallow the list.
1144    if unattached.is_empty() {
1145        render_cycle_list(f, body[0], &entries, state.selected);
1146    } else {
1147        // Two rows of chrome, plus three rows per finding: a rendered diagnostic is
1148        // one long sentence that wraps in a pane this narrow, and clipping it to a
1149        // single row would hide the half that says which schedule is missing. Capped
1150        // so a wall of findings cannot swallow the list.
1151        let height = (unattached.len() as u16 * 3 + 2).min(12);
1152        let columns = Layout::default()
1153            .direction(Direction::Vertical)
1154            .constraints([Constraint::Min(3), Constraint::Length(height)])
1155            .split(body[0]);
1156        render_cycle_list(f, columns[0], &entries, state.selected);
1157        let findings: Vec<Line<'static>> = unattached
1158            .iter()
1159            .map(|line| Line::from(Span::styled(line.clone(), dim())))
1160            .collect();
1161        f.render_widget(
1162            Paragraph::new(findings)
1163                .block(
1164                    Block::default()
1165                        .borders(Borders::ALL)
1166                        .title(format!("Unattached findings ({})", unattached.len())),
1167                )
1168                .wrap(Wrap { trim: true }),
1169            columns[1],
1170        );
1171    }
1172
1173    let block = Block::default()
1174        .borders(Borders::ALL)
1175        .title("Cycle schedule detail");
1176    match detail.as_ref() {
1177        Some(detail) => {
1178            let mut lines: Vec<Line<'static>> = detail
1179                .fields
1180                .iter()
1181                .map(|(key, value)| {
1182                    Line::from(vec![
1183                        Span::styled(format!("{key}: "), dim()),
1184                        Span::raw(value.clone()),
1185                    ])
1186                })
1187                .collect();
1188            section(&mut lines, "cycles", &detail.cycles);
1189            section(&mut lines, "snapshots", &detail.snapshots);
1190            section(&mut lines, "findings", &detail.findings);
1191            section(&mut lines, "warnings", &detail.warnings);
1192            f.render_widget(
1193                Paragraph::new(lines)
1194                    .block(block)
1195                    .wrap(Wrap { trim: false }),
1196                body[1],
1197            );
1198        }
1199        None => f.render_widget(
1200            Paragraph::new(Span::styled(
1201                "Nothing selected: a cycle schedule has to exist before the page can \
1202                 describe it.",
1203                dim(),
1204            ))
1205            .block(block)
1206            .wrap(Wrap { trim: true }),
1207            body[1],
1208        ),
1209    }
1210}
1211
1212/// The schedule list block, its empty state explicit.
1213fn render_cycle_list(f: &mut Frame, area: Rect, entries: &[CycleScheduleEntry], selected: usize) {
1214    let block = Block::default()
1215        .borders(Borders::ALL)
1216        .title(format!("Cycle schedules ({})", entries.len()));
1217    if entries.is_empty() {
1218        f.render_widget(
1219            Paragraph::new(ScheduleTab::Cycles.empty_hint())
1220                .block(block)
1221                .wrap(Wrap { trim: true })
1222                .style(dim()),
1223            area,
1224        );
1225        return;
1226    }
1227    let mut lines: Vec<Line<'static>> = Vec::new();
1228    for (index, entry) in entries.iter().enumerate() {
1229        let is_selected = index == selected;
1230        let mut spans = vec![Span::raw(if is_selected { "> " } else { "  " })];
1231        spans.push(Span::styled(
1232            entry.title.clone(),
1233            if is_selected {
1234                Style::default().add_modifier(Modifier::BOLD)
1235            } else {
1236                Style::default()
1237            },
1238        ));
1239        if entry.warned {
1240            spans.push(Span::styled("  [warn]", Style::default().fg(Color::Yellow)));
1241        }
1242        lines.push(Line::from(spans));
1243        lines.push(Line::from(Span::styled(
1244            format!("    {}", entry.subtitle),
1245            dim(),
1246        )));
1247    }
1248    f.render_widget(Paragraph::new(lines).block(block), area);
1249}
1250
1251/// Append one `heading (N):` section and its lines — or an explicit `none`, so a
1252/// section can never be mistaken for a rendering failure.
1253fn section(lines: &mut Vec<Line<'static>>, heading: &str, body: &[String]) {
1254    lines.push(Line::from(""));
1255    lines.push(Line::from(Span::styled(
1256        format!("{heading} ({}):", body.len()),
1257        Style::default().add_modifier(Modifier::BOLD),
1258    )));
1259    if body.is_empty() {
1260        lines.push(Line::from(Span::styled("  none", dim())));
1261        return;
1262    }
1263    for line in body {
1264        lines.push(Line::from(Span::raw(format!("  {line}"))));
1265    }
1266}
1267
1268/// The Capacity tab: the configured scopes beside the selected one's shared
1269/// calculation, its findings and — for a cycle scope — its immutable close evidence.
1270///
1271/// Read-only: it draws what [`crate::app::App`] read from the shared DTOs and offers
1272/// no key that writes. Its empty state is a sentence — both when nothing is
1273/// configured and when nothing is committed — so a blank pane can never be mistaken
1274/// for a rendering failure; and a **failed** read is distinguished from an empty
1275/// selection in both panes, so neither can claim nothing is configured while the
1276/// question was never answered.
1277fn render_capacity(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1278    let rows = app.capacity_rows();
1279    let body = Layout::default()
1280        .direction(Direction::Horizontal)
1281        .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1282        .split(area);
1283
1284    let list = Block::default()
1285        .borders(Borders::ALL)
1286        .title(format!("Capacity ({})", rows.len()));
1287    if !app.capacity_read() {
1288        f.render_widget(
1289            Paragraph::new(
1290                "Could not read the capacity snapshot for this project, so no capacity view \
1291                 is available.",
1292            )
1293            .block(list)
1294            .wrap(Wrap { trim: true })
1295            .style(dim()),
1296            body[0],
1297        );
1298    } else if rows.is_empty() {
1299        f.render_widget(
1300            Paragraph::new(ScheduleTab::Capacity.empty_hint())
1301                .block(list)
1302                .wrap(Wrap { trim: true })
1303                .style(dim()),
1304            body[0],
1305        );
1306    } else {
1307        let mut lines: Vec<Line<'static>> = Vec::new();
1308        for (index, row) in rows.iter().enumerate() {
1309            let selected = index == state.selected;
1310            let mut spans = vec![Span::raw(if selected { "> " } else { "  " })];
1311            spans.push(Span::styled(
1312                row.title.clone(),
1313                if selected {
1314                    Style::default().add_modifier(Modifier::BOLD)
1315                } else {
1316                    Style::default()
1317                },
1318            ));
1319            if row.overflowing {
1320                spans.push(Span::styled("  [over]", Style::default().fg(Color::Red)));
1321            } else if row.warned {
1322                spans.push(Span::styled("  [warn]", Style::default().fg(Color::Yellow)));
1323            }
1324            lines.push(Line::from(spans));
1325            lines.push(Line::from(Span::styled(
1326                format!("    {}", row.subtitle),
1327                dim(),
1328            )));
1329        }
1330        // "Nothing committed" is a fact about the work rather than about the
1331        // configuration, and a reader must not have to infer it from `-` values.
1332        if app.capacity_nothing_committed() {
1333            lines.push(Line::from(""));
1334            lines.push(Line::from(Span::styled(
1335                "no committed work in any configured scope yet",
1336                dim(),
1337            )));
1338        }
1339        f.render_widget(Paragraph::new(lines).block(list), body[0]);
1340    }
1341
1342    let block = Block::default()
1343        .borders(Borders::ALL)
1344        .title("Capacity detail");
1345    match app.capacity_detail(state.selected) {
1346        Some(detail) => {
1347            let mut lines: Vec<Line<'static>> = Vec::new();
1348            for (key, value) in &detail.fields {
1349                lines.push(Line::from(vec![
1350                    Span::styled(format!("{key}: "), dim()),
1351                    Span::raw(value.clone()),
1352                ]));
1353            }
1354            section(&mut lines, "findings", &detail.diagnostics);
1355            if let Some(snapshot) = &detail.snapshot {
1356                lines.push(Line::from(""));
1357                lines.push(Line::from(Span::styled(
1358                    format!("closed snapshot — {}", snapshot.label),
1359                    Style::default().add_modifier(Modifier::BOLD),
1360                )));
1361                for (key, value) in &snapshot.fields {
1362                    lines.push(Line::from(vec![
1363                        Span::styled(format!("{key}: "), dim()),
1364                        Span::raw(value.clone()),
1365                    ]));
1366                }
1367                if snapshot.fields.is_empty() {
1368                    lines.push(Line::from(Span::styled("  none", dim())));
1369                }
1370            }
1371            f.render_widget(
1372                Paragraph::new(lines)
1373                    .block(block)
1374                    .wrap(Wrap { trim: false }),
1375                body[1],
1376            );
1377        }
1378        None => {
1379            // A missing detail has two causes, and they are not the same statement:
1380            // there is nothing selected, or the read that would have supplied the
1381            // rows failed. The list pane already distinguishes them; a detail pane
1382            // claiming "nothing is configured" over a failed read would be exactly the
1383            // conflation the mission's absent-value rules forbid.
1384            let sentence = if app.capacity_read() {
1385                "Nothing selected: a capacity scope has to be configured before the page \
1386                 can describe it."
1387            } else {
1388                "Could not read the capacity snapshot for this project, so no capacity \
1389                 view is available."
1390            };
1391            f.render_widget(
1392                Paragraph::new(Span::styled(sentence, dim()))
1393                    .block(block)
1394                    .wrap(Wrap { trim: true }),
1395                body[1],
1396            );
1397        }
1398    }
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    #[allow(clippy::wildcard_imports)]
1404    use super::*;
1405
1406    #[test]
1407    fn the_cursor_clamps_to_the_row_count() {
1408        let mut state = ScheduleState::new();
1409        assert!(state.move_selected(3, 5));
1410        assert_eq!(state.selected, 3);
1411        assert!(state.move_selected(9, 5), "clamped to the last row");
1412        assert_eq!(state.selected, 4);
1413        assert!(!state.move_selected(1, 5));
1414        state.move_selected(-9, 5);
1415        assert_eq!(state.selected, 0);
1416        state.move_selected(1, 0);
1417        assert_eq!(state.selected, 0, "an empty list keeps the cursor at zero");
1418    }
1419
1420    #[test]
1421    fn a_shrinking_registry_clamps_the_cursor() {
1422        let mut state = ScheduleState {
1423            selected: 4,
1424            ..Default::default()
1425        };
1426        state.clamp_selected(2);
1427        assert_eq!(state.selected, 1);
1428        state.clamp_selected(0);
1429        assert_eq!(state.selected, 0);
1430    }
1431
1432    #[test]
1433    fn the_tab_strip_has_all_three_tabs_and_the_series_tab_is_the_default() {
1434        assert_eq!(ScheduleTab::default(), ScheduleTab::Series);
1435        assert_eq!(
1436            ScheduleTab::ALL,
1437            [
1438                ScheduleTab::Series,
1439                ScheduleTab::Cycles,
1440                ScheduleTab::Capacity
1441            ]
1442        );
1443        assert_eq!(ScheduleTab::Series.label(), "Series");
1444        assert_eq!(ScheduleTab::Cycles.label(), "Cycle schedules");
1445        assert_eq!(ScheduleTab::Capacity.label(), "Capacity");
1446        let drawn: String = tab_line(ScheduleTab::Capacity)
1447            .spans
1448            .iter()
1449            .map(|span| span.content.clone())
1450            .collect();
1451        assert!(drawn.contains("[Series]"), "{drawn}");
1452        assert!(drawn.contains("[Cycle schedules]"), "{drawn}");
1453        assert!(drawn.contains("[Capacity]"), "{drawn}");
1454        assert!(drawn.contains("F8 Schedule"), "{drawn}");
1455    }
1456
1457    #[test]
1458    fn the_tab_strip_geometry_matches_the_drawn_line() {
1459        // The pointer addresses a tab by the rect this reports, so it must land on
1460        // the label the strip actually draws — a mismatch would make a click switch
1461        // to the wrong tab. Each rect's label is read back out of the same line.
1462        let area = Rect::new(3, 7, 80, 24);
1463        let drawn: Vec<String> = tab_line(ScheduleTab::Series)
1464            .spans
1465            .iter()
1466            .map(|span| span.content.to_string())
1467            .collect();
1468        let line = drawn.concat();
1469        let rects = tab_rects(area);
1470        assert_eq!(rects.len(), ScheduleTab::ALL.len());
1471        for (tab, rect) in rects {
1472            assert_eq!(rect.y, area.y, "the strip is one row at the top");
1473            assert_eq!(rect.height, 1);
1474            let start = usize::from(rect.x - area.x);
1475            let end = start + usize::from(rect.width);
1476            assert_eq!(
1477                &line[start..end],
1478                format!("[{}]", tab.label()),
1479                "the rect addresses {tab:?}'s own label"
1480            );
1481        }
1482    }
1483
1484    #[test]
1485    fn switching_tabs_wraps_and_resets_the_row_cursor() {
1486        let mut state = ScheduleState {
1487            selected: 3,
1488            ..Default::default()
1489        };
1490        state.switch_tab(1);
1491        assert_eq!(state.tab, ScheduleTab::Cycles);
1492        assert_eq!(
1493            state.selected, 0,
1494            "the cursor indexes the active tab's rows"
1495        );
1496        state.switch_tab(1);
1497        assert_eq!(state.tab, ScheduleTab::Capacity);
1498        state.switch_tab(1);
1499        assert_eq!(state.tab, ScheduleTab::Series, "`Tab` wraps");
1500        state.switch_tab(-1);
1501        assert_eq!(state.tab, ScheduleTab::Capacity, "`Shift+Tab` steps back");
1502    }
1503
1504    #[test]
1505    fn every_tab_has_an_explicit_empty_state() {
1506        for tab in ScheduleTab::ALL {
1507            assert!(
1508                tab.empty_hint().contains("No ") || tab.empty_hint().contains("yet"),
1509                "{tab:?} says what is missing: {}",
1510                tab.empty_hint()
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn the_series_empty_state_keeps_the_cli_hint_and_adds_the_tui_offer() {
1517        let hint = ScheduleTab::Series.empty_hint();
1518        assert!(
1519            hint.contains(
1520                "Create one with `tix recurring create --title <TITLE> --rule daily \
1521                 --anchor <DATE>`"
1522            ),
1523            "the CLI hint survives verbatim: {hint}"
1524        );
1525        assert!(
1526            hint.contains("press `n` to create one here"),
1527            "and the TUI offers its own key: {hint}"
1528        );
1529    }
1530
1531    #[test]
1532    fn the_hint_line_advertises_every_key_the_series_tab_handles() {
1533        for expected in [
1534            "j/k",
1535            "n: new",
1536            "g: generate",
1537            "p: pause",
1538            "r: resume",
1539            "x: end",
1540            "s: skip",
1541            "d: defer",
1542            "i: ignore",
1543            "q: back",
1544        ] {
1545            assert!(
1546                SERIES_KEYS.contains(expected),
1547                "the page advertises `{expected}`: {SERIES_KEYS}"
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn the_cycle_tab_hint_advertises_every_key_it_handles() {
1554        for expected in [
1555            "j/k",
1556            "Tab: tabs",
1557            "n: new",
1558            "E: edit",
1559            "g: generate",
1560            "c: close",
1561            "e: extend",
1562            "s: skip",
1563            "p: pause",
1564            "r: resume",
1565            "a: adjust-future",
1566            "i: ignore",
1567            "q: back",
1568        ] {
1569            assert!(
1570                CYCLE_SCHEDULE_KEYS.contains(expected),
1571                "the tab advertises `{expected}`: {CYCLE_SCHEDULE_KEYS}"
1572            );
1573        }
1574    }
1575
1576    #[test]
1577    fn the_cycle_help_rows_advertise_the_same_keys_as_the_hint() {
1578        // The `?` overlay and the page's own hint row are two instructions to the
1579        // same user; a key the tab acts on must be in both, or one of them lies.
1580        let advertised: Vec<&str> = CYCLE_HELP_RAW.iter().map(|(key, _)| *key).collect();
1581        for key in ["tab / shift+tab", "n", "E", "c", "e", "s", "a"] {
1582            assert!(
1583                advertised.contains(&key),
1584                "the help names `{key}`: {advertised:?}"
1585            );
1586        }
1587        // And the two rows the tab does not act on are gone.
1588        let actions: Vec<TuiAction> = CYCLE_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
1589        assert!(
1590            !actions.contains(&TuiAction::SeriesEnd),
1591            "`x` ends a series and means nothing here"
1592        );
1593        assert!(
1594            !actions.contains(&TuiAction::SeriesDefer),
1595            "`d` defers a series' due date and means nothing here"
1596        );
1597    }
1598
1599    #[test]
1600    fn the_capacity_help_rows_advertise_the_same_keys_as_the_hint() {
1601        // The Capacity tab is read-only, so its overlay must name only the keys it
1602        // acts on: `j`/`k`, the tab switch and the way back. A series action here
1603        // would be an instruction that does nothing.
1604        let actions: Vec<TuiAction> = CAPACITY_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
1605        for action in [
1606            TuiAction::MoveUp,
1607            TuiAction::MoveDown,
1608            TuiAction::Help,
1609            TuiAction::Close,
1610        ] {
1611            assert!(
1612                actions.contains(&action),
1613                "the help names {action:?}: {actions:?}"
1614            );
1615        }
1616        for absent in [
1617            TuiAction::SeriesGenerate,
1618            TuiAction::SeriesPause,
1619            TuiAction::SeriesEnd,
1620        ] {
1621            assert!(
1622                !actions.contains(&absent),
1623                "{absent:?} acts on nothing here"
1624            );
1625        }
1626        assert!(CAPACITY_KEYS.contains("j/k"), "{CAPACITY_KEYS}");
1627        assert!(CAPACITY_KEYS.contains("Tab: tabs"), "{CAPACITY_KEYS}");
1628        assert!(CAPACITY_KEYS.contains("q: back"), "{CAPACITY_KEYS}");
1629        assert_eq!(
1630            CAPACITY_HELP_RAW
1631                .iter()
1632                .map(|(key, _)| *key)
1633                .collect::<Vec<_>>(),
1634            vec!["tab / shift+tab"]
1635        );
1636    }
1637
1638    #[test]
1639    fn the_create_form_shows_every_field_and_the_edit_form_every_but_the_id() {
1640        let create = ScheduleForm::create();
1641        assert_eq!(create.fields, ScheduleField::ALL);
1642        assert_eq!(create.focused(), ScheduleField::Id);
1643        assert!(create.title().contains("New cycle schedule"));
1644
1645        let edit = ScheduleForm::edit("cs-x".to_string(), vec!["Name".to_string()]);
1646        assert_eq!(edit.fields, ScheduleField::EDIT);
1647        assert!(
1648            !edit.fields.contains(&ScheduleField::Id),
1649            "the identity is the registry key and does not change"
1650        );
1651        assert_eq!(edit.value(ScheduleField::Name), "Name");
1652        assert_eq!(
1653            edit.value(ScheduleField::Id),
1654            "",
1655            "not shown by the edit form"
1656        );
1657        assert_eq!(
1658            edit.value(ScheduleField::StartDate),
1659            "",
1660            "a missing value is empty"
1661        );
1662        assert!(edit.title().contains("cs-x"));
1663    }
1664
1665    #[test]
1666    fn the_form_focus_wraps_and_typing_lands_in_the_focused_field() {
1667        let mut form = ScheduleForm::create();
1668        form.insert_char('c');
1669        form.insert_char('s');
1670        assert_eq!(form.value(ScheduleField::Id), "cs");
1671        form.focus_next();
1672        assert_eq!(form.focused(), ScheduleField::Name);
1673        form.insert_char('X');
1674        assert_eq!(form.value(ScheduleField::Name), "X");
1675        form.delete_char();
1676        assert_eq!(form.value(ScheduleField::Name), "");
1677        form.focus_prev();
1678        assert_eq!(form.focused(), ScheduleField::Id, "backward wraps too");
1679        // Forward from the last field wraps to the first.
1680        for _ in 0..ScheduleField::ALL.len() {
1681            form.focus_next();
1682        }
1683        assert_eq!(form.focused(), ScheduleField::Id);
1684
1685        // A refusal is kept on the form, so what was typed survives it.
1686        form.set_error("nope".to_string());
1687        assert_eq!(form.error.as_deref(), Some("nope"));
1688    }
1689
1690    #[test]
1691    fn the_series_create_form_asks_for_the_three_required_fields() {
1692        let form = SeriesForm::create();
1693        assert_eq!(form.fields, SeriesField::ALL);
1694        assert_eq!(
1695            form.fields,
1696            [SeriesField::Title, SeriesField::Rule, SeriesField::Anchor],
1697            "the first-run path is title, rule, anchor — the three the CLI requires"
1698        );
1699        assert_eq!(form.focused(), SeriesField::Title);
1700        // Every field names the vocabulary it accepts, so the form is its own remedy.
1701        for field in SeriesField::ALL {
1702            assert!(!field.label().is_empty(), "{field:?} has a label");
1703            assert!(!field.hint().is_empty(), "{field:?} has a hint");
1704        }
1705        assert_eq!(
1706            SeriesField::Rule.hint(),
1707            "daily | weekly | monthly",
1708            "the rule hint names only the families the form's three fields can complete"
1709        );
1710        assert!(
1711            SeriesForm::NOTE.contains("`interval`")
1712                && SeriesForm::NOTE.contains("`cron-like`")
1713                && SeriesForm::NOTE.contains("tix recurring create"),
1714            "the families the form cannot complete are named as the CLI's: {}",
1715            SeriesForm::NOTE
1716        );
1717        assert_eq!(SeriesField::Anchor.hint(), "YYYY-MM-DD");
1718        assert!(form.title().contains("New recurring series"));
1719    }
1720
1721    #[test]
1722    fn the_series_form_focus_wraps_and_typing_lands_in_the_focused_field() {
1723        let mut form = SeriesForm::create();
1724        for c in "Nightly".chars() {
1725            form.insert_char(c);
1726        }
1727        assert_eq!(form.value(SeriesField::Title), "Nightly");
1728        form.focus_next();
1729        assert_eq!(form.focused(), SeriesField::Rule);
1730        for c in "daily".chars() {
1731            form.insert_char(c);
1732        }
1733        assert_eq!(form.value(SeriesField::Rule), "daily");
1734        form.focus_next();
1735        form.insert_char('X');
1736        assert_eq!(form.value(SeriesField::Anchor), "X");
1737        form.delete_char();
1738        assert_eq!(form.value(SeriesField::Anchor), "");
1739        form.clear_field();
1740        assert_eq!(form.value(SeriesField::Anchor), "");
1741        form.focus_prev();
1742        assert_eq!(form.focused(), SeriesField::Rule, "backward wraps too");
1743        // Forward from the last field wraps to the first.
1744        form.focus_next(); // anchor
1745        form.focus_next(); // wraps back to the title
1746        assert_eq!(
1747            form.focused(),
1748            SeriesField::Title,
1749            "forward wraps to the first field"
1750        );
1751        for _ in 0..SeriesField::ALL.len() {
1752            form.focus_next();
1753        }
1754        assert_eq!(form.focused(), SeriesField::Title);
1755
1756        form.set_error("refused".to_string());
1757        assert_eq!(form.error.as_deref(), Some("refused"));
1758        // A field the form does not show reads as empty rather than panicking.
1759        assert_eq!(form.value(SeriesField::Title), "Nightly");
1760    }
1761}