OXYGENHUB
◆ main oxygenhub / crates / hub / src / web / board_charts.rs
18.2 KiB · Rust · last changed Board charts and forecasts from the board's history, and its timeline (closes #357, closes #361) 1 day ago source blame history raw
f2d2776b Andy McCall ↶ 1
//! A board's Charts tab (oxygenhub#357): cumulative flow, cycle time,
2
//! throughput, the open iteration's burn-down, and forecasts of when the
3
//! remaining cards are done, all from the board's card moves
4
//! ([`crate::board_stats`]). Drawn as inline SVG in the hub's chart style,
5
//! with the numbers behind each chart in a table; the API gives the series.
6
7
use super::projects::{board_columns, rights_of, CardFilter};
8
use super::{Error, Page, Shell};
9
use crate::auth::Viewer;
10
use crate::board_stats::{self as bs, Move, DAY_MS, WEEK_MS};
11
use crate::db::{self, projects as pdb};
12
use crate::Shared;
13
use askama::Template;
14
use axum::extract::{Path, Query, State};
15
use axum::response::{IntoResponse, Response};
16
use serde::Deserialize;
17
use std::fmt::Write;
18
19
/// Days of cumulative flow drawn, and weeks of throughput a forecast
20
/// draws from.
21
pub const FLOW_DAYS: usize = 60;
22
pub const HISTORY_WEEKS: usize = 12;
23
/// Monte Carlo trials per forecast.
24
const TRIALS: usize = 4000;
25
/// The trials, for the page to say.
26
pub const TRIALS_SHOWN: usize = TRIALS;
27
28
const W: i64 = 640;
29
const H: i64 = 170;
30
31
fn day_of(ms: i64) -> String {
32
    let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
33
    format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
34
}
35
36
fn esc(s: &str) -> String {
37
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
38
}
39
40
/// One forecast: what it covers, how many cards are left, and the dates
41
/// they are done by at 50% and 85%, or why there are none.
42
#[derive(Clone, Debug)]
43
pub struct ForecastRow {
44
    pub scope: String,
45
    pub remaining: usize,
46
    pub weeks_of_history: usize,
47
    pub p50: Option<String>,
48
    pub p85: Option<String>,
49
}
50
51
/// The open iteration's name, first day, and each day's (left, scope).
52
pub type Burn = (String, i64, Vec<(usize, usize)>);
53
54
/// Everything the Charts tab and the API show.
55
pub struct Charts {
56
    pub columns: Vec<String>,
57
    /// Cumulative flow: the first day and each day's counts per column.
58
    pub flow_first: i64,
59
    pub flow: Vec<Vec<usize>>,
60
    /// Cycle time of the cards finished in the last [`HISTORY_WEEKS`], in days.
61
    pub cycle_median_days: Option<f64>,
62
    pub cycle_p85_days: Option<f64>,
63
    pub cycle_finished: usize,
64
    /// Cards done in each of the last [`HISTORY_WEEKS`] weeks, and when
65
    /// the first of them began.
66
    pub throughput: Vec<usize>,
67
    pub throughput_from: i64,
68
    /// The open iteration's name, first day and its days' (left, scope).
69
    pub burn: Option<Burn>,
70
    pub forecasts: Vec<ForecastRow>,
71
    /// The timeline (#361): iterations as spans, and the board's milestones
72
    /// with their due dates and forecast spans.
73
    pub timeline: Vec<TimelineRow>,
74
}
75
76
/// A row of the timeline: an iteration's span, or a milestone's due date
77
/// with the span its open cards are forecast to be done in (50% to 85%).
78
#[derive(Clone, Debug)]
79
pub struct TimelineRow {
80
    pub name: String,
81
    pub milestone: bool,
82
    pub start: Option<i64>,
83
    pub end: Option<i64>,
84
    pub due: Option<i64>,
85
    pub forecast: Option<(i64, i64)>,
86
    pub open: bool,
87
}
88
89
/// The board's charts as `account` may see them; `q` narrows the
90
/// forecast's cards as the board's filter bar does.
91
pub async fn compute(
92
    app: &Shared,
93
    account: Option<&db::accounts::Account>,
94
    project: &pdb::Project,
95
    q: &str,
96
    now: i64,
97
) -> sqlx::Result<Charts> {
98
    let (columns, _) = board_columns(app, account, &project.owner, project, false).await?;
99
    let ids: Vec<i64> = columns.iter().map(|c| c.column.id).collect();
100
    let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>());
