1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 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 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 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 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 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 return;
73 };
74 self.cycle_schedule_warnings =
75 Some(crate::cycle_schedule::evaluate::evaluate(&index, &context));
76 }
77
78 pub fn cycle_schedule_evaluation(
80 &self,
81 ) -> Option<&crate::cycle_schedule::evaluate::CycleEvaluation> {
82 self.cycle_schedule_warnings.as_ref()
83 }
84
85 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 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 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 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 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 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 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 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 pub fn schedule_state(&self) -> crate::ui::schedule::ScheduleState {
392 self.schedule.clone().unwrap_or_default()
393 }
394
395 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 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 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 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 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}