tix-tracker 0.11.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
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
//! The daily count-mode series, its coverage markers and its one-pass snapshot
//! (mission `burndown-scope-analytics-01M3C9J1`, WP02 T007–T012).
//!
//! # What this module is
//!
//! One pure fold from the tree the caller already loaded to an ordered
//! [`BurndownSeries`]:
//!
//! ```text
//! issues + config + schedule index + AnalyticsScope
//!     ──ScopeSnapshot::build──▶ one classified, deduplicated event index
//!     ──ScopeSnapshot::series──▶ BurndownSeries { DailyPoint[], diagnostics }
//! ```
//!
//! Nothing here opens a store, reads a clock, or writes anything: the period is the
//! caller's, the issues are the caller's slice, and the result is an owned value.
//! That is C-001's "pure projection", and it is what lets CLI, TUI, MCP and the
//! chart document share one answer (C-008).
//!
//! # The one pass (T007)
//!
//! [`ScopeSnapshot::build`] walks the issue slice **once**, and in that one
//! iteration it decides membership, folds the member's History through WP01's
//! [`EventIndex`] (which is where the C-002 idempotency and the period filter live),
//! reads the shared [#183 completion answer](crate::workflow::is_completed), and
//! notes which days carry recorded history. Nothing later re-walks the slice: the
//! member list, the classified index, the observed days and the findings are all read
//! back out of the snapshot — the shape
//! [`crate::capacity::scope::IssueSnapshot`] established for the same reason. The
//! tally [`ScopeSnapshot::issues_visited`] grows **inside that loop**, one step per
//! issue record the walk reads, so a second walk of the slice doubles it and a test
//! can anchor "one pass" structurally instead of by wall clock.
//!
//! Membership resolves through the crate's **one** cycle-reference resolver
//! ([`crate::capacity::resolve_cycle`]), keyed on the identity that resolver answers
//! with — so a generated cycle's id, its schedule name and a hand-written legacy name
//! select exactly the same issues, and two spellings of one cycle are one scope, not
//! two. The key rule is the crate's own (`capacity::calculate::scope_identity`):
//! `cycle-id:<id>` for a generated cycle, `cycle-name:<name>` for a legacy name or a
//! name the configuration declares, and `cycle-ref:<name>` for a reference neither
//! knows — that last namespace so two unresolvable names are never folded into one
//! scope. That is where this service deliberately parts with
//! [`crate::capacity::scope::IssueSnapshot`]: capacity selects **nobody** for an
//! unresolvable reference, because a capacity entry has to be validated against a
//! cycle that exists, while analytics only *reads* and therefore reports the series
//! the issues naming that very name support. The divergence is the membership policy,
//! not the resolution: both answer one reference the same way.
//!
//! # The boundary of the classification
//!
//! An event the classification cannot place — a `parent_cleared` removal, a malformed
//! transition record, a relationship or a time mutation — contributes nothing, and the
//! day it falls on is not marked incomplete for it: the day *was* read, and the classes
//! that can move a line were folded. The limit is stated rather than hidden, and it is
//! visible as an
//! [`UnclassifiedEvent`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::UnclassifiedEvent)
//! finding. A recorded scope *removal* therefore shows up as that finding and not as a
//! decrease: the closed four-name classification places additions and completions, and
//! inventing a fifth answer for a removal is the guessing C-001 refuses.
//!
//! # What a day means (T008/T009/T010)
//!
//! A point's five numbers are **counts of recorded contributions**, and a `Some`
//! always means "the record says this", never "no record means zero":
//!
//! | field | meaning |
//! |---|---|
//! | `scope_added` | the `scope_addition` contributions **recorded on** that day |
//! | `completions` | the `completion` contributions recorded on that day |
//! | `scope_total` | the planned scope **carried** to that day: the period's running sum of recorded additions |
//! | `completed` | the completed work carried to that day: the running sum of recorded completions |
//! | `remaining` | `scope_total - completed`, when that difference is answerable |
//!
//! The origin is the period **start**, which the caller supplies (`--from`); a level
//! is carried forward across days without events, because the running sum is a fact
//! about the recorded events and not about the day. When the caller supplies no
//! start the origin is unknown, and then every level is `None` — the series reports
//! only the per-day additions and completions it can place. A milestone declares a
//! target date and no start, and is exactly that case; a cycle declares both.
//!
//! # Coverage, and why a missing day is never a zero (T010, C-005)
//!
//! A day is not "zero"; it is **known**, **coverage-limited** or **unknown**, and
//! the marker is a function of what the point can actually say:
//!
//! * [`Coverage::Known`] — every number on the day is Some and nothing weakened it.
//! * [`Coverage::CoverageLimited`] — some numbers are known and some are not: a day
//!   with no recorded history still carries the level, but its `scope_added` and
//!   `completions` are `None` because nothing was observed; a day whose recorded
//!   events cannot be turned into counts is limited for the same reason.
//! * [`Coverage::Unknown`] — the day can say nothing at all, which is what an
//!   unanchored origin (no period start) produces for a day with no events.
//!
//! The three markers therefore describe the *point*, not the calendar, and the
//! series summarizes them with [`BurndownSeries::coverage`] and with a
//! [`MissingHistory`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::MissingHistory)
//! diagnostic per unbroken gap. A missing day is never serialized as an ordinary
//! zero, which is the one thing the mission escalates over.
//!
//! # Recorded history that does not reach the period stays visible
//!
//! The fold's levels count what the period *records*. If the tree records completed
//! members the period holds no completion event for, the series says so with a
//! diagnostic rather than by inventing a baseline or by reading current state back
//! into a historical value. The sentence never asserts a direction the fold cannot
//! see: a member completed after the period end is filtered as outside-period exactly
//! like one completed before the start, so the finding reports whether the period
//! excluded any recorded event at all and lets the two directions stay open. That
//! check is the one place the shared #183 status-category service is consulted for the
//! series, and it only ever produces a sentence, never a number.
//!
//! # Closed snapshots (T011, C-006)
//!
//! A scope bound to a closed snapshot ([`AnalyticsScope::snapshot_id`]) keeps that
//! identity in the series it produces and reports it as a
//! [`ClosedSnapshot`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::ClosedSnapshot)
//! diagnostic. The exclusion is the **caller-supplied period's**:
//! [`EventIndex::outside_period`] counts every recorded event the period kept out, and
//! this fold reads no snapshot file — so nothing here resolves `snapshot_id` to a close
//! date. A caller that wants the closed view bounded by the close passes the snapshot's
//! own recorded period (its `start_date`/`due_date`, or the day of its `closed_at`) as
//! the scope period, and only then can a later edit not contribute; the diagnostic
//! names the period that was actually applied instead of claiming a bound the fold never
//! established.
//!
//! # Determinism
//!
//! Days are enumerated in calendar order, each day's provenance is ordered by the
//! crate's recorded key — issue number, then entry index, never the order the caller
//! happened to load the issues in — the member list is sorted once the walk is over,
//! and the diagnostics are sorted by their own `Ord`, with each finding's example event
//! chosen as the smallest id rather than the first one seen. Two runs over one snapshot
//! therefore produce the same bytes (C-007) *including* when the caller loads the issues
//! in a different order, which is what lets a chart export be compared
//! character-for-character.
//!
//! # Mode
//!
//! Count mode is issue counts and is what this package folds. Estimate mode is
//! declared here so the DTO has one shape, and resolves the shared unit conversion
//! in WP03's [`crate::analytics::units`]; until a conversion is available this
//! service reports
//! [`MissingConversion`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::MissingConversion)
//! and folds **no** units, because mixing or guessing them is exactly the
//! arithmetic C-003 forbids.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::capacity::resolve_cycle;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::model::Issue;
use crate::workflow;

