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}