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
//! One-pass integer-minute totals and the counts beside them (mission
//! `per-issue-time-tracking-01M3C9A4`, WP03 T014–T018).
//!
//! # One snapshot, derived once
//!
//! [`TimeSnapshot::of`] indexes the issues a caller already holds **once**: every
//! current entry with the issue it belongs to, in the data-model §6 order (issue,
//! then dated entries oldest first, undated last, ties by stable id), plus the time
//! History each issue retains. Every scope — a whole filter, a per-issue breakdown,
//! a per-identity breakdown, a per-date breakdown — is then evaluated against that
//! in-memory index. Nothing here opens a file, reads a clock or re-parses
//! frontmatter, and [`TimeSnapshot::totals`] evaluates a whole set of requested
//! scopes with no second scan, so `columns × entries` cannot creep back in and two
//! surfaces cannot count the same entries differently (T014, NFR-001, C-005).
//!
//! # Unknown is not zero, and nothing is not zero
//!
//! [`TimeTotal::total_minutes`] is an `Option`: `Some(0)` means a zero was
//! **counted**, and `None` means nothing was counted at all. The two are kept apart
//! because they answer different questions — "0m" is a measurement, and no
//! measurement at all is not one — and [`TimeTotal::state`] names which case a
//! reader is in: a scope that selected no entry is [`TimeTotalState::Nothing`], one
//! whose selected entries came back without a number (every one malformed, or the
//! period could not place the valid undated ones) is [`TimeTotalState::Unknown`],
//! and one whose issue selector names an issue the snapshot does not hold is
//! [`TimeTotalState::UnknownIssue`] rather than an issue that recorded nothing.
//!
//! A malformed entry contributes **no number**: it is excluded from the valid total,
//! counted in [`TimeTotal::malformed_count`] and reported by its own finding, and a
//! total that dropped one can never be read as complete without the reader seeing
//! the count ([`TimeTotal::is_partial`]) — the mirror of the WIP mission's "an
//! uncountable estimate makes the total unknown, not zero" (C-005, research R10).
//! The sum itself is accumulated in `i128`, so a store whose entries are each a
//! representable `i64` cannot wrap the total into a wrong number.
//!
//! # The audit trail is History's
//!
//! Normal totals are the **current** projection: a deleted entry is simply absent,
//! and its minutes are never added back. History is consulted only when the caller
//! asks for it — [`TimeFilter::include_deleted`] on a total, or
//! [`TimeSnapshot::audit`] — and then it is *reported*: how many prior values a
//! deletion left behind, on which issues, by whom, and when
//! ([`TimeDeletion`]). The prior value's own text stays in the event, because
//! re-parsing the History grammar here would be a second owner of a grammar WP02
//! already owns; this module reads only the entry id the grammar's own inverse
//! ([`entry_id_in`]) recovers.
//!
//! # Read-only by construction
//!
//! Neither [`TimeSnapshot`] nor [`TimeTotal`] holds a store, a config or a writer,
//! so there is no seam through which aggregation could mutate an issue: the numbers
//! are derived from a slice a caller read, and the slice it was handed is untouched
//! (C-001/C-008).

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

use crate::model::{HistoryEventKind, Issue, validate_date};
use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
use crate::time_tracking::entry::TimeEntry;
use crate::time_tracking::filters::{
    PeriodPlacement, TimeFilter, canonical_identity, identity_cmp,
};
use crate::time_tracking::service::entry_id_in;

/// The dimension a total is grouped by (T014).
///
/// The variants are ordered so that a breakdown comes out of a `BTreeMap` in the
/// order a reader expects — everything first, then issues ascending, then identities,
/// then unattributed entries, then dates ascending, undated last — without a second
/// sort key to keep in step.
///
/// Identity groups compare through [`identity_cmp`], so `ada` and `Ada` are one
/// group; [`Ord`] and [`PartialEq`] are written together here so that two groups that
/// order equal also compare equal, which the crate's other `Ord` implementations are
/// equally careful about ([`TimeEntry`] among them).
#[derive(Debug, Clone)]
pub enum TimeGroup {
    /// The whole scope, ungrouped.
    All,
    /// One issue's entries.
    Issue(u32),
    /// One identity's entries, keyed by canonical identity.
    Assignee(String),
    /// Entries whose author names no identity, so they cannot be attributed to a
    /// person. They are shown, not dropped.
    Unattributed,
    /// One calendar date's entries.
    Date(String),
    /// Entries no period can place: no date, or a date the calendar validator
    /// refuses (the entry is diagnosed as `invalid_date` in the same total).
    Undated,
}