use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::{AnalyticsScope, EventClass, EventIndex, ScopeKind};

/// The documented formula the count-mode fold implements.
///
/// A stable string rather than a number: a reader compares it for equality, and a
/// version *sequence* invites arithmetic on it that means nothing.
pub const COUNT_FORMULA_VERSION: &str = "burndown.count.v1";

/// The documented formula the estimate-mode fold implements (WP03).
pub const ESTIMATE_FORMULA_VERSION: &str = "burndown.estimate.v1";

/// The longest span one series will enumerate.
///
/// A period is caller-supplied, so a mistyped year would otherwise ask for a
/// millions-long vector. A span past this bound is reported as an
/// [`InvalidPeriod`](AnalyticsDiagnosticKind::InvalidPeriod) diagnostic rather than
/// allocated, and the bound is far above any real planning window.
pub const MAX_SERIES_DAYS: usize = 100_000;

/// How a series counts (`data-model.md` §4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
    /// Issue counts (`data-model.md` §3: "Count mode uses issue counts").
    Count,
    /// Explicitly converted estimate units, resolved by the shared unit service.
    Estimate,
}

impl Mode {
    /// Every mode, in the order a surface renders them.
    pub const ALL: [Mode; 2] = [Mode::Count, Mode::Estimate];

    /// The canonical spelling used in output and in payloads.
    pub const fn as_str(self) -> &'static str {
        match self {
            Mode::Count => "count",
            Mode::Estimate => "estimate",
        }
    }

    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
    pub fn parse(raw: &str) -> Option<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "count" => Some(Mode::Count),
            "estimate" => Some(Mode::Estimate),
            _ => None,
        }
    }

    /// The formula version this mode folds under.
    pub const fn formula_version(self) -> &'static str {
        match self {
            Mode::Count => COUNT_FORMULA_VERSION,
            Mode::Estimate => ESTIMATE_FORMULA_VERSION,
        }
    }
}

impl std::fmt::Display for Mode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// How much of one day the series can answer (`data-model.md` §3 `coverage`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Coverage {
    /// Every value on the day is known.
    Known,
    /// The day says nothing: no level could be carried and nothing was observed.
    Unknown,
    /// Some values are known and some are not — the day was not observed, or a
    /// recorded change could not be turned into a count.
    CoverageLimited,
}

impl Coverage {
    /// Every state, in the order a surface renders them.
    pub const ALL: [Coverage; 3] = [
        Coverage::Known,
        Coverage::Unknown,
        Coverage::CoverageLimited,
    ];

    /// The canonical spelling used in output and in payloads.
    pub const fn as_str(self) -> &'static str {
        match self {
            Coverage::Known => "known",
            Coverage::Unknown => "unknown",
            Coverage::CoverageLimited => "coverage_limited",
        }
    }

    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
    pub fn parse(raw: &str) -> Option<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "known" => Some(Coverage::Known),
            "unknown" => Some(Coverage::Unknown),
            "coverage_limited" => Some(Coverage::CoverageLimited),
            _ => None,
        }
    }

    /// Whether the day answers everything it carries.
    pub const fn is_known(self) -> bool {
        matches!(self, Coverage::Known)
    }
}

