tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    /// The series/occurrence index, built from the files on the spot.
    ///
    /// Never cached: a lifecycle action or a generation changes the files, and an
    /// index that outlived the change would let a second action decide from a
    /// snapshot (the bug WP05's first version had).
    pub(in crate::app) fn series_index(&self) -> Result<crate::recurrence::series::SeriesIndex> {
        Ok(crate::recurrence::series::SeriesIndex::build(
            &self.store.list()?,
        ))
    }

    /// Today, as the `time::Date` a rule context takes.
    ///
    /// [`crate::model::today`] is the crate's one clock read and hands back the ISO
    /// string the file format stores; the rule context wants the value. The TUI is a
    /// boundary that may resolve "now" — detection itself never does — and the parse
    /// is a handful of lines because `DateKey` exposes no parser of its own.
    pub(in crate::app) fn today_as_date() -> Option<time::Date> {
        let raw = crate::model::today();
        let year: i32 = raw.get(0..4)?.parse().ok()?;
        let month: u8 = raw.get(5..7)?.parse().ok()?;
        let day: u8 = raw.get(8..10)?.parse().ok()?;
        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
    }

    /// The date context a cycle-schedule pass evaluates in: the crate's one boundary
    /// clock read, exactly as [`Self::due_keys`] resolves it for the series. The
    /// domain never reads a clock, so the tab and `tix cycle schedule detect` take
    /// the same value from the same place.
    pub(in crate::app) fn cycle_schedule_context() -> Option<crate::recurrence::rule::RuleContext> {
        Self::today_as_date().map(crate::recurrence::rule::RuleContext::on)
    }

    /// The read model the Cycle schedules tab renders (WP02's [`ScheduleIndex`]),
    /// built from the registry and the records on disk.
    ///
    /// Rebuilt rather than cached, for the reason [`Self::series_index`] is: a write
    /// — the user's own confirmed action, or another terminal's `tix cycle schedule
    /// close` — has to be visible to the next read, and an index that outlived the
    /// change would let the page decide from a snapshot.
    pub(in crate::app) fn cycle_schedule_index(
        &self,
    ) -> Result<crate::cycle_schedule::identity::ScheduleIndex> {
        let root = self.project_root();
        let records = crate::cycle_store::CycleStore::new(&root).list()?;
        Ok(crate::cycle_schedule::identity::ScheduleIndex::build(
            &self.store.config,
            &records,
        ))
    }

    /// The read-only cycle-schedule evaluation the tab shows (T035).
    ///
    /// **Read-only, by construction**: it builds the index from the registry and the
    /// stored records and calls the pure
    /// [`crate::cycle_schedule::evaluate::evaluate`] on it. No record is written, no
    /// snapshot is captured, no cursor is advanced and no History event is appended —
    /// the only writers in this mission are WP04's `generate` and WP05's `apply`, and
    /// neither is reachable from here. A refresh that finds a warning finds it again
    /// on the next refresh, which is exactly what closing it by reading cannot change.
    pub(in crate::app) fn refresh_cycle_schedule_warnings(&mut self) {
        let Some(context) = Self::cycle_schedule_context() else {
            return;
        };
        let Ok(index) = self.cycle_schedule_index() else {
            // A read failure leaves the previous value: the page then shows what it
            // last knew rather than a fabricated "nothing is due".
            return;
        };
        self.cycle_schedule_warnings =
            Some(crate::cycle_schedule::evaluate::evaluate(&index, &context));
    }

    /// The last evaluation, or `None` before the first pass.
    pub fn cycle_schedule_evaluation(
        &self,
    ) -> Option<&crate::cycle_schedule::evaluate::CycleEvaluation> {
        self.cycle_schedule_warnings.as_ref()
    }

    /// The F8 Schedule page's active tab, and `None` in every other mode.
    ///
    /// The `?` overlay is the one surface that has to know it: the page's two tabs
    /// act on different keys, and an overlay that advertised the Series keys while the
    /// Cycle schedules tab is showing would name two keys that do nothing there
    /// (WP07 review). The overlay is drawn while `mode` is [`Mode::Help`], so the page
    /// the help was **opened from** is what counts — its state is still held, and the
    /// top mode is still Schedule.
    pub fn schedule_help_tab(&self) -> Option<crate::ui::schedule::ScheduleTab> {
        let on_schedule = matches!(self.mode, Mode::Schedule)
            || matches!(&self.mode, Mode::Help { return_to } if matches!(**return_to, Mode::Schedule));
        on_schedule.then(|| self.schedule_state().tab)
    }

    /// One row per cycle schedule, in declaration order, flagged when the last
    /// evaluation reported a warning for it.
    ///
    /// The row's tail is the same set of values `tix cycle schedule list` prints
    /// (its columns minus the ones the detail pane spells out in full), taken from
    /// the one read model rather than re-derived (C-008/NFR-003).
    pub fn cycle_schedule_entries(&self) -> Vec<crate::ui::schedule::CycleScheduleEntry> {
        let Ok(index) = self.cycle_schedule_index() else {
            return Vec::new();
        };
        let warned: std::collections::BTreeSet<_> = self
            .cycle_schedule_warnings
            .as_ref()
            .map(|evaluation| {
                evaluation
                    .warnings()
                    .iter()
                    .map(|warning| warning.schedule_id.clone())
                    .collect()
            })
            .unwrap_or_default();
        index
            .schedules()
            .map(|(id, record)| crate::ui::schedule::CycleScheduleEntry {
                title: record.name.clone(),
                subtitle: format!(
                    "{} · {} · {} · start {} · next {} · {} cycle(s)",
                    id,
                    record.state.as_str(),
                    crate::cycle_schedule::identity::cadence_rule(record)
                        .map(|rule| rule.summary())
                        .unwrap_or_else(|| "(no cadence)".to_string()),
                    record.start_date,
                    index
                        .cursor(id)
                        .next_sequence
                        .map(|sequence| sequence.to_string())
                        .unwrap_or_else(|| "-".to_string()),
                    index.cycles_of(id).count(),
                ),
                warned: warned.contains(id),
            })
            .collect()
    }

    /// The selected schedule's detail: its record, its generated cycles, its closed
    /// snapshots, its identity findings and its evaluated warnings (T036/T038).
    ///
    /// Every value is the one the CLI's `show` prints or the one `detect` evaluated —
    /// nothing is recomputed here, so a generated cycle's `state` is the stored
    /// record's rather than this surface's opinion about its dates (C-007).
    pub fn cycle_schedule_detail(
        &self,
        selected: usize,
    ) -> Option<crate::ui::schedule::CycleScheduleDetail> {
        use crate::cycle_schedule::identity::cadence_rule;
        use crate::recurrence::rule::IntervalUnit;

        let index = self.cycle_schedule_index().ok()?;
        let (id, record) = index.schedules().nth(selected)?;
        let id = id.clone();
        let record = record.clone();
        let cursor = index.cursor(&id);

        let fields = vec![
            ("id".to_string(), id.as_str().to_string()),
            ("name".to_string(), record.name.clone()),
            ("state".to_string(), record.state.as_str().to_string()),
            (
                "cadence".to_string(),
                cadence_rule(&record)
                    .map(|rule| rule.summary())
                    .unwrap_or_else(|| "(no cadence)".to_string()),
            ),
            (
                "duration".to_string(),
                format!(
                    "{} {}",
                    record.duration.count,
                    record
                        .duration
                        .unit
                        .map_or("(no unit)", IntervalUnit::as_str)
                ),
            ),
            ("start_date".to_string(), record.start_date.clone()),
            (
                "timezone".to_string(),
                record
                    .timezone
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| "(none)".to_string()),
            ),
            ("look_ahead".to_string(), record.look_ahead.to_string()),
            (
                "next_sequence".to_string(),
                cursor
                    .next_sequence
                    .map(|sequence| sequence.to_string())
                    .unwrap_or_else(|| "-".to_string()),
            ),
            (
                "next_start".to_string(),
                cursor.next_start.clone().unwrap_or_else(|| "-".to_string()),
            ),
            (
                "last_generated_key".to_string(),
                cursor
                    .last_generated_key
                    .as_ref()
                    .map(crate::cycle_schedule::identity::GeneratedCycleId::as_str)
                    .unwrap_or("-")
                    .to_string(),
            ),
            (
                "generated".to_string(),
                index.cycles_of(&id).count().to_string(),
            ),
        ];

        let cycles = index
            .cycles_of(&id)
            .map(|cycle| {
                format!(
                    "{}  seq {}  {}  {} → {}  {}",
                    cycle.id,
                    cycle.sequence,
                    cycle.name,
                    cycle.start_date,
                    cycle.due_date,
                    cycle.state.as_str()
                )
            })
            .collect();

        let snapshots =
            crate::cycle_schedule::snapshot::CycleSnapshotStore::new(&self.project_root())
                .list()
                .unwrap_or_default()
                .into_iter()
                .filter(|snapshot| snapshot.schedule_id == id)
                .map(|snapshot| {
                    format!(
                        "docs/cycle_snapshots/{}  closed {} by {} ({})",
                        crate::cycle_schedule::snapshot::CycleSnapshotStore::filename_for(
                            &snapshot.cycle_id
                        ),
                        snapshot.closed_at,
                        snapshot.closed_by,
                        snapshot.state_summary
                    )
                })
                .collect();

        let findings = index
            .diagnostics()
            .iter()
            .filter(|diagnostic| diagnostic.schedule_id.as_ref() == Some(&id))
            .map(crate::cycle_schedule::identity::IdentityDiagnostic::render)
            .collect();

        let warnings = self
            .cycle_schedule_warnings
            .as_ref()
            .map(|evaluation| {
                evaluation
                    .warnings_of(&id)
                    .map(Self::cycle_schedule_warning_line)
                    .collect()
            })
            .unwrap_or_default();

        Some(crate::ui::schedule::CycleScheduleDetail {
            fields,
            cycles,
            snapshots,
            findings,
            warnings,
        })
    }

    /// Findings that belong to **no** listed schedule: a record naming a schedule
    /// that is not in the registry (a dangling identity), or one the index could not
    /// attribute at all.
    ///
    /// They get their own block on the tab rather than being folded into a
    /// schedule's detail, because no schedule's detail is theirs.
    pub fn cycle_schedule_unattached_findings(&self) -> Vec<String> {
        let Ok(index) = self.cycle_schedule_index() else {
            return Vec::new();
        };
        index
            .diagnostics()
            .iter()
            .filter(|diagnostic| {
                diagnostic
                    .schedule_id
                    .as_ref()
                    .is_none_or(|id| index.schedule(id).is_none())
            })
            .map(crate::cycle_schedule::identity::IdentityDiagnostic::render)
            .collect()
    }

    /// One warning as a line: what it is, what it is about, the candidate a
    /// confirmed generation would create, and the explicit decisions it accepts.
    pub(in crate::app) fn cycle_schedule_warning_line(
        warning: &crate::cycle_schedule::evaluate::ScheduleWarning,
    ) -> String {
        let mut line = format!("{} · {}", warning.kind.as_str(), warning.schedule_id);
        if let Some(cycle) = &warning.cycle_id {
            line.push_str(&format!(" · {cycle}"));
        }
        if let Some(dates) = &warning.effective_dates {
            line.push_str(&format!(" · {} → {}", dates.start, dates.due));
        }
        if let Some(next) = &warning.next_generation {
            line.push_str(&format!(" · next {}", next.id));
            if let Some(start) = &next.start_date {
                line.push_str(&format!(" ({start})"));
            }
        }
        if !warning.available_actions.is_empty() {
            let actions: Vec<&str> = warning
                .available_actions
                .iter()
                .map(|action| action.as_str())
                .collect();
            line.push_str(&format!(" · actions {}", actions.join(",")));
        }
        if let Some(diagnostic) = &warning.diagnostic {
            line.push_str(&format!(" · {}", diagnostic.message));
        }
        line
    }

    /// The stable id of the cycle schedule under the Cycle schedules tab's cursor.
    pub(in crate::app) fn selected_cycle_schedule_id(
        &self,
    ) -> Option<crate::cycle_schedule::config::CycleScheduleId> {
        let selected = self.schedule_state().selected;
        self.cycle_schedule_index()
            .ok()?
            .schedules()
            .nth(selected)
            .map(|(id, _)| id.clone())
    }

    /// The instance a lifecycle action targets: the schedule's first instance in
    /// sequence order that is not terminal — its "current" cycle.
    ///
    /// `None` when there is none, and that is deliberate: the request then names no
    /// instance and the **shared service** answers with its own `missing cycle`
    /// refusal, rather than this surface inventing a target. A closed or skipped
    /// instance is terminal and is never the current one.
    pub(in crate::app) fn current_cycle_id(
        &self,
        schedule: &crate::cycle_schedule::config::CycleScheduleId,
    ) -> Option<crate::cycle_schedule::identity::GeneratedCycleId> {
        self.cycle_schedule_index()
            .ok()?
            .cycles_of(schedule)
            .find(|cycle| !cycle.state.is_terminal())
            .map(|cycle| cycle.id.clone())
    }

    /// The instance a **reopen** undoes: the selected schedule's closed instance, in
    /// sequence order (the most recent one).
    ///
    /// `close`'s siblings act on the schedule's *current* instance — the first that is
    /// not terminal. A `closed` instance **is** terminal, so it is never that one, and
    /// a reopen that targeted it through [`Self::current_cycle_id`] would name no
    /// instance at all. The target is still the explicit one the action is about; a
    /// schedule with no closed instance returns `None`, and the shared service then
    /// answers with its own `missing cycle` refusal rather than this surface inventing
    /// one.
    pub(in crate::app) fn reopenable_cycle_id(
        &self,
        schedule: &crate::cycle_schedule::config::CycleScheduleId,
    ) -> Option<crate::cycle_schedule::identity::GeneratedCycleId> {
        use crate::cycle_schedule::identity::GeneratedCycleState;

        self.cycle_schedule_index()
            .ok()?
            .cycles_of(schedule)
            .filter(|cycle| cycle.state == GeneratedCycleState::Closed)
            .last()
            .map(|cycle| cycle.id.clone())
    }

    /// The Schedule page\'s state, or a fresh default when the page is not open.
    pub fn schedule_state(&self) -> crate::ui::schedule::ScheduleState {
        self.schedule.clone().unwrap_or_default()
    }

    /// One row per series, in id order, with the due flag the detector reports.
    pub fn schedule_entries(&self) -> Vec<crate::ui::schedule::ScheduleEntry> {
        let Ok(index) = self.series_index() else {
            return Vec::new();
        };
        let due = Self::due_keys(&index);
        index
            .series()
            .map(|series| crate::ui::schedule::ScheduleEntry {
                title: series.name.clone(),
                subtitle: format!(
                    "{} · {} · {} · cursor {}",
                    series.id,
                    series.rule.summary(),
                    series.state.as_str(),
                    series
                        .next_cursor
                        .as_ref()
                        .map(crate::recurrence::rule::DateKey::as_str)
                        .unwrap_or("-")
                ),
                due: due.contains(&series.id),
            })
            .collect()
    }

    /// The series that have a due candidate right now, from the shared detector.
    ///
    /// An associated function: the answer depends on the index and on today, not on
    /// any other app state.
    pub(in crate::app) fn due_keys(
        index: &crate::recurrence::series::SeriesIndex,
    ) -> std::collections::BTreeSet<crate::recurrence::series::SeriesId> {
        let Some(today) = Self::today_as_date() else {
            return std::collections::BTreeSet::new();
        };
        let context = crate::recurrence::rule::RuleContext::on(today);
        crate::recurrence::detect::detect(index, &context)
            .warnings()
            .iter()
            .map(|warning| warning.series_id.clone())
            .collect()
    }

    /// The selected series\' detail: identity, rule, state, cursor, skipped dates
    /// and the due candidate — everything the CLI\'s `show` prints, from the same
    /// values (T036, C-008).
    pub fn schedule_detail(&self, selected: usize) -> Option<crate::ui::schedule::ScheduleDetail> {
        let index = self.series_index().ok()?;
        let series = index.series().nth(selected)?;
        let due = Self::due_candidate(&index, series);
        let mut lines = vec![
            ("id".to_string(), series.id.as_str().to_string()),
            ("name".to_string(), series.name.clone()),
            ("state".to_string(), series.state.as_str().to_string()),
            ("rule".to_string(), series.rule.summary()),
            (
                "anchor".to_string(),
                series
                    .rule
                    .anchor_key()
                    .map(|key| key.as_str().to_string())
                    .unwrap_or_else(|_| "(invalid)".to_string()),
            ),
            (
                "next_cursor".to_string(),
                series
                    .next_cursor
                    .as_ref()
                    .map(crate::recurrence::rule::DateKey::as_str)
                    .unwrap_or("-")
                    .to_string(),
            ),
            (
                "skipped".to_string(),
                if series.skipped.is_empty() {
                    "-".to_string()
                } else {
                    series
                        .skipped
                        .iter()
                        .map(crate::recurrence::rule::DateKey::as_str)
                        .collect::<Vec<_>>()
                        .join(",")
                },
            ),
            (
                "due".to_string(),
                due.map(|warning| format!("{} ({})", warning.due_date, warning.occurrence_key))
                    .unwrap_or_else(|| "nothing due".to_string()),
            ),
            (
                "issue".to_string(),
                format!("#{} ({})", series.issue_number, series.filename),
            ),
        ];
        lines.push((
            "keys".to_string(),
            "g generate · p pause · r resume · x end · s skip · d defer · i ignore".to_string(),
        ));
        Some(crate::ui::schedule::ScheduleDetail {
            heading: "Series".to_string(),
            lines,
        })
    }

    /// The due warning for `series`, if the detector reports one.
    ///
    /// An associated function for the reason [`App::due_keys`] is: it reads the
    /// index and today, and nothing else.
    pub(in crate::app) fn due_candidate(
        index: &crate::recurrence::series::SeriesIndex,
        series: &crate::recurrence::series::Series,
    ) -> Option<crate::recurrence::detect::DueWarning> {
        let today = Self::today_as_date()?;
        let context = crate::recurrence::rule::RuleContext::on(today);
        crate::recurrence::detect::detect(index, &context)
            .warnings()
            .iter()
            .find(|warning| warning.series_id == series.id)
            .cloned()
    }

    /// The stable id of the series under the Schedule cursor.
    pub(in crate::app) fn selected_series_id(&self) -> Option<crate::recurrence::series::SeriesId> {
        let selected = self.schedule_state().selected;
        self.series_index()
            .ok()?
            .series()
            .nth(selected)
            .map(|series| series.id.clone())
    }
}