OXYGENHUB

Board charts and forecasts from the board's history, and its timeline (closes #357, closes #361) f2d2776b2a14

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b5cb4895dda3
When
· 2026-11-04 00:06 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
f2d2776b2a14a2bb090bdb7a6a26debd0a86f6357c3ffdd8267a36207049fa9e
Depends on
0bc95135954d 17a3a84b00d1 18e381ea88c2 2e9188ca4f98 306aec1839aa 33d98147511b 38833ed78707 5c6e92fd8d71 6a306cd03fc1 93be9ce4653e b5668108487f b64e2537d702 d23464c0ac60 d7d182467382 e1ec9fbf538e eb0c44a9ad37 ef20fdc14e8b
Depended on by
40a43921ef42
In bookmarks
main
In tags
v0.9.24
Edits
19 atoms, 3 tree operations, 15 files

Browse the tree at this change

added crates/hub/src/board_stats.rs +284 −0
1 +
//! A board's charts and forecasts from its own history (oxygenhub#357):
2 +
//! cumulative flow, cycle time, throughput and a Monte Carlo of when the
3 +
//! remaining cards will be done, with no estimates to keep. Everything here
4 +
//! is a pure function of the board's card moves (`project_card_moves`), so
5 +
//! the series and the forecast's percentiles are testable from a fixture
6 +
//! board and a fixed seed.
7 +
8 +
use std::collections::{BTreeMap, HashMap};
9 +
10 +
pub const DAY_MS: i64 = 86_400_000;
11 +
pub const WEEK_MS: i64 = 7 * DAY_MS;
12 +
13 +
/// A card's move: to a column, or off the board (`None`), at a time.
14 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15 +
pub struct Move {
16 +
    pub card: i64,
17 +
    pub column: Option<i64>,
18 +
    pub at: i64,
19 +
}
20 +
21 +
/// Cards per column at the end of each day from `first_day` (midnight UTC,
22 +
/// ms) for `days` days: one row a day, one count per column of `columns`
23 +
/// (in board order). Moves must be oldest first.
24 +
pub fn cumulative_flow(moves: &[Move], columns: &[i64], first_day: i64, days: usize) -> Vec<Vec<usize>> {
25 +
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
26 +
    let mut i = 0;
27 +
    let mut out = Vec::with_capacity(days);
28 +
    for d in 0..days {
29 +
        let end = first_day + (d as i64 + 1) * DAY_MS;
30 +
        while i < moves.len() && moves[i].at < end {
31 +
            at.insert(moves[i].card, moves[i].column);
32 +
            i += 1;
33 +
        }
34 +
        let mut counts = vec![0; columns.len()];
35 +
        for col in at.values().flatten() {
36 +
            if let Some(k) = columns.iter().position(|c| c == col) {
37 +
                counts[k] += 1;
38 +
            }
39 +
        }
40 +
        out.push(counts);
41 +
    }
42 +
    out
43 +
}
44 +
45 +
/// Each finished card's cycle time in ms: from when it first left the
46 +
/// board's first column (work started) to when it first reached `done`
47 +
/// after that. A card added straight into a later column starts there.
48 +
pub fn cycle_times(moves: &[Move], first_column: i64, done: i64) -> Vec<i64> {
49 +
    let mut started: HashMap<i64, i64> = HashMap::new();
50 +
    let mut finished: BTreeMap<i64, i64> = BTreeMap::new();
51 +
    for m in moves {
52 +
        let Some(col) = m.column else { continue };
53 +
        if col != first_column && !started.contains_key(&m.card) {
54 +
            started.insert(m.card, m.at);
55 +
        }
56 +
        if col == done && !finished.contains_key(&m.card) {
57 +
            if let Some(s) = started.get(&m.card) {
58 +
                finished.insert(m.card, m.at - s);
59 +
            }
60 +
        }
61 +
    }
62 +
    finished.into_values().collect()
63 +
}
64 +
65 +
/// The `p`th percentile (0 to 100) of `values`, nearest rank; `None` for none.
66 +
pub fn percentile(values: &[i64], p: u32) -> Option<i64> {
67 +
    if values.is_empty() {
68 +
        return None;
69 +
    }
70 +
    let mut v = values.to_vec();
71 +
    v.sort_unstable();
72 +
    let rank = ((p as f64 / 100.0) * v.len() as f64).ceil().max(1.0) as usize;
73 +
    Some(v[rank.min(v.len()) - 1])
74 +
}
75 +
76 +
/// Cards that first reached `done` in each of the `weeks` whole weeks
77 +
/// before `now`, oldest first.
78 +
pub fn weekly_throughput(moves: &[Move], done: i64, now: i64, weeks: usize) -> Vec<usize> {
79 +
    let mut first: HashMap<i64, i64> = HashMap::new();
80 +
    for m in moves {
81 +
        if m.column == Some(done) {
82 +
            first.entry(m.card).or_insert(m.at);
83 +
        }
84 +
    }
85 +
    let start = now - weeks as i64 * WEEK_MS;
86 +
    let mut out = vec![0; weeks];
87 +
    for at in first.values() {
88 +
        if *at >= start && *at < now {
89 +
            out[((at - start) / WEEK_MS) as usize] += 1;
90 +
        }
91 +
    }
92 +
    out
93 +
}
94 +
95 +
/// A forecast: in how many weeks the remaining cards are done at 50% and
96 +
/// 85% confidence, from how many weeks of history, and `None` when the
97 +
/// history holds no finished card to go on.
98 +
#[derive(Clone, Debug, PartialEq, Eq)]
99 +
pub struct Forecast {
100 +
    pub remaining: usize,
101 +
    pub weeks_of_history: usize,
102 +
    pub p50_weeks: Option<usize>,
103 +
    pub p85_weeks: Option<usize>,
104 +
}
105 +
106 +
/// A small, seeded generator (xorshift64*): the forecast is the same for
107 +
/// the same history and seed, so it is testable and does not flicker
108 +
/// between page loads.
109 +
struct Rng(u64);
110 +
111 +
impl Rng {
112 +
    fn next(&mut self) -> u64 {
113 +
        let mut x = self.0;
114 +
        x ^= x >> 12;
115 +
        x ^= x << 25;
116 +
        x ^= x >> 27;
117 +
        self.0 = x;
118 +
        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
119 +
    }
120 +
    fn below(&mut self, n: usize) -> usize {
121 +
        (self.next() % n as u64) as usize
122 +
    }
123 +
}
124 +
125 +
/// Weeks a run of trials can take at most before it is counted as never.
126 +
const HORIZON_WEEKS: usize = 520;
127 +
128 +
/// A Monte Carlo of when `remaining` cards are done: each trial draws
129 +
/// weeks from `throughput` (cards finished in each past week) until the
130 +
/// remaining cards are used up, `trials` times, seeded with `seed`.
131 +
pub fn forecast(throughput: &[usize], remaining: usize, trials: usize, seed: u64) -> Forecast {
132 +
    let weeks_of_history = throughput.len();
133 +
    if remaining == 0 {
134 +
        return Forecast { remaining, weeks_of_history, p50_weeks: Some(0), p85_weeks: Some(0) };
135 +
    }
136 +
    if throughput.iter().all(|t| *t == 0) || trials == 0 {
137 +
        return Forecast { remaining, weeks_of_history, p50_weeks: None, p85_weeks: None };
138 +
    }
139 +
    let mut rng = Rng(seed | 1);
140 +
    let mut taken: Vec<i64> = Vec::with_capacity(trials);
141 +
    for _ in 0..trials {
142 +
        let (mut left, mut weeks) = (remaining as i64, 0usize);
143 +
        while left > 0 && weeks < HORIZON_WEEKS {
144 +
            left -= throughput[rng.below(throughput.len())] as i64;
145 +
            weeks += 1;
146 +
        }
147 +
        taken.push(weeks as i64);
148 +
    }
149 +
    let at = |p| percentile(&taken, p).map(|w| w as usize).filter(|w| *w < HORIZON_WEEKS);
150 +
    Forecast { remaining, weeks_of_history, p50_weeks: at(50), p85_weeks: at(85) }
151 +
}
152 +
153 +
/// The open iteration's burn-down and scope: at the end of each day so
154 +
/// far, cards on the board not in `done` (left) and those plus the ones
155 +
/// that reached `done` since the iteration began (scope).
156 +
pub fn burn_down(moves: &[Move], done: i64, start: i64, days: usize) -> Vec<(usize, usize)> {
157 +
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
158 +
    let mut done_since: std::collections::HashSet<i64> = Default::default();
159 +
    let mut i = 0;
160 +
    let mut out = Vec::with_capacity(days);
161 +
    for d in 0..days {
162 +
        let end = start + (d as i64 + 1) * DAY_MS;
163 +
        while i < moves.len() && moves[i].at < end {
164 +
            let m = moves[i];
165 +
            at.insert(m.card, m.column);
166 +
            if m.column == Some(done) && m.at >= start {
167 +
                done_since.insert(m.card);
168 +
            }
169 +
            i += 1;
170 +
        }
171 +
        let left = at.values().filter(|c| c.is_some() && **c != Some(done)).count();
172 +
        // Done stays done when it leaves the board (an iteration closing).
173 +
        out.push((left, left + done_since.len()));
174 +
    }
175 +
    out
176 +
}
177 +
178 +
/// Points for an SVG polyline of `values` in a `w` × `h` box scaled to
179 +
/// `max` (at least 1), one step per value.
180 +
pub fn polyline(values: &[usize], max: usize, w: i64, h: i64) -> String {
181 +
    let max = max.max(1) as f64;
182 +
    let n = values.len().max(2) - 1;
183 +
    values
184 +
        .iter()
185 +
        .enumerate()
186 +
        .map(|(i, v)| format!("{},{}", (i as i64 * w) / n as i64, h - ((*v as f64 / max) * h as f64).round() as i64))
187 +
        .collect::<Vec<_>>()
188 +
        .join(" ")
189 +
}
190 +
191 +
/// The bands of a cumulative flow chart as SVG polygon points, one per
192 +
/// column (bottom band last in board order, Done at the bottom): each band
193 +
/// spans from the sum of the columns after it to that sum plus its own.
194 +
pub fn flow_bands(rows: &[Vec<usize>], w: i64, h: i64) -> Vec<String> {
195 +
    let cols = rows.first().map(|r| r.len()).unwrap_or(0);
196 +
    let max = rows.iter().map(|r| r.iter().sum::<usize>()).max().unwrap_or(0).max(1) as f64;
197 +
    let n = rows.len().max(2) - 1;
198 +
    let x = |i: usize| (i as i64 * w) / n as i64;
199 +
    let y = |v: usize| h - ((v as f64 / max) * h as f64).round() as i64;
200 +
    (0..cols)
201 +
        .map(|k| {
202 +
            let below = |r: &Vec<usize>| r[k + 1..].iter().sum::<usize>();
203 +
            let top: Vec<String> = rows.iter().enumerate().map(|(i, r)| format!("{},{}", x(i), y(below(r) + r[k]))).collect();
204 +
            let bottom: Vec<String> = rows.iter().enumerate().rev().map(|(i, r)| format!("{},{}", x(i), y(below(r)))).collect();
205 +
            [top, bottom].concat().join(" ")
206 +
        })
207 +
        .collect()
208 +
}
209 +
210 +
#[cfg(test)]
211 +
mod tests {
212 +
    use super::*;
213 +
214 +
    const T0: i64 = 1_000 * DAY_MS;
215 +
216 +
    fn m(card: i64, column: Option<i64>, day: i64) -> Move {
217 +
        Move { card, column, at: T0 + day * DAY_MS + 3_600_000 }
218 +
    }
219 +
220 +
    /// Backlog 1, Doing 2, Done 3: card 1 goes the whole way in four days,
221 +
    /// card 2 reaches Doing on day 2 and stays, card 3 is added straight
222 +
    /// into Doing on day 3 and done on day 5, card 4 is archived on day 4.
223 +
    fn board() -> Vec<Move> {
224 +
        vec![
225 +
            m(1, Some(1), 0),
226 +
            m(2, Some(1), 0),
227 +
            m(4, Some(1), 0),
228 +
            m(1, Some(2), 1),
229 +
            m(2, Some(2), 2),
230 +
            m(3, Some(2), 3),
231 +
            m(4, None, 4),
232 +
            m(1, Some(3), 5),
233 +
            m(3, Some(3), 5),
234 +
        ]
235 +
    }
236 +
237 +
    #[test]
238 +
    fn cumulative_flow_replays_the_moves() {
239 +
        let rows = cumulative_flow(&board(), &[1, 2, 3], T0, 6);
240 +
        assert_eq!(rows, vec![vec![3, 0, 0], vec![2, 1, 0], vec![1, 2, 0], vec![1, 3, 0], vec![0, 3, 0], vec![0, 1, 2]]);
241 +
        let bands = flow_bands(&rows, 100, 30);
242 +
        assert_eq!(bands.len(), 3);
243 +
        assert!(bands[2].starts_with("0,30"), "Done is the bottom band: {}", bands[2]);
244 +
    }
245 +
246 +
    #[test]
247 +
    fn cycle_time_and_throughput() {
248 +
        let mut times = cycle_times(&board(), 1, 3);
249 +
        times.sort();
250 +
        assert_eq!(times, vec![2 * DAY_MS, 4 * DAY_MS], "card 3: day 3 to 5; card 1: day 1 to 5");
251 +
        assert_eq!(percentile(&times, 50), Some(2 * DAY_MS));
252 +
        assert_eq!(percentile(&times, 85), Some(4 * DAY_MS));
253 +
        assert_eq!(percentile(&[], 50), None);
254 +
        let now = T0 + 7 * DAY_MS;
255 +
        assert_eq!(weekly_throughput(&board(), 3, now, 2), vec![0, 2]);
256 +
    }
257 +
258 +
    #[test]
259 +
    fn the_forecast_is_seeded_and_honest() {
260 +
        // Two cards a week, every week: ten cards take five weeks, always.
261 +
        let f = forecast(&[2, 2, 2, 2], 10, 1000, 7);
262 +
        assert_eq!((f.p50_weeks, f.p85_weeks, f.weeks_of_history), (Some(5), Some(5), 4));
263 +
        // Uneven weeks: the 85% date is no earlier than the 50% one, and the
264 +
        // same seed gives the same answer.
265 +
        let a = forecast(&[0, 1, 4, 2, 3], 12, 2000, 42);
266 +
        assert_eq!(a, forecast(&[0, 1, 4, 2, 3], 12, 2000, 42));
267 +
        let (p50, p85) = (a.p50_weeks.unwrap(), a.p85_weeks.unwrap());
268 +
        assert!((4..=7).contains(&p50) && p85 >= p50, "{a:?}");
269 +
        // Nothing finished yet: no forecast, said plainly.
270 +
        assert_eq!(forecast(&[0, 0], 5, 100, 1).p50_weeks, None);
271 +
        assert_eq!(forecast(&[], 5, 100, 1).p50_weeks, None);
272 +
        assert_eq!(forecast(&[1], 0, 100, 1).p85_weeks, Some(0));
273 +
    }
274 +
275 +
    #[test]
276 +
    fn burn_down_counts_left_and_scope() {
277 +
        let bd = burn_down(&board(), 3, T0, 6);
278 +
        assert_eq!(bd[0], (3, 3));
279 +
        assert_eq!(bd[3], (4, 4), "card 3 added on day 3 grows the scope");
280 +
        assert_eq!(bd[4], (3, 3), "card 4 archived on day 4 leaves it");
281 +
        assert_eq!(bd[5], (1, 3), "two done");
282 +
        assert_eq!(polyline(&[0, 2, 4], 4, 10, 10), "0,10 5,5 10,0");
283 +
    }
284 +
}
added crates/hub/src/web/board_charts.rs +448 −0
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 +
}
added templates/project_charts.html +54 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Charts · {{ project.title }} · Projects · {{ project.owner }}{% endblock %}
3 +
{% block mainclass %} class="wide"{% endblock %}
4 +
{% block content %}
5 +
<div class="head"><h1>{{ project.title }} <small><a href="/projects/{{ project.owner }}">{{ project.owner }}'s projects</a> · #{{ project.number }}</small></h1></div>
6 +
<nav class="board-tabs" aria-label="Views of this board">
7 +
  <a class="board-tab" href="/projects/{{ project.owner }}/{{ project.number }}">Board</a>