impl std::fmt::Display for Coverage {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// One calendar-day series value (`data-model.md` §3).
///
/// Every number is an `Option`, and every `None` is a statement: the value is not
/// known, and rendering it as `0` would be the assumption C-005 forbids. `event_ids`
/// is the day's provenance in the crate's recorded order — issue number, then entry
/// index — so a reader can walk from a point
/// back to the recorded lines that produced it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DailyPoint {
    /// The canonical `YYYY-MM-DD` day.
    pub date: String,
    /// The planned scope carried to this day, when the origin is known.
    pub scope_total: Option<i64>,
    /// The remaining work carried to this day, when it is answerable.
    pub remaining: Option<i64>,
    /// The completed work carried to this day, when the origin is known.
    pub completed: Option<i64>,
    /// The scope additions **recorded on** this day, when the day was observed.
    pub scope_added: Option<i64>,
    /// The completions recorded on this day, when the day was observed.
    pub completions: Option<i64>,
    /// How much of the day the fold can answer.
    pub coverage: Coverage,
    /// The recorded contributions placed on this day, in the crate's recorded order
    /// (issue number, then entry index) — never the order the caller listed the store.
    pub event_ids: Vec<String>,
}

impl DailyPoint {
    /// Whether every number on the day is known.
    pub fn is_known(&self) -> bool {
        self.coverage.is_known()
    }

    /// The day's stable provenance ids.
    pub fn event_ids(&self) -> &[String] {
        &self.event_ids
    }

    /// The five numbers the day carries, in the schema's order.
    fn values(&self) -> [Option<i64>; 5] {
        [
            self.scope_total,
            self.remaining,
            self.completed,
            self.scope_added,
            self.completions,
        ]
    }
}

/// How many days of a series fall into each coverage state.
///
/// The count a surface reports and an export carries (NFR-002): "3 of 15 days are
/// coverage-limited" is the metadata that keeps a reader from reading a gap as
/// progress.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct CoverageSummary {
    /// Days whose every value is known.
    pub known: usize,
    /// Days that say nothing.
    pub unknown: usize,
    /// Days that answer some of what they carry.
    pub coverage_limited: usize,
}

impl CoverageSummary {
    /// How many days the series holds.
    pub const fn total(&self) -> usize {
        self.known + self.unknown + self.coverage_limited
    }

    /// Count one day's state.
    fn add(&mut self, coverage: Coverage) {
        match coverage {
            Coverage::Known => self.known += 1,
            Coverage::Unknown => self.unknown += 1,
            Coverage::CoverageLimited => self.coverage_limited += 1,
        }
    }
}

/// The ordered daily series for one scope and mode (`data-model.md` §4).
///
/// `unit` is `None` in count mode — an issue count is unitless — and is required in
/// estimate mode. `formula_version` is [`Mode::formula_version`], so a consumer can
/// tell two folds apart without inspecting the points.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BurndownSeries {
    /// The selected cycle/milestone, its period and its snapshot binding.
    pub scope: AnalyticsScope,
    /// How the series counts.
    pub mode: Mode,
    /// The unit the values are counted in, required in estimate mode.
    pub unit: Option<String>,
    /// The ordered daily points.
    pub points: Vec<DailyPoint>,
    /// The findings, sorted and deterministic.
    pub diagnostics: Vec<AnalyticsDiagnostic>,
    /// The documented formula this series was folded under.
    pub formula_version: String,
}

impl BurndownSeries {
    /// An empty series — what an unusable period produces, with its diagnostic.
    fn empty(scope: &AnalyticsScope, mode: Mode, diagnostics: Vec<AnalyticsDiagnostic>) -> Self {
        Self {
            scope: scope.clone(),
            mode,
            unit: None,
            points: Vec::new(),
            diagnostics,
            formula_version: mode.formula_version().to_string(),
        }
    }

    /// Whether the series holds no day at all.
    pub fn is_empty(&self) -> bool {
        self.points.is_empty()
    }

    /// How many days the series holds.
    pub fn len(&self) -> usize {
        self.points.len()
    }

    /// The coverage tally a surface reports and an export carries (NFR-002).
    pub fn coverage(&self) -> CoverageSummary {
        let mut summary = CoverageSummary::default();
        for point in &self.points {
            summary.add(point.coverage);
        }
        summary
    }

    /// The point for one day, when the series covers it.
    pub fn point_on(&self, date: &str) -> Option<&DailyPoint> {
        self.points.iter().find(|point| point.date == date)
    }

    /// The findings of one kind, in the series' deterministic order.
    pub fn diagnostics_of(
        &self,
        kind: AnalyticsDiagnosticKind,
    ) -> impl Iterator<Item = &AnalyticsDiagnostic> {
        self.diagnostics.iter().filter(move |d| d.kind == kind)
    }
}