impl TimeGroup {
    /// A short label for the group, for a heading or a payload (C-006).
    ///
    /// One spelling for every surface: `all`, `#3`, `@ada`, `(unattributed)`,
    /// `2026-03-01`, `(undated)`.
    pub fn label(&self) -> String {
        match self {
            Self::All => "all".to_string(),
            Self::Issue(number) => format!("#{number}"),
            Self::Assignee(identity) => crate::assignee::assignee_display(identity),
            Self::Unattributed => "(unattributed)".to_string(),
            Self::Date(date) => date.clone(),
            Self::Undated => "(undated)".to_string(),
        }
    }
}

impl PartialEq for TimeGroup {
    fn eq(&self, other: &Self) -> bool {
        self.cmp(other) == Ordering::Equal
    }
}

impl Eq for TimeGroup {}

impl PartialOrd for TimeGroup {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for TimeGroup {
    fn cmp(&self, other: &Self) -> Ordering {
        match (self, other) {
            (Self::All, Self::All) => Ordering::Equal,
            (Self::All, _) => Ordering::Less,
            (_, Self::All) => Ordering::Greater,
            (Self::Issue(left), Self::Issue(right)) => left.cmp(right),
            (Self::Issue(_), _) => Ordering::Less,
            (_, Self::Issue(_)) => Ordering::Greater,
            (Self::Assignee(left), Self::Assignee(right)) => identity_cmp(left, right),
            (Self::Assignee(_), _) => Ordering::Less,
            (_, Self::Assignee(_)) => Ordering::Greater,
            (Self::Unattributed, Self::Unattributed) => Ordering::Equal,
            (Self::Unattributed, _) => Ordering::Less,
            (_, Self::Unattributed) => Ordering::Greater,
            (Self::Date(left), Self::Date(right)) => left.cmp(right),
            (Self::Date(_), Self::Undated) => Ordering::Less,
            (Self::Undated, Self::Date(_)) => Ordering::Greater,
            (Self::Undated, Self::Undated) => Ordering::Equal,
        }
    }
}

/// The explicit state of a [`TimeTotal`] (T015).
///
/// Derived from the total's own fields, never stored, so a surface can branch on a
/// typed answer instead of inferring one from `0` — which is the whole point: a
/// zero-minute total and an empty scope are different facts, and so are a refused
/// filter and an unknown issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimeTotalState {
    /// The filter was refused before any entry was evaluated; the findings say why.
    InvalidFilter,
    /// The issue selector named an issue this snapshot does not hold.
    UnknownIssue,
    /// The scope selected no entry at all — no entry belonged to it, or a period
    /// placed none of the entries it selected (a date outside the period is simply
    /// not in it).
    Nothing,
    /// The scope selected entries and no usable number came back: every selected
    /// entry was malformed, or the period could not place the valid ones it selected
    /// because they carry no date. Never reported as zero, and never as `Nothing` —
    /// this scope did select real entries (C-005).
    Unknown,
    /// At least one entry was counted; `total_minutes` is its sum, where `Some(0)`
    /// means a zero was genuinely recorded. `is_partial` says whether some selected
    /// entry was left out of it.
    Recorded,
}

impl TimeTotalState {
    /// The stable spelling every surface prints and every payload carries.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::InvalidFilter => "invalid_filter",
            Self::UnknownIssue => "unknown_issue",
            Self::Nothing => "nothing",
            Self::Unknown => "unknown",
            Self::Recorded => "recorded",
        }
    }
}