8 +
  {% for v in views %}<a class="board-tab" href="/projects/{{ project.owner }}/{{ project.number }}?view={{ v.name|urlencode }}">{{ v.name }} <span class="small mute">{{ v.layout }}</span></a>{% endfor %}
9 +
  <a class="board-tab on" href="/projects/{{ project.owner }}/{{ project.number }}/charts" aria-current="page">Charts</a>
10 +
</nav>
11 +
<p class="small mute">From the board's own history: when each card joined each column. No estimates; the forecasts sample the weeks the board has had.</p>
12 +
13 +
<section aria-labelledby="fc-h">
14 +
<h2 id="fc-h">Forecasts</h2>
15 +
<form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}/charts" role="search">
16 +
  <label class="visually-hidden" for="fc-q">Forecast the cards matching</label><input type="search" id="fc-q" name="q" value="{{ q }}" placeholder="forecast cards matching: label:bug, milestone:…, repo:…">
17 +
  <button type="submit" class="small">FORECAST</button>
18 +
</form>
19 +
<div class="table-wrap"><table class="board-table">
20 +
  <thead><tr><th scope="col">Cards</th><th scope="col">Left</th><th scope="col">50% done by</th><th scope="col">85% done by</th></tr></thead>
21 +
  <tbody>{% for f in c.forecasts %}<tr><td>{{ f.scope }}</td><td>{{ f.remaining }}</td>
