Skip to main content

tix/analytics/
events.rs

1//! Recorded-event classification and the idempotency/provenance index (mission
2//! `burndown-scope-analytics-01M3C9J1`, WP01 T001–T006).
3//!
4//! # What this module is
5//!
6//! The vocabulary every later child consumes, and the one place the mission decides
7//! *what* a recorded event means:
8//!
9//! - [`AnalyticsScope`] — the selected cycle/milestone, its period, and the
10//!   optional closed-snapshot binding (`data-model.md` §1).
11//! - [`AnalyticsEvent`] — one recorded contribution, with its stable identity, its
12//!   class, its optional day, and its provenance (`data-model.md` §2).
13//! - [`EventClass`] — the closed four-name classification.
14//! - [`EventIndex`] — the contribution set, deduplicated by event id (C-002).
15//!
16//! # Recorded only, and nothing else
17//!
18//! Classification reads the History the tree actually holds. It never infers a
19//! scope change that was not logged, never treats a missing day as zero, and never
20//! compares a status *string*: a completion is decided by the category the
21//! transition **recorded** (`transition_accepted`'s `to_category`), read through the
22//! shared [#183 vocabulary](crate::workflow::WorkflowCategory) rather than by
23//! matching a column name against a hard-coded list (C-001/C-008).
24//!
25//! # The four classes, and why they are the right partition
26//!
27//! FR-002's whole claim is that scope growth is distinguishable from completion, so
28//! the partition splits the two answers a burndown chart needs on a given day:
29//!
30//! | class | recorded decision | count-level delta |
31//! |---|---|---|
32//! | `scope_addition` | `parent_set`, or a `cycle_membership_changed` that **added** a cycle | `Some(1)` for `parent_set`, `None` when only the detail can say |
33//! | `completion` | `transition_accepted` whose recorded `to_category` is `completed` | `Some(1)` |
34//! | `estimate_change` | `estimate_changed` | `None` (the unit is the caller's mode) |
35//! | `unclassified` | every other recorded kind | `None` |
36//!
37//! Three things follow from that, and each is deliberate:
38//!
39//! 1. **A move that is not a completion is not a completion.** `transition_accepted`
40//!    into `active`/`backlog`/`canceled` is a plain move and lands in
41//!    `unclassified` rather than being counted as progress.
42//! 2. **A removal has no class.** The vocabulary is closed at four names, and neither
43//!    a `parent_cleared` nor a membership change that only *removed* cycles is an
44//!    addition; both stay `unclassified` and visible, which is the honest answer for
45//!    an event the class table cannot place. They are never read as growth. A
46//!    membership change is read by **direction** — the detail names the before and
47//!    after lists, and the event is a `scope_addition` only when the after list has a
48//!    cycle the before list did not — so `cycles: Default -> none` is a removal and
49//!    `cycles: none -> Default` is an addition.
50//! 3. **Every other kind keeps its record.** All of #184's time-mutation kinds, the
51//!    relationship/triage/health/project/epic/cycle-lifecycle kinds, `archived`,
52//!    `restored` and `blocked_move_acknowledged` classify as `unclassified` — never
53//!    as completion or scope — so a time entry can never move a burndown line.
54//!
55//! # What a class cannot see: a write nobody recorded
56//!
57//! A class is only as wide as the record. `tix edit --estimate` and `tix edit
58//! --cycles` append the two kinds above, and `tix edit --parent` already appended its
59//! own, so what the **edit seam** writes is visible here. A path that writes a field
60//! without appending anything — the bulk patch service reaches `Store::update`
61//! directly — leaves no event, so such a change cannot appear in `estimate_change` or
62//! `scope_addition` no matter how the series is folded. That is a **coverage limit**
63//! a surface reports as an explicit marker, never a licence to read the current
64//! frontmatter back into the past: reconstructing unrecorded history is the mission's
65//! non-goal and would defeat FR-002's whole distinction.
66//!
67//! # Idempotency is the index's, not the caller's care
68//!
69//! [`EventIndex::contribute`] deduplicates by [`AnalyticsEvent::event_id`] and
70//! reports what it did ([`Contribution`]). Classifying the same recorded event twice
71//! therefore contributes once, which is C-002's "the same recorded event produces
72//! the same contribution"; the guard lives in the one function every contribution
73//! goes through, so a caller cannot forget it.
74//!
75//! # Provenance
76//!
77//! A History event's identity is `<source-ref>#<n>`, where `n` is the entry's
78//! **ordinal among the section's parsed events**, not its slot in the raw line list.
79//! The section is append-only, so a new line lands at the end: an unparseable line a
80//! human **inserts** occupies no ordinal, cannot renumber the events after it, and two
81//! reads of one file agree on every id. The one hand edit that still moves an id is a
82//! line that was a readable event and was later **mangled into unreadability** — the
83//! record genuinely lost that event, so the ordinals after it close up. That is the
84//! deliberate trade: the only alternative, hashing the entry's content, would give two
85//! identical events appended twice the same id and make C-002's "one recorded event,
86//! one contribution" collapse two real events into one. A closed snapshot's identity
87//! is the snapshot's own stable `snapshot_id`, which is also its event id — so
88//! repeated exports of one closed snapshot contribute once.
89
90use std::collections::{HashMap, HashSet};
91use std::fmt;
92
93use crate::error::{Result, TixError};
94use crate::model::{
95    History, HistoryEntry, HistoryEvent, HistoryEventKind, TransitionDecision, validate_date,
96};
97use crate::planning::Planner;
98use crate::workflow::WorkflowCategory;
99
100/// Which planning entity a scope selects (`data-model.md` §1).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub enum ScopeKind {
103    /// A cycle (hand-written in `[cycles.<name>]` or a generated instance).
104    Cycle,
105    /// A milestone.
106    Milestone,
107}
108
109impl ScopeKind {
110    /// Every kind, so a surface can enumerate the closed set.
111    pub const ALL: [ScopeKind; 2] = [ScopeKind::Cycle, ScopeKind::Milestone];
112
113    /// The canonical spelling used in output and comparisons.
114    pub const fn as_str(self) -> &'static str {
115        match self {
116            ScopeKind::Cycle => "cycle",
117            ScopeKind::Milestone => "milestone",
118        }
119    }
120
121    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
122    pub fn parse(raw: &str) -> Option<Self> {
123        match raw.trim().to_ascii_lowercase().as_str() {
124            "cycle" => Some(ScopeKind::Cycle),
125            "milestone" => Some(ScopeKind::Milestone),
126            _ => None,
127        }
128    }
129}
130
131impl fmt::Display for ScopeKind {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// The inclusive calendar period a series covers (`data-model.md` §1).
138///
139/// Both bounds are **caller-supplied** `YYYY-MM-DD` strings — this module reads no
140/// clock — and each is optional because a cycle need not declare its dates: an
141/// absent bound is "unbounded on that side", which is coverage, not a guess. The
142/// bounds come from existing planning data (`[`cycle_scope`]`/`[`milestone_scope`]`),
143/// never from an assumption about when work started.
144///
145/// Comparison is `&str` comparison, which is chronological for that fixed-width
146/// shape and needs no date arithmetic.
147#[derive(Debug, Clone, PartialEq, Eq, Default)]
148pub struct Period {
149    /// Inclusive start, or `None` for "unbounded from the beginning".
150    pub start: Option<String>,
151    /// Inclusive end, or `None` for "still open".
152    pub end: Option<String>,
153}
154
155impl Period {
156    /// A validated period: each present bound must be a canonical calendar date and
157    /// `start <= end` must hold.
158    ///
159    /// A malformed bound is refused here rather than silently dropped, because a
160    /// period that quietly lost an end date would turn a closed series into an open
161    /// one — the one thing C-006 forbids.
162    pub fn new(start: Option<String>, end: Option<String>) -> Result<Self> {
163        let period = Self { start, end };
164        for (field, value) in [("start", &period.start), ("end", &period.end)] {
165            if let Some(date) = value
166                && !validate_date(date)
167            {
168                return Err(TixError::Config(format!(
169                    "invalid_period: `{field}` is `{date}`, not a `YYYY-MM-DD` calendar date"
170                )));
171            }
172        }
173        if let (Some(start), Some(end)) = (&period.start, &period.end)
174            && start > end
175        {
176            return Err(TixError::Config(format!(
177                "invalid_period: start `{start}` is after end `{end}`"
178            )));
179        }
180        Ok(period)
181    }
182
183    /// A period with neither bound.
184    pub fn unbounded() -> Self {
185        Self::default()
186    }
187
188    /// Whether the period constrains anything at all.
189    pub fn is_bounded(&self) -> bool {
190        self.start.is_some() || self.end.is_some()
191    }
192
193    /// Whether a canonical `YYYY-MM-DD` date falls inside the period, **both bounds
194    /// inclusive**.
195    ///
196    /// A date that is not a canonical calendar date is *not* contained: the caller
197    /// reports the unreadable day as coverage rather than placing it somewhere.
198    pub fn contains(&self, date: &str) -> bool {
199        if !validate_date(date) {
200            return false;
201        }
202        if let Some(start) = self.start.as_deref()
203            && date < start
204        {
205            return false;
206        }
207        if let Some(end) = self.end.as_deref()
208            && date > end
209        {
210            return false;
211        }
212        true
213    }
214}
215
216/// The selected cycle/milestone and its period context (`data-model.md` §1).
217///
218/// The `snapshot_id` is the closed-series binding: when it is present the scope
219/// refers to a **closed** historical view, and events after the period's end are not
220/// contributions to it (C-006 — a later edit never rewrites a closed series).
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct AnalyticsScope {
223    /// Which planning entity the `id` names.
224    pub kind: ScopeKind,
225    /// The stable planning identity (a cycle name, a milestone name).
226    pub id: String,
227    /// The inclusive period context.
228    pub period: Period,
229    /// The closed snapshot this series is bound to, when it is bound to one.
230    pub snapshot_id: Option<String>,
231}
232
233impl AnalyticsScope {
234    /// A scope. A blank id is refused: an unnamed scope cannot be reported or
235    /// compared, and defaulting it would hide the caller's mistake.
236    pub fn new(kind: ScopeKind, id: impl Into<String>, period: Period) -> Result<Self> {
237        let id = id.into();
238        if id.trim().is_empty() {
239            return Err(TixError::Config(format!(
240                "invalid_scope: a {} scope needs a name",
241                kind.as_str()
242            )));
243        }
244        Ok(Self {
245            kind,
246            id,
247            period,
248            snapshot_id: None,
249        })
250    }
251
252    /// A cycle scope.
253    pub fn cycle(id: impl Into<String>, period: Period) -> Result<Self> {
254        Self::new(ScopeKind::Cycle, id, period)
255    }
256
257    /// A milestone scope.
258    pub fn milestone(id: impl Into<String>, period: Period) -> Result<Self> {
259        Self::new(ScopeKind::Milestone, id, period)
260    }
261
262    /// Bind this scope to a closed snapshot's stable identity (C-006).
263    pub fn with_snapshot_id(mut self, snapshot_id: impl Into<String>) -> Self {
264        let snapshot_id = snapshot_id.into();
265        self.snapshot_id = (!snapshot_id.trim().is_empty()).then_some(snapshot_id);
266        self
267    }
268
269    /// The inclusive period start, when the planning data declares one.
270    pub fn period_start(&self) -> Option<&str> {
271        self.period.start.as_deref()
272    }
273
274    /// The inclusive period end, when the planning data declares one.
275    pub fn period_end(&self) -> Option<&str> {
276        self.period.end.as_deref()
277    }
278
279    /// Whether this scope is closed: bound to a snapshot or bounded by an end date.
280    ///
281    /// A closed scope's series stops at its period, which is what keeps a later edit
282    /// from extending a historical view.
283    pub fn is_closed(&self) -> bool {
284        self.snapshot_id.is_some() || self.period.end.is_some()
285    }
286
287    /// Whether `date` belongs to this scope's period. An event with no readable day
288    /// is handled by [`EventIndex::contribute`], not here.
289    pub fn contains(&self, date: &str) -> bool {
290        self.period.contains(date)
291    }
292}
293
294impl fmt::Display for AnalyticsScope {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        let start = self.period_start().unwrap_or("open");
297        let end = self.period_end().unwrap_or("open");
298        write!(f, "{} `{}` {start}..{end}", self.kind, self.id)?;
299        if let Some(snapshot_id) = self.snapshot_id.as_deref() {
300            write!(f, " snapshot {snapshot_id}")?;
301        }
302        Ok(())
303    }
304}
305
306/// Resolve a cycle scope through the existing planning registry (T001).
307///
308/// The period is the cycle's **own** declared `start_date`/`due_date`, so it comes
309/// from existing planning data rather than from an assumption; a cycle that declares
310/// neither is unbounded on that side, which the series reports as coverage.
311pub fn cycle_scope(planner: &Planner, name: &str) -> Result<AnalyticsScope> {
312    let cycle = planner
313        .cycle(name)
314        .ok_or_else(|| TixError::NotFound(format!("cycle `{name}`")))?;
315    let period = Period::new(cycle.start_date.clone(), cycle.due_date.clone())?;
316    AnalyticsScope::cycle(name, period)
317}
318
319/// Resolve a milestone scope through the existing planning registry (T001).
320///
321/// A milestone declares a target date and no start, so the period is bounded on its
322/// end only — the same value the projection later forecasts against.
323pub fn milestone_scope(planner: &Planner, name: &str) -> Result<AnalyticsScope> {
324    let milestone = planner.milestone(name)?;
325    let target = milestone.target_date.trim();
326    let end = (!target.is_empty()).then(|| target.to_string());
327    let period = Period::new(None, end)?;
328    AnalyticsScope::milestone(name, period)
329}
330
331/// The closed classification vocabulary (`data-model.md` §2).
332#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
333pub enum EventClass {
334    /// The recorded event adds work to the selected scope's planned set.
335    ScopeAddition,
336    /// The recorded event completes work in the selected scope.
337    Completion,
338    /// The recorded event changes the planned size of work in the scope.
339    EstimateChange,
340    /// A recorded event this classification does not place, or one it can only
341    /// partly read. Never counted as progress.
342    Unclassified,
343}
344
345impl EventClass {
346    /// Every class, in the order a surface renders them.
347    pub const ALL: [EventClass; 4] = [
348        EventClass::ScopeAddition,
349        EventClass::Completion,
350        EventClass::EstimateChange,
351        EventClass::Unclassified,
352    ];
353
354    /// The canonical spelling (`data-model.md` §2: `scope_addition`, `completion`,
355    /// `estimate_change`, `unclassified`).
356    pub const fn as_str(self) -> &'static str {
357        match self {
358            EventClass::ScopeAddition => "scope_addition",
359            EventClass::Completion => "completion",
360            EventClass::EstimateChange => "estimate_change",
361            EventClass::Unclassified => "unclassified",
362        }
363    }
364
365    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
366    pub fn parse(raw: &str) -> Option<Self> {
367        match raw.trim().to_ascii_lowercase().as_str() {
368            "scope_addition" => Some(EventClass::ScopeAddition),
369            "completion" => Some(EventClass::Completion),
370            "estimate_change" => Some(EventClass::EstimateChange),
371            "unclassified" => Some(EventClass::Unclassified),
372            _ => None,
373        }
374    }
375
376    /// Whether the event carries a class other than [`EventClass::Unclassified`].
377    pub const fn is_classified(self) -> bool {
378        !matches!(self, EventClass::Unclassified)
379    }
380}
381
382impl fmt::Display for EventClass {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        f.write_str(self.as_str())
385    }
386}
387
388/// Where a recorded contribution came from (`data-model.md` §2).
389#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
390pub enum EventSource {
391    /// An append-only History entry.
392    History,
393    /// A closed cycle/milestone snapshot's own recorded value.
394    Snapshot,
395    /// A recorded frontmatter value.
396    Frontmatter,
397}
398
399impl EventSource {
400    /// Every source, in the order a surface renders them.
401    pub const ALL: [EventSource; 3] = [
402        EventSource::History,
403        EventSource::Snapshot,
404        EventSource::Frontmatter,
405    ];
406
407    /// The canonical spelling used in output.
408    pub const fn as_str(self) -> &'static str {
409        match self {
410            EventSource::History => "history",
411            EventSource::Snapshot => "snapshot",
412            EventSource::Frontmatter => "frontmatter",
413        }
414    }
415
416    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
417    pub fn parse(raw: &str) -> Option<Self> {
418        match raw.trim().to_ascii_lowercase().as_str() {
419            "history" => Some(EventSource::History),
420            "snapshot" => Some(EventSource::Snapshot),
421            "frontmatter" => Some(EventSource::Frontmatter),
422            _ => None,
423        }
424    }
425}
426
427impl fmt::Display for EventSource {
428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429        f.write_str(self.as_str())
430    }
431}
432
433/// One recorded contribution to a scope's series (`data-model.md` §2).
434///
435/// Every field is either read from the record or explicitly `None`. `delta` is the
436/// **count-level** contribution of the event and is `None` where the event does not
437/// carry one at that precision — a repoint, a membership change and an estimate
438/// change all name their before/after values in [`Self::detail`], and inventing a
439/// number for them would be the arithmetic C-003 forbids. `unit` is reserved for the
440/// estimate mode's explicit conversion (WP03) and is `None` here.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct AnalyticsEvent {
443    /// Stable identity, and therefore the idempotency key (C-002).
444    pub event_id: String,
445    /// The canonical `YYYY-MM-DD` day the event is placed on, or `None` when the
446    /// recorded timestamp carried no readable calendar date. `None` is coverage, not
447    /// a fabricated day (C-005).
448    pub date: Option<String>,
449    /// The closed classification.
450    pub class: EventClass,
451    /// The issue the event belongs to, when the source ref is an issue number.
452    pub issue: Option<u32>,
453    /// The scope the event was classified against.
454    pub scope_id: Option<String>,
455    /// The count-level contribution, when the event carries one.
456    pub delta: Option<i64>,
457    /// The estimate unit, for the estimate mode's conversions (WP03).
458    pub unit: Option<String>,
459    /// Where the contribution came from.
460    pub source: EventSource,
461    /// The closed snapshot the contribution came from, when it is snapshot evidence.
462    pub snapshot_id: Option<String>,
463    /// The recorded detail, verbatim, so a reader can see the before/after values
464    /// the classification did not turn into numbers.
465    pub detail: String,
466}
467
468impl AnalyticsEvent {
469    /// Classify one **recorded** History entry (T002–T004).
470    ///
471    /// `source_ref` names the file the entry lives in (an issue number, a generated
472    /// cycle id) and `index` is its ordinal among that section's **parsed events**
473    /// (see [`EventIndex::contribute_history`], which is what assigns it); together
474    /// they are the event's stable identity, so re-reading the same file classifies
475    /// the same event to the same id.
476    pub fn from_history(
477        source_ref: &str,
478        index: usize,
479        scope: &AnalyticsScope,
480        event: &HistoryEvent,
481    ) -> Self {
482        let (class, delta) = classify(event);
483        Self {
484            event_id: format!("{source_ref}#{index}"),
485            date: calendar_date(&event.timestamp),
486            class,
487            issue: source_ref.trim().parse::<u32>().ok(),
488            scope_id: Some(scope.id.clone()),
489            delta,
490            unit: None,
491            source: EventSource::History,
492            snapshot_id: None,
493            detail: event.detail.clone(),
494        }
495    }
496
497    /// A contribution read from an immutable closed snapshot (T003/T005).
498    ///
499    /// The caller supplies values it **read from** the snapshot; nothing here
500    /// derives a number from a schedule or a configuration, because those are
501    /// exactly what a later edit is allowed to change (C-006). The event id is the
502    /// snapshot's own stable `snapshot_id`, so the same closed snapshot contributes
503    /// once however many times it is read.
504    pub fn from_snapshot(
505        snapshot_id: &str,
506        date: Option<&str>,
507        class: EventClass,
508        scope_id: Option<&str>,
509        delta: Option<i64>,
510    ) -> Self {
511        Self {
512            event_id: format!("snapshot:{}", snapshot_id.trim()),
513            date: date
514                .map(str::trim)
515                .filter(|d| validate_date(d))
516                .map(str::to_string),
517            class,
518            issue: None,
519            scope_id: scope_id.map(str::to_string),
520            delta,
521            unit: None,
522            source: EventSource::Snapshot,
523            snapshot_id: Some(snapshot_id.trim().to_string()),
524            detail: String::new(),
525        }
526    }
527
528    /// Whether the event is placed on `date`.
529    pub fn is_on(&self, date: &str) -> bool {
530        self.date.as_deref() == Some(date)
531    }
532}
533
534/// What [`EventIndex::contribute`] did with one event.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
536pub enum Contribution {
537    /// The event was recorded as a contribution.
538    Added,
539    /// The event's id was already present: a repeated classification contributes
540    /// once (C-002).
541    Duplicate,
542    /// The event carries a day outside the scope's period, so it is not a
543    /// contribution to this series. Counted, never discarded silently.
544    OutsidePeriod,
545}
546
547impl Contribution {
548    /// The canonical spelling used in output.
549    pub const fn as_str(self) -> &'static str {
550        match self {
551            Contribution::Added => "added",
552            Contribution::Duplicate => "duplicate",
553            Contribution::OutsidePeriod => "outside_period",
554        }
555    }
556
557    /// Whether this contribution changed the index.
558    pub const fn contributed(self) -> bool {
559        matches!(self, Contribution::Added)
560    }
561}
562
563/// The contribution set for one scope: contributions in first-seen order,
564/// deduplicated by event id (T005).
565///
566/// Inflating this type is deliberate — the count-mode fold (WP02) reads it once, so
567/// a contribution cannot be counted twice by being walked twice, and the dedupe
568/// lives here rather than at each caller.
569#[derive(Debug, Clone)]
570pub struct EventIndex {
571    scope: AnalyticsScope,
572    order: Vec<String>,
573    events: HashMap<String, AnalyticsEvent>,
574    /// Ids already refused for falling outside the scope's period.
575    ///
576    /// Remembered for the same reason the admitted ids are: one recorded event is one
577    /// event, so classifying it again must report a duplicate rather than counting a
578    /// second exclusion. Without this, a repeated fold inflated
579    /// [`Self::outside_period`] and a caller reading it as a coverage size saw a count
580    /// of *classifications*.
581    outside_ids: HashSet<String>,
582    duplicates: usize,
583    outside_period: usize,
584}
585
586impl EventIndex {
587    /// An empty index for a scope.
588    pub fn new(scope: AnalyticsScope) -> Self {
589        Self {
590            scope,
591            order: Vec::new(),
592            events: HashMap::new(),
593            outside_ids: HashSet::new(),
594            duplicates: 0,
595            outside_period: 0,
596        }
597    }
598
599    /// The scope every contribution in this index was classified against.
600    pub fn scope(&self) -> &AnalyticsScope {
601        &self.scope
602    }
603
604    /// Contribute one event, reporting what happened.
605    ///
606    /// The duplicate check covers ids already **admitted and** ids already refused for
607    /// being outside the period, and it comes first; the period check comes second. So
608    /// classifying one recorded event twice is inert whichever side of the period it
609    /// fell on, and both counters measure recorded events rather than classifications.
610    pub fn contribute(&mut self, event: AnalyticsEvent) -> Contribution {
611        if self.events.contains_key(&event.event_id) || self.outside_ids.contains(&event.event_id) {
612            self.duplicates += 1;
613            return Contribution::Duplicate;
614        }
615        if let Some(date) = event.date.as_deref()
616            && !self.scope.contains(date)
617        {
618            self.outside_ids.insert(event.event_id);
619            self.outside_period += 1;
620            return Contribution::OutsidePeriod;
621        }
622        self.order.push(event.event_id.clone());
623        self.events.insert(event.event_id.clone(), event);
624        Contribution::Added
625    }
626
627    /// Contribute every parsed entry of a History section, returning how many were
628    /// newly added.
629    ///
630    /// The ordinal an event is identified by counts **parsed events only**: an
631    /// unparseable line a human inserted occupies no ordinal, so it cannot renumber
632    /// the events after it and two classifications of one file agree on every id. A
633    /// line that was a readable event and was mangled into unreadability does still
634    /// close up the ordinals behind it — see the module's provenance note for why that
635    /// is the accepted trade rather than a content hash.
636    pub fn contribute_history(&mut self, source_ref: &str, history: &History) -> usize {
637        let scope = self.scope.clone();
638        let mut added = 0;
639        let mut index = 0;
640        for entry in &history.entries {
641            let HistoryEntry::Event(event) = entry else {
642                continue;
643            };
644            let event = AnalyticsEvent::from_history(source_ref, index, &scope, event);
645            index += 1;
646            if self.contribute(event).contributed() {
647                added += 1;
648            }
649        }
650        added
651    }
652
653    /// The contributed events, in first-seen order.
654    pub fn events(&self) -> impl Iterator<Item = &AnalyticsEvent> + '_ {
655        self.order.iter().filter_map(|id| self.events.get(id))
656    }
657
658    /// The contributed events of one class, in first-seen order.
659    pub fn of_class(&self, class: EventClass) -> impl Iterator<Item = &AnalyticsEvent> + '_ {
660        self.events().filter(move |event| event.class == class)
661    }
662
663    /// How many events of one class contributed.
664    pub fn count_of(&self, class: EventClass) -> usize {
665        self.of_class(class).count()
666    }
667
668    /// How many contributions the index holds.
669    pub fn len(&self) -> usize {
670        self.events.len()
671    }
672
673    /// Whether the index holds no contribution at all — which is the honest answer
674    /// for a project whose files carry no events, not an error.
675    pub fn is_empty(&self) -> bool {
676        self.events.is_empty()
677    }
678
679    /// How many repeated classifications the index rejected (C-002).
680    pub fn duplicates(&self) -> usize {
681        self.duplicates
682    }
683
684    /// How many recorded events fell outside the scope's period and were therefore
685    /// not contributions — an **event** count, not a count of classifications, because
686    /// an id already refused here is remembered (C-002). Coverage context: a non-zero
687    /// count says the period excluded recorded history, never that the history is
688    /// absent.
689    pub fn outside_period(&self) -> usize {
690        self.outside_period
691    }
692}
693
694/// The recorded day a History timestamp names, when it carries one.
695///
696/// The timestamp is RFC 3339 (`2026-09-25T16:00:00Z`), so its first ten characters
697/// are the calendar date; anything that is not a canonical date — including a
698/// truncated or hand-mangled stamp — is `None` rather than a coerced guess.
699pub fn calendar_date(timestamp: &str) -> Option<String> {
700    let stamp = timestamp.trim();
701    let date = stamp.get(..10)?;
702    validate_date(date).then(|| date.to_string())
703}
704
705/// Classify one recorded event kind.
706///
707/// See the module documentation for the partition and why each entry is where it is.
708fn classify(event: &HistoryEvent) -> (EventClass, Option<i64>) {
709    match event.kind {
710        // The hierarchy's membership kinds: the issue joins a scope. A *repoint*
711        // names both parents in its detail, so it carries no count-level delta —
712        // the departing scope's reduction is not something one event can express.
713        HistoryEventKind::ParentSet => (EventClass::ScopeAddition, Some(1)),
714        HistoryEventKind::ParentChanged => (EventClass::ScopeAddition, None),
715        // WP01's own producer (T046/T047): a membership edit routinely adds and
716        // removes in one command, so the direction is read from the detail the
717        // producer writes. Only a change that **added** a cycle is an addition; a
718        // change that only removed one is not growth and stays visible as unclassified.
719        HistoryEventKind::CycleMembershipChanged => membership_outcome(&event.detail),
720        // WP01's other producer: the planned size changed, not the membership.
721        HistoryEventKind::EstimateChanged => (EventClass::EstimateChange, None),
722        HistoryEventKind::TransitionAccepted => completion_outcome(event),
723        // Everything else stays visible and unclassified — including every one of
724        // #184's time-mutation kinds, which must never move a burndown line.
725        _ => (EventClass::Unclassified, None),
726    }
727}
728
729/// Whether a recorded transition completed the issue, read through the shared
730/// category vocabulary the decision *recorded*.
731fn completion_outcome(event: &HistoryEvent) -> (EventClass, Option<i64>) {
732    let Ok(decision) = TransitionDecision::from_event(event) else {
733        // A malformed or hand-edited decision is not counted as progress; the raw
734        // detail is still carried on the event for a reader.
735        return (EventClass::Unclassified, None);
736    };
737    match WorkflowCategory::from_key(&decision.to_category) {
738        Some(category) if category.is_complete() => (EventClass::Completion, Some(1)),
739        // A move into any other category is a move. `canceled` is terminal and is
740        // *not* complete, and an unknown category is never read as one.
741        _ => (EventClass::Unclassified, None),
742    }
743}
744
745/// Whether a recorded membership change **added** a cycle, read from the detail the
746/// producer writes.
747///
748/// The grammar is the one `tix edit --cycles` appends (`src/commands/edit.rs`):
749/// `cycles: <before> -> <after>`, each side a comma-joined list of cycle names or
750/// `none`. `--cycles` splits on commas, so a name cannot contain one, which is what
751/// makes the list unambiguous. A detail that does not have that shape (a hand edit, a
752/// future writer) is not guessed at: it classifies as unclassified and keeps its text
753/// on the event, so a reader sees exactly what could not be read.
754///
755/// Only an **addition** counts. A change that removed cycles — `cycles: Default ->
756/// none` — or merely reordered them has nothing to add, so it is not growth; the
757/// closed four-name vocabulary has no removal class, and reading one as an addition
758/// would be the exact confusion FR-002 exists to prevent.
759///
760/// The delta is `None` because the count a membership change carries is per *joined*
761/// cycle, and which cycle the series is for is the fold's question (WP02); a
762/// scope-agnostic `Some(1)` here would credit a cycle the event never named.
763fn membership_outcome(detail: &str) -> (EventClass, Option<i64>) {
764    let Some((before, after)) = membership_lists(detail) else {
765        return (EventClass::Unclassified, None);
766    };
767    if after.iter().any(|name| !before.contains(name)) {
768        (EventClass::ScopeAddition, None)
769    } else {
770        (EventClass::Unclassified, None)
771    }
772}
773
774/// The before/after cycle lists a `cycle_membership_changed` detail names.
775fn membership_lists(detail: &str) -> Option<(Vec<String>, Vec<String>)> {
776    let rest = detail.trim().strip_prefix("cycles:")?;
777    let (before, after) = rest.split_once("->")?;
778    Some((membership_list(before), membership_list(after)))
779}
780
781/// One side of the membership detail: the names, or empty for `none`.
782fn membership_list(side: &str) -> Vec<String> {
783    let side = side.trim();
784    if side.is_empty() || side == "none" {
785        return Vec::new();
786    }
787    side.split(',')
788        .map(str::trim)
789        .filter(|name| !name.is_empty())
790        .map(str::to_string)
791        .collect()
792}
793
794#[cfg(test)]
795mod tests {
796    #[allow(clippy::wildcard_imports)]
797    use super::*;
798    use crate::model::utc_now_rfc3339;
799
800    fn scope() -> AnalyticsScope {
801        AnalyticsScope::cycle("Sprint 7", Period::new(None, None).unwrap()).unwrap()
802    }
803
804    fn history_event(kind: HistoryEventKind, detail: &str) -> HistoryEvent {
805        HistoryEvent::new("2026-09-25T16:00:00Z", "alice", kind, detail)
806    }
807
808    #[test]
809    fn t005_the_period_bounds_are_validated_and_inclusive() {
810        let period = Period::new(Some("2026-09-01".into()), Some("2026-09-30".into())).unwrap();
811        assert!(period.contains("2026-09-01"), "start is inclusive");
812        assert!(period.contains("2026-09-30"), "end is inclusive");
813        assert!(!period.contains("2026-08-31"), "the day before is out");
814        assert!(!period.contains("2026-10-01"), "the day after is out");
815        // The load-bearing case for the validator: a malformed day that *sorts inside*
816        // the bounds (`2026-09-1` is greater than `2026-09-01` and less than
817        // `2026-09-30` character by character), so the bounds comparison alone would
818        // admit it. Deleting `validate_date` from `contains` makes this assertion fail.
819        assert!(
820            !period.contains("2026-09-1"),
821            "a malformed day inside the bounds is still not placed"
822        );
823        assert!(
824            !period.contains("2026-09-01T00:00:00Z"),
825            "a stamp is not a day either"
826        );
827        assert!(
828            !period.contains("not-a-date"),
829            "an unreadable day is not placed"
830        );
831        assert!(Period::unbounded().contains("1999-01-01"), "open is open");
832
833        let backwards = Period::new(Some("2026-09-30".into()), Some("2026-09-01".into()));
834        assert!(backwards.is_err(), "a backwards period is refused");
835        let malformed = Period::new(Some("2026-9-1".into()), None);
836        assert!(malformed.is_err(), "a non-canonical bound is refused");
837    }
838
839    #[test]
840    fn t006_a_blank_scope_name_is_refused() {
841        assert!(AnalyticsScope::cycle("  ", Period::unbounded()).is_err());
842    }
843
844    #[test]
845    fn t005_a_duplicate_event_contributes_once() {
846        let mut index = EventIndex::new(scope());
847        let event = history_event(HistoryEventKind::EstimateChanged, "estimate: none -> 3");
848        let classified = AnalyticsEvent::from_history("7", 0, index.scope(), &event);
849        assert_eq!(index.contribute(classified.clone()), Contribution::Added);
850        assert_eq!(
851            index.contribute(classified.clone()),
852            Contribution::Duplicate
853        );
854        assert_eq!(index.len(), 1, "the same event contributed once");
855        assert_eq!(index.duplicates(), 1);
856        assert_eq!(index.of_class(EventClass::EstimateChange).count(), 1);
857    }
858
859    #[test]
860    fn t001_the_scope_classifies_only_the_event_it_was_given() {
861        let index = EventIndex::new(scope());
862        let event = history_event(HistoryEventKind::ParentSet, "parent none -> #1");
863        let classified = AnalyticsEvent::from_history("7", 3, index.scope(), &event);
864        assert_eq!(classified.event_id, "7#3");
865        assert_eq!(classified.issue, Some(7));
866        assert_eq!(classified.scope_id.as_deref(), Some("Sprint 7"));
867        assert_eq!(classified.date.as_deref(), Some("2026-09-25"));
868        assert_eq!(classified.class, EventClass::ScopeAddition);
869        assert_eq!(classified.delta, Some(1));
870        assert_eq!(classified.source, EventSource::History);
871    }
872
873    #[test]
874    fn t005_an_unreadable_timestamp_is_no_day_rather_than_a_guess() {
875        assert_eq!(
876            calendar_date("2026-09-25T16:00:00Z").as_deref(),
877            Some("2026-09-25")
878        );
879        assert_eq!(
880            calendar_date("2026-13-45T00:00:00Z"),
881            None,
882            "not a real date"
883        );
884        assert_eq!(calendar_date("2026-09"), None, "truncated");
885        assert_eq!(calendar_date(""), None);
886    }
887
888    #[test]
889    fn t005_a_snapshot_contribution_is_identified_by_its_snapshot() {
890        let mut index = EventIndex::new(scope());
891        let event = AnalyticsEvent::from_snapshot(
892            "cs-1:000001@2026-09-30T00:00:00Z",
893            Some("2026-09-30"),
894            EventClass::Completion,
895            Some("cs-1:000001"),
896            Some(2),
897        );
898        assert_eq!(index.contribute(event.clone()), Contribution::Added);
899        assert_eq!(index.contribute(event), Contribution::Duplicate);
900        assert_eq!(index.len(), 1);
901        let stored = index.events().next().unwrap();
902        assert_eq!(stored.event_id, "snapshot:cs-1:000001@2026-09-30T00:00:00Z");
903        assert_eq!(stored.source, EventSource::Snapshot);
904        assert_eq!(
905            stored.snapshot_id.as_deref(),
906            Some("cs-1:000001@2026-09-30T00:00:00Z"),
907            "the closed snapshot's identity is retained verbatim"
908        );
909    }
910
911    #[test]
912    fn t005_a_time_mutation_kind_is_never_progress() {
913        for kind in [
914            HistoryEventKind::TimeEntryCreated,
915            HistoryEventKind::TimeEntryUpdated,
916            HistoryEventKind::TimeEntryDeleted,
917        ] {
918            let event = history_event(kind, "entry t-1; created 30m on 2026-09-25 by alice");
919            let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
920            assert_eq!(
921                classified.class,
922                EventClass::Unclassified,
923                "{kind} must not be read as progress"
924            );
925            assert_eq!(classified.delta, None);
926        }
927    }
928
929    #[test]
930    fn t003_a_parent_cleared_event_is_not_growth() {
931        let event = history_event(HistoryEventKind::ParentCleared, "parent #1 -> none");
932        let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
933        assert_eq!(classified.class, EventClass::Unclassified);
934        assert_eq!(classified.delta, None, "a removal is never counted as +1");
935    }
936
937    #[test]
938    fn t003_a_membership_change_is_read_by_direction() {
939        for (detail, class) in [
940            ("cycles: none -> Default", EventClass::ScopeAddition),
941            (
942                "cycles: Default -> Default, Sprint 7",
943                EventClass::ScopeAddition,
944            ),
945            ("cycles: Default -> none", EventClass::Unclassified),
946            (
947                "cycles: Default, Sprint 7 -> Default",
948                EventClass::Unclassified,
949            ),
950            (
951                "cycles: Default, Sprint 7 -> Sprint 7, Default",
952                EventClass::Unclassified,
953            ),
954            ("mangled", EventClass::Unclassified),
955            ("cycles: Default ->", EventClass::Unclassified),
956        ] {
957            let event = history_event(HistoryEventKind::CycleMembershipChanged, detail);
958            let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
959            assert_eq!(classified.class, class, "{detail}");
960            assert_eq!(classified.delta, None, "{detail}");
961            assert_eq!(classified.detail, detail, "the record is kept verbatim");
962        }
963    }
964
965    #[test]
966    fn t005_a_repeated_out_of_period_event_is_a_duplicate_not_a_second_exclusion() {
967        let period = Period::new(Some("2026-09-01".into()), Some("2026-09-30".into())).unwrap();
968        let bounded = AnalyticsScope::cycle("Sprint 7", period).unwrap();
969        let mut index = EventIndex::new(bounded);
970        let event = HistoryEvent::new(
971            "2026-10-05T00:00:00Z",
972            "alice",
973            HistoryEventKind::ParentSet,
974            "parent none -> #2",
975        );
976        let classified = AnalyticsEvent::from_history("1", 0, index.scope(), &event);
977        assert_eq!(
978            index.contribute(classified.clone()),
979            Contribution::OutsidePeriod
980        );
981        assert_eq!(
982            index.contribute(classified),
983            Contribution::Duplicate,
984            "one recorded event, however often it is classified"
985        );
986        assert_eq!(
987            index.outside_period(),
988            1,
989            "an event count, not a count of classifications"
990        );
991        assert_eq!(index.duplicates(), 1);
992        assert_eq!(index.len(), 0);
993    }
994
995    #[test]
996    fn t004_a_malformed_transition_record_is_not_a_completion() {
997        let event = history_event(HistoryEventKind::TransitionAccepted, "mangled");
998        let classified = AnalyticsEvent::from_history("7", 0, &scope(), &event);
999        assert_eq!(classified.class, EventClass::Unclassified);
1000        assert_eq!(classified.detail, "mangled", "the raw record is kept");
1001    }
1002
1003    #[test]
1004    fn t005_the_round_trip_of_a_recorded_file_is_idempotent() {
1005        let mut history = History::new();
1006        history.append(HistoryEvent::new(
1007            utc_now_rfc3339(),
1008            "alice",
1009            HistoryEventKind::EstimateChanged,
1010            "estimate: none -> 3",
1011        ));
1012        let mut index = EventIndex::new(scope());
1013        assert_eq!(index.contribute_history("7", &history), 1);
1014        assert_eq!(index.contribute_history("7", &history), 0, "already seen");
1015        assert_eq!(index.len(), 1, "the same file contributes once");
1016    }
1017}