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
//! The read-only planning-end projection (mission `burndown-scope-analytics-01M3C9J1`,
//! WP03 T016/T017).
//!
//! # What it answers, and what it never does
//!
//! Given one [`BurndownSeries`], the day the caller asks **as of**, and the planning
//! **end date** the caller supplies, [`project`] answers one question: at the
//! completion rate the recorded series actually shows, when does the recorded
//! remaining work reach zero — and does that fall on or before the planning end?
//!
//! Nothing here reads a clock ([`Projection::as_of`] is the caller's day), opens a
//! store, or writes anything: it is a forecast over an already-folded series, and
//! every number it publishes is one the series already carries or one the documented
//! formula derives from those numbers.
//!
//! # The documented formula (version [`PROJECTION_FORMULA_VERSION`])
//!
//! ```text
//! first            = the series' first covered day
//! elapsed          = calendar days from `first` to `as_of`
//! completed_delta  = completed(as_of) − completed(first)
//! observed_rate    = completed_delta / elapsed                 [work per day, in the series' unit]
//! days_needed      = ceil(remaining(as_of) / observed_rate)    [remaining > 0, observed_rate > 0]
//! projected        = as_of + days_needed
//! ```
//!
//! `days_needed` is evaluated as `ceil(remaining · elapsed / completed_delta)` in
//! integers, so the projected **date** never depends on a float rounding; the float
//! `observed_rate` is the same quotient, published for a reader.
//!
//! # The three states, and why a date is never invented (T017)
//!
//! | state | when |
//! |---|---|
//! | [`Projected`](ProjectionStatus::Projected) | the series answers for `as_of` and a completion date follows: the completions the record shows consumed the whole scope (already complete), or the observed rate is positive |
//! | [`Unknown`](ProjectionStatus::Unknown) | the series answers, but no rate that can reach zero appears: no completion was recorded inside the window, or the derived date is outside the calendar this build can express |
//! | [`InsufficientData`](ProjectionStatus::InsufficientData) | the record cannot answer at all: the series holds no point for `as_of`, the levels on it are unknown, the window holds no elapsed day, or the period recorded nothing at all — an empty level is not a finished one |
//!
//! Nothing is reconstructed to fill a gap: the levels, the rate and the remaining work
//! are all read off the series, and a state that cannot answer says so with one
//! [`ProjectionUnknown`](AnalyticsDiagnosticKind::ProjectionUnknown) finding and no
//! date. A closed series ([`crate::analytics::events::AnalyticsScope::snapshot_id`])
//! projects from the snapshot's own immutable points like any other — the projection
//! never rewrites them.
//!
//! # Determinism (C-007)
//!
//! The whole projection is a pure function of the series and the two dates, and the
//! only comparisons are over canonical `YYYY-MM-DD` strings (chronological for that
//! shape) — so two runs produce the same value, and a chart document carrying it is
//! byte-stable.

use time::{Date, Month};

use crate::error::{Result, TixError};
use crate::model::validate_date;

use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::AnalyticsScope;
use super::series::{BurndownSeries, Mode};

/// The documented formula this projection implements.
///
/// A stable string rather than a number, for the same reason
/// [`crate::analytics::series::COUNT_FORMULA_VERSION`] is: a reader compares it for
/// equality, and a version *sequence* invites arithmetic on it that means nothing.
pub const PROJECTION_FORMULA_VERSION: &str = "burndown.projection.v1";

/// Whether the projection could answer, and with what (data-model §5 `status`).
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ProjectionStatus {
    /// A completion date follows from the recorded series.
    Projected,
    /// The series answers, but no rate that can reach zero appears, so no date is
    /// invented.
    Unknown,
    /// The record cannot answer: no level, or no elapsed observation, at `as_of`.
    InsufficientData,
}

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

    /// The canonical spelling used in output and in payloads.
    pub const fn as_str(self) -> &'static str {
        match self {
            ProjectionStatus::Projected => "projected",
            ProjectionStatus::Unknown => "unknown",
            ProjectionStatus::InsufficientData => "insufficient_data",
        }
    }

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

    /// Whether a completion date was derived.
    pub const fn is_projected(self) -> bool {
        matches!(self, ProjectionStatus::Projected)
    }
}

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