22 +
    {% match f.p50 %}{% when Some(d) %}<td><strong>{{ d }}</strong></td>{% when None %}<td class="mute" colspan="2">no forecast: no card has reached Done in the weeks it draws from</td>{% endmatch %}
23 +
    {% if let Some(d) = f.p85 %}<td>{{ d }}</td>{% else if f.p50.is_some() %}<td class="mute">beyond ten years</td>{% endif %}</tr>{% endfor %}</tbody>
24 +
</table></div>
25 +
{% if let Some(f) = c.forecasts.first() %}<p class="small mute">From {{ f.weeks_of_history }} week{% if f.weeks_of_history != 1 %}s{% endif %} of throughput (cards reaching Done each week), sampled {{ crate::web::board_charts::TRIALS_SHOWN }} times; the same history gives the same dates all day.</p>{% endif %}
26 +
</section>
27 +
28 +
<div class="chart-grid">
29 +
<figure class="chart-card wide-card" id="timeline"><figcaption>Timeline</figcaption>
30 +
  {% if timeline_svg.is_empty() %}<p class="mute">No iterations yet, and no open card with a milestone. Start an iteration on the board, or give cards milestones, and they appear here.</p>
31 +
  {% else %}{{ timeline_svg|safe }}
32 +
  <div class="legend"><span class="key s-main">open iteration</span><span class="key s-mute">past iteration</span><span class="key s-warn">forecast: 50% to 85%</span><span class="key s-bad">milestone due</span></div>{% endif %}
