//! A board's Charts tab (oxygenhub#357): cumulative flow, cycle time, //! throughput, the open iteration's burn-down, and forecasts of when the //! remaining cards are done, all from the board's card moves //! ([`crate::board_stats`]). Drawn as inline SVG in the hub's chart style, //! with the numbers behind each chart in a table; the API gives the series. use super::projects::{board_columns, rights_of, CardFilter}; use super::{Error, Page, Shell}; use crate::auth::Viewer; use crate::board_stats::{self as bs, Move, DAY_MS, WEEK_MS}; use crate::db::{self, projects as pdb}; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use std::fmt::Write; /// Days of cumulative flow drawn, and weeks of throughput a forecast /// draws from. pub const FLOW_DAYS: usize = 60; pub const HISTORY_WEEKS: usize = 12; /// Monte Carlo trials per forecast. const TRIALS: usize = 4000; /// The trials, for the page to say. pub const TRIALS_SHOWN: usize = TRIALS; const W: i64 = 640; const H: i64 = 170; fn day_of(ms: i64) -> String { let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) } fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } /// One forecast: what it covers, how many cards are left, and the dates /// they are done by at 50% and 85%, or why there are none. #[derive(Clone, Debug)] pub struct ForecastRow { pub scope: String, pub remaining: usize, pub weeks_of_history: usize, pub p50: Option, pub p85: Option, } /// The open iteration's name, first day, and each day's (left, scope). pub type Burn = (String, i64, Vec<(usize, usize)>); /// Everything the Charts tab and the API show. pub struct Charts { pub columns: Vec, /// Cumulative flow: the first day and each day's counts per column. pub flow_first: i64, pub flow: Vec>, /// Cycle time of the cards finished in the last [`HISTORY_WEEKS`], in days. pub cycle_median_days: Option, pub cycle_p85_days: Option, pub cycle_finished: usize, /// Cards done in each of the last [`HISTORY_WEEKS`] weeks, and when /// the first of them began. pub throughput: Vec, pub throughput_from: i64, /// The open iteration's name, first day and its days' (left, scope). pub burn: Option, pub forecasts: Vec, /// The timeline (#361): iterations as spans, and the board's milestones /// with their due dates and forecast spans. pub timeline: Vec, } /// A row of the timeline: an iteration's span, or a milestone's due date /// with the span its open cards are forecast to be done in (50% to 85%). #[derive(Clone, Debug)] pub struct TimelineRow { pub name: String, pub milestone: bool, pub start: Option, pub end: Option, pub due: Option, pub forecast: Option<(i64, i64)>, pub open: bool, } /// The board's charts as `account` may see them; `q` narrows the /// forecast's cards as the board's filter bar does. pub async fn compute( app: &Shared, account: Option<&db::accounts::Account>, project: &pdb::Project, q: &str, now: i64, ) -> sqlx::Result { let (columns, _) = board_columns(app, account, &project.owner, project, false).await?; let ids: Vec = columns.iter().map(|c| c.column.id).collect(); let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::>()); // Only the cards this viewer may see count: the history of the rest stays out. let visible: std::collections::HashSet = { let (archived, _) = board_columns(app, account, &project.owner, project, true).await?; columns.iter().chain(archived.iter()).flat_map(|c| c.cards.iter().map(|v| v.card.id)).collect() }; let moves: Vec = pdb::moves(&app.db, project.id, 0) .await? .into_iter() .filter(|(card, _, _)| visible.contains(card)) .map(|(card, column, at)| Move { card, column, at }) .collect(); let today = now - now.rem_euclid(DAY_MS); let flow_first = (today - (FLOW_DAYS as i64 - 1) * DAY_MS).max(project.created_ms - project.created_ms.rem_euclid(DAY_MS)); let days = ((today - flow_first) / DAY_MS + 1) as usize; let flow = bs::cumulative_flow(&moves, &ids, flow_first, days); let (mut cycle_median_days, mut cycle_p85_days, mut cycle_finished) = (None, None, 0); let mut throughput = vec![0; HISTORY_WEEKS]; if let (Some(first), Some(done)) = (ids.first().copied(), done) { let times = bs::cycle_times(&moves, first, done); cycle_finished = times.len(); let days = |ms: i64| (ms as f64 / DAY_MS as f64 * 10.0).round() / 10.0; cycle_median_days = bs::percentile(×, 50).map(days); cycle_p85_days = bs::percentile(×, 85).map(days); throughput = bs::weekly_throughput(&moves, done, now, HISTORY_WEEKS); } // History begins with the board: fewer weeks than that are not history. let weeks_old = ((now - project.created_ms) / WEEK_MS).max(1) as usize; let history: Vec = throughput[HISTORY_WEEKS.saturating_sub(weeks_old)..].to_vec(); let iterations = pdb::iterations(&app.db, project.id).await?; let open = iterations.iter().find(|i| i.closed_ms.is_none()); let burn = match (open, done) { (Some(it), Some(done)) => { let so_far = ((now.min(it.end_ms - 1) - it.start_ms) / DAY_MS + 1).clamp(1, pdb::iteration_days(it)) as usize; Some((it.name.clone(), it.start_ms, bs::burn_down(&moves, done, it.start_ms, so_far))) } _ => None, }; // Forecasts: the open iteration's (or the whole board's) open cards, // each milestone's, and the filter's when one is given. let filter = CardFilter::parse(q); let open_cards: Vec<&super::projects::CardView> = columns .iter() .filter(|c| Some(c.column.id) != done) .flat_map(|c| c.cards.iter()) .filter(|c| !matches!(c.state.as_str(), "closed" | "landed")) .collect(); let seed = (project.id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (today / DAY_MS) as u64; let row = |scope: String, remaining: usize| { let f = bs::forecast(&history, remaining, TRIALS, seed); let date = |w: Option| w.map(|w| day_of(now + w as i64 * WEEK_MS)); ForecastRow { scope, remaining, weeks_of_history: f.weeks_of_history, p50: date(f.p50_weeks), p85: date(f.p85_weeks) } }; let mut forecasts = Vec::new(); let mut timeline: Vec = iterations .iter() .take(6) .rev() .map(|it| TimelineRow { name: it.name.clone(), milestone: false, start: Some(it.start_ms), end: Some(it.end_ms), due: None, forecast: None, open: it.closed_ms.is_none(), }) .collect(); if !q.trim().is_empty() { forecasts.push(row(format!("Cards matching {}", q.trim()), open_cards.iter().filter(|c| filter.keeps(c, now)).count())); } let scope = match open { Some(it) => format!("{} (open cards on the board)", it.name), None => "Open cards on the board".to_string(), }; forecasts.push(row(scope, open_cards.len())); let mut milestones: Vec = open_cards.iter().map(|c| c.milestone.clone()).filter(|m| !m.is_empty()).collect(); milestones.sort(); milestones.dedup(); // Milestones' due dates, from the repositories of the cards that name them. let mut due: std::collections::HashMap = Default::default(); let mut repos: Vec = open_cards.iter().filter(|c| !c.milestone.is_empty()).filter_map(|c| c.card.repo_id).collect(); repos.sort(); repos.dedup(); for rid in repos { for ms in db::issues::milestones(&app.db, rid).await? { if let Some(d) = ms.due_ms { due.entry(ms.title).and_modify(|e| *e = (*e).min(d)).or_insert(d); } } } for m in milestones { let n = open_cards.iter().filter(|c| c.milestone == m).count(); let f = bs::forecast(&history, n, TRIALS, seed); let at = |w: Option| w.map(|w| now + w as i64 * WEEK_MS); timeline.push(TimelineRow { name: m.clone(), milestone: true, start: None, end: None, due: due.get(&m).copied(), forecast: at(f.p50_weeks).zip(at(f.p85_weeks)), open: true, }); forecasts.push(row(format!("Milestone {m}"), n)); } Ok(Charts { columns: columns.iter().map(|c| c.column.name.clone()).collect(), flow_first, flow, cycle_median_days, cycle_p85_days, cycle_finished, throughput, throughput_from: now - HISTORY_WEEKS as i64 * WEEK_MS, burn, forecasts, timeline, }) } /// The timeline as SVG: a row per iteration (its span) and per milestone /// (its due date as a mark, its forecast as a band), today as a line. pub fn timeline_svg(rows: &[TimelineRow], now: i64) -> String { let times: Vec = rows .iter() .flat_map(|r| [r.start, r.end, r.due, r.forecast.map(|f| f.0), r.forecast.map(|f| f.1)]) .flatten() .chain([now - 14 * DAY_MS, now + 14 * DAY_MS]) .collect(); let (lo, hi) = (*times.iter().min().unwrap_or(&now), *times.iter().max().unwrap_or(&now)); let span = (hi - lo).max(DAY_MS) as f64; const LABEL: f64 = 150.0; let x = |t: i64| LABEL + (t - lo) as f64 / span * (W as f64 - LABEL - 4.0); let row_h = 22.0; let h = rows.len() as f64 * row_h + 22.0; let mut out = format!( r#""#, rows.len(), day_of(lo), day_of(hi) ); for (i, r) in rows.iter().enumerate() { let y = i as f64 * row_h + 4.0; let _ = write!(out, r#"{}"#, y + 13.0, esc(&r.name.chars().take(22).collect::())); if let (Some(a), Some(b)) = (r.start, r.end) { let class = if r.open { "s-main" } else { "s-mute" }; let _ = write!( out, r#"{}: {} to {}"#, x(a), y + 3.0, (x(b) - x(a)).max(2.0), esc(&r.name), day_of(a), day_of(b - 1) ); } if let Some((a, b)) = r.forecast { let _ = write!( out, r#"{}: done 50% by {}, 85% by {}"#, x(a), y + 3.0, (x(b) - x(a)).max(3.0), esc(&r.name), day_of(a), day_of(b) ); } if let Some(d) = r.due { let _ = write!( out, r#"{2} due {3}"#, x(d), y + 2.0, esc(&r.name), day_of(d) ); } } let _ = write!(out, r#""#, x(now), h - 18.0); let _ = write!(out, r#"{}"#, h - 4.0, day_of(lo)); let _ = write!(out, r#"today"#, x(now), h - 4.0); let _ = write!(out, r#"{}"#, h - 4.0, day_of(hi)); out } /// Cumulative flow as stacked bands, the board's first column on top. fn flow_svg(c: &Charts) -> String { let bands = bs::flow_bands(&c.flow, W, H); let mut out = format!( r#""#, H + 18, c.flow.len() ); for (k, pts) in bands.iter().enumerate() { let class = if k + 1 == bands.len() { 5 } else { k % 5 }; let _ = write!(out, r#"{}"#, esc(&c.columns[k])); } let _ = write!(out, r#"{}"#, H + 14, day_of(c.flow_first)); let _ = write!(out, r#"today"#, H + 14); out } /// Bars, one per week. fn bars_svg(values: &[usize], label: &str) -> String { let max = values.iter().copied().max().unwrap_or(0).max(1); let step = W as f64 / values.len().max(1) as f64; let mut out = format!(r#""#, H + 18, esc(label)); for (i, v) in values.iter().enumerate() { let h = (*v as f64 / max as f64) * H as f64; let _ = write!( out, r#"{v} done"#, i as f64 * step + step * 0.15, H as f64 - h, step * 0.7 ); } let _ = write!(out, r#"{} weeks ago"#, H + 14, values.len()); let _ = write!(out, r#"this week"#, H + 14); out } /// The burn-down: cards left, and the scope above it. fn burn_svg(days: &[(usize, usize)]) -> String { let max = days.iter().map(|(_, s)| *s).max().unwrap_or(0).max(1); let left: Vec = days.iter().map(|(l, _)| *l).collect(); let scope: Vec = days.iter().map(|(_, s)| *s).collect(); format!( r#"scopeleft"#, left.last().unwrap_or(&0), scope.last().unwrap_or(&0), days.len(), bs::polyline(&scope, max, W, H), bs::polyline(&left, max, W, H) ) } #[derive(Template)] #[template(path = "project_charts.html")] pub struct ChartsPage { shell: Shell, project: pdb::Project, views: Vec, c: Charts, flow_svg: String, throughput_svg: String, burn_svg: String, timeline_svg: String, /// The last fortnight of cumulative flow, for its table: day and counts. flow_table: Vec<(String, Vec)>, week_table: Vec<(String, usize)>, q: String, } #[derive(Deserialize, Default)] pub struct ChartsQuery { #[serde(default)] q: Option, } pub async fn show( State(app): State, viewer: Viewer, Path((owner, number)): Path<(String, i64)>, Query(query): Query, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}/{number}/charts"), &viewer); let e = |e| Error::from_db(shell.clone(), e); let r = rights_of(&app, viewer.account(), &owner).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; let project = pdb::get(&app.db, &owner, number) .await .map_err(e)? .filter(|p| p.public || r.member) .ok_or_else(|| Error::not_found(shell.clone()))?; let now = db::now_ms(); let q = query.q.unwrap_or_default(); let c = compute(&app, viewer.account(), &project, &q, now).await.map_err(e)?; let flow_table = c .flow .iter() .enumerate() .skip(c.flow.len().saturating_sub(14)) .map(|(d, row)| (day_of(c.flow_first + d as i64 * DAY_MS), row.clone())) .collect(); let week_table = c.throughput.iter().enumerate().map(|(i, n)| (day_of(c.throughput_from + i as i64 * WEEK_MS), *n)).collect(); let page = ChartsPage { flow_svg: flow_svg(&c), throughput_svg: bars_svg(&c.throughput, "Throughput: cards done each week"), burn_svg: c.burn.as_ref().map(|(_, _, d)| burn_svg(d)).unwrap_or_default(), timeline_svg: if c.timeline.is_empty() { String::new() } else { timeline_svg(&c.timeline, now) }, views: pdb::views(&app.db, project.id).await.map_err(e)?, shell, project, c, flow_table, week_table, q, }; Ok(Page(page).into_response()) } #[cfg(test)] mod tests { use super::*; #[test] fn the_timeline_draws_iterations_milestones_and_forecasts() { let now = 1_000 * DAY_MS; let rows = vec![ TimelineRow { name: "Sprint 1".into(), milestone: false, start: Some(now - 20 * DAY_MS), end: Some(now - 6 * DAY_MS), due: None, forecast: None, open: false, }, TimelineRow { name: "Sprint 2".into(), milestone: false, start: Some(now - 6 * DAY_MS), end: Some(now + 8 * DAY_MS), due: None, forecast: None, open: true, }, TimelineRow { name: "Release <1>".into(), milestone: true, start: None, end: None, due: Some(now + 10 * DAY_MS), forecast: Some((now + 7 * DAY_MS, now + 21 * DAY_MS)), open: true, }, ]; let svg = timeline_svg(&rows, now); assert!(svg.starts_with("")); assert_eq!(svg.matches("class=\"bar s-mute\"").count(), 1, "a past iteration"); assert_eq!(svg.matches("class=\"bar s-main\"").count(), 1, "the open one"); assert!(svg.contains("Release <1>: done 50% by") && svg.contains("class=\"line s-bad\""), "{svg}"); assert!(svg.contains("aria-label=\"Timeline: 3 iterations and milestones"), "named for a screen reader"); assert!(!svg.contains("style="), "coloured by the theme's classes only"); } }