/// The one-pass scope snapshot the series is folded from (T007).
///
/// Built by [`ScopeSnapshot::build`] and immutable afterwards: the member list, the
/// classified event index, the observed days and the findings are all read once, so a
/// fold cannot re-read an issue or re-classify an event. The tally
/// [`ScopeSnapshot::issues_visited`] grows inside the build's one walk, which is what
/// makes "one pass" checkable rather than merely claimed.
#[derive(Debug, Clone)]
pub struct ScopeSnapshot {
    scope: AnalyticsScope,
    index: EventIndex,
    members: Vec<u32>,
    completed_members: usize,
    observed_days: BTreeSet<String>,
    unplaced_events: usize,
    visited: usize,
    diagnostics: Vec<AnalyticsDiagnostic>,
}

impl ScopeSnapshot {
    /// Index `issues` for `scope` in **one pass** (T007).
    ///
    /// `config` supplies the board whose status-category resolution the shared
    /// [#183 service](crate::workflow) is built for, and `index` is the caller's
    /// [`ScheduleIndex`] — the same index [`crate::capacity`] resolves cycle
    /// references through, so a scope naming a generated cycle and one naming its
    /// schedule select the same issues. Nothing here touches a store.
    ///
    /// The tally [`ScopeSnapshot::issues_visited`] is advanced in the loop below, so it
    /// measures the walk this function performs and not the length of the slice it was
    /// handed.
    #[must_use]
    pub fn build(
        config: &Config,
        index: &ScheduleIndex,
        issues: &[Issue],
        scope: &AnalyticsScope,
    ) -> Self {
        let resolution = workflow::resolution(config, config.default_config(), "default");
        // The scope's identity is resolved **once**, not once per issue.
        let scope_key = match scope.kind {
            ScopeKind::Cycle => Some(cycle_key(config, index, &scope.id)),
            ScopeKind::Milestone => None,
        };

        let mut index_events = EventIndex::new(scope.clone());
        let mut members: Vec<u32> = Vec::new();
        let mut completed_members = 0usize;
        let mut visited = 0usize;
        // The pass: one iteration per issue record, in which everything the issue
        // contributes is read — its membership, the shared completion answer and its
        // History. The tally advances **here**, in the same iteration, so duplicating
        // this walk doubles it instead of leaving it reporting the input's length.
        for issue in issues {
            visited += 1;
            if !is_member(issue, scope, config, index, scope_key.as_deref()) {
                continue;
            }
            members.push(issue.number);
            if workflow::is_completed(issue, &resolution) {
                completed_members += 1;
            }
            index_events.contribute_history(&issue.number.to_string(), &issue.history);
        }
        // The member list is a summary of the walk, not an input to it: sorting it
        // afterwards reads no record and cannot change what the walk contributed.
        members.sort_unstable();
        members.dedup();

        let mut observed_days = BTreeSet::new();
        let mut unplaced_events = 0usize;
        for event in index_events.events() {
            match event.date.as_deref() {
                Some(date) => {
                    observed_days.insert(date.to_string());
                }
                None => unplaced_events += 1,
            }
        }

        let mut diagnostics = Vec::new();
        if let Some(snapshot_id) = scope.snapshot_id.as_deref() {
            let start = scope.period_start().unwrap_or("open");
            let end = scope.period_end().unwrap_or("open");
            diagnostics.push(
                AnalyticsDiagnostic::new(
                    AnalyticsDiagnosticKind::ClosedSnapshot,
                    scope.id.clone(),
                    format!(
                        "the series is bound to closed snapshot `{snapshot_id}`; the period the fold \
                         applied is the caller's `{start}..{end}`, and events recorded after it are \
                         not contributions"
                    ),
                )
                .with_remedy(
                    "pass the snapshot's own recorded period — its `start_date`/`due_date`, or the \
                     day of its `closed_at` — so the exclusion ends where the cycle closed",
                ),
            );
        }
        if unplaced_events > 0 {
            diagnostics.push(AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingHistory,
                scope.id.clone(),
                format!(
                    "{unplaced_events} recorded event(s) carry no readable calendar date and are \
                     placed on no day, so every day's change set is incomplete"
                ),
            ));
        }