33 +
</figure>
34 +
{% if let Some((name, _, days)) = c.burn %}
35 +
<figure class="chart-card"><figcaption>{{ name }}: burn-down</figcaption>{{ burn_svg|safe }}
36 +
  <div class="legend"><span class="key s-main">cards left</span><span class="key s-mute">scope</span></div>
37 +
  <p class="small mute">{% if let Some((l, s)) = days.last() %}{{ l }} left of {{ s }} after {{ days.len() }} day{% if days.len() != 1 %}s{% endif %}{% endif %}.</p>
38 +
</figure>
39 +
{% endif %}
40 +
<figure class="chart-card"><figcaption>Cycle time</figcaption>
41 +
  {% match c.cycle_median_days %}{% when Some(m) %}<p class="kpi"><span class="kpi-v">{{ m }} d</span> <span class="kpi-l">median</span> · <span class="kpi-v">{% if let Some(p) = c.cycle_p85_days %}{{ p }}{% endif %} d</span> <span class="kpi-l">85th percentile</span></p>
42 +
  <p class="small mute">From leaving the first column to reaching Done, over {{ c.cycle_finished }} finished card{% if c.cycle_finished != 1 %}s{% endif %}.</p>
43 +
  {% when None %}<p class="mute">No card has gone from started to Done yet.</p>{% endmatch %}
44 +
</figure>
45 +
<figure class="chart-card"><figcaption>Throughput: cards done each week</figcaption>{{ throughput_svg|safe }}
46 +
  <details><summary class="small">the numbers</summary><table class="board-table"><thead><tr><th scope="col">Week from</th><th scope="col">Done</th></tr></thead><tbody>{% for (w, n) in week_table %}<tr><td>{{ w }}</td><td>{{ n }}</td></tr>{% endfor %}</tbody></table></details>
