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
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
//! Estimate mode: the shared unit conversion, and the series estimate mode folds
//! (mission `burndown-scope-analytics-01M3C9J1`, WP03 T013–T015).
//!
//! # What estimate mode is, and what it is not
//!
//! Count mode ([`crate::analytics::series`]) values every counted contribution at
//! `1`. Estimate mode runs the **same fold over the same recorded contributions**
//! and values each one at the estimated size of the issue it belongs to, in one
//! explicitly selected unit:
//!
//! | the fold sees | count mode | estimate mode |
//! |---|---|---|
//! | a recorded scope addition | `+1` on the day | `+` the issue's estimate |
//! | a recorded completion | `+1` on the day | `+` the issue's estimate |
//! | a contribution the record cannot quantify (`delta: None`) | nothing; the day is limited | nothing; the day is limited |
//! | a recorded estimate change | nothing | nothing; the day is limited, and it is reported |
//! | any other recorded event | nothing; reported | nothing; reported |
//!
//! The two modes therefore agree on the **days**, on the provenance of every day and
//! on what they can and cannot answer; they differ only in what a contribution is
//! worth. On a project where every counted contribution is worth the same `n`, the
//! estimate series is exactly `n` times the count series — the parity the package's
//! tests anchor.
//!
//! # The conversion is the crate's, not a second rule (T013, C-003)
//!
//! An issue's size is read by [`EstimateValue::parse`] (the raw text kept verbatim;
//! human duration words like `day` fold to the board symbol `d`, and each term is
//! converted separately) and turned into the series' unit by
//! [`EstimateValue::converted_to`], whose arithmetic is
//! [`crate::wip::WipConfig::convert`] — the **same** board registry `tix capacity`
//! converts through, resolved from [`Config::default_config`] once per series
//! ([`EstimateConversion::resolve`]). Nothing here invents a unit, a factor or a
//! rounding rule, and a unit the board does not declare is reported rather than
//! guessed.
//!
//! # A missing or mixed unit is a finding, not a number (T015)
//!
//! * A series whose **unit the board does not declare** cannot count anything: the
//!   fold returns no point at all and says why (C-003's "never invent arithmetic").
//! * A member whose stated estimate **cannot be converted** contributes nothing, is
//!   reported once per (reason, unit, field) group, and limits the coverage of every
//!   day its contributions fall on.
//! * An **unestimated** member is not a zero either: its contributions have no size,
//!   which is a finding and a coverage marker, never a `0` in the sum.
//!
//! Every number the series publishes is a **lower bound over the contributions the
//! shared conversion could value**, exactly as [`crate::capacity::calculate`]
//! publishes `committed` beside its `uncounted_issues`/`unestimated_issues` counts —
//! with the coverage marker and the finding standing in for the counts a
//! [`DailyPoint`] cannot carry. A day with no recorded history keeps `None` for its
//! day's changes and is `coverage_limited`, as in count mode; a day whose arithmetic
//! was refused is `coverage_limited` with the finding beside it. Nothing
//! unanswerable is rendered as a zero.
//!
//! # An estimate change is reported, never replayed
//!
//! `estimate_changed` records a before/after pair in its detail. Replaying those
//! pairs into per-day sizes would be a **new formula** (`spec.md` §6: "Escalate any
//! formula that treats missing events as known"), and it would double-count the
//! change against the stated estimate that every other contribution is valued at.
//! The fold therefore values every contribution at the estimate the issue states
//! **now** and reports any re-sizing recorded inside the period, limiting the day it
//! was recorded on: the caveat stays visible and no level is invented.
//!
//! # Determinism (C-007)
//!
//! Members are read in the snapshot's own `(number, filename)` order, the recorded
//! contributions in the index's first-seen order, group tallies are `BTreeMap`s, and
//! the findings are sorted by their own `Ord` — so two runs over one snapshot, in
//! any input order, produce the same bytes.
//!
//! # Findings
//!
//! The estimate fold speaks the **one** vocabulary
//! [`crate::analytics::diagnostics`] fixes — `missing_conversion` for a value the
//! shared conversion could not state, `missing_history` for a contribution the
//! record cannot size, `unclassified_event` for an event that moves no line — and
//! invents no seventh name, so WP02's `diagnostics.rs` needs no edit for this
//! package.
//!
//! # Why the day grid comes from the count fold
//!
//! Which days a series covers is not a property of a mode: it is the scope's period
//! and the recorded days ([`ScopeSnapshot`]). The estimate fold therefore takes its
//! day list from [`ScopeSnapshot::series`]'s count fold — the crate's one period
//! enumeration, including its refusal of a period it cannot enumerate — instead of
//! re-implementing the calendar rules beside it, and it reports a gap of days with
//! no recorded history in the count fold's own sentence, because that fact is the
//! same in both modes.
//!
//! An estimate series therefore carries **everything a count series carries** —
//! the snapshot's own findings, the gap findings — and adds the findings only its
//! valuation can produce. A reader that switches `--mode` on one scope keeps the
//! context and gains the unit caveats.

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