        diagnostics.sort();
        Self {
            scope: scope.clone(),
            index: index_events,
            members,
            completed_members,
            observed_days,
            unplaced_events,
            visited,
            diagnostics,
        }
    }

    /// The scope every contribution was classified against.
    #[must_use]
    pub fn scope(&self) -> &AnalyticsScope {
        &self.scope
    }

    /// The scope's member issue numbers, ascending and deduplicated.
    ///
    /// The walk reads the caller's slice in its own order; this list is the summary it
    /// produced, sorted after the walk, so two runs agree however the store was listed.
    #[must_use]
    pub fn members(&self) -> &[u32] {
        &self.members
    }

    /// The classified, deduplicated, period-filtered contributions.
    #[must_use]
    pub fn event_index(&self) -> &EventIndex {
        &self.index
    }

    /// Every day that carries a placed recorded event, in calendar order.
    #[must_use]
    pub fn observed_days(&self) -> &BTreeSet<String> {
        &self.observed_days
    }

    /// How many recorded events carried no readable date.
    #[must_use]
    pub fn unplaced_events(&self) -> usize {
        self.unplaced_events
    }

    /// How many issue records the pass read — its own "one pass" record.
    ///
    /// The counter is advanced inside [`ScopeSnapshot::build`]'s single loop over the
    /// caller's slice, once per issue record, so it is a measurement of the walk and not
    /// a restatement of `issues.len()`: a second walk doubles it, and the NFR-001 case
    /// anchors "one pass" on it structurally rather than on a wall-clock time. What it
    /// does **not** observe is stated by omission — it counts reads of the slice walk,
    /// not the cost of the work performed inside the iteration.
    #[must_use]
    pub fn issues_visited(&self) -> usize {
        self.visited
    }

    /// The findings the snapshot itself produced, sorted.
    #[must_use]
    pub fn diagnostics(&self) -> &[AnalyticsDiagnostic] {
        &self.diagnostics
    }

    /// Fold the snapshot into a series for `mode`.
    ///
    /// Count mode is folded here; estimate mode resolves the shared unit conversion
    /// in WP03's [`crate::analytics::units`], and until one is available reports
    /// [`MissingConversion`](AnalyticsDiagnosticKind::MissingConversion) and folds no
    /// units rather than guessing them.
    #[must_use]
    pub fn series(&self, mode: Mode) -> BurndownSeries {
        match mode {
            Mode::Count => self.count_series(),
            Mode::Estimate => {
                let mut diagnostics = self.diagnostics.clone();
                diagnostics.push(AnalyticsDiagnostic::new(
                    AnalyticsDiagnosticKind::MissingConversion,
                    self.scope.id.clone(),
                    "estimate mode needs an explicit unit conversion, and no compatible unit is \
                     declared for this scope yet",
                ));
                diagnostics.sort();
                BurndownSeries::empty(&self.scope, mode, diagnostics)
            }
        }
    }

    /// The count-mode fold (T008/T009/T010).
    #[allow(clippy::too_many_lines)]
    fn count_series(&self) -> BurndownSeries {
        let scope = &self.scope;
        let mut diagnostics = self.diagnostics.clone();
        let origin_known = scope.period.start.is_some();

        let days = match self.series_days() {
            Ok(days) => days,
            Err(problem) => {
                diagnostics.push(problem.into_diagnostic(&scope.id));
                diagnostics.sort();
                return BurndownSeries::empty(scope, Mode::Count, diagnostics);
            }
        };

        // One walk of the index, grouping by day. The sums and the counts below do not
        // depend on the index's order; each day's provenance does, so it is ordered by
        // the recorded key once the walk is over.
        let mut additions: BTreeMap<String, i64> = BTreeMap::new();
        let mut completions: BTreeMap<String, i64> = BTreeMap::new();
        let mut event_ids: BTreeMap<String, Vec<String>> = BTreeMap::new();
        let mut weak_days: BTreeSet<String> = BTreeSet::new();
        let mut unclassified = 0usize;
        let mut unclassified_example: Option<String> = None;
        let mut unquantified = 0usize;
        let mut unquantified_example: Option<String> = None;
        for event in self.index.events() {
            let Some(date) = event.date.as_deref() else {
                continue;
            };
            event_ids
                .entry(date.to_string())
                .or_default()
                .push(event.event_id.clone());
            match event.class {
                EventClass::ScopeAddition | EventClass::Completion => match event.delta {
                    Some(delta) => {
                        let bucket = match event.class {
                            EventClass::ScopeAddition => &mut additions,
                            _ => &mut completions,
                        };
                        *bucket.entry(date.to_string()).or_default() += delta;
                    }
                    // A classified change the record cannot turn into a count: the
                    // day's change set is observed but not complete.
                    None => {
                        weak_days.insert(date.to_string());
                        unquantified += 1;
                        keep_smallest(&mut unquantified_example, &event.event_id);
                    }
                },
                EventClass::EstimateChange => {}
                EventClass::Unclassified => {
                    unclassified += 1;
                    keep_smallest(&mut unclassified_example, &event.event_id);
                }
            }
        }

        // An event that cannot be placed could belong to any day, so no day's change
        // set is complete until it is placed.
        let global_weak = self.unplaced_events > 0;

        let mut points = Vec::with_capacity(days.len());
        let mut scope_total = 0i64;
        let mut completed = 0i64;
        let mut first_unanswerable: Option<(&str, i64, i64)> = None;
        for day in &days {
            let observed = self.observed_days.contains(day);
            let added = additions.get(day).copied();
            let done = completions.get(day).copied();
            if observed {
                scope_total += added.unwrap_or(0);
                completed += done.unwrap_or(0);
            }

            let (level_total, level_remaining, level_completed) = if origin_known {
                let remaining = scope_total - completed;
                if remaining < 0 {
                    // Recorded completions outran recorded additions: the level's
                    // origin is not visible in this period, so the answer is unknown
                    // rather than a negative (or a clamped) invention.
                    first_unanswerable.get_or_insert((day, scope_total, completed));
                    (Some(scope_total), None, Some(completed))
                } else {
                    (Some(scope_total), Some(remaining), Some(completed))
                }
            } else {
                (None, None, None)
            };

            let point = DailyPoint {
                date: day.clone(),
                scope_total: level_total,
                remaining: level_remaining,
                completed: level_completed,
                scope_added: observed.then(|| added.unwrap_or(0)),
                completions: observed.then(|| done.unwrap_or(0)),
                coverage: Coverage::Known,
                event_ids: event_ids.get(day).cloned().unwrap_or_default(),
            };
            let weak = global_weak || weak_days.contains(day);
            let coverage = coverage_of(&point, weak);
            points.push(DailyPoint { coverage, ..point });
        }

        // Each day's provenance is ordered by the crate's recorded key, never by the
        // order the caller loaded the issues in, so the point does not depend on it.
        for point in &mut points {
            point
                .event_ids
                .sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));
        }

        if let Some((day, total, done)) = first_unanswerable {
            diagnostics.push(
                AnalyticsDiagnostic::new(
                    AnalyticsDiagnosticKind::MissingHistory,
                    scope.id.clone(),
                    format!(
                        "on {day} the period records {done} completion(s) but only {total} scope \
                         addition(s), so remaining work cannot be answered from the period alone"
                    ),
                )
                .on(day),
            );
        }

        if unclassified > 0 {
            let mut diagnostic = AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::UnclassifiedEvent,
                scope.id.clone(),
                format!(
                    "{unclassified} recorded event(s) in the period carry no scope or completion \
                     meaning and move no line"
                ),
            );
            if let Some(event_id) = unclassified_example {
                diagnostic = diagnostic.about_event(event_id);
            }
            diagnostics.push(diagnostic);
        }

        if unquantified > 0 {
            let mut diagnostic = AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingHistory,
                scope.id.clone(),
                format!(
                    "{unquantified} recorded scope/completion event(s) carry no count-level value, \
                     so the days they fall on are coverage-limited"
                ),
            );
            if let Some(event_id) = unquantified_example {
                diagnostic = diagnostic.about_event(event_id);
            }
            diagnostics.push(diagnostic);
        }

        diagnostics.extend(self.gap_diagnostics(&points));
        diagnostics.extend(self.baseline_diagnostic(origin_known, completed));
        diagnostics.sort();

        BurndownSeries {
            scope: scope.clone(),
            mode: Mode::Count,
            unit: None,
            points,
            diagnostics,
            formula_version: COUNT_FORMULA_VERSION.to_string(),
        }
    }

    /// The days the series covers, in calendar order.
    ///
    /// The caller's bounds are used verbatim. A bound the caller left absent is
    /// resolved to the observed extent of the recorded events — which is coverage,
    /// not a guess, and is reported as an unanchored origin by the caller of this
    /// helper. When neither a bound nor an observation exists, there is nothing to
    /// enumerate and the period is reported instead.
    fn series_days(&self) -> Result<Vec<String>, PeriodProblem> {
        let start = self
            .scope
            .period
            .start
            .clone()
            .or_else(|| self.observed_days.iter().next().cloned());
        let end = self
            .scope
            .period
            .end
            .clone()
            .or_else(|| self.observed_days.iter().next_back().cloned());
        let (Some(start), Some(end)) = (start, end) else {
            return Err(PeriodProblem::Unbounded);
        };
        if start > end {
            return Err(PeriodProblem::Reversed { start, end });
        }

        let mut days = Vec::new();
        let mut cursor = start;
        loop {
            if days.len() >= MAX_SERIES_DAYS {
                return Err(PeriodProblem::TooLong { from: cursor });
            }
            days.push(cursor.clone());
            if cursor == end {
                return Ok(days);
            }
            let Some(next) = next_day(&cursor) else {
                return Err(PeriodProblem::Unsteppable { from: cursor });
            };
            cursor = next;
        }
    }

    /// One missing-history finding per unbroken run of days with no recorded event.
    ///
    /// A run, not a day: a series with one quiet week says "7 days" once rather than
    /// seven times, which keeps the diagnostics to the number of *gaps* instead of
    /// the number of days.
    fn gap_diagnostics(&self, points: &[DailyPoint]) -> Vec<AnalyticsDiagnostic> {
        let mut diagnostics = Vec::new();
        let mut run: Option<(String, String, usize)> = None;
        for point in points {
            if self.observed_days.contains(&point.date) {
                if let Some((first, last, days)) = run.take() {
                    diagnostics.push(self.gap_diagnostic(&first, &last, days));
                }
            } else {
                run = match run {
                    Some((first, _, days)) => Some((first, point.date.clone(), days + 1)),
                    None => Some((point.date.clone(), point.date.clone(), 1)),
                };
            }
        }
        if let Some((first, last, days)) = run {
            diagnostics.push(self.gap_diagnostic(&first, &last, days));
        }
        diagnostics
    }

    /// One gap's finding.
    fn gap_diagnostic(&self, first: &str, last: &str, days: usize) -> AnalyticsDiagnostic {
        let span = if first == last {
            first.to_string()
        } else {
            format!("{first}..{last}")
        };
        AnalyticsDiagnostic::new(
            AnalyticsDiagnosticKind::MissingHistory,
            self.scope.id.clone(),
            format!("{days} day(s) in {span} carry no recorded history"),
        )
        .on(first)
    }

    /// Say when the period holds no completion event for members the tree records as
    /// complete.
    ///
    /// The single place the shared status-category service informs a series, and it
    /// informs it with a sentence only: the count of completed members comes from
    /// [`crate::workflow::is_completed`], and a shortfall against the period's folded
    /// completions means those members' completions are not in the period. No level is
    /// filled in from it.
    ///
    /// The sentence names only what the fold can see. A member completed **after** the
    /// period end is filtered as outside-period exactly like one completed before the
    /// start, so the difference alone cannot say which side it is on; the message
    /// therefore reports whether the period excluded any recorded event at all
    /// ([`EventIndex::outside_period`]) and never asserts a direction it cannot know.
    /// The remedy is the finding's own for the same reason — the kind's default tells a
    /// reader to widen the period backwards, which a post-period completion cannot use.
    fn baseline_diagnostic(
        &self,
        origin_known: bool,
        folded_completions: i64,
    ) -> Option<AnalyticsDiagnostic> {
        if !origin_known || self.completed_members as i64 <= folded_completions {
            return None;
        }
        let missing = self.completed_members as i64 - folded_completions;
        let excluded = self.index.outside_period();
        let (because, remedy) = if excluded > 0 {
            (
                format!(
                    "the period excluded {excluded} recorded event(s), so those {missing} \
                     completion(s) may lie before its start or after its end"
                ),
                "pass a period that contains the members' completion events — for a closed cycle, \
                 the snapshot's own recorded period — or read each member's History to place them",
            )
        } else {
            (
                "the period excluded no recorded event at all, so those completions carry no \
                 transition inside the series' period"
                    .to_string(),
                "record the completion through `tix mv` so the transition lands in the History this \
                 series folds",
            )
        };
        Some(
            AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingHistory,
                self.scope.id.clone(),
                format!(
                    "the tree records {} of the scope's {} member(s) as complete, but the period \
                     holds only {folded_completions} completion event(s); {because}",
                    self.completed_members,
                    self.members.len()
                ),
            )
            .with_remedy(remedy),
        )
    }
}