47 +
</figure>
48 +
<figure class="chart-card wide-card"><figcaption>Cumulative flow</figcaption>{{ flow_svg|safe }}
49 +
  <div class="legend">{% for name in c.columns %}<span class="key cf-key cf-{% if loop.last %}5{% else %}{{ loop.index0 % 5 }}{% endif %}">{{ name }}</span>{% endfor %}</div>
50 +
  <p class="small mute">Cards in each column at the end of each day. A band that keeps widening is where work waits.</p>
51 +
  <details><summary class="small">the last fortnight's numbers</summary><div class="table-wrap"><table class="board-table"><thead><tr><th scope="col">Day</th>{% for name in c.columns %}<th scope="col">{{ name }}</th>{% endfor %}</tr></thead><tbody>{% for (d, row) in flow_table %}<tr><td>{{ d }}</td>{% for n in row %}<td>{{ n }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></details>
52 +
</figure>
53 +
</div>
54 +
{% endblock %}
modified CHANGELOG.md +6 −0
…
11 unchanged lines
12 12
  - **Keys**: `j`/`k` between cards, `Enter` opens one, `m` moves it, `/` filters.
13 13
  - **History**: every card's moves (added, moved, archived, restored, removed) are kept from now on, for the charts and forecasts of #357.
14 14
  - The API answers `GET /projects/{owner}/{n}?q=` or `?view=NAME` with the filter applied, and lists the board's views and each column's limit; `oh project view N --filter TEXT` or `--view NAME`.
15 +
- **A hung test no longer holds a runner (#365):** the PostgreSQL tests run under nextest like the rest, and CI stops any test still running after ten minutes; the history scan's tests have four worker threads and a two-minute bound of their own. Two of them once sat for an hour on plex-1.
16 +
- **Charts and forecasts from the board's own history (#357):** a **Charts** tab on every board, with no estimates to keep.
17 +
  - **Forecasts**: a Monte Carlo of the board's weekly throughput (the last 12 weeks, or its life if shorter) says when the open cards will be done, at 50% and 85%: for the open iteration or the whole board, for each milestone, and for any filter. It says how many weeks it rests on, and says plainly when there is nothing to go on. Seeded by the board and the day, so it does not change between page loads.
18 +
  - **Cycle time** (leaving the first column to reaching Done: median and 85th percentile), **throughput** (cards done each week), the open iteration's **burn-down** with its scope, and **cumulative flow** (cards per column per day over 60 days, a widening band showing where work waits).
19 +
  - **Timeline** (#361): iterations as spans and milestones with their due dates and forecast bands on one time axis, today marked.
20 +
  - Drawn server-side as SVG in the hub's chart style, readable in both themes, each with its numbers in a table. `GET /projects/{owner}/{n}/charts` (`?q=`) gives the series and forecasts; `oh project charts N [-q FILTER]`.
15 21
16 22
## 0.9.23 (2026-11-03)
17 23
…
455 unchanged lines
modified crates/hub-api/src/lib.rs +59 −0
…
1097 unchanged lines
1098 1098
    pub sort: String,
1099 1099
}
1100 1100
1101 +
/// `GET /projects/{owner}/{n}/charts` (#357): the board's charts and
1102 +
/// forecasts from its own history.
1103 +
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1104 +
pub struct BoardCharts {
1105 +
    /// The board's columns, in order.
1106 +
    pub columns: Vec<String>,
1107 +
    /// Cumulative flow: each day (YYYY-MM-DD) with its cards per column.
1108 +
    pub cumulative_flow: Vec<FlowDay>,
1109 +
    /// From leaving the first column to reaching Done, in days: median and
1110 +
    /// 85th percentile, over `finished` cards; absent before any.
1111 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1112 +
    pub cycle_time_median_days: Option<f64>,
1113 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1114 +
    pub cycle_time_p85_days: Option<f64>,
1115 +
    pub finished: usize,
1116 +
    /// Cards that reached Done each week, oldest first.
1117 +
    pub throughput: Vec<WeekCount>,
1118 +
    /// The open iteration's burn-down, when one is open.
1119 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1120 +
    pub burn_down: Option<BurnDown>,
1121 +
    pub forecasts: Vec<BoardForecast>,
1122 +
}
1123 +
1124 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1125 +
pub struct FlowDay {
1126 +
    pub day: String,
1127 +
    pub counts: Vec<usize>,
1128 +
}
1129 +
1130 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1131 +
pub struct WeekCount {
1132 +
    /// The week's first day.
1133 +
    pub week: String,
1134 +
    pub done: usize,
1135 +
}
1136 +
1137 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1138 +
pub struct BurnDown {
1139 +
    pub iteration: String,
1140 +
    pub first_day: String,
1141 +
    /// Each day so far: cards left and the scope.
1142 +
    pub left: Vec<usize>,
1143 +
    pub scope: Vec<usize>,
1144 +
}
1145 +
1146 +
/// When `remaining` cards are done, at 50% and 85% (YYYY-MM-DD), from
1147 +
/// `weeks_of_history` weeks of throughput; absent when none reached Done.
1148 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1149 +
pub struct BoardForecast {
1150 +
    pub scope: String,
1151 +
    pub remaining: usize,
1152 +
    pub weeks_of_history: usize,
1153 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1154 +
    pub p50: Option<String>,
1155 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1156 +
    pub p85: Option<String>,
1157 +
}
1158 +
1101 1159
/// `POST /projects/{owner}/{n}/columns` (#366): a column at the right.
1102 1160
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1103 1161
pub struct NewColumn {
…
550 unchanged lines
1654 1712
        ("ProjectEdit", schemars::schema_for!(ProjectEdit)),
1655 1713
        ("NewColumn", schemars::schema_for!(NewColumn)),
1656 1714
        ("ProjectView", schemars::schema_for!(ProjectView)),
1715 +
        ("BoardCharts", schemars::schema_for!(BoardCharts)),
1657 1716
        ("ColumnEdit", schemars::schema_for!(ColumnEdit)),
1658 1717
        ("NewCard", schemars::schema_for!(NewCard)),
1659 1718
        ("CardMove", schemars::schema_for!(CardMove)),
…
334 unchanged lines
modified crates/hub/src/api/mod.rs +1 −0
…
98 unchanged lines
99 99
        .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit))