/// The read-only end-date forecast over one series (data-model §5).
///
/// `observed_rate` and `remaining` are in the **series' own unit** — the counts of
/// count mode, or the converted units of estimate mode — which `mode`/`unit` carry
/// so a reader never sees a rate without the unit it is expressed in.
///
/// The type holds an [`AnalyticsScope`] (and therefore no serde derive, like
/// [`BurndownSeries`]): the chart document either derives these types at the
/// export layer or mirrors them there.
#[derive(Debug, Clone, PartialEq)]
pub struct Projection {
    /// The selected cycle/milestone, period and snapshot binding.
    pub scope: AnalyticsScope,
    /// How the series the projection was drawn from counts.
    pub mode: Mode,
    /// The unit the remaining work and the rate are expressed in, in estimate mode.
    pub unit: Option<String>,
    /// The forecast origin — the caller's day, never a clock.
    pub as_of: String,
    /// The planning end date the forecast is drawn against, as the caller supplied it.
    pub end_date: String,
    /// The remaining work the series carried to `as_of`, in the series' unit.
    pub remaining: Option<i64>,
    /// The observed completion rate, in the series' unit per calendar day.
    pub observed_rate: Option<f64>,
    /// The forecast completion date, when one follows.
    pub projected_completion: Option<String>,
    /// Whether the projected completion falls on or before `end_date`; `None` when
    /// no date was projected.
    pub meets_end_date: Option<bool>,
    /// Whether the projection could answer, and with what.
    pub status: ProjectionStatus,
    /// The documented formula this projection was drawn under.
    pub formula_version: String,
    /// Why no date was projected, when one was not.
    pub diagnostics: Vec<AnalyticsDiagnostic>,
}

impl Projection {
    /// Whether a completion date was derived.
    #[must_use]
    pub fn is_projected(&self) -> bool {
        self.status.is_projected()
    }

    /// The projected completion date, when one was derived.
    #[must_use]
    pub fn projected_completion(&self) -> Option<&str> {
        self.projected_completion.as_deref()
    }

    /// Settle on `status` and record why no date follows.
    fn unanswered(mut self, status: ProjectionStatus, message: String, remedy: &str) -> Self {
        self.status = status;
        self.diagnostics.push(
            AnalyticsDiagnostic::new(
                AnalyticsDiagnosticKind::ProjectionUnknown,
                self.scope.id.clone(),
                message,
            )
            .on(self.as_of.clone())
            .with_remedy(remedy),
        );
        self
    }

    /// Settle the projection on a derived completion date.
    fn project_on(mut self, completion: String) -> Self {
        self.meets_end_date = Some(completion <= self.end_date);
        self.projected_completion = Some(completion);
        self.status = ProjectionStatus::Projected;
        self
    }
}

