Skip to main content

tix/app/
schedule.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// The series/occurrence index, built from the files on the spot.
6    ///
7    /// Never cached: a lifecycle action or a generation changes the files, and an
8    /// index that outlived the change would let a second action decide from a
9    /// snapshot (the bug WP05's first version had).
10    pub(in crate::app) fn series_index(&self) -> Result<crate::recurrence::series::SeriesIndex> {
11        Ok(crate::recurrence::series::SeriesIndex::build(
12            &self.store.list()?,
13        ))
14    }
15
16    /// Today, as the `time::Date` a rule context takes.
17    ///
18    /// [`crate::model::today`] is the crate's one clock read and hands back the ISO
19    /// string the file format stores; the rule context wants the value. The TUI is a
20    /// boundary that may resolve "now" — detection itself never does — and the parse
21    /// is a handful of lines because `DateKey` exposes no parser of its own.
22    pub(in crate::app) fn today_as_date() -> Option<time::Date> {
23        let raw = crate::model::today();
24        let year: i32 = raw.get(0..4)?.parse().ok()?;
25        let month: u8 = raw.get(5..7)?.parse().ok()?;
26        let day: u8 = raw.get(8..10)?.parse().ok()?;
27        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
28    }
29
30    /// The date context a cycle-schedule pass evaluates in: the crate's one boundary
31    /// clock read, exactly as [`Self::due_keys`] resolves it for the series. The
32    /// domain never reads a clock, so the tab and `tix cycle schedule detect` take
33    /// the same value from the same place.
34    pub(in crate::app) fn cycle_schedule_context() -> Option<crate::recurrence::rule::RuleContext> {
35        Self::today_as_date().map(crate::recurrence::rule::RuleContext::on)
36    }
37
38    /// The read model the Cycle schedules tab renders (WP02's [`ScheduleIndex`]),
39    /// built from the registry and the records on disk.
40    ///
41    /// Rebuilt rather than cached, for the reason [`Self::series_index`] is: a write
42    /// — the user's own confirmed action, or another terminal's `tix cycle schedule
43    /// close` — has to be visible to the next read, and an index that outlived the
44    /// change would let the page decide from a snapshot.
45    pub(in crate::app) fn cycle_schedule_index(
46        &self,
47    ) -> Result<crate::cycle_schedule::identity::ScheduleIndex> {
48        let root = self.project_root();
49        let records = crate::cycle_store::CycleStore::new(&root).list()?;
50        Ok(crate::cycle_schedule::identity::ScheduleIndex::build(
51            &self.store.config,
52            &records,
53        ))
54    }
55
56    /// The read-only cycle-schedule evaluation the tab shows (T035).
57    ///
58    /// **Read-only, by construction**: it builds the index from the registry and the
59    /// stored records and calls the pure
60    /// [`crate::cycle_schedule::evaluate::evaluate`] on it. No record is written, no
61    /// snapshot is captured, no cursor is advanced and no History event is appended —
62    /// the only writers in this mission are WP04's `generate` and WP05's `apply`, and
63    /// neither is reachable from here. A refresh that finds a warning finds it again
64    /// on the next refresh, which is exactly what closing it by reading cannot change.
65    pub(in crate::app) fn refresh_cycle_schedule_warnings(&mut self) {
66        let Some(context) = Self::cycle_schedule_context() else {
67            return;
68        };
69        let Ok(index) = self.cycle_schedule_index() else {
70            // A read failure leaves the previous value: the page then shows what it
71            // last knew rather than a fabricated "nothing is due".
72            return;
73        };
74        self.cycle_schedule_warnings =
75            Some(crate::cycle_schedule::evaluate::evaluate(&index, &context));
76    }
77
78    /// The last evaluation, or `None` before the first pass.
79    pub fn cycle_schedule_evaluation(
80        &self,
81    ) -> Option<&crate::cycle_schedule::evaluate::CycleEvaluation> {
82        self.cycle_schedule_warnings.as_ref()
83    }
84
85    /// The F8 Schedule page's active tab, and `None` in every other mode.
86    ///
87    /// The `?` overlay is the one surface that has to know it: the page's two tabs
88    /// act on different keys, and an overlay that advertised the Series keys while the
89    /// Cycle schedules tab is showing would name two keys that do nothing there
90    /// (WP07 review). The overlay is drawn while `mode` is [`Mode::Help`], so the page
91    /// the help was **opened from** is what counts — its state is still held, and the
92    /// top mode is still Schedule.
93    pub fn schedule_help_tab(&self) -> Option<crate::ui::schedule::ScheduleTab> {
94        let on_schedule = matches!(self.mode, Mode::Schedule)
95            || matches!(&self.mode, Mode::Help { return_to } if matches!(**return_to, Mode::Schedule));
96        on_schedule.then(|| self.schedule_state().tab)
97    }
98
99    /// One row per cycle schedule, in declaration order, flagged when the last
100    /// evaluation reported a warning for it.
101    ///
102    /// The row's tail is the same set of values `tix cycle schedule list` prints
103    /// (its columns minus the ones the detail pane spells out in full), taken from
104    /// the one read model rather than re-derived (C-008/NFR-003).
105    pub fn cycle_schedule_entries(&self) -> Vec<crate::ui::schedule::CycleScheduleEntry> {
106        let Ok(index) = self.cycle_schedule_index() else {
107            return Vec::new();
108        };
109        let warned: std::collections::BTreeSet<_> = self
110            .cycle_schedule_warnings
111            .as_ref()
112            .map(|evaluation| {
113                evaluation
114                    .warnings()
115                    .iter()
116                    .map(|warning| warning.schedule_id.clone())
117                    .collect()
118            })
119            .unwrap_or_default();
120        index
121            .schedules()
122            .map(|(id, record)| crate::ui::schedule::CycleScheduleEntry {
123                title: record.name.clone(),
124                subtitle: format!(
125                    "{} · {} · {} · start {} · next {} · {} cycle(s)",
126                    id,
127                    record.state.as_str(),
128                    crate::cycle_schedule::identity::cadence_rule(record)
129                        .map(|rule| rule.summary())
130                        .unwrap_or_else(|| "(no cadence)".to_string()),
131                    record.start_date,
132                    index
133                        .cursor(id)
134                        .next_sequence
135                        .map(|sequence| sequence.to_string())
136                        .unwrap_or_else(|| "-".to_string()),
137                    index.cycles_of(id).count(),
138                ),
139                warned: warned.contains(id),
140            })
141            .collect()
142    }
143
144    /// The selected schedule's detail: its record, its generated cycles, its closed
145    /// snapshots, its identity findings and its evaluated warnings (T036/T038).
146    ///
147    /// Every value is the one the CLI's `show` prints or the one `detect` evaluated —
148    /// nothing is recomputed here, so a generated cycle's `state` is the stored
149    /// record's rather than this surface's opinion about its dates (C-007).
150    pub fn cycle_schedule_detail(
151        &self,
152        selected: usize,
153    ) -> Option<crate::ui::schedule::CycleScheduleDetail> {
154        use crate::cycle_schedule::identity::cadence_rule;
155        use crate::recurrence::rule::IntervalUnit;
156
157        let index = self.cycle_schedule_index().ok()?;
158        let (id, record) = index.schedules().nth(selected)?;
159        let id = id.clone();
160        let record = record.clone();
161        let cursor = index.cursor(&id);
162
163        let fields = vec![
164            ("id".to_string(), id.as_str().to_string()),
165            ("name".to_string(), record.name.clone()),
166            ("state".to_string(), record.state.as_str().to_string()),
167            (
168                "cadence".to_string(),
169                cadence_rule(&record)
170                    .map(|rule| rule.summary())
171                    .unwrap_or_else(|| "(no cadence)".to_string()),
172            ),
173            (
174                "duration".to_string(),
175                format!(
176                    "{} {}",
177                    record.duration.count,
178                    record
179                        .duration
180                        .unit
181                        .map_or("(no unit)", IntervalUnit::as_str)
182                ),
183            ),
184            ("start_date".to_string(), record.start_date.clone()),
185            (
186                "timezone".to_string(),
187                record
188                    .timezone
189                    .as_ref()
190                    .map(ToString::to_string)
191                    .unwrap_or_else(|| "(none)".to_string()),
192            ),
193            ("look_ahead".to_string(), record.look_ahead.to_string()),
194            (
195                "next_sequence".to_string(),
196                cursor
197                    .next_sequence
198                    .map(|sequence| sequence.to_string())
199                    .unwrap_or_else(|| "-".to_string()),
200            ),
201            (
202                "next_start".to_string(),
203                cursor.next_start.clone().unwrap_or_else(|| "-".to_string()),
204            ),
205            (
206                "last_generated_key".to_string(),
207                cursor
208                    .last_generated_key
209                    .as_ref()
210                    .map(crate::cycle_schedule::identity::GeneratedCycleId::as_str)
211                    .unwrap_or("-")
212                    .to_string(),
213            ),
214            (
215                "generated".to_string(),
216                index.cycles_of(&id).count().to_string(),
217            ),
218        ];
219
220        let cycles = index
221            .cycles_of(&id)
222            .map(|cycle| {
223                format!(
224                    "{}  seq {}  {}  {} → {}  {}",
225                    cycle.id,
226                    cycle.sequence,
227                    cycle.name,
228                    cycle.start_date,
229                    cycle.due_date,
230                    cycle.state.as_str()
231                )
232            })
233            .collect();
234
235        let snapshots =
236            crate::cycle_schedule::snapshot::CycleSnapshotStore::new(&self.project_root())
237                .list()
238                .unwrap_or_default()
239                .into_iter()
240                .filter(|snapshot| snapshot.schedule_id == id)
241                .map(|snapshot| {
242                    format!(
243                        "docs/cycle_snapshots/{}  closed {} by {} ({})",
244                        crate::cycle_schedule::snapshot::CycleSnapshotStore::filename_for(
245                            &snapshot.cycle_id
246                        ),
247                        snapshot.closed_at,
248                        snapshot.closed_by,
249                        snapshot.state_summary
250                    )
251                })
252                .collect();
253
254        let findings = index
255            .diagnostics()
256            .iter()
257            .filter(|diagnostic| diagnostic.schedule_id.as_ref() == Some(&id))
258            .map(crate::cycle_schedule::identity::IdentityDiagnostic::render)
259            .collect();
260
261        let warnings = self
262            .cycle_schedule_warnings
263            .as_ref()
264            .map(|evaluation| {
265                evaluation
266                    .warnings_of(&id)
267                    .map(Self::cycle_schedule_warning_line)
268                    .collect()
269            })
270            .unwrap_or_default();
271
272        Some(crate::ui::schedule::CycleScheduleDetail {
273            fields,
274            cycles,
275            snapshots,
276            findings,
277            warnings,
278        })
279    }
280
281    /// Findings that belong to **no** listed schedule: a record naming a schedule
282    /// that is not in the registry (a dangling identity), or one the index could not
283    /// attribute at all.
284    ///
285    /// They get their own block on the tab rather than being folded into a
286    /// schedule's detail, because no schedule's detail is theirs.
287    pub fn cycle_schedule_unattached_findings(&self) -> Vec<String> {
288        let Ok(index) = self.cycle_schedule_index() else {
289            return Vec::new();
290        };
291        index
292            .diagnostics()
293            .iter()
294            .filter(|diagnostic| {
295                diagnostic
296                    .schedule_id
297                    .as_ref()
298                    .is_none_or(|id| index.schedule(id).is_none())
299            })
300            .map(crate::cycle_schedule::identity::IdentityDiagnostic::render)
301            .collect()
302    }
303
304    /// One warning as a line: what it is, what it is about, the candidate a
305    /// confirmed generation would create, and the explicit decisions it accepts.
306    pub(in crate::app) fn cycle_schedule_warning_line(
307        warning: &crate::cycle_schedule::evaluate::ScheduleWarning,
308    ) -> String {
309        let mut line = format!("{} · {}", warning.kind.as_str(), warning.schedule_id);
310        if let Some(cycle) = &warning.cycle_id {
311            line.push_str(&format!(" · {cycle}"));
312        }
313        if let Some(dates) = &warning.effective_dates {
314            line.push_str(&format!(" · {} → {}", dates.start, dates.due));
315        }
316        if let Some(next) = &warning.next_generation {
317            line.push_str(&format!(" · next {}", next.id));
318            if let Some(start) = &next.start_date {
319                line.push_str(&format!(" ({start})"));
320            }
321        }
322        if !warning.available_actions.is_empty() {
323            let actions: Vec<&str> = warning
324                .available_actions
325                .iter()
326                .map(|action| action.as_str())
327                .collect();
328            line.push_str(&format!(" · actions {}", actions.join(",")));
329        }
330        if let Some(diagnostic) = &warning.diagnostic {
331            line.push_str(&format!(" · {}", diagnostic.message));
332        }
333        line
334    }
335
336    /// The stable id of the cycle schedule under the Cycle schedules tab's cursor.
337    pub(in crate::app) fn selected_cycle_schedule_id(
338        &self,
339    ) -> Option<crate::cycle_schedule::config::CycleScheduleId> {
340        let selected = self.schedule_state().selected;
341        self.cycle_schedule_index()
342            .ok()?
343            .schedules()
344            .nth(selected)
345            .map(|(id, _)| id.clone())
346    }
347
348    /// The instance a lifecycle action targets: the schedule's first instance in
349    /// sequence order that is not terminal — its "current" cycle.
350    ///
351    /// `None` when there is none, and that is deliberate: the request then names no
352    /// instance and the **shared service** answers with its own `missing cycle`
353    /// refusal, rather than this surface inventing a target. A closed or skipped
354    /// instance is terminal and is never the current one.
355    pub(in crate::app) fn current_cycle_id(
356        &self,
357        schedule: &crate::cycle_schedule::config::CycleScheduleId,
358    ) -> Option<crate::cycle_schedule::identity::GeneratedCycleId> {
359        self.cycle_schedule_index()
360            .ok()?
361            .cycles_of(schedule)
362            .find(|cycle| !cycle.state.is_terminal())
363            .map(|cycle| cycle.id.clone())
364    }
365
366    /// The instance a **reopen** undoes: the selected schedule's closed instance, in
367    /// sequence order (the most recent one).
368    ///
369    /// `close`'s siblings act on the schedule's *current* instance — the first that is
370    /// not terminal. A `closed` instance **is** terminal, so it is never that one, and
371    /// a reopen that targeted it through [`Self::current_cycle_id`] would name no
372    /// instance at all. The target is still the explicit one the action is about; a
373    /// schedule with no closed instance returns `None`, and the shared service then
374    /// answers with its own `missing cycle` refusal rather than this surface inventing
375    /// one.
376    pub(in crate::app) fn reopenable_cycle_id(
377        &self,
378        schedule: &crate::cycle_schedule::config::CycleScheduleId,
379    ) -> Option<crate::cycle_schedule::identity::GeneratedCycleId> {
380        use crate::cycle_schedule::identity::GeneratedCycleState;
381
382        self.cycle_schedule_index()
383            .ok()?
384            .cycles_of(schedule)
385            .filter(|cycle| cycle.state == GeneratedCycleState::Closed)
386            .last()
387            .map(|cycle| cycle.id.clone())
388    }
389
390    /// The Schedule page\'s state, or a fresh default when the page is not open.
391    pub fn schedule_state(&self) -> crate::ui::schedule::ScheduleState {
392        self.schedule.clone().unwrap_or_default()
393    }
394
395    /// One row per series, in id order, with the due flag the detector reports.
396    pub fn schedule_entries(&self) -> Vec<crate::ui::schedule::ScheduleEntry> {
397        let Ok(index) = self.series_index() else {
398            return Vec::new();
399        };
400        let due = Self::due_keys(&index);
401        index
402            .series()
403            .map(|series| crate::ui::schedule::ScheduleEntry {
404                title: series.name.clone(),
405                subtitle: format!(
406                    "{} · {} · {} · cursor {}",
407                    series.id,
408                    series.rule.summary(),
409                    series.state.as_str(),
410                    series
411                        .next_cursor
412                        .as_ref()
413                        .map(crate::recurrence::rule::DateKey::as_str)
414                        .unwrap_or("-")
415                ),
416                due: due.contains(&series.id),
417            })
418            .collect()
419    }
420
421    /// The series that have a due candidate right now, from the shared detector.
422    ///
423    /// An associated function: the answer depends on the index and on today, not on
424    /// any other app state.
425    pub(in crate::app) fn due_keys(
426        index: &crate::recurrence::series::SeriesIndex,
427    ) -> std::collections::BTreeSet<crate::recurrence::series::SeriesId> {
428        let Some(today) = Self::today_as_date() else {
429            return std::collections::BTreeSet::new();
430        };
431        let context = crate::recurrence::rule::RuleContext::on(today);
432        crate::recurrence::detect::detect(index, &context)
433            .warnings()
434            .iter()
435            .map(|warning| warning.series_id.clone())
436            .collect()
437    }
438
439    /// The selected series\' detail: identity, rule, state, cursor, skipped dates
440    /// and the due candidate — everything the CLI\'s `show` prints, from the same
441    /// values (T036, C-008).
442    pub fn schedule_detail(&self, selected: usize) -> Option<crate::ui::schedule::ScheduleDetail> {
443        let index = self.series_index().ok()?;
444        let series = index.series().nth(selected)?;
445        let due = Self::due_candidate(&index, series);
446        let mut lines = vec![
447            ("id".to_string(), series.id.as_str().to_string()),
448            ("name".to_string(), series.name.clone()),
449            ("state".to_string(), series.state.as_str().to_string()),
450            ("rule".to_string(), series.rule.summary()),
451            (
452                "anchor".to_string(),
453                series
454                    .rule
455                    .anchor_key()
456                    .map(|key| key.as_str().to_string())
457                    .unwrap_or_else(|_| "(invalid)".to_string()),
458            ),
459            (
460                "next_cursor".to_string(),
461                series
462                    .next_cursor
463                    .as_ref()
464                    .map(crate::recurrence::rule::DateKey::as_str)
465                    .unwrap_or("-")
466                    .to_string(),
467            ),
468            (
469                "skipped".to_string(),
470                if series.skipped.is_empty() {
471                    "-".to_string()
472                } else {
473                    series
474                        .skipped
475                        .iter()
476                        .map(crate::recurrence::rule::DateKey::as_str)
477                        .collect::<Vec<_>>()
478                        .join(",")
479                },
480            ),
481            (
482                "due".to_string(),
483                due.map(|warning| format!("{} ({})", warning.due_date, warning.occurrence_key))
484                    .unwrap_or_else(|| "nothing due".to_string()),
485            ),
486            (
487                "issue".to_string(),
488                format!("#{} ({})", series.issue_number, series.filename),
489            ),
490        ];
491        lines.push((
492            "keys".to_string(),
493            "g generate · p pause · r resume · x end · s skip · d defer · i ignore".to_string(),
494        ));
495        Some(crate::ui::schedule::ScheduleDetail {
496            heading: "Series".to_string(),
497            lines,
498        })
499    }
500
501    /// The due warning for `series`, if the detector reports one.
502    ///
503    /// An associated function for the reason [`App::due_keys`] is: it reads the
504    /// index and today, and nothing else.
505    pub(in crate::app) fn due_candidate(
506        index: &crate::recurrence::series::SeriesIndex,
507        series: &crate::recurrence::series::Series,
508    ) -> Option<crate::recurrence::detect::DueWarning> {
509        let today = Self::today_as_date()?;
510        let context = crate::recurrence::rule::RuleContext::on(today);
511        crate::recurrence::detect::detect(index, &context)
512            .warnings()
513            .iter()
514            .find(|warning| warning.series_id == series.id)
515            .cloned()
516    }
517
518    /// The stable id of the series under the Schedule cursor.
519    pub(in crate::app) fn selected_series_id(&self) -> Option<crate::recurrence::series::SeriesId> {
520        let selected = self.schedule_state().selected;
521        self.series_index()
522            .ok()?
523            .series()
524            .nth(selected)
525            .map(|series| series.id.clone())
526    }
527}