100 100
        .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card))
101 101
        .route("/projects/{owner}/{number}/columns", post(projects::add_column))
102 +
        .route("/projects/{owner}/{number}/charts", get(projects::charts))
102 103
        .route("/projects/{owner}/{number}/columns/{column}", axum::routing::patch(projects::edit_column).delete(projects::remove_column))
103 104
        .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards))
104 105
        .route("/projects/{owner}/{number}/cards/archive", post(projects::archive_cards))
…
3412 unchanged lines
modified crates/hub/src/api/projects.rs +55 −0
…
211 unchanged lines
212 212
    }))
213 213
}
214 214
215 +
/// `GET /projects/{owner}/{n}/charts` (#357): `?q=` narrows the forecast
216 +
/// as the board's filter bar does.
217 +
pub async fn charts(
218 +
    State(app): State<Shared>,
219 +
    headers: HeaderMap,
220 +
    Path((owner, number)): Path<(String, i64)>,
221 +
    Query(f): Query<BoardFilter>,
222 +
) -> Result<Json<hub_api::BoardCharts>, ApiError> {
223 +
    use crate::web::board_charts as bc;
224 +
    let c = caller(&app, &headers).await?;
225 +
    let (_, p) = visible(&app, &c, &owner, number).await?;
226 +
    let now = crate::db::now_ms();
227 +
    let ch = bc::compute(&app, c.account.as_ref(), &p, f.q.as_deref().unwrap_or(""), now).await?;
228 +
    let day = |ms: i64| {
229 +
        let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
230 +
        format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
231 +
    };
232 +
    use crate::board_stats::{DAY_MS, WEEK_MS};
233 +
    Ok(Json(hub_api::BoardCharts {
234 +
        cumulative_flow: ch
235 +
            .flow
236 +
            .iter()
237 +
            .enumerate()
238 +
            .map(|(d, counts)| hub_api::FlowDay { day: day(ch.flow_first + d as i64 * DAY_MS), counts: counts.clone() })
239 +
            .collect(),
240 +
        cycle_time_median_days: ch.cycle_median_days,
241 +
        cycle_time_p85_days: ch.cycle_p85_days,
242 +
        finished: ch.cycle_finished,
243 +
        throughput: ch
244 +
            .throughput
245 +
            .iter()
246 +
            .enumerate()
247 +
            .map(|(i, n)| hub_api::WeekCount { week: day(ch.throughput_from + i as i64 * WEEK_MS), done: *n })
248 +
            .collect(),
249 +
        burn_down: ch.burn.as_ref().map(|(name, start, days)| hub_api::BurnDown {
250 +
            iteration: name.clone(),
251 +
            first_day: day(*start),
252 +
            left: days.iter().map(|(l, _)| *l).collect(),
253 +
            scope: days.iter().map(|(_, s)| *s).collect(),
254 +
        }),
255 +
        forecasts: ch
256 +
            .forecasts
257 +
            .into_iter()
258 +
            .map(|f| hub_api::BoardForecast {
259 +
                scope: f.scope,
260 +
                remaining: f.remaining,
261 +
                weeks_of_history: f.weeks_of_history,
262 +
                p50: f.p50,
263 +
                p85: f.p85,
264 +
            })
265 +
            .collect(),
266 +
        columns: ch.columns,
267 +
    }))
268 +
}
269 +
215 270
/// A column's name: trimmed, 1 to 40 characters, as the board page has it.
216 271
fn column_name(name: &str) -> Result<&str, ApiError> {
217 272
    let name = name.trim();
…
263 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
10 unchanged lines
11 11
pub mod auth;
12 12
pub mod autoland;
13 13
pub mod backups;
14 +
pub mod board_stats;
14 15
pub mod bus;
15 16
pub mod config;
16 17
pub mod coverage;
…
414 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
7 unchanged lines
8 8
pub(crate) mod assistants;
9 9
mod avatar;
10 10
pub(crate) mod backups;
11 +
pub mod board_charts;
11 12
pub mod chain;
12 13
pub mod charts;
13 14
pub mod coverage;
…
229 unchanged lines
243 244
        .route("/help", get(help::help))
244 245
        .route("/projects/{owner}", get(projects::list).post(projects::create))
245 246
        .route("/projects/{owner}/{number}", get(projects::show).post(projects::act))
247 +
        .route("/projects/{owner}/{number}/charts", get(board_charts::show))
246 248
        .route("/notifications/thread", post(inbox::thread))
247 249
        .route("/notifications/open/{id}", get(inbox::open))
248 250
        .route("/login/reset", get(recovery::request_form).post(recovery::request))
…
726 unchanged lines
modified crates/hub/tests/api.rs +9 −0
…
1018 unchanged lines
1019 1019
    assert_eq!(names, [("Backlog", 1), ("Doing", 0), ("Done", 1)]);
1020 1020
    assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#1");
1021 1021
    // The cards by column (#115), and a wrong method answers with a body.
1022 +
    // Charts by API (#357): the series and a forecast, no estimates.
1023 +
    let (st, v) = api(app, "GET", "/api/v1/projects/andy/1/charts", None, None).await;
1024 +
    assert_eq!(st, StatusCode::OK, "{v}");
1025 +
    assert_eq!(v["columns"], serde_json::json!(["Backlog", "Doing", "Done"]));
1026 +
    assert!(!v["cumulative_flow"].as_array().unwrap().is_empty() && v["throughput"].as_array().unwrap().len() == 12, "{v}");
1027 +
    assert_eq!(v["forecasts"][0]["scope"], "Open cards on the board", "{v}");
1028 +
    let (_, v) = api(app, "GET", "/api/v1/projects/andy/1/charts?q=label:none", None, None).await;
1029 +
    let today = v["cumulative_flow"].as_array().unwrap().last().unwrap()["day"].clone();
1030 +
    assert_eq!((v["forecasts"][0]["remaining"].as_u64(), &v["forecasts"][0]["p50"]), (Some(0), &today), "nothing left is done today: {v}");
1022 1031
    // Columns by API (#366): add, rename, move, remove; answers are the board.
1023 1032
    let col_names = |v: &serde_json::Value| -> Vec<String> {
1024 1033
        v["columns"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap().to_string()).collect()
…
841 unchanged lines
modified crates/hub/tests/issues.rs +13 −0
…
987 unchanged lines
988 988
    assert!(lanes.contains("aria-label=\"demo\"") && lanes.contains("aria-label=\"(notes)\""), "{lanes}");
989 989
    let only = get(app, "/projects/andy/1?q=repo%3Asecret", Some(&andy)).await.body;
990 990
    assert!(only.contains("secret issue") && !only.contains("demo issue"), "repo: filters");
991 +
    // Charts (#357): one card reached Done this week, so two open cards
992 +
    // (an issue and a note) are forecast two weeks out; the flow's Done band
993 +
    // is drawn in the success colour.
994 +
    let ch = get(app, "/projects/andy/1/charts", Some(&andy)).await.body;
995 +
    assert!(ch.contains("Cumulative flow") && ch.contains("class=\"band cf-5\""), "{ch}");
996 +
    let two_weeks = {
997 +
        let t = time::OffsetDateTime::now_utc() + time::Duration::days(14);
998 +
        format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
999 +
    };
1000 +
    assert!(ch.contains("Open cards on the board</td><td>2</td>") && ch.contains(&format!("<strong>{two_weeks}</strong>")), "{ch}");
1001 +
    assert!(ch.contains("From 1 week of throughput"), "says what it rests on");
1002 +
    let anon_ch = get(app, "/projects/andy/1/charts", None).await.body;
1003 +
    assert!(anon_ch.contains("Open cards on the board</td><td>1</td>"), "a reader's forecast counts only what it may see");
991 1004
    let view_id: i64 = sqlx::query_scalar("SELECT id FROM project_views").fetch_one(&app.db).await.unwrap();
992 1005
    act(format!("_csrf={csrf}&action=delete_view&view={view_id}")).await;
993 1006
    assert!(!get(app, "/projects/andy/1", Some(&andy)).await.body.contains(">By repo <"), "deleted");
…
643 unchanged lines
modified crates/oh/src/main.rs +50 −0
…
567 unchanged lines
568 568
        #[command(subcommand)]
569 569
        cmd: CardCmd,
570 570
    },
571 +
    /// A board's charts from its own history: forecasts, cycle time,
572 +
    /// throughput (#357)
573 +
    Charts {
574 +
        /// The board
575 +
        number: i64,
576 +
        /// Forecast only the cards this filter keeps (words, repo:, label:, milestone:…)
577 +
        #[arg(long, short = 'q')]
578 +
        filter: Option<String>,
579 +
    },
571 580
    /// A board's columns: add, rename, move, remove (#366)
572 581
    Column {
573 582
        #[command(subcommand)]
…
1866 unchanged lines
2440 2449
                println!("{}", serde_json::to_string(&p)?);
2441 2450
            } else {
2442 2451
                println!("{} project {} {} {}", ui::ok("✔"), p.number, p.title, ui::dim(&p.url));
2452 +
            }
2453 +
        }