/// Project `series` against the planning end date `end_date`, as of `as_of`.
///
/// Both dates are the **caller's** — this function reads no clock — and each must be
/// a canonical `YYYY-MM-DD` calendar date; anything else is refused here, in the same
/// words [`crate::analytics::events::Period::new`] refuses a malformed bound, rather
/// than silently dropped into a forecast.
///
/// The end date is not required to be after `as_of`: "we are past the planning end,
/// when will this finish?" is a question the projection answers like any other (with
/// `meets_end_date: Some(false)`).
pub fn project(series: &BurndownSeries, as_of: &str, end_date: &str) -> Result<Projection> {
    let as_of = canonical("as_of", as_of)?;
    let end_date = canonical("end_date", end_date)?;
    let mut projection = Projection {
        scope: series.scope.clone(),
        mode: series.mode,
        unit: series.unit.clone(),
        as_of: as_of.clone(),
        end_date,
        remaining: None,
        observed_rate: None,
        projected_completion: None,
        meets_end_date: None,
        status: ProjectionStatus::InsufficientData,
        formula_version: PROJECTION_FORMULA_VERSION.to_string(),
        diagnostics: Vec::new(),
    };

    let covered = series.points.first().zip(series.points.last()).map_or_else(
        || "no day at all".to_string(),
        |(first, last)| format!("{}..{}", first.date, last.date),
    );

    // (1) The origin must be a day the series answers for.
    let Some(index) = series.points.iter().position(|point| point.date == as_of) else {
        return Ok(projection.unanswered(
            ProjectionStatus::InsufficientData,
            format!(
                "the series covers {covered} and holds no point for {as_of}, so the record cannot \
                 answer a forecast for that day"
            ),
            "pass an `as-of` day the series covers, or widen the series' period",
        ));
    };
    let origin = &series.points[index];
    projection.remaining = origin.remaining;

    // (2) The levels the fold carried to the origin must be known.
    let Some(remaining) = origin.remaining else {
        return Ok(projection.unanswered(
            ProjectionStatus::InsufficientData,
            format!(
                "the remaining work on {as_of} is not known from the record (the period's levels \
                 are unanchored or a recorded completion outweighs its additions), so no rate can \
                 be read from it"
            ),
            "widen the series' period with an earlier `--from` so its levels are anchored, then \
             project again",
        ));
    };

    // (3) The observed window: the series' first covered day to the origin.
    let (Some(first), Some(completed_first), Some(completed_origin)) = (
        series.points.first(),
        series.points.first().and_then(|point| point.completed),
        origin.completed,
    ) else {
        return Ok(projection.unanswered(
            ProjectionStatus::InsufficientData,
            format!(
                "the recorded completions carried to {as_of} are not known, so no completion rate \
                 can be observed over the period"
            ),
            "widen the series' period with an earlier `--from` so the level's origin is in it",
        ));
    };
    if index == 0 {
        return Ok(projection.unanswered(
            ProjectionStatus::InsufficientData,
            format!(
                "{as_of} is the series' first covered day, so the period holds no elapsed day to \
                 observe a completion rate over"
            ),
            "ask for an origin later than the series' first covered day",
        ));
    }

    let delta = completed_origin - completed_first;
    projection.observed_rate = Some(rate_per_day(delta, index));

    if remaining == 0 {
        // Nothing remained at the origin. When the record also shows the completions
        // that consumed the scope, that is a fact and the projection states it — the
        // origin itself, not a forecast standing in for one. When the period recorded
        // nothing at all, its levels are *empty* rather than finished (`0 − 0`), and
        // calling that complete would be the zero-by-assumption C-005 forbids.
        if completed_origin > 0 {
            return Ok(projection.project_on(as_of));
        }
        return Ok(projection.unanswered(
            ProjectionStatus::InsufficientData,
            "the period records no scope addition and no completion for this scope, so its levels \
             are empty rather than finished and no completion date is drawn"
                .to_string(),
            "widen the series' period with an earlier `--from` so the scope's recorded work is in \
             it",
        ));
    }

    if delta <= 0 {
        return Ok(projection.unanswered(
            ProjectionStatus::Unknown,
            format!(
                "the period records no completion between {} and {as_of}, so there is no observed \
                 rate to project {remaining} remaining `{}` from and no completion date is \
                 invented",
                first.date,
                series.unit.as_deref().unwrap_or("unit")
            ),
            "record at least one completion inside the window before asking for a forecast",
        ));
    }

    let Some(days) = days_needed(remaining, index, delta) else {
        return Ok(projection.unanswered(
            ProjectionStatus::Unknown,
            format!(
                "the observed rate projects {remaining} remaining `{}` past the calendar this \
                 build can express, so no completion date is drawn",
                series.unit.as_deref().unwrap_or("unit")
            ),
            "project over a shorter remaining scope, or from an origin the rate can reach zero from",
        ));
    };
    let Some(completion) = shift(&as_of, days) else {
        return Ok(projection.unanswered(
            ProjectionStatus::Unknown,
            format!(
                "a completion {days} day(s) after {as_of} is outside the calendar this build can \
                 express, so no completion date is drawn"
            ),
            "project from a different origin",
        ));
    };
    Ok(projection.project_on(completion))
}