/// A typed integer-minute aggregate for one scope (data-model §4).
///
/// Every number beside the total is a count a reader needs to know whether the total
/// is the whole story: how many entries were **counted**, how many were **malformed**
/// (excluded from the total, each with a finding), how many the period could not
/// place because they carry no date, and — only when the filter asked for audit
/// context — how many prior values a deletion left in History.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeTotal {
    /// The selector this total was evaluated under (data-model §4's `scope`).
    pub filter: TimeFilter,
    /// The dimension this total is grouped by.
    pub group: TimeGroup,
    /// The sum of the counted entries' minutes. `None` when nothing was counted;
    /// `Some(0)` only when a zero was actually recorded.
    pub total_minutes: Option<i128>,
    /// Valid entries counted into the sum.
    pub entry_count: usize,
    /// Entries whose own fields are unusable. They contribute no number, and each has
    /// its own finding.
    pub malformed_count: usize,
    /// Valid entries a period-bounded filter could not place because they carry no
    /// date. Their value is unknown to the period, never zero (C-005).
    pub undated_count: usize,
    /// Prior values deletions left in History on the issues this total is about.
    /// `Some` only when the filter asked for audit context (T017), and never part of
    /// the sum.
    ///
    /// The set counted is the scope's own: an ungrouped total counts the issues the
    /// filter's selector covers, which is exactly what [`TimeSnapshot::audit`]
    /// reports for the same filter — so an issue whose current entries were all
    /// deleted still reports the values History keeps, never a zero; a per-issue
    /// group counts its issue; an identity or date group counts the issues its
    /// entries were drawn from, because History's prior values are not re-parsed here
    /// and a deletion cannot be attributed to an identity or a date.
    pub deleted_count: Option<usize>,
    /// Every finding, scope findings first, then per entry in index order (T016).
    pub diagnostics: Vec<TimeDiagnostic>,
}

impl TimeTotal {
    /// The explicit state of this total (T015).
    pub fn state(&self) -> TimeTotalState {
        if self.diagnostics.iter().any(|finding| {
            finding.is_scope_finding() && finding.kind == TimeDiagnosticKind::UnknownIssue
        }) {
            return TimeTotalState::UnknownIssue;
        }
        if self
            .diagnostics
            .iter()
            .any(TimeDiagnostic::is_scope_finding)
        {
            return TimeTotalState::InvalidFilter;
        }
        if self.total_minutes.is_some() {
            TimeTotalState::Recorded
        } else if self.malformed_count > 0 || self.undated_count > 0 {
            TimeTotalState::Unknown
        } else {
            TimeTotalState::Nothing
        }
    }

    /// Whether some selected entry was left out of the sum (T015).
    ///
    /// True when a malformed entry contributed no number, and when a period-bounded
    /// scope selected a valid entry it could not place: in both cases the total is
    /// not the whole story, and a reader must be shown the counts and findings before
    /// the number means anything.
    pub const fn is_partial(&self) -> bool {
        self.malformed_count > 0 || self.undated_count > 0
    }

    /// A total with no numbers and the findings that refused it.
    fn bare(filter: TimeFilter, group: TimeGroup, diagnostics: Vec<TimeDiagnostic>) -> Self {
        Self {
            filter,
            group,
            total_minutes: None,
            entry_count: 0,
            malformed_count: 0,
            undated_count: 0,
            deleted_count: None,
            diagnostics,
        }
    }
}

/// One current entry, indexed with the issue it belongs to (T014).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct IndexedEntry {
    /// The issue number the entry belongs to.
    pub issue: u32,
    /// The current entry itself.
    pub entry: TimeEntry,
    /// The entry's `author` in canonical identity form, resolved once when the
    /// snapshot was built. `None` when the author names no identity: a named
    /// criterion can never match it, and a breakdown shows such an entry under
    /// [`TimeGroup::Unattributed`] rather than dropping it.
    pub canonical_author: Option<String>,
}

/// One prior value a deletion left in History (T017).
///
/// `entry_id` is the id the event's own grammar inverse recovered. It is `Option`
/// because a hand-edited `History` line can name an entry the grammar cannot read;
/// such an event is still counted and still listed, so the audit never silently
/// shrinks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeDeletion {
    /// The issue whose History retains the value.
    pub issue: u32,
    /// The deleted entry's stable id, when the event's detail names one.
    pub entry_id: Option<String>,
    /// When the deletion was recorded, from the event itself.
    pub deleted_at: String,
    /// Who recorded the deletion, from the event itself.
    pub deleted_by: String,
}

/// One issue's current-versus-History context (T017).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueTimeAudit {
    /// The issue this context is about.
    pub issue: u32,
    /// The issue's current entries, whatever the filter's period or assignee criteria
    /// select: the comparison this exists for is current against History.
    pub current: usize,
    /// The prior values deletions left in this issue's History, in History order.
    pub deletions: Vec<TimeDeletion>,
    /// How many corrections this issue's History retains; each keeps a prior value
    /// too, which is why a correction is auditable rather than silent (C-004).
    pub corrections: usize,
}