/// Why a period could not be enumerated.
///
/// The small half of an
/// [`InvalidPeriod`](AnalyticsDiagnosticKind::InvalidPeriod) finding, kept separate
/// from [`AnalyticsDiagnostic`] on purpose: the diagnostic is a display value of
/// well over a hundred bytes, and returning it as a `Result` error would put that
/// weight on the `Ok` path of every call (clippy's `result_large_err`). The reason
/// travels as a problem and becomes a diagnostic only where it is reported.
enum PeriodProblem {
    /// Neither bound is present and nothing was observed to stand in for one.
    Unbounded,
    /// The resolved start falls after the resolved end.
    Reversed {
        /// The resolved inclusive start.
        start: String,
        /// The resolved inclusive end.
        end: String,
    },
    /// The span is past [`MAX_SERIES_DAYS`].
    TooLong {
        /// The day the enumeration stopped on.
        from: String,
    },
    /// A day the calendar stepper cannot advance from.
    Unsteppable {
        /// The day that could not be stepped.
        from: String,
    },
}

impl PeriodProblem {
    /// The `invalid_period` finding this problem produces.
    fn into_diagnostic(self, scope: &str) -> AnalyticsDiagnostic {
        let (message, date) = match self {
            PeriodProblem::Unbounded => (
                "the period has no bound and the scope records no dated event, so there is no day \
                 to enumerate"
                    .to_string(),
                None,
            ),
            PeriodProblem::Reversed { start, end } => (
                format!("the period starts at {start}, after its end {end}"),
                None,
            ),
            PeriodProblem::TooLong { from } => (
                format!(
                    "the period spans more than {MAX_SERIES_DAYS} days, which is past any planning \
                     window and is refused rather than enumerated"
                ),
                Some(from),
            ),
            PeriodProblem::Unsteppable { from } => (
                format!("`{from}` is not a calendar day this build can step from"),
                Some(from),
            ),
        };
        let diagnostic =
            AnalyticsDiagnostic::new(AnalyticsDiagnosticKind::InvalidPeriod, scope, message);
        match date {
            Some(date) => diagnostic.on(date),
            None => diagnostic,
        }
    }
}