101
    // Only the cards this viewer may see count: the history of the rest stays out.
102
    let visible: std::collections::HashSet<i64> = {
103
        let (archived, _) = board_columns(app, account, &project.owner, project, true).await?;
104
        columns.iter().chain(archived.iter()).flat_map(|c| c.cards.iter().map(|v| v.card.id)).collect()
105
    };
106
    let moves: Vec<Move> = pdb::moves(&app.db, project.id, 0)
107
        .await?
108
        .into_iter()
109
        .filter(|(card, _, _)| visible.contains(card))
110
        .map(|(card, column, at)| Move { card, column, at })
111
        .collect();
112
    let today = now - now.rem_euclid(DAY_MS);
113
    let flow_first = (today - (FLOW_DAYS as i64 - 1) * DAY_MS).max(project.created_ms - project.created_ms.rem_euclid(DAY_MS));
114
    let days = ((today - flow_first) / DAY_MS + 1) as usize;
115
    let flow = bs::cumulative_flow(&moves, &ids, flow_first, days);
116
    let (mut cycle_median_days, mut cycle_p85_days, mut cycle_finished) = (None, None, 0);
117
    let mut throughput = vec![0; HISTORY_WEEKS];
118
    if let (Some(first), Some(done)) = (ids.first().copied(), done) {
119
        let times = bs::cycle_times(&moves, first, done);
120
        cycle_finished = times.len();
121
        let days = |ms: i64| (ms as f64 / DAY_MS as f64 * 10.0).round() / 10.0;
122
        cycle_median_days = bs::percentile(&times, 50).map(days);
123
        cycle_p85_days = bs::percentile(&times, 85).map(days);
124
        throughput = bs::weekly_throughput(&moves, done, now, HISTORY_WEEKS);
125
    }
126
    // History begins with the board: fewer weeks than that are not history.
127
    let weeks_old = ((now - project.created_ms) / WEEK_MS).max(1) as usize;
128
    let history: Vec<usize> = throughput[HISTORY_WEEKS.saturating_sub(weeks_old)..].to_vec();
129
    let iterations = pdb::iterations(&app.db, project.id).await?;
130
    let open = iterations.iter().find(|i| i.closed_ms.is_none());
131
    let burn = match (open, done) {
132
        (Some(it), Some(done)) => {
133
            let so_far = ((now.min(it.end_ms - 1) - it.start_ms) / DAY_MS + 1).clamp(1, pdb::iteration_days(it)) as usize;
134
            Some((it.name.clone(), it.start_ms, bs::burn_down(&moves, done, it.start_ms, so_far)))
135
        }
136
        _ => None,
137
    };
138
    // Forecasts: the open iteration's (or the whole board's) open cards,
139
    // each milestone's, and the filter's when one is given.
140
    let filter = CardFilter::parse(q);
141
    let open_cards: Vec<&super::projects::CardView> = columns
142
        .iter()
143
        .filter(|c| Some(c.column.id) != done)
144
        .flat_map(|c| c.cards.iter())
145
        .filter(|c| !matches!(c.state.as_str(), "closed" | "landed"))
146
        .collect();