/// The explicit audit view: the current projection beside what History retains
/// (T017).
///
/// Asking for this **is** the explicit request, so
/// [`TimeFilter::include_deleted`] does not gate it. The filter's issue selector
/// chooses which issues are described; its period and assignee criteria are not
/// applied, because History's prior values are not re-parsed here and so a deletion
/// cannot honestly be placed in a period or attributed to an author.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeAudit {
    /// The filter the audit was requested under.
    pub filter: TimeFilter,
    /// One context per issue the filter's selector covers, in issue order.
    pub issues: Vec<IssueTimeAudit>,
    /// Prior values deletions left in History across those issues.
    pub deleted_count: usize,
    /// Corrections those issues' History retains.
    pub corrected_count: usize,
    /// Findings about the request itself (a refused filter, an unknown issue).
    pub diagnostics: Vec<TimeDiagnostic>,
}

/// One read-only index of current entries and retained time History (T014).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeSnapshot {
    /// Every current entry, in deterministic order.
    entries: Vec<IndexedEntry>,
    /// Every issue number the snapshot holds, ascending — including issues with no
    /// entry, so "this issue recorded nothing" stays distinguishable from "this
    /// snapshot never held this issue".
    issues: Vec<u32>,
    /// The deletions each issue's History retains, by issue.
    deletions: BTreeMap<u32, Vec<TimeDeletion>>,
    /// The corrections each issue's History retains, by issue.
    corrections: BTreeMap<u32, usize>,
}

impl TimeSnapshot {
    /// Index the current entries and time History of the issues a caller holds
    /// (T014).
    ///
    /// One pass over the slice: each entry is resolved once (including the identity
    /// it is attributed to, which every later grouping and comparison reuses), and
    /// each issue's time events are read once. Entries come out in data-model §6
    /// order, so the same issues always produce the same bytes — which is what makes
    /// CLI, TUI, MCP and portable output agree on them (NFR-003).
    pub fn of(issues: &[Issue]) -> Self {
        let mut entries = Vec::new();
        let mut numbers: Vec<u32> = Vec::with_capacity(issues.len());
        let mut deletions: BTreeMap<u32, Vec<TimeDeletion>> = BTreeMap::new();
        let mut corrections: BTreeMap<u32, usize> = BTreeMap::new();
        for issue in issues {
            numbers.push(issue.number);
            entries.extend(issue.time_entries.iter().map(|entry| IndexedEntry {
                issue: issue.number,
                canonical_author: canonical_identity(&entry.author),
                entry: entry.clone(),
            }));
            let mut deleted = Vec::new();
            let mut corrected = 0usize;
            for event in issue.history.events() {
                match event.kind {
                    HistoryEventKind::TimeEntryDeleted => deleted.push(TimeDeletion {
                        issue: issue.number,
                        entry_id: entry_id_in(&event.detail).map(str::to_string),
                        deleted_at: event.timestamp.clone(),
                        deleted_by: event.actor.clone(),
                    }),
                    HistoryEventKind::TimeEntryUpdated => corrected += 1,
                    _ => {}
                }
            }
            if !deleted.is_empty() {
                deletions.insert(issue.number, deleted);
            }
            if corrected > 0 {
                corrections.insert(issue.number, corrected);
            }
        }
        numbers.sort_unstable();
        numbers.dedup();
        entries.sort();
        Self {
            entries,
            issues: numbers,
            deletions,
            corrections,
        }
    }

    /// Every indexed entry, in the snapshot's deterministic order.
    pub fn entries(&self) -> &[IndexedEntry] {
        &self.entries
    }

    /// Every issue number the snapshot holds, ascending.
    pub fn issues(&self) -> &[u32] {
        &self.issues
    }

    /// Whether the snapshot holds this issue — with or without entries.
    pub fn holds_issue(&self, number: u32) -> bool {
        self.issues.binary_search(&number).is_ok()
    }

    /// The prior values deletions left in one issue's History, in History order.
    pub fn deletions_on(&self, issue: u32) -> &[TimeDeletion] {
        self.deletions
            .get(&issue)
            .map_or([].as_slice(), Vec::as_slice)
    }

    /// How many corrections one issue's History retains.
    pub fn corrections_on(&self, issue: u32) -> usize {
        self.corrections.get(&issue).copied().unwrap_or(0)
    }