use crate::capacity::estimate::EstimateValue;
use crate::config::Config;
use crate::model::Issue;
use crate::wip::WipConfig;

use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::{AnalyticsEvent, AnalyticsScope, EventClass};
use super::series::{
    BurndownSeries, Coverage, DailyPoint, ESTIMATE_FORMULA_VERSION, Mode, ScopeSnapshot,
    event_id_key,
};

/// The board's declared estimate units, in the order the board carries them.
///
/// The board is [`Config::default_config`] — the one [`crate::capacity`]'s issue
/// snapshot converts under — so a surface validating a `--unit` before folding asks
/// the same table the fold will ask (C-008). A loaded configuration normalizes the
/// order, so this is stable for one config rather than necessarily the order the file
/// was written in; the finding below quotes it as the declared table.
#[must_use]
pub fn declared_units(config: &Config) -> Vec<String> {
    config.default_config().wip.units.keys().cloned().collect()
}

/// One series' resolved estimate conversion (T013).
///
/// Resolved **once per series**: the board's registry is read at resolve time, and
/// every member's estimate is then converted through [`EstimateConversion::value_of`],
/// so a 10,000-issue series does not re-read the configuration per issue.
#[derive(Debug, Clone)]
pub struct EstimateConversion {
    registry: WipConfig,
    unit: String,
}

impl EstimateConversion {
    /// Resolve the shared conversion for `unit`, or `None` when the board does not
    /// declare that unit.
    ///
    /// `None` is not a fallback: the registry has no factor for the unit, so
    /// [`crate::wip::WipConfig::convert`] would refuse every value anyway (C-003).
    /// The caller reports it once instead of converting per issue.
    #[must_use]
    pub fn resolve(config: &Config, unit: &str) -> Option<Self> {
        let registry = config.default_config().wip.clone();
        let unit = unit.trim();
        registry.factor(unit)?;
        Some(Self {
            registry,
            unit: unit.to_string(),
        })
    }

    /// The unit every value in the series counts in.
    #[must_use]
    pub fn unit(&self) -> &str {
        &self.unit
    }

    /// One issue's stated estimate, read and converted in this unit.
    ///
    /// The crate's one estimate seam: [`EstimateValue::parse`] (raw text kept
    /// verbatim; prose units fold to their board symbol and every term is converted
    /// separately) followed by [`EstimateValue::converted_to`] (the registry's
    /// arithmetic). A blank or absent statement is
    /// [`EstimateValue::unestimated`] — "no estimate" is not an error, and it never
    /// becomes a zero.
    #[must_use]
    pub fn value_of(&self, number: u32, stated: Option<&str>) -> EstimateValue {
        match stated {
            Some(raw) if !raw.trim().is_empty() => {
                EstimateValue::parse(number, raw).converted_to(number, &self.registry, &self.unit)
            }
            _ => EstimateValue::unestimated(),
        }
    }