2454 +
        ProjectCmd::Charts { number, filter } => {
2455 +
            let path = match &filter {
2456 +
                Some(q) => format!("{base}/{number}/charts?q={}", enc(q)),
2457 +
                None => format!("{base}/{number}/charts"),
2458 +
            };
2459 +
            if json {
2460 +
                println!("{}", c.get_text(&path)?);
2461 +
                return Ok(0);
2462 +
            }
2463 +
            let ch: hub_api::BoardCharts = c.get(&path)?;
2464 +
            println!("{}", ui::strong("Forecasts"));
2465 +
            for f in &ch.forecasts {
2466 +
                let when = match (&f.p50, &f.p85) {
2467 +
                    (Some(a), Some(b)) => format!("50% by {a}, 85% by {b}"),
2468 +
                    (Some(a), None) => format!("50% by {a}, 85% beyond ten years"),
2469 +
                    _ => "no forecast: nothing has reached Done in the weeks it draws from".to_string(),
2470 +
                };
2471 +
                println!("  {}  {} left · {when}", f.scope, f.remaining);
2472 +
            }
2473 +
            if let Some(f) = ch.forecasts.first() {
2474 +
                println!("  {}", ui::dim(&format!("from {} week(s) of throughput", f.weeks_of_history)));
2475 +
            }
2476 +
            println!();
2477 +
            match (ch.cycle_time_median_days, ch.cycle_time_p85_days) {
2478 +
                (Some(m), Some(p)) => {
2479 +
                    println!("{} median {m} d, 85th percentile {p} d over {} finished", ui::strong("Cycle time"), ch.finished)
2480 +
                }
2481 +
                _ => println!("{} {}", ui::strong("Cycle time"), ui::dim("no card has gone from started to Done yet")),
2482 +
            }
2483 +
            let weeks: Vec<String> = ch.throughput.iter().map(|w| w.done.to_string()).collect();
2484 +
            println!("{} {} {}", ui::strong("Throughput"), weeks.join(" "), ui::dim("(cards done each week, oldest first)"));
2485 +
            if let Some(b) = &ch.burn_down {
2486 +
                println!(
2487 +
                    "{} {}: {} left of {}",
2488 +
                    ui::strong("Burn-down"),
2489 +
                    b.iteration,
2490 +
                    b.left.last().unwrap_or(&0),
2491 +
                    b.scope.last().unwrap_or(&0)
2492 +
                );
2443 2493
            }
2444 2494
        }