/// Classify a point's coverage from what it can actually say.
///
/// `weak` marks a day whose recorded changes include one the fold could not turn
/// into a count, or a period with an unplaceable event; it downgrades an otherwise
/// fully-known day to coverage-limited, never to unknown.
fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
    let values = point.values();
    let known = values.iter().filter(|value| value.is_some()).count();
    match known {
        0 => Coverage::Unknown,
        5 if !weak => Coverage::Known,
        _ => Coverage::CoverageLimited,
    }
}

/// The identity key a cycle reference resolves to.
///
/// The rule is the crate's own — `capacity::calculate::scope_identity` answers a cycle
/// reference identically — so a scope and an issue that name one cycle are one identity
/// here for the same reason they are there:
///
/// * a generated cycle is `cycle-id:<id>`, whatever name spelled it;
/// * a legacy name the identity index carries is `cycle-name:<name>`;
/// * a name the *configuration* declares but no record carries is a legacy name too,
///   and keeps `cycle-name:<name>`;
/// * a reference neither of them knows keeps its own text in its own namespace,
///   `cycle-ref:<name>`, so two unresolvable names are not folded into one scope.
///
/// What the identity is *used for* is where this module's policy parts with capacity's:
/// the same unresolvable reference selects **nobody** in
/// [`crate::capacity::scope::IssueSnapshot`] (a capacity entry must be validated against
/// a cycle that exists), while this fold selects the issues that spell that name. See
/// the module documentation; the resolution itself is shared.
fn cycle_key(config: &Config, index: &ScheduleIndex, reference: &str) -> String {
    match resolve_cycle(index, reference) {
        CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
        CycleReference::Legacy { name } => format!("cycle-name:{name}"),
        CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
            format!("cycle-name:{reference}")
        }
        CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
    }
}