    /// How one member's stated estimate reads in this unit.
    fn size_of(&self, number: u32, stated: Option<&str>) -> MemberSize {
        let value = self.value_of(number, stated);
        if let Some(size) = value.converted_value {
            return MemberSize::Sized(i64::from(size));
        }
        if value.is_unestimated() {
            return MemberSize::Unestimated;
        }
        // A stated estimate that could not be read or converted. The registry's own
        // finding is kept verbatim (it already names the issue), with its
        // machine-readable kind and its own remedy.
        let Some(finding) = value.diagnostics().first() else {
            // Unreachable for the shared seam: a value that is neither unestimated
            // nor converted carries the finding that refused it. Reported as the
            // conversion problem it must have been rather than dropped.
            return MemberSize::Refused(Refused {
                key: RefusalKey {
                    kind: "missing_conversion",
                    spelled: value.unit().map(str::to_string),
                },
                reason: format!("issue #{number} states `{}`", value.render()),
                remedy: None,
            });
        };
        MemberSize::Refused(Refused {
            key: RefusalKey {
                kind: finding.kind.as_str(),
                spelled: value.unit().map(str::to_string),
            },
            reason: finding.message.clone(),
            remedy: finding.because.clone(),
        })
    }
}

/// Fold `snapshot` into an **estimate-mode** series counting in `unit` (T014/T015).
///
/// `issues` is the same slice [`ScopeSnapshot::build`] read, so a member's stated
/// estimate is looked up in the record the snapshot was folded from and nothing is
/// re-read from a store. `config` supplies the board whose unit table the conversion
/// runs under (the same one `tix capacity` uses).
///
/// The result is a [`BurndownSeries`] — the crate's one series shape — with
/// `mode: Estimate`, `unit: Some(unit)`, the documented
/// [`ESTIMATE_FORMULA_VERSION`], the days and coverage the count fold would report,
/// and this fold's own findings beside them.
#[must_use]
pub fn estimate_series(
    snapshot: &ScopeSnapshot,
    issues: &[Issue],
    config: &Config,
    unit: &str,
) -> BurndownSeries {
    let scope = snapshot.scope();
    let Some(conversion) = EstimateConversion::resolve(config, unit) else {
        let mut diagnostics = snapshot.diagnostics().to_vec();
        diagnostics.push(undeclared_unit(scope, unit.trim(), config));
        diagnostics.sort();
        return empty(scope, unit.trim(), diagnostics);
    };

    // The days come from the count fold: one period enumeration for the whole
    // module, and its refusal when the period cannot be enumerated at all.
    let days = snapshot.series(Mode::Count);
    if days.is_empty() {
        return empty(scope, conversion.unit(), days.diagnostics);
    }

    let sizes = size_members(snapshot, issues, &conversion);
    let valued = value_days(snapshot, &sizes);

    let origin_known = scope.period_start().is_some();
    let global_weak = snapshot.unplaced_events() > 0;
    let mut points = Vec::with_capacity(days.points.len());
    let mut scope_total = 0i64;
    let mut completed = 0i64;
    let mut first_unanswerable: Option<(&str, i64, i64)> = None;
    let mut level_overflow: Option<&str> = None;
    for covered in &days.points {
        let date = covered.date.as_str();
        let observed = snapshot.observed_days().contains(date);
        let added = valued.added.get(date).copied();
        let done = valued.completed.get(date).copied();
        if observed
            && level_overflow.is_none()
            && (!super::series::carry_level(&mut scope_total, added.unwrap_or(0))
                || !super::series::carry_level(&mut completed, done.unwrap_or(0)))
        {
            level_overflow = Some(date);
        }

        let (total, remaining, done_level) = if level_overflow.is_some() {
            (None, None, None)
        } else if origin_known {
            let remaining = scope_total - completed;
            if remaining < 0 {
                first_unanswerable.get_or_insert((date, scope_total, completed));
                (Some(scope_total), None, Some(completed))
            } else {
                (Some(scope_total), Some(remaining), Some(completed))
            }
        } else {
            (None, None, None)
        };

        // The day's provenance is the crate's recorded order — issue number, then entry
        // index — never the order the caller loaded the issues in, so a point, and the
        // document built from it, does not depend on that order. `DailyPoint::event_ids`
        // documents exactly this, and the count fold orders its ids through the same key;
        // without it the estimate fold reported a day's ids first-seen, and two runs over
        // one snapshot disagreed on a reversed input (C-007).
        let mut event_ids = valued.event_ids.get(date).cloned().unwrap_or_default();
        event_ids.sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));

        let point = DailyPoint {
            date: date.to_string(),
            scope_total: total,
            remaining,
            completed: done_level,
            scope_added: observed.then(|| added.unwrap_or(0)),
            completions: observed.then(|| done.unwrap_or(0)),
            coverage: Coverage::Known,
            event_ids,
        };
        let weak = global_weak || valued.weak.contains(date);
        points.push(DailyPoint {
            coverage: coverage_of(&point, weak),
            ..point
        });
    }

    let mut diagnostics = snapshot.diagnostics().to_vec();
    diagnostics.extend(gap_findings(scope, &points, snapshot.observed_days()));
    diagnostics.extend(valued.findings(scope, conversion.unit()));
    if let Some((day, total, done)) = first_unanswerable {
        diagnostics.push(level_finding(scope, conversion.unit(), day, total, done));
    }
    if let Some(day) = level_overflow {
        diagnostics.push(
            AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingHistory,
                scope.id.clone(),
                format!(
                    "on {day} the carried estimate level exceeded the i64 range, so remaining \
                     work cannot be answered without wrapping or inventing a value"
                ),
            )
            .on(day)
            .with_remedy(
                "reduce the synthetic fixture: persisted estimates use u32 values, so a \
                 realistic project is orders of magnitude below this bound",
            ),
        );
    }
    diagnostics.sort();

    BurndownSeries {
        scope: scope.clone(),
        mode: Mode::Estimate,
        unit: Some(conversion.unit().to_string()),
        points,
        diagnostics,
        formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
    }
}

