| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 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 | |
|
| 20 | |
|
| 21 | | pub const FLOW_DAYS: usize = 60; |
| 22 | | pub const HISTORY_WEEKS: usize = 12; |
| 23 | |
|
| 24 | | const TRIALS: usize = 4000; |
| 25 | |
|
| 26 | | pub const TRIALS_SHOWN: usize = TRIALS; |
| 27 | |
|
| 28 | | const W: i64 = 640; |
| 29 | | const H: i64 = 170; |
| 30 | |
|
| 31 | 49× | fn day_of(ms: i64) -> String { |
| 32 | 49× | let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 33 | 49× | format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) |
| 34 | 49× | } |
| 35 | |
|
| 36 | 15× | fn esc(s: &str) -> String { |
| 37 | 15× | s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 38 | 15× | } |
| 39 | |
|
| 40 | |
|
| 41 | |
|
| 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 | |
|
| 52 | | pub type Burn = (String, i64, Vec<(usize, usize)>); |
| 53 | |
|
| 54 | |
|
| 55 | | pub struct Charts { |
| 56 | | pub columns: Vec<String>, |
| 57 | | |
| 58 | | pub flow_first: i64, |
| 59 | | pub flow: Vec<Vec<usize>>, |
| 60 | | |
| 61 | | pub cycle_median_days: Option<f64>, |
| 62 | | pub cycle_p85_days: Option<f64>, |
| 63 | | pub cycle_finished: usize, |
| 64 | | |
| 65 | | |
| 66 | | pub throughput: Vec<usize>, |
| 67 | | pub throughput_from: i64, |
| 68 | | |
| 69 | | pub burn: Option<Burn>, |
| 70 | | pub forecasts: Vec<ForecastRow>, |
| 71 | | |
| 72 | | |
| 73 | | pub timeline: Vec<TimelineRow>, |
| 74 | | } |
| 75 | |
|
| 76 | |
|
| 77 | |
|
| 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 | |
|
| 90 | |
|
| 91 | 4× | pub async fn compute( |
| 92 | 4× | app: &Shared, |
| 93 | 4× | account: Option<&db::accounts::Account>, |
| 94 | 4× | project: &pdb::Project, |
| 95 | 4× | q: &str, |
| 96 | 4× | now: i64, |
| 97 | 4× | ) -> sqlx::Result<Charts> { |
| 98 | 4× | let (columns, _) = board_columns(app, account, &project.owner, project, false).await?; |
| 99 | 4× | let ids: Vec<i64> = columns.iter().map(|c| c.column.id).collect(); |
| 100 | 12× | let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>()); |
| 101 | | |
| 102 | 4× | let visible: std::collections::HashSet<i64> = { |
| 103 | 4× | let (archived, _) = board_columns(app, account, &project.owner, project, true).await?; |
| 104 | 24× | columns.iter().chain(archived.iter()).flat_map(|c| c.cards.iter().map(|v| v.card.id)).collect() |
| 105 | | }; |
| 106 | 4× | let moves: Vec<Move> = pdb::moves(&app.db, project.id, 0) |
| 107 | 4× | .await? |
| 108 | 4× | .into_iter() |
| 109 | 14× | .filter(|(card, _, _)| visible.contains(card)) |
| 110 | 13× | .map(|(card, column, at)| Move { card, column, at }) |
| 111 | 4× | .collect(); |
| 112 | 4× | let today = now - now.rem_euclid(DAY_MS); |
| 113 | 4× | let flow_first = (today - (FLOW_DAYS as i64 - 1) * DAY_MS).max(project.created_ms - project.created_ms.rem_euclid(DAY_MS)); |
| 114 | 4× | let days = ((today - flow_first) / DAY_MS + 1) as usize; |
| 115 | 4× | let flow = bs::cumulative_flow(&moves, &ids, flow_first, days); |
| 116 | 4× | let (mut cycle_median_days, mut cycle_p85_days, mut cycle_finished) = (None, None, 0); |
| 117 | 4× | let mut throughput = vec![0; HISTORY_WEEKS]; |
| 118 | 4× | if let (Some(first), Some(done)) = (ids.first().copied(), done) { |
| 119 | 4× | let times = bs::cycle_times(&moves, first, done); |
| 120 | 4× | cycle_finished = times.len(); |
| 121 | 8× | let days = |ms: i64| (ms as f64 / DAY_MS as f64 * 10.0).round() / 10.0; |
| 122 | 4× | cycle_median_days = bs::percentile(×, 50).map(days); |
| 123 | 4× | cycle_p85_days = bs::percentile(×, 85).map(days); |
| 124 | 4× | throughput = bs::weekly_throughput(&moves, done, now, HISTORY_WEEKS); |
| 125 | 0× | } |
| 126 | | |
| 127 | 4× | let weeks_old = ((now - project.created_ms) / WEEK_MS).max(1) as usize; |
| 128 | 4× | let history: Vec<usize> = throughput[HISTORY_WEEKS.saturating_sub(weeks_old)..].to_vec(); |
| 129 | 4× | let iterations = pdb::iterations(&app.db, project.id).await?; |
| 130 | 4× | let open = iterations.iter().find(|i| i.closed_ms.is_none()); |
| 131 | 4× | let burn = match (open, done) { |
| 132 | 0× | (Some(it), Some(done)) => { |
| 133 | 0× | let so_far = ((now.min(it.end_ms - 1) - it.start_ms) / DAY_MS + 1).clamp(1, pdb::iteration_days(it)) as usize; |
| 134 | 0× | Some((it.name.clone(), it.start_ms, bs::burn_down(&moves, done, it.start_ms, so_far))) |
| 135 | | } |
| 136 | 4× | _ => None, |
| 137 | | }; |
| 138 | | |
| 139 | | |
| 140 | 4× | let filter = CardFilter::parse(q); |
| 141 | 4× | let open_cards: Vec<&super::projects::CardView> = columns |
| 142 | 4× | .iter() |
| 143 | 12× | .filter(|c| Some(c.column.id) != done) |
| 144 | 8× | .flat_map(|c| c.cards.iter()) |
| 145 | 5× | .filter(|c| !matches!(c.state.as_str(), "closed" | "landed")) |
| 146 | 4× | .collect(); |
| 147 | 4× | let seed = (project.id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (today / DAY_MS) as u64; |
| 148 | 5× | let row = |scope: String, remaining: usize| { |
| 149 | 5× | let f = bs::forecast(&history, remaining, TRIALS, seed); |
| 150 | 10× | let date = |w: Option<usize>| w.map(|w| day_of(now + w as i64 * WEEK_MS)); |
| 151 | 5× | ForecastRow { scope, remaining, weeks_of_history: f.weeks_of_history, p50: date(f.p50_weeks), p85: date(f.p85_weeks) } |
| 152 | 5× | }; |
| 153 | 4× | let mut forecasts = Vec::new(); |
| 154 | 4× | let mut timeline: Vec<TimelineRow> = iterations |
| 155 | 4× | .iter() |
| 156 | 4× | .take(6) |
| 157 | 4× | .rev() |
| 158 | 4× | .map(|it| TimelineRow { |
| 159 | 0× | name: it.name.clone(), |
| 160 | | milestone: false, |
| 161 | 0× | start: Some(it.start_ms), |
| 162 | 0× | end: Some(it.end_ms), |
| 163 | 0× | due: None, |
| 164 | 0× | forecast: None, |
| 165 | 0× | open: it.closed_ms.is_none(), |
| 166 | 0× | }) |
| 167 | 4× | .collect(); |
| 168 | 4× | if !q.trim().is_empty() { |
| 169 | 1× | forecasts.push(row(format!("Cards matching {}", q.trim()), open_cards.iter().filter(|c| filter.keeps(c, now)).count())); |
| 170 | 3× | } |
| 171 | 4× | let scope = match open { |
| 172 | 0× | Some(it) => format!("{} (open cards on the board)", it.name), |
| 173 | 4× | None => "Open cards on the board".to_string(), |
| 174 | | }; |
| 175 | 4× | forecasts.push(row(scope, open_cards.len())); |
| 176 | 5× | let mut milestones: Vec<String> = open_cards.iter().map(|c| c.milestone.clone()).filter(|m| !m.is_empty()).collect(); |
| 177 | 4× | milestones.sort(); |
| 178 | 4× | milestones.dedup(); |
| 179 | | |
| 180 | 4× | let mut due: std::collections::HashMap<String, i64> = Default::default(); |
| 181 | 5× | let mut repos: Vec<i64> = open_cards.iter().filter(|c| !c.milestone.is_empty()).filter_map(|c| c.card.repo_id).collect(); |
| 182 | 4× | repos.sort(); |
| 183 | 4× | repos.dedup(); |
| 184 | 4× | for rid in repos { |
| 185 | 0× | for ms in db::issues::milestones(&app.db, rid).await? { |
| 186 | 0× | if let Some(d) = ms.due_ms { |
| 187 | 0× | due.entry(ms.title).and_modify(|e| *e = (*e).min(d)).or_insert(d); |
| 188 | 0× | } |
| 189 | | } |
| 190 | | } |
| 191 | 4× | for m in milestones { |
| 192 | 0× | let n = open_cards.iter().filter(|c| c.milestone == m).count(); |
| 193 | 0× | let f = bs::forecast(&history, n, TRIALS, seed); |
| 194 | 0× | let at = |w: Option<usize>| w.map(|w| now + w as i64 * WEEK_MS); |
| 195 | 0× | timeline.push(TimelineRow { |
| 196 | 0× | name: m.clone(), |
| 197 | 0× | milestone: true, |
| 198 | 0× | start: None, |
| 199 | 0× | end: None, |
| 200 | 0× | due: due.get(&m).copied(), |
| 201 | 0× | forecast: at(f.p50_weeks).zip(at(f.p85_weeks)), |
| 202 | 0× | open: true, |
| 203 | 0× | }); |
| 204 | 0× | forecasts.push(row(format!("Milestone {m}"), n)); |
| 205 | | } |
| 206 | | Ok(Charts { |
| 207 | 12× | columns: columns.iter().map(|c| c.column.name.clone()).collect(), |
| 208 | 4× | flow_first, |
| 209 | 4× | flow, |
| 210 | 4× | cycle_median_days, |
| 211 | 4× | cycle_p85_days, |
| 212 | 4× | cycle_finished, |
| 213 | 4× | throughput, |
| 214 | 4× | throughput_from: now - HISTORY_WEEKS as i64 * WEEK_MS, |
| 215 | 4× | burn, |
| 216 | 4× | forecasts, |
| 217 | 4× | timeline, |
| 218 | | }) |
| 219 | 4× | } |
| 220 | |
|
| 221 | |
|
| 222 | |
|
| 223 | 1× | pub fn timeline_svg(rows: &[TimelineRow], now: i64) -> String { |
| 224 | 1× | let times: Vec<i64> = rows |
| 225 | 1× | .iter() |
| 226 | 3× | .flat_map(|r| [r.start, r.end, r.due, r.forecast.map(|f| f.0), r.forecast.map(|f| f.1)]) |
| 227 | 1× | .flatten() |
| 228 | 1× | .chain([now - 14 * DAY_MS, now + 14 * DAY_MS]) |
| 229 | 1× | .collect(); |
| 230 | 1× | let (lo, hi) = (*times.iter().min().unwrap_or(&now), *times.iter().max().unwrap_or(&now)); |
| 231 | 1× | let span = (hi - lo).max(DAY_MS) as f64; |
| 232 | | const LABEL: f64 = 150.0; |
| 233 | 12× | let x = |t: i64| LABEL + (t - lo) as f64 / span * (W as f64 - LABEL - 4.0); |
| 234 | 1× | let row_h = 22.0; |
| 235 | 1× | let h = rows.len() as f64 * row_h + 22.0; |
| 236 | 1× | 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 | 1× | rows.len(), |
| 239 | 1× | day_of(lo), |
| 240 | 1× | day_of(hi) |
| 241 | | ); |
| 242 | 3× | for (i, r) in rows.iter().enumerate() { |
| 243 | 3× | let y = i as f64 * row_h + 4.0; |
| 244 | 3× | let _ = write!(out, r#"<text class="axis" x="0" y="{:.1}">{}</text>"#, y + 13.0, esc(&r.name.chars().take(22).collect::<String>())); |
| 245 | 3× | if let (Some(a), Some(b)) = (r.start, r.end) { |
| 246 | 2× | let class = if r.open { "s-main" } else { "s-mute" }; |
| 247 | 2× | let _ = write!( |
| 248 | 2× | out, |
| 249 | | r#"<rect class="bar {class}" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: {} to {}</title></rect>"#, |
| 250 | 2× | x(a), |
| 251 | 2× | y + 3.0, |
| 252 | 2× | (x(b) - x(a)).max(2.0), |
| 253 | 2× | esc(&r.name), |
| 254 | 2× | day_of(a), |
| 255 | 2× | day_of(b - 1) |
| 256 | | ); |
| 257 | 1× | } |
| 258 | 3× | if let Some((a, b)) = r.forecast { |
| 259 | 1× | let _ = write!( |
| 260 | 1× | out, |
| 261 | 1× | r#"<rect class="bar s-warn forecast" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: done 50% by {}, 85% by {}</title></rect>"#, |
| 262 | 1× | x(a), |
| 263 | 1× | y + 3.0, |
| 264 | 1× | (x(b) - x(a)).max(3.0), |
| 265 | 1× | esc(&r.name), |
| 266 | 1× | day_of(a), |
| 267 | 1× | day_of(b) |
| 268 | 1× | ); |
| 269 | 2× | } |
| 270 | 3× | if let Some(d) = r.due { |
| 271 | 1× | let _ = write!( |
| 272 | 1× | out, |
| 273 | 1× | 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 | 1× | x(d), |
| 275 | 1× | y + 2.0, |
| 276 | 1× | esc(&r.name), |
| 277 | 1× | day_of(d) |
| 278 | 1× | ); |
| 279 | 2× | } |
| 280 | | } |
| 281 | 1× | 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 | 1× | let _ = write!(out, r#"<text class="axis" x="{LABEL}" y="{:.0}">{}</text>"#, h - 4.0, day_of(lo)); |
| 283 | 1× | let _ = write!(out, r#"<text class="axis" x="{:.1}" y="{:.0}" text-anchor="middle">today</text>"#, x(now), h - 4.0); |
| 284 | 1× | let _ = write!(out, r#"<text class="axis" x="{W}" y="{:.0}" text-anchor="end">{}</text></svg>"#, h - 4.0, day_of(hi)); |
| 285 | 1× | out |
| 286 | 1× | } |
| 287 | |
|
| 288 | |
|
| 289 | 2× | fn flow_svg(c: &Charts) -> String { |
| 290 | 2× | let bands = bs::flow_bands(&c.flow, W, H); |
| 291 | 2× | 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 | 2× | H + 18, |
| 294 | 2× | c.flow.len() |
| 295 | | ); |
| 296 | 6× | for (k, pts) in bands.iter().enumerate() { |
| 297 | 6× | let class = if k + 1 == bands.len() { 5 } else { k % 5 }; |
| 298 | 6× | let _ = write!(out, r#"<polygon class="band cf-{class}" points="{pts}"><title>{}</title></polygon>"#, esc(&c.columns[k])); |
| 299 | | } |
| 300 | 2× | let _ = write!(out, r#"<text class="axis" x="0" y="{}">{}</text>"#, H + 14, day_of(c.flow_first)); |
| 301 | 2× | let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">today</text></svg>"#, H + 14); |
| 302 | 2× | out |
| 303 | 2× | } |
| 304 | |
|
| 305 | |
|
| 306 | 2× | fn bars_svg(values: &[usize], label: &str) -> String { |
| 307 | 2× | let max = values.iter().copied().max().unwrap_or(0).max(1); |
| 308 | 2× | let step = W as f64 / values.len().max(1) as f64; |
| 309 | 2× | let mut out = format!(r#"<svg class="chart" viewBox="0 0 {W} {}" role="img" aria-label="{}">"#, H + 18, esc(label)); |
| 310 | 24× | for (i, v) in values.iter().enumerate() { |
| 311 | 24× | let h = (*v as f64 / max as f64) * H as f64; |
| 312 | 24× | let _ = write!( |
| 313 | 24× | out, |
| 314 | 24× | r#"<rect class="bar s-ok" x="{:.1}" y="{:.1}" width="{:.1}" height="{h:.1}"><title>{v} done</title></rect>"#, |
| 315 | 24× | i as f64 * step + step * 0.15, |
| 316 | 24× | H as f64 - h, |
| 317 | 24× | step * 0.7 |
| 318 | 24× | ); |
| 319 | 24× | } |
| 320 | 2× | let _ = write!(out, r#"<text class="axis" x="0" y="{}">{} weeks ago</text>"#, H + 14, values.len()); |
| 321 | 2× | let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">this week</text></svg>"#, H + 14); |
| 322 | 2× | out |
| 323 | 2× | } |
| 324 | |
|
| 325 | |
|
| 326 | 0× | fn burn_svg(days: &[(usize, usize)]) -> String { |
| 327 | 0× | let max = days.iter().map(|(_, s)| *s).max().unwrap_or(0).max(1); |
| 328 | 0× | let left: Vec<usize> = days.iter().map(|(l, _)| *l).collect(); |
| 329 | 0× | let scope: Vec<usize> = days.iter().map(|(_, s)| *s).collect(); |
| 330 | 0× | 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 | 0× | left.last().unwrap_or(&0), |
| 333 | 0× | scope.last().unwrap_or(&0), |
| 334 | 0× | days.len(), |
| 335 | 0× | bs::polyline(&scope, max, W, H), |
| 336 | 0× | bs::polyline(&left, max, W, H) |
| 337 | | ) |
| 338 | 0× | } |
| 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 | | |
| 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 | 2× | pub async fn show( |
| 364 | 2× | State(app): State<Shared>, |
| 365 | 2× | viewer: Viewer, |
| 366 | 2× | Path((owner, number)): Path<(String, i64)>, |
| 367 | 2× | Query(query): Query<ChartsQuery>, |
| 368 | 2× | ) -> Result<Response, Error> { |
| 369 | 2× | let shell = Shell::new(&app, &format!("/projects/{owner}/{number}/charts"), &viewer); |
| 370 | 2× | let e = |e| Error::from_db(shell.clone(), e); |
| 371 | 2× | let r = rights_of(&app, viewer.account(), &owner).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 372 | 2× | let project = pdb::get(&app.db, &owner, number) |
| 373 | 2× | .await |
| 374 | 2× | .map_err(e)? |
| 375 | 2× | .filter(|p| p.public || r.member) |
| 376 | 2× | .ok_or_else(|| Error::not_found(shell.clone()))?; |
| 377 | 2× | let now = db::now_ms(); |
| 378 | 2× | let q = query.q.unwrap_or_default(); |
| 379 | 2× | let c = compute(&app, viewer.account(), &project, &q, now).await.map_err(e)?; |
| 380 | 2× | let flow_table = c |
| 381 | 2× | .flow |
| 382 | 2× | .iter() |
| 383 | 2× | .enumerate() |
| 384 | 2× | .skip(c.flow.len().saturating_sub(14)) |
| 385 | 2× | .map(|(d, row)| (day_of(c.flow_first + d as i64 * DAY_MS), row.clone())) |
| 386 | 2× | .collect(); |
| 387 | 24× | let week_table = c.throughput.iter().enumerate().map(|(i, n)| (day_of(c.throughput_from + i as i64 * WEEK_MS), *n)).collect(); |
| 388 | 2× | let page = ChartsPage { |
| 389 | 2× | flow_svg: flow_svg(&c), |
| 390 | 2× | throughput_svg: bars_svg(&c.throughput, "Throughput: cards done each week"), |
| 391 | 2× | burn_svg: c.burn.as_ref().map(|(_, _, d)| burn_svg(d)).unwrap_or_default(), |
| 392 | 2× | timeline_svg: if c.timeline.is_empty() { String::new() } else { timeline_svg(&c.timeline, now) }, |
| 393 | 2× | views: pdb::views(&app.db, project.id).await.map_err(e)?, |
| 394 | 2× | shell, |
| 395 | 2× | project, |
| 396 | 2× | c, |
| 397 | 2× | flow_table, |
| 398 | 2× | week_table, |
| 399 | 2× | q, |
| 400 | | }; |
| 401 | 2× | Ok(Page(page).into_response()) |
| 402 | 2× | } |
| 403 | |
|
| 404 | | #[cfg(test)] |
| 405 | | mod tests { |
| 406 | | use super::*; |
| 407 | |
|
| 408 | | #[test] |
| 409 | 1× | fn the_timeline_draws_iterations_milestones_and_forecasts() { |
| 410 | 1× | let now = 1_000 * DAY_MS; |
| 411 | 1× | let rows = vec![ |
| 412 | 1× | TimelineRow { |
| 413 | 1× | name: "Sprint 1".into(), |
| 414 | 1× | milestone: false, |
| 415 | 1× | start: Some(now - 20 * DAY_MS), |
| 416 | 1× | end: Some(now - 6 * DAY_MS), |
| 417 | 1× | due: None, |
| 418 | 1× | forecast: None, |
| 419 | 1× | open: false, |
| 420 | 1× | }, |
| 421 | 1× | TimelineRow { |
| 422 | 1× | name: "Sprint 2".into(), |
| 423 | 1× | milestone: false, |
| 424 | 1× | start: Some(now - 6 * DAY_MS), |
| 425 | 1× | end: Some(now + 8 * DAY_MS), |
| 426 | 1× | due: None, |
| 427 | 1× | forecast: None, |
| 428 | 1× | open: true, |
| 429 | 1× | }, |
| 430 | 1× | TimelineRow { |
| 431 | 1× | name: "Release <1>".into(), |
| 432 | 1× | milestone: true, |
| 433 | 1× | start: None, |
| 434 | 1× | end: None, |
| 435 | 1× | due: Some(now + 10 * DAY_MS), |
| 436 | 1× | forecast: Some((now + 7 * DAY_MS, now + 21 * DAY_MS)), |
| 437 | 1× | open: true, |
| 438 | 1× | }, |
| 439 | | ]; |
| 440 | 1× | let svg = timeline_svg(&rows, now); |
| 441 | 1× | assert!(svg.starts_with("<svg class=\"chart timeline\"") && svg.ends_with("</svg>")); |
| 442 | 1× | assert_eq!(svg.matches("class=\"bar s-mute\"").count(), 1, "a past iteration"); |
| 443 | 1× | assert_eq!(svg.matches("class=\"bar s-main\"").count(), 1, "the open one"); |
| 444 | 1× | assert!(svg.contains("Release <1>: done 50% by") && svg.contains("class=\"line s-bad\""), "{svg}"); |
| 445 | 1× | assert!(svg.contains("aria-label=\"Timeline: 3 iterations and milestones"), "named for a screen reader"); |
| 446 | 1× | assert!(!svg.contains("style="), "coloured by the theme's classes only"); |
| 447 | 1× | } |
| 448 | | } |