/// Whether an issue is recorded as a member of `scope`.
///
/// A milestone matches its recorded name. A cycle matches the identity key `scope_key`
/// carries — computed once per build, so the resolver runs once per series and not once
/// per issue.
fn is_member(
    issue: &Issue,
    scope: &AnalyticsScope,
    config: &Config,
    index: &ScheduleIndex,
    scope_key: Option<&str>,
) -> bool {
    match scope.kind {
        ScopeKind::Milestone => issue
            .milestones
            .iter()
            .flatten()
            .any(|milestone| milestone == &scope.id),
        // `scope_key` is `Some` for every cycle scope — the caller derives it from the
        // scope kind — so an absent key means the scope is not a cycle and this arm is
        // never reached. It answers `false` rather than panicking: a read that cannot
        // place a member reports no membership, and the empty series follows.
        ScopeKind::Cycle => scope_key.is_some_and(|key| {
            issue
                .cycles
                .iter()
                .flatten()
                .any(|cycle| cycle_key(config, index, cycle) == key)
        }),
    }
}

/// The recorded order of a contribution id.
///
/// A History contribution's id is `<issue number>#<entry index>`, so ordering on the
/// parsed pair puts `2#0` before `10#0`, which sorting the text would not. Anything of
/// another shape (a snapshot id, say) orders after every such id, by its own text, so
/// every id has exactly one place in the order.
pub(super) fn event_id_key(id: &str) -> (u8, u64, u64, &str) {
    if let Some((source, index)) = id.split_once('#')
        && let Ok(number) = source.parse::<u64>()
    {
        return (0, number, index.parse().unwrap_or(u64::MAX), "");
    }
    (1, 0, 0, id)
}

/// Keep the smallest id in an optional slot, so a finding's example event does not
/// depend on the order the caller loaded the issues in.
fn keep_smallest(slot: &mut Option<String>, candidate: &str) {
    let smaller = match slot.as_deref() {
        Some(current) => event_id_key(candidate) < event_id_key(current),
        None => true,
    };
    if smaller {
        *slot = Some(candidate.to_string());
    }
}

/// The next calendar day, or `None` for a string this build cannot step from.
///
/// The crate validates `YYYY-MM-DD` ([`crate::model::validate_date`]) but has no
/// public calendar-step helper — [`super::events::Period`] deliberately compares
/// dates as strings — so a series that must enumerate days steps them here, with the
/// leap-year rule the validator already uses.
fn next_day(date: &str) -> Option<String> {
    if !crate::model::validate_date(date) {
        return None;
    }
    let year: i32 = date.get(0..4)?.parse().ok()?;
    let month: u32 = date.get(5..7)?.parse().ok()?;
    let mut day: u32 = date.get(8..10)?.parse::<u32>().ok()? + 1;
    let mut month = month;
    let mut year = year;
    if day > days_in_month(year, month) {
        day = 1;
        month += 1;
        if month > 12 {
            month = 1;
            year += 1;
        }
    }
    Some(format!("{year:04}-{month:02}-{day:02}"))
}

/// How many days a month has, with the proleptic Gregorian leap rule
/// [`crate::model::validate_date`] applies.
fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

/// Whether `year` is a leap year in the proleptic Gregorian calendar.
fn is_leap_year(year: i32) -> bool {
    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn a_series_steps_days_across_month_year_and_leap_boundaries() {
        assert_eq!(next_day("2026-01-05").as_deref(), Some("2026-01-06"));
        assert_eq!(next_day("2026-01-31").as_deref(), Some("2026-02-01"));
        assert_eq!(next_day("2026-02-28").as_deref(), Some("2026-03-01"));
        assert_eq!(next_day("2028-02-28").as_deref(), Some("2028-02-29"));
        assert_eq!(next_day("2028-02-29").as_deref(), Some("2028-03-01"));
        assert_eq!(next_day("2026-12-31").as_deref(), Some("2027-01-01"));
        // The century rule both ways: 2000 is a leap year (divisible by 400),
        // 2100 is not (divisible by 100 but not 400). `validate_date` starts at
        // 1970, so the cases stay inside the years the crate accepts.
        assert_eq!(next_day("2000-02-28").as_deref(), Some("2000-02-29"));
        assert_eq!(next_day("2100-02-28").as_deref(), Some("2100-03-01"));
    }

    #[test]
    fn a_string_that_is_not_a_calendar_day_does_not_step() {
        assert_eq!(next_day("2026-02-30"), None);
        assert_eq!(next_day("not-a-day"), None);
        assert_eq!(next_day("2026-2-3"), None);
    }

    #[test]
    fn the_coverage_marker_describes_the_point() {
        let base = DailyPoint {
            date: "2026-01-05".to_string(),
            scope_total: Some(3),
            remaining: Some(2),
            completed: Some(1),
            scope_added: Some(1),
            completions: Some(1),
            coverage: Coverage::Known,
            event_ids: vec!["1#0".to_string()],
        };
        assert_eq!(coverage_of(&base, false), Coverage::Known);
        assert_eq!(
            coverage_of(&base, true),
            Coverage::CoverageLimited,
            "a day the fold could not fully quantify is limited, not known"
        );

        let gap = DailyPoint {
            scope_added: None,
            completions: None,
            ..base.clone()
        };
        assert_eq!(coverage_of(&gap, false), Coverage::CoverageLimited);

        let unanchored = DailyPoint {
            scope_total: None,
            remaining: None,
            completed: None,
            scope_added: None,
            completions: None,
            ..base.clone()
        };
        assert_eq!(coverage_of(&unanchored, false), Coverage::Unknown);
    }
}