/// The series an estimate-mode request that cannot be answered returns.
fn empty(
    scope: &AnalyticsScope,
    unit: &str,
    diagnostics: Vec<AnalyticsDiagnostic>,
) -> BurndownSeries {
    BurndownSeries {
        scope: scope.clone(),
        mode: Mode::Estimate,
        unit: Some(unit.to_string()),
        points: Vec::new(),
        diagnostics,
        formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
    }
}

/// The finding for a unit the board does not declare: no point is folded at all.
fn undeclared_unit(scope: &AnalyticsScope, unit: &str, config: &Config) -> AnalyticsDiagnostic {
    let declared = declared_units(config);
    let table = if declared.is_empty() {
        "the board declares no estimate unit".to_string()
    } else {
        format!(
            "the board declares {}",
            declared
                .iter()
                .map(|unit| format!("`{unit}`"))
                .collect::<Vec<_>>()
                .join(", ")
        )
    };
    AnalyticsDiagnostic::new(
        AnalyticsDiagnosticKind::MissingConversion,
        scope.id.clone(),
        format!(
            "estimate mode cannot count in `{unit}`: {table}, so no day is folded and no \
             estimate total is fabricated"
        ),
    )
    .with_remedy(
        "choose `--mode count`, or declare the unit under `[kanban.<board>.wip.units]` and \
         state the estimates in it",
    )
}