/// The observed completion rate: `delta` contributions over `elapsed` calendar days.
///
/// The projection's one division, and the only place a float appears — the data
/// model types `observed_rate` as a numeric, and "0.5 items a day" is a rate a reader
/// can act on. The **date** the rate produces is computed in integers ([`days_needed`])
/// precisely so this quotient's rounding can never move it, and the values it divides
/// are bounded by what a project's history records.
#[allow(clippy::cast_precision_loss)]
fn rate_per_day(delta: i64, elapsed: usize) -> f64 {
    delta as f64 / elapsed as f64
}

/// The whole days `remaining` needs at `delta` completions per `elapsed` days.
///
/// The documented `ceil(remaining / observed_rate)`, evaluated in integers, so the
/// projected date is exact rather than a float's neighbour. `None` when the quotient
/// does not fit an `i64` day count; whether the shift from that count is a day the
/// calendar can express is [`shift`]'s answer, and both resolve to the caller's
/// "outside the calendar" branch.
fn days_needed(remaining: i64, elapsed: usize, delta: i64) -> Option<i64> {
    let denominator = i128::from(delta);
    if denominator <= 0 {
        return None;
    }
    let numerator = i128::from(remaining) * i128::try_from(elapsed).ok()?;
    // Ceiling division in integers; both operands are positive here.
    let days = (numerator + denominator - 1) / denominator;
    i64::try_from(days).ok()
}

/// `date` shifted by `days` calendar days, or `None` past the expressible calendar.
///
/// The shift goes through Julian day numbers rather than a `time::Duration`: the
/// duration's own day constructor multiplies by 86,400 and **panics** on overflow
/// (`SignedDuration::days`'s `expect`), and it would be constructed before
/// `Date::checked_add` could reject the magnitude. The days this function is handed are
/// bounded only by the estimates a record can state over a window up to
/// [`crate::analytics::series::MAX_SERIES_DAYS`], so an unshiftable magnitude must come
/// back as `None` — the caller's "outside the calendar this build can express" answer —
/// rather than as a panic. The `i32` bound is above what the calendar itself holds
/// (`Date::MIN`..`Date::MAX` is ~±5.4e6 days around the anchor), so nothing a date
/// could have answered is refused here.
fn shift(date: &str, days: i64) -> Option<String> {
    let start = calendar(date)?;
    let julian = start
        .to_julian_day()
        .checked_add(i32::try_from(days).ok()?)?;
    Date::from_julian_day(julian).ok().map(render)
}

/// A canonical `YYYY-MM-DD` day as a `time` date.
fn calendar(date: &str) -> Option<Date> {
    let year: i32 = date.get(0..4)?.parse().ok()?;
    let month: u8 = date.get(5..7)?.parse().ok()?;
    let day: u8 = date.get(8..10)?.parse().ok()?;
    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}

/// A `time` date as the canonical `YYYY-MM-DD` string.
fn render(date: Date) -> String {
    format!(
        "{:04}-{:02}-{:02}",
        date.year(),
        u8::from(date.month()),
        date.day()
    )
}

/// A required date input, refused in the crate's `invalid_period` words when it is
/// not a canonical calendar day.
fn canonical(field: &str, raw: &str) -> Result<String> {
    let date = raw.trim();
    if !validate_date(date) {
        return Err(TixError::Config(format!(
            "invalid_period: `{field}` is `{raw}`, not a `YYYY-MM-DD` calendar date"
        )));
    }
    Ok(date.to_string())
}