2445 2495
        ProjectCmd::Column { cmd } => {
…
447 unchanged lines
modified docs/spec.md +1 −1
…
493 unchanged lines
494 494
495 495
### 12.1 API
496 496
497 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
497 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `/charts` its cumulative flow, cycle time, weekly throughput, the open iteration's burn-down and Monte Carlo forecasts of when its open cards, each milestone's and `?q=`'s are done, at 50% and 85%, from its card moves, #357, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
498 498
499 499
### 12.2 Webhooks
500 500
…
398 unchanged lines
modified templates/project.html +1 −0
…
26 unchanged lines
27 27
<nav class="board-tabs" aria-label="Views of this board">
28 28
  <a class="board-tab{% if view_name.is_empty() %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}"{% if view_name.is_empty() %} aria-current="page"{% endif %}>Board</a>
29 29
  {% for v in views %}<a class="board-tab{% if v.name == *view_name %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}?view={{ v.name|urlencode }}"{% if v.name == *view_name %} aria-current="page"{% endif %}>{{ v.name }} <span class="small mute">{{ v.layout }}</span></a>{% endfor %}
30 +
  <a class="board-tab" href="/projects/{{ project.owner }}/{{ project.number }}/charts">Charts</a>
30 31
</nav>
31 32
{% if let Some(v) = shown_iteration %}
32 33
<section class="iteration past" aria-label="Iteration {{ v.it.name }}">
…
147 unchanged lines
modified templates/style.css +12 −0
…
666 unchanged lines
667 667
.board-table tr.card-item { background: none; border: none; }
668 668
.board-save-view { margin: 0 0 10px; }
669 669
.board-keys { margin-top: 8px; }
670 +
/* Cumulative flow bands (#357): the mark's colours, Done in the success colour. */
671 +
svg.chart .band { stroke: var(--panel); stroke-width: .5; }
672 +
.cf-0 { fill: var(--m3); color: var(--m3); } .cf-1 { fill: var(--m1); color: var(--m1); } .cf-2 { fill: var(--warn); color: var(--warn); }
673 +
.cf-3 { fill: var(--m4); color: var(--m4); } .cf-4 { fill: var(--m5); color: var(--m5); } .cf-5 { fill: var(--ok); color: var(--ok); }
674 +
.legend .key.cf-key { color: var(--ink); fill: none; }
675 +
.legend .key.cf-0::before { background: var(--m3); } .legend .key.cf-1::before { background: var(--m1); } .legend .key.cf-2::before { background: var(--warn); }
676 +
.legend .key.cf-3::before { background: var(--m4); } .legend .key.cf-4::before { background: var(--m5); } .legend .key.cf-5::before { background: var(--ok); }
677 +
.chart-card.wide-card { grid-column: 1 / -1; }
678 +
.kpi { margin: 6px 0; }
679 +
svg.timeline .forecast { opacity: .55; }
680 +
svg.timeline .today { stroke: var(--acc); stroke-dasharray: 3 3; }
681 +
svg.timeline path.line { fill: currentColor; }
670 682
.card-item .card-labels { display: flex; flex-wrap: wrap; gap: 4px; }
671 683
.card-item .card-labels .label { margin-right: 0; text-decoration: none; }
672 684
.card-item .card-labels .label:hover, .card-item .card-labels .label:focus-visible { background: color-mix(in srgb, var(--c, var(--acc)) 14%, transparent); }
…
870 unchanged lines