    /// The issue numbers a filter's selector covers, in issue order (T017).
    ///
    /// The **selector** is the issue criterion alone: an identity or a period cannot
    /// be applied to a deletion, because History's prior values are not re-parsed
    /// here, so no number of this snapshot's issues is invented or dropped for them.
    /// One definition of "the issues this scope covers" is what keeps
    /// [`Self::audit`] and [`TimeTotal::deleted_count`] from disagreeing about the
    /// same scope — including the case where every current entry an issue recorded
    /// was deleted, which is exactly when the audit number is the only one there is.
    fn scope_issues<'a>(&'a self, filter: &'a TimeFilter) -> impl Iterator<Item = u32> + 'a {
        self.issues
            .iter()
            .copied()
            .filter(move |number| filter.issue.is_none_or(|selected| selected == *number))
    }

    /// The prior values deletions left on the issues a filter's selector covers.
    fn scope_deletions(&self, filter: &TimeFilter) -> usize {
        self.scope_issues(filter)
            .map(|number| self.deletions_on(number).len())
            .sum()
    }

    /// The typed total for one scope (T015).
    ///
    /// A refused filter yields one total whose state is
    /// [`TimeTotalState::InvalidFilter`] and whose findings are the refusals — no
    /// entry is evaluated, so an invalid bound can never be read as "no bound". A
    /// selector naming an issue this snapshot does not hold yields
    /// [`TimeTotalState::UnknownIssue`] for the same reason.
    pub fn total(&self, filter: &TimeFilter) -> TimeTotal {
        if let Some(refused) = self.refusal(filter) {
            return refused;
        }
        let matcher = Matcher::new(filter);
        let mut totals = Totals::default();
        for indexed in &self.entries {
            if !matcher.matches(indexed) {
                continue;
            }
            let placement = filter.place(indexed.entry.date.as_deref());
            totals.fold(indexed, placement);
        }
        totals.into_total(
            self,
            filter.clone(),
            TimeGroup::All,
            DeletionScope::Selector,
        )
    }

    /// Every requested scope, evaluated against this one snapshot (T014).
    ///
    /// The filters are answered in the order they were given and each is derived from
    /// the same index, so a set of scopes costs no second scan, no second read and no
    /// second opinion.
    pub fn totals(&self, filters: &[TimeFilter]) -> Vec<TimeTotal> {
        filters.iter().map(|filter| self.total(filter)).collect()
    }

    /// One total per issue the snapshot holds (T014, FR-003).
    ///
    /// Every issue appears, **including an issue holding no entry** — its total is
    /// [`TimeTotalState::Nothing`] with zero counts, which is a real answer and not
    /// an absence to hide. An entry whose date falls outside a period is still this
    /// issue's entry, so a period-bounded breakdown keeps the issue and reports
    /// nothing inside the period, rather than making the issue vanish.
    pub fn by_issue(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
        if let Some(refused) = self.refusal(filter) {
            return vec![refused];
        }
        let matcher = Matcher::new(filter);
        let mut groups: BTreeMap<u32, Totals> = BTreeMap::new();
        for number in &self.issues {
            if matcher.holds_issue(*number) {
                groups.insert(*number, Totals::default());
            }
        }
        for indexed in &self.entries {
            if !matcher.matches(indexed) {
                continue;
            }
            let placement = filter.place(indexed.entry.date.as_deref());
            if let Some(totals) = groups.get_mut(&indexed.issue) {
                totals.fold(indexed, placement);
            }
        }
        groups
            .into_iter()
            .map(|(number, totals)| {
                totals.into_total(
                    self,
                    filter.clone(),
                    TimeGroup::Issue(number),
                    DeletionScope::Issue(number),
                )
            })
            .collect()
    }

    /// One total per identity that did work in scope (T014, FR-003).
    ///
    /// `ada` and `@Ada` are one identity, through the identity model the rest of the
    /// crate uses; an entry whose author names no identity is shown under
    /// [`TimeGroup::Unattributed`] rather than dropped.
    pub fn by_assignee(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
        if let Some(refused) = self.refusal(filter) {
            return vec![refused];
        }
        let matcher = Matcher::new(filter);
        let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
        for indexed in &self.entries {
            if !matcher.matches(indexed) {
                continue;
            }
            let key = match &indexed.canonical_author {
                Some(identity) => TimeGroup::Assignee(identity.clone()),
                None => TimeGroup::Unattributed,
            };
            let placement = filter.place(indexed.entry.date.as_deref());
            groups.entry(key).or_default().fold(indexed, placement);
        }
        groups
            .into_iter()
            .map(|(group, totals)| {
                totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
            })
            .collect()
    }