/// One finding per unbroken run of days with no recorded contribution.
///
/// A run, not a day: one quiet week says "7 days" once rather than seven times. The
/// sentence is the one [`crate::analytics::series`] uses for the count fold's own
/// gaps — the fact is the same in both modes, and a reader comparing two series for
/// one scope should not have to learn a second phrasing for one gap.
fn gap_findings(
    scope: &AnalyticsScope,
    points: &[DailyPoint],
    observed: &BTreeSet<String>,
) -> Vec<AnalyticsDiagnostic> {
    let mut findings = Vec::new();
    let mut run: Option<(String, String, usize)> = None;
    for point in points {
        if observed.contains(&point.date) {
            if let Some((first, last, days)) = run.take() {
                findings.push(gap_finding(scope, &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 {
        findings.push(gap_finding(scope, &first, &last, days));
    }
    findings
}

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

/// The finding for a level the period cannot answer: recorded completions outweigh
/// recorded additions, so the remaining work has no origin inside the period.
fn level_finding(
    scope: &AnalyticsScope,
    unit: &str,
    day: &str,
    total: i64,
    done: i64,
) -> AnalyticsDiagnostic {
    AnalyticsDiagnostic::new(
        AnalyticsDiagnosticKind::MissingHistory,
        scope.id.clone(),
        format!(
            "on {day} the period records `{done}` `{unit}` of completion but only `{total}` \
             `{unit}` of scope addition, so the remaining work cannot be answered from the \
             period alone"
        ),
    )
    .on(day)
}

/// Classify a point's coverage from what it can actually say.
///
/// The rule [`crate::analytics::series`] applies to a count point, kept identical
/// here so a reader gets the same three states from either mode: every value known
/// is known (unless the day is weak), no value known is unknown, and anything in
/// between is coverage-limited.
fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
    let values = [
        point.scope_total,
        point.remaining,
        point.completed,
        point.scope_added,
        point.completions,
    ];
    let known = values.iter().filter(|value| value.is_some()).count();
    match known {
        0 => Coverage::Unknown,
        5 if !weak => Coverage::Known,
        _ => Coverage::CoverageLimited,
    }
}

/// The scope's members, each with the size its stated estimate resolves to.
fn size_members(
    snapshot: &ScopeSnapshot,
    issues: &[Issue],
    conversion: &EstimateConversion,
) -> BTreeMap<u32, MemberSize> {
    let records: BTreeMap<u32, &Issue> = issues.iter().map(|issue| (issue.number, issue)).collect();
    snapshot
        .members()
        .iter()
        .map(|&number| match records.get(&number) {
            Some(issue) => (
                number,
                conversion.size_of(number, issue.estimate.as_deref()),
            ),
            // The snapshot's members came from the same slice, so this is a caller
            // that passed a narrower slice; the contribution is reported, not sized.
            None => (number, MemberSize::Unrecorded),
        })
        .collect()
}

/// The valued contributions of one scope (T014), and everything the fold could not
/// value.
#[derive(Debug, Default)]
struct ValuedDays {
    /// The `scope_added` values carried per day, in the selected unit.
    added: BTreeMap<String, i64>,
    /// The `completions` values carried per day, in the selected unit.
    completed: BTreeMap<String, i64>,
    /// Every contribution's stable id per day, in first-seen order.
    event_ids: BTreeMap<String, Vec<String>>,
    /// The days whose change set the fold could not fully value.
    weak: BTreeSet<String>,
    /// Refused values, by (field, shared reason).
    refusals: BTreeMap<(Field, RefusalKey), Tally>,
    /// Contributions of an unestimated member, by field.
    unestimated: BTreeMap<Field, Tally>,
    /// Contributions no loaded issue can be sized from, by field.
    unattributed: BTreeMap<Field, Tally>,
    /// Contributions the record carries no count-level value for, by field.
    unquantified: BTreeMap<Field, Tally>,
    /// Recorded estimate changes inside the period.
    rescoped: Tally,
    /// Every other recorded event.
    unclassified: Tally,
}

impl ValuedDays {
    /// The findings this fold's own valuation produced, in a stable order.
    fn findings(&self, scope: &AnalyticsScope, unit: &str) -> Vec<AnalyticsDiagnostic> {
        let mut diagnostics = Vec::new();

        for ((field, key), tally) in &self.refusals {
            let reason = tally
                .reason
                .as_deref()
                .unwrap_or("the shared conversion refused it");
            let mut diagnostic = AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingConversion,
                scope.id.clone(),
                format!(
                    "{} recorded `{}` contribution(s) are left out of the estimate series in \
                     `{unit}`: {reason}",
                    tally.count,
                    field.as_str()
                ),
            );
            if let Some(event) = tally.first_event.as_deref() {
                diagnostic = diagnostic.about_event(event);
            }
            // The registry's own "what to do about it" when it gave one, and a
            // shape-naming remedy otherwise: a conversion the board cannot make is
            // fixed where the unit or the estimate is declared, not here.
            diagnostic = match tally.remedy.as_deref() {
                Some(remedy) => diagnostic.with_remedy(remedy),
                None if key.kind == "malformed_estimate" => diagnostic.with_remedy(
                    "rewrite the issue's `estimate:` as `<number><unit>`, or run `tix lint`",
                ),
                None => diagnostic,
            };
            diagnostics.push(diagnostic);
        }

        for (field, tally) in &self.unestimated {
            diagnostics.push(sized_finding(
                scope,
                tally,
                format!(
                    "{} recorded `{}` contribution(s) belong to an unestimated issue, so \
                         their size in `{unit}` is unknown and their days are coverage-limited",
                    tally.count,
                    field.as_str()
                ),
                "state the estimate on the issue (`tix edit <n> --estimate <number><unit>`) \
                     so its work can be sized",
            ));
        }

        for (field, tally) in &self.unattributed {
            diagnostics.push(sized_finding(
                scope,
                tally,
                format!(
                    "{} recorded `{}` contribution(s) name no issue this series can size (a \
                     closed snapshot's contribution carries no issue), so they are not valued in \
                     `{unit}`",
                    tally.count,
                    field.as_str()
                ),
                "read the day as coverage-limited: only the recorded per-issue contributions can \
                 be sized",
            ));
        }

        for (field, tally) in &self.unquantified {
            diagnostics.push(sized_finding(
                scope,
                tally,
                format!(
                    "{} recorded `{}` contribution(s) carry no count-level value, so their size in \
                     `{unit}` is unknown and their days are coverage-limited",
                    tally.count,
                    field.as_str()
                ),
                "read the day as coverage-limited: the recorded detail names the change but not \
                 its size in this unit",
            ));
        }

        if self.rescoped.count > 0 {
            let tally = &self.rescoped;
            let mut diagnostic = AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::MissingHistory,
                scope.id.clone(),
                format!(
                    "{} recorded estimate change(s) inside the period re-sized an issue; the series \
                     values every contribution at the estimate the issue states now, so a level \
                     before such a change is not the level the record held then",
                    tally.count
                ),
            );
            if let Some(event) = tally.first_event.as_deref() {
                diagnostic = diagnostic.about_event(event);
            }
            diagnostics.push(diagnostic);
        }

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

        diagnostics
    }
}

/// One contribution-group finding, with the first contributing event as provenance.
///
/// The message already names the field the value would have landed in, so the group
/// is reported once per (reason, field) with a count rather than once per issue.
fn sized_finding(
    scope: &AnalyticsScope,
    tally: &Tally,
    message: String,
    remedy: &str,
) -> AnalyticsDiagnostic {
    let mut diagnostic = AnalyticsDiagnostic::new(
        AnalyticsDiagnosticKind::MissingHistory,
        scope.id.clone(),
        message,
    )
    .with_remedy(remedy);
    if let Some(event) = tally.first_event.as_deref() {
        diagnostic = diagnostic.about_event(event);
    }
    diagnostic
}

/// Value every recorded contribution of the scope on its day.
fn value_days(snapshot: &ScopeSnapshot, sizes: &BTreeMap<u32, MemberSize>) -> ValuedDays {
    let mut valued = ValuedDays::default();
    for event in snapshot.event_index().events() {
        // A contribution with no readable day is a service-level finding (the
        // snapshot reports it) and weakens every day through `unplaced_events`.
        let Some(date) = event.date.as_deref() else {
            continue;
        };
        valued
            .event_ids
            .entry(date.to_string())
            .or_default()
            .push(event.event_id.clone());
        match event.class {
            EventClass::ScopeAddition | EventClass::Completion => {
                let field = match event.class {
                    EventClass::ScopeAddition => Field::Added,
                    _ => Field::Completed,
                };
                if event.delta.is_none() {
                    valued.weak.insert(date.to_string());
                    valued.unquantified.entry(field).or_default().record(event);
                    continue;
                }
                match event.issue.and_then(|number| sizes.get(&number)) {
                    Some(MemberSize::Sized(size)) => {
                        let bucket = match field {
                            Field::Added => &mut valued.added,
                            Field::Completed => &mut valued.completed,
                        };
                        *bucket.entry(date.to_string()).or_default() += size;
                    }
                    Some(MemberSize::Unestimated) => {
                        valued.weak.insert(date.to_string());
                        valued.unestimated.entry(field).or_default().record(event);
                    }
                    Some(MemberSize::Refused(refusal)) => {
                        valued.weak.insert(date.to_string());
                        valued
                            .refusals
                            .entry((field, refusal.key.clone()))
                            .or_default()
                            .record_refusal(event, refusal);
                    }
                    Some(MemberSize::Unrecorded) | None => {
                        valued.weak.insert(date.to_string());
                        valued.unattributed.entry(field).or_default().record(event);
                    }
                }
            }
            // A re-sizing recorded inside the period: reported, and the day limited.
            // It is never replayed into the levels (see the module documentation).
            EventClass::EstimateChange => {
                valued.weak.insert(date.to_string());
                valued.rescoped.record(event);
            }
            EventClass::Unclassified => valued.unclassified.record(event),
        }
    }
    valued
}

/// How one member's stated estimate reads in the series' unit (T013).
#[derive(Debug, Clone)]
enum MemberSize {
    /// Converted: the size every counted contribution of this member carries.
    Sized(i64),
    /// The member states no estimate at all.
    Unestimated,
    /// Stated but not countable in this unit, with the registry's own finding.
    Refused(Refused),
    /// The member's record was not among the issues this series was given.
    Unrecorded,
}

/// Why a stated estimate could not be counted, in the shared vocabulary.
#[derive(Debug, Clone)]
struct Refused {
    /// What refused it: the registry's kind and the unit spelling the issue used.
    key: RefusalKey,
    /// The registry's sentence, verbatim (it already names the issue).
    reason: String,
    /// The registry's own remedy, when it gave one.
    remedy: Option<String>,
}

/// The grouping key of a refusal: the shared reason and the spelling behind it.
///
/// Two issues that wrote the same undeclared unit, or that both failed the same
/// arithmetic, group into one finding — the count is the actionable part, and one
/// finding per issue would bury it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct RefusalKey {
    /// The registry's machine-readable kind (`missing_conversion`, `unknown_unit`,
    /// `ambiguous_conversion`, `malformed_estimate`).
    kind: &'static str,
    /// The unit the issue named, when it named one.
    spelled: Option<String>,
}

/// The `DailyPoint` field a contribution's value lands in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Field {
    /// `scope_added` (and, carried, `scope_total`).
    Added,
    /// `completions` (and, carried, `completed`).
    Completed,
}

impl Field {
    /// The stable field name the finding quotes.
    const fn as_str(self) -> &'static str {
        match self {
            Field::Added => "scope_added",
            Field::Completed => "completions",
        }
    }
}

/// One group of contributions the fold could not value: how many, and the first.
#[derive(Debug, Clone, Default)]
struct Tally {
    /// How many contributions the group holds.
    count: usize,
    /// The stable id of the first contribution in the group.
    first_event: Option<String>,
    /// The registry's sentence for the first contribution, when it gave one.
    reason: Option<String>,
    /// The registry's own remedy for the first contribution, when it gave one.
    remedy: Option<String>,
}

impl Tally {
    /// Count one contribution, keeping the first as the group's provenance.
    fn record(&mut self, event: &AnalyticsEvent) {
        self.count += 1;
        if self.first_event.is_none() {
            self.first_event = Some(event.event_id.clone());
        }
    }

    /// Count one refused contribution, keeping the first refusal's own words.
    fn record_refusal(&mut self, event: &AnalyticsEvent, refusal: &Refused) {
        let first = self.count == 0;
        self.record(event);
        if first {
            self.reason = Some(refusal.reason.clone());
            self.remedy = refusal.remedy.clone();
        }
    }
}