147
    let seed = (project.id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (today / DAY_MS) as u64;
148
    let row = |scope: String, remaining: usize| {
149
        let f = bs::forecast(&history, remaining, TRIALS, seed);
150
        let date = |w: Option<usize>| w.map(|w| day_of(now + w as i64 * WEEK_MS));
151
        ForecastRow { scope, remaining, weeks_of_history: f.weeks_of_history, p50: date(f.p50_weeks), p85: date(f.p85_weeks) }
152
    };
153
    let mut forecasts = Vec::new();
154
    let mut timeline: Vec<TimelineRow> = iterations
155
        .iter()
156
        .take(6)
157
        .rev()
158
        .map(|it| TimelineRow {
159
            name: it.name.clone(),
160
            milestone: false,
161
            start: Some(it.start_ms),
162
            end: Some(it.end_ms),
163
            due: None,
164
            forecast: None,
165
            open: it.closed_ms.is_none(),
166
        })
167
        .collect();
168
    if !q.trim().is_empty() {
169
        forecasts.push(row(format!("Cards matching {}", q.trim()), open_cards.iter().filter(|c| filter.keeps(c, now)).count()));
170
    }
171
    let scope = match open {
172
        Some(it) => format!("{} (open cards on the board)", it.name),
173
        None => "Open cards on the board".to_string(),
174
    };
175
    forecasts.push(row(scope, open_cards.len()));
176
    let mut milestones: Vec<String> = open_cards.iter().map(|c| c.milestone.clone()).filter(|m| !m.is_empty()).collect();
177
    milestones.sort();
178
    milestones.dedup();
179
    // Milestones' due dates, from the repositories of the cards that name them.
180
    let mut due: std::collections::HashMap<String, i64> = Default::default();
181
    let mut repos: Vec<i64> = open_cards.iter().filter(|c| !c.milestone.is_empty()).filter_map(|c| c.card.repo_id).collect();
182
    repos.sort();
183
    repos.dedup();
184
    for rid in repos {
185
        for ms in db::issues::milestones(&app.db, rid).await? {
186
            if let Some(d) = ms.due_ms {
187
                due.entry(ms.title).and_modify(|e| *e = (*e).min(d)).or_insert(d);
188
            }
189
        }
190
    }
191
    for m in milestones {
192
        let n = open_cards.iter().filter(|c| c.milestone == m).count();
193
        let f = bs::forecast(&history, n, TRIALS, seed);
194
        let at = |w: Option<usize>| w.map(|w| now + w as i64 * WEEK_MS);
195
        timeline.push(TimelineRow {
196
            name: m.clone(),
197
            milestone: true,
198
            start: None,
199
            end: None,
200
            due: due.get(&m).copied(),
201
            forecast: at(f.p50_weeks).zip(at(f.p85_weeks)),
202
            open: true,
203
        });
204
        forecasts.push(row(format!("Milestone {m}"), n));
205
    }
206
    Ok(Charts {
207
        columns: columns.iter().map(|c| c.column.name.clone()).collect(),
208
        flow_first,
209
        flow,
210
        cycle_median_days,
211
        cycle_p85_days,
212
        cycle_finished,
213
        throughput,
214
        throughput_from: now - HISTORY_WEEKS as i64 * WEEK_MS,
215
        burn,
216
        forecasts,
217
        timeline,
218
    })
219
}
220
221
/// The timeline as SVG: a row per iteration (its span) and per milestone
222
/// (its due date as a mark, its forecast as a band), today as a line.
223
pub fn timeline_svg(rows: &[TimelineRow], now: i64) -> String {
224
    let times: Vec<i64> = rows
225
        .iter()
226
        .flat_map(|r| [r.start, r.end, r.due, r.forecast.map(|f| f.0), r.forecast.map(|f| f.1)])
227
        .flatten()
228
        .chain([now - 14 * DAY_MS, now + 14 * DAY_MS])
229
        .collect();
230
    let (lo, hi) = (*times.iter().min().unwrap_or(&now), *times.iter().max().unwrap_or(&now));
231
    let span = (hi - lo).max(DAY_MS) as f64;
232
    const LABEL: f64 = 150.0;
233
    let x = |t: i64| LABEL + (t - lo) as f64 / span * (W as f64 - LABEL - 4.0);
234
    let row_h = 22.0;
235
    let h = rows.len() as f64 * row_h + 22.0;
236
    let mut out = format!(
237
        r#"<svg class="chart timeline" viewBox="0 0 {W} {h:.0}" role="img" aria-label="Timeline: {} iterations and milestones from {} to {}">"#,
238
        rows.len(),
239
        day_of(lo),
240
        day_of(hi)
241
    );
242
    for (i, r) in rows.iter().enumerate() {
243
        let y = i as f64 * row_h + 4.0;
244
        let _ = write!(out, r#"<text class="axis" x="0" y="{:.1}">{}</text>"#, y + 13.0, esc(&r.name.chars().take(22).collect::<String>()));
245
        if let (Some(a), Some(b)) = (r.start, r.end) {
246
            let class = if r.open { "s-main" } else { "s-mute" };
247
            let _ = write!(
248
                out,
249
                r#"<rect class="bar {class}" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: {} to {}</title></rect>"#,
250
                x(a),
251
                y + 3.0,
252
                (x(b) - x(a)).max(2.0),
253
                esc(&r.name),
254
                day_of(a),
255
                day_of(b - 1)
256
            );
257
        }
258
        if let Some((a, b)) = r.forecast {
259
            let _ = write!(
260
                out,
261
                r#"<rect class="bar s-warn forecast" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: done 50% by {}, 85% by {}</title></rect>"#,
262
                x(a),
263
                y + 3.0,
264
                (x(b) - x(a)).max(3.0),
265
                esc(&r.name),
266
                day_of(a),
267
                day_of(b)
268
            );
269
        }
270
        if let Some(d) = r.due {
271
            let _ = write!(
272
                out,
273
                r#"<path class="line s-bad" d="M{0:.1},{1:.1} l5,7 l-5,7 l-5,-7 z"><title>{2} due {3}</title></path>"#,
274
                x(d),
275
                y + 2.0,
276
                esc(&r.name),
277
                day_of(d)
278
            );
279
        }
280
    }
281
    let _ = write!(out, r#"<line class="grid today" x1="{0:.1}" x2="{0:.1}" y1="0" y2="{1:.0}"/>"#, x(now), h - 18.0);
282
    let _ = write!(out, r#"<text class="axis" x="{LABEL}" y="{:.0}">{}</text>"#, h - 4.0, day_of(lo));
283
    let _ = write!(out, r#"<text class="axis" x="{:.1}" y="{:.0}" text-anchor="middle">today</text>"#, x(now), h - 4.0);
284
    let _ = write!(out, r#"<text class="axis" x="{W}" y="{:.0}" text-anchor="end">{}</text></svg>"#, h - 4.0, day_of(hi));
285
    out
286
}
287
288
/// Cumulative flow as stacked bands, the board's first column on top.
289
fn flow_svg(c: &Charts) -> String {
290
    let bands = bs::flow_bands(&c.flow, W, H);
291
    let mut out = format!(
292
        r#"<svg class="chart flow" viewBox="0 0 {W} {}" role="img" aria-label="Cumulative flow: cards per column each day for {} days">"#,
293
        H + 18,
294
        c.flow.len()
295
    );
296
    for (k, pts) in bands.iter().enumerate() {
297
        let class = if k + 1 == bands.len() { 5 } else { k % 5 };
298
        let _ = write!(out, r#"<polygon class="band cf-{class}" points="{pts}"><title>{}</title></polygon>"#, esc(&c.columns[k]));
299
    }
300
    let _ = write!(out, r#"<text class="axis" x="0" y="{}">{}</text>"#, H + 14, day_of(c.flow_first));
301
    let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">today</text></svg>"#, H + 14);
302
    out
303
}
304
305
/// Bars, one per week.
306
fn bars_svg(values: &[usize], label: &str) -> String {
307
    let max = values.iter().copied().max().unwrap_or(0).max(1);
308
    let step = W as f64 / values.len().max(1) as f64;
309
    let mut out = format!(r#"<svg class="chart" viewBox="0 0 {W} {}" role="img" aria-label="{}">"#, H + 18, esc(label));
310
    for (i, v) in values.iter().enumerate() {
311
        let h = (*v as f64 / max as f64) * H as f64;
312
        let _ = write!(
313
            out,
314
            r#"<rect class="bar s-ok" x="{:.1}" y="{:.1}" width="{:.1}" height="{h:.1}"><title>{v} done</title></rect>"#,
315
            i as f64 * step + step * 0.15,
316
            H as f64 - h,
317
            step * 0.7
318
        );
319
    }
320
    let _ = write!(out, r#"<text class="axis" x="0" y="{}">{} weeks ago</text>"#, H + 14, values.len());
321
    let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">this week</text></svg>"#, H + 14);
322
    out
323
}
324
325
/// The burn-down: cards left, and the scope above it.
326
fn burn_svg(days: &[(usize, usize)]) -> String {
327
    let max = days.iter().map(|(_, s)| *s).max().unwrap_or(0).max(1);
328
    let left: Vec<usize> = days.iter().map(|(l, _)| *l).collect();
329
    let scope: Vec<usize> = days.iter().map(|(_, s)| *s).collect();
330
    format!(
331
        r#"<svg class="chart" viewBox="0 0 {W} {H}" role="img" aria-label="Burn-down: {} cards left of {} after {} days"><polyline class="line s-mute" points="{}"><title>scope</title></polyline><polyline class="line s-main" points="{}"><title>left</title></polyline></svg>"#,
332
        left.last().unwrap_or(&0),
333
        scope.last().unwrap_or(&0),
334
        days.len(),
335
        bs::polyline(&scope, max, W, H),
336
        bs::polyline(&left, max, W, H)
337
    )
338
}
339
340
#[derive(Template)]
341
#[template(path = "project_charts.html")]
342
pub struct ChartsPage {
343
    shell: Shell,
344
    project: pdb::Project,
345
    views: Vec<pdb::View>,
346
    c: Charts,
347
    flow_svg: String,
348
    throughput_svg: String,
349
    burn_svg: String,
350
    timeline_svg: String,
351
    /// The last fortnight of cumulative flow, for its table: day and counts.
352
    flow_table: Vec<(String, Vec<usize>)>,
353
    week_table: Vec<(String, usize)>,
354
    q: String,
355
}
356
357
#[derive(Deserialize, Default)]
358
pub struct ChartsQuery {
359
    #[serde(default)]
360
    q: Option<String>,
361
}
362
363
pub async fn show(
364
    State(app): State<Shared>,
365
    viewer: Viewer,
366
    Path((owner, number)): Path<(String, i64)>,
367
    Query(query): Query<ChartsQuery>,
368
) -> Result<Response, Error> {
369
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}/charts"), &viewer);
370
    let e = |e| Error::from_db(shell.clone(), e);
371
    let r = rights_of(&app, viewer.account(), &owner).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
372
    let project = pdb::get(&app.db, &owner, number)
373
        .await
374
        .map_err(e)?
375
        .filter(|p| p.public || r.member)
376
        .ok_or_else(|| Error::not_found(shell.clone()))?;
377
    let now = db::now_ms();
378
    let q = query.q.unwrap_or_default();
379
    let c = compute(&app, viewer.account(), &project, &q, now).await.map_err(e)?;
380
    let flow_table = c
381
        .flow
382
        .iter()
383
        .enumerate()
384
        .skip(c.flow.len().saturating_sub(14))
385
        .map(|(d, row)| (day_of(c.flow_first + d as i64 * DAY_MS), row.clone()))
386
        .collect();
387
    let week_table = c.throughput.iter().enumerate().map(|(i, n)| (day_of(c.throughput_from + i as i64 * WEEK_MS), *n)).collect();
388
    let page = ChartsPage {
389
        flow_svg: flow_svg(&c),
390
        throughput_svg: bars_svg(&c.throughput, "Throughput: cards done each week"),
391
        burn_svg: c.burn.as_ref().map(|(_, _, d)| burn_svg(d)).unwrap_or_default(),
392
        timeline_svg: if c.timeline.is_empty() { String::new() } else { timeline_svg(&c.timeline, now) },
393
        views: pdb::views(&app.db, project.id).await.map_err(e)?,
394
        shell,
395
        project,
396
        c,
397
        flow_table,
398
        week_table,
399
        q,
400
    };
401
    Ok(Page(page).into_response())
402
}
403
404
#[cfg(test)]
405
mod tests {
406
    use super::*;
407
408
    #[test]
409
    fn the_timeline_draws_iterations_milestones_and_forecasts() {
410
        let now = 1_000 * DAY_MS;
411
        let rows = vec![
412
            TimelineRow {
413
                name: "Sprint 1".into(),
414
                milestone: false,
415
                start: Some(now - 20 * DAY_MS),
416
                end: Some(now - 6 * DAY_MS),
417
                due: None,
418
                forecast: None,
419
                open: false,
420
            },
421
            TimelineRow {
422
                name: "Sprint 2".into(),
423
                milestone: false,
424
                start: Some(now - 6 * DAY_MS),
425
                end: Some(now + 8 * DAY_MS),
426
                due: None,
427
                forecast: None,
428
                open: true,
429
            },
430
            TimelineRow {
431
                name: "Release <1>".into(),
432
                milestone: true,
433
                start: None,
434
                end: None,
435
                due: Some(now + 10 * DAY_MS),
436
                forecast: Some((now + 7 * DAY_MS, now + 21 * DAY_MS)),
437
                open: true,
438
            },
439
        ];
440
        let svg = timeline_svg(&rows, now);
441
        assert!(svg.starts_with("<svg class=\"chart timeline\"") && svg.ends_with("</svg>"));
442
        assert_eq!(svg.matches("class=\"bar s-mute\"").count(), 1, "a past iteration");
443
        assert_eq!(svg.matches("class=\"bar s-main\"").count(), 1, "the open one");
444
        assert!(svg.contains("Release &lt;1&gt;: done 50% by") && svg.contains("class=\"line s-bad\""), "{svg}");
445
        assert!(svg.contains("aria-label=\"Timeline: 3 iterations and milestones"), "named for a screen reader");
446
        assert!(!svg.contains("style="), "coloured by the theme's classes only");
447
    }
448
}