    /// One total per calendar date in scope (T014, FR-003).
    ///
    /// A date outside a period gets **no group at all** — a group whose key is a date
    /// the period excludes would contradict itself — while an entry the period cannot
    /// place (no date, or a date the validator refuses) is shown under
    /// [`TimeGroup::Undated`] and counted, so the breakdown never hides it.
    pub fn by_date(&self, filter: &TimeFilter) -> Vec<TimeTotal> {
        if let Some(refused) = self.refusal(filter) {
            return vec![refused];
        }
        let matcher = Matcher::new(filter);
        let mut groups: BTreeMap<TimeGroup, Totals> = BTreeMap::new();
        for indexed in &self.entries {
            if !matcher.matches(indexed) {
                continue;
            }
            let placement = filter.place(indexed.entry.date.as_deref());
            if placement == PeriodPlacement::OutOfPeriod {
                continue;
            }
            let key = match indexed.entry.date.as_deref() {
                Some(date) if validate_date(date) => TimeGroup::Date(date.to_string()),
                _ => TimeGroup::Undated,
            };
            groups.entry(key).or_default().fold(indexed, placement);
        }
        groups
            .into_iter()
            .map(|(group, totals)| {
                totals.into_total(self, filter.clone(), group, DeletionScope::Drawn)
            })
            .collect()
    }

    /// The explicit audit view for one scope (T017).
    pub fn audit(&self, filter: &TimeFilter) -> TimeAudit {
        let problems = filter.problems();
        if !problems.is_empty() {
            return TimeAudit {
                filter: filter.clone(),
                issues: Vec::new(),
                deleted_count: 0,
                corrected_count: 0,
                diagnostics: problems
                    .iter()
                    .map(TimeDiagnostic::from_filter_problem)
                    .collect(),
            };
        }
        let mut current: BTreeMap<u32, usize> = BTreeMap::new();
        for indexed in &self.entries {
            *current.entry(indexed.issue).or_default() += 1;
        }
        let mut issues = Vec::new();
        let mut deleted_count = 0;
        let mut corrected_count = 0;
        for number in self.scope_issues(filter) {
            let deletions = self.deletions_on(number).to_vec();
            deleted_count += deletions.len();
            corrected_count += self.corrections_on(number);
            issues.push(IssueTimeAudit {
                issue: number,
                current: current.get(&number).copied().unwrap_or(0),
                deletions,
                corrections: self.corrections_on(number),
            });
        }
        let mut diagnostics = Vec::new();
        if let Some(number) = filter.issue
            && !self.holds_issue(number)
        {
            diagnostics.push(TimeDiagnostic::unknown_issue(number));
        }
        TimeAudit {
            filter: filter.clone(),
            issues,
            deleted_count,
            corrected_count,
            diagnostics,
        }
    }

    /// The one total that refuses a scope instead of reporting numbers for it.
    ///
    /// A refused or unknown scope yields **one** ungrouped total carrying the
    /// findings, never one invented group per issue: there is nothing to break down.
    fn refusal(&self, filter: &TimeFilter) -> Option<TimeTotal> {
        let problems = filter.problems();
        if !problems.is_empty() {
            return Some(TimeTotal::bare(
                filter.clone(),
                TimeGroup::All,
                problems
                    .iter()
                    .map(TimeDiagnostic::from_filter_problem)
                    .collect(),
            ));
        }
        let number = filter.issue.filter(|number| !self.holds_issue(*number))?;
        Some(TimeTotal::bare(
            filter.clone(),
            TimeGroup::All,
            vec![TimeDiagnostic::unknown_issue(number)],
        ))
    }
}

/// The filter's entry test with its identity criterion resolved once (T014).
///
/// [`TimeFilter::selects`] canonicalizes per call, which is right for one entry and
/// wrong for ten thousand: this is the same AND rule with the identity resolved once,
/// so a pass over the index allocates nothing per entry.
struct Matcher<'a> {
    filter: &'a TimeFilter,
    assignee: Option<String>,
}

impl<'a> Matcher<'a> {
    fn new(filter: &'a TimeFilter) -> Self {
        Self {
            filter,
            assignee: filter.canonical_assignee(),
        }
    }

    fn holds_issue(&self, number: u32) -> bool {
        self.filter.issue.is_none_or(|selected| selected == number)
    }

    fn matches(&self, indexed: &IndexedEntry) -> bool {
        if !self.holds_issue(indexed.issue) {
            return false;
        }
        let Some(criterion) = self.assignee.as_deref() else {
            return true;
        };
        match indexed.canonical_author.as_deref() {
            Some(author) => identity_cmp(author, criterion) == Ordering::Equal,
            None => false,
        }
    }
}

/// Which issues a total's audit count is about (T017).
///
/// A total carries one number for "how many prior values a deletion left behind", and
/// the number only means something beside the issue set it was counted over. An
/// ungrouped total counts the filter's own selector — the very set
/// [`TimeSnapshot::audit`] reports, so the two public methods cannot contradict each
/// other, and an issue whose current entries were **all** deleted still reports the
/// values History keeps instead of a zero. A per-issue group counts its one issue; an
/// identity or date group cannot attribute a deletion to itself (History's prior
/// values are not re-parsed), so it counts the issues its entries were drawn from.
enum DeletionScope {
    /// The issues the filter's selector covers.
    Selector,
    /// The single issue a per-issue group is about.
    Issue(u32),
    /// The issues a group's entries were drawn from.
    Drawn,
}

/// The running counts and findings of one scope.
#[derive(Default)]
struct Totals {
    minutes: i128,
    entry_count: usize,
    malformed_count: usize,
    undated_count: usize,
    diagnostics: Vec<TimeDiagnostic>,
    /// The issues this scope drew an entry from, for the audit count (T017).
    drawn: BTreeSet<u32>,
}

impl Totals {
    /// One selected entry into the counts.
    ///
    /// Malformed first, always: an entry whose own fields are unusable contributes no
    /// number wherever it is placed, and saying so is what keeps it out of the total
    /// instead of adding a zero. Then the period placement, which only a valid entry
    /// can reach [`PeriodPlacement::InPeriod`] under.
    fn fold(&mut self, indexed: &IndexedEntry, placement: PeriodPlacement) {
        self.drawn.insert(indexed.issue);
        let problems = indexed.entry.problems();
        if !problems.is_empty() {
            self.malformed_count += 1;
            self.diagnostics.extend(problems.iter().map(|problem| {
                TimeDiagnostic::from_entry_problem(indexed.issue, &indexed.entry.id, problem)
            }));
            return;
        }
        let Some(minutes) = indexed.entry.minutes() else {
            // `minutes()` is `None` exactly when the entry carries an
            // `invalid_duration` problem, so this arm is unreachable for a
            // problem-free entry: counting it as malformed keeps the partition total
            // rather than letting a missing value become a zero (C-005).
            self.malformed_count += 1;
            self.diagnostics.push(
                TimeDiagnostic::new(
                    TimeDiagnosticKind::InvalidDuration,
                    "the entry carries no usable `duration_minutes` value",
                )
                .for_entry(indexed.issue, &indexed.entry.id)
                .in_field("duration_minutes"),
            );
            return;
        };
        match placement {
            PeriodPlacement::InPeriod => {
                self.minutes += i128::from(minutes);
                self.entry_count += 1;
            }
            PeriodPlacement::Undated => self.undated_count += 1,
            // An out-of-period entry is simply not in the period, and an unreadable
            // date belongs to an entry that was already reported as malformed above.
            PeriodPlacement::OutOfPeriod | PeriodPlacement::Unreadable => {}
        }
    }

    /// Finish the scope into its DTO.
    fn into_total(
        self,
        snapshot: &TimeSnapshot,
        filter: TimeFilter,
        group: TimeGroup,
        deletion_scope: DeletionScope,
    ) -> TimeTotal {
        let total_minutes = (self.entry_count > 0).then_some(self.minutes);
        let deleted_count = filter.include_deleted.then(|| match deletion_scope {
            DeletionScope::Selector => snapshot.scope_deletions(&filter),
            DeletionScope::Issue(number) => snapshot.deletions_on(number).len(),
            DeletionScope::Drawn => self
                .drawn
                .iter()
                .map(|number| snapshot.deletions_on(*number).len())
                .sum(),
        });
        TimeTotal {
            filter,
            group,
            total_minutes,
            entry_count: self.entry_count,
            malformed_count: self.malformed_count,
            undated_count: self.undated_count,
            deleted_count,
            diagnostics: self.diagnostics,
        }
    }
}