Iterations on project boards: a named span of days with a burn-up of cards done; closing one archives its Done column's cards under it, its page lists them (closes #343) 046e3352cdfd
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ba395cb6f6ba
- When
- · 2026-10-27 17:48 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
046e3352cdfde9e656f0b864f482618b96e792c2c794cf756a2e85e9fc54e938- Depends on
- 18e381ea88c2 3b1bc27fe805 439ecd8708a1 657e7d5c9da9 6a306cd03fc1 7c572789349b a7e6e8e6094c b5668108487f c79a1111dd56 d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
- Depended on by
- 0bc95135954d 351cf57e5608
- In bookmarks
- main
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 27 atoms, 2 tree operations, 8 files
Browse the tree at this change
added crates/hub/migrations-pg/0100_board_iterations.sql +15 −0
| 1 | + | -- Iterations on project boards (oxygenhub#343): a named span of days. A |
|
| 2 | + | -- board has at most one open at a time; closing it archives the cards in |
|
| 3 | + | -- its Done column under it, so the board shows the current iteration and |
|
| 4 | + | -- each past one keeps its cards and its burn-up. |
|
| 5 | + | CREATE TABLE project_iterations ( |
|
| 6 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 7 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | start_ms BIGINT NOT NULL, |
|
| 10 | + | end_ms BIGINT NOT NULL, |
|
| 11 | + | closed_ms BIGINT |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX project_iterations_by_project ON project_iterations (project_id, start_ms); |
|
| 14 | + | -- The iteration a card was archived under when that iteration closed. |
|
| 15 | + | ALTER TABLE project_cards ADD COLUMN iteration_id BIGINT REFERENCES project_iterations(id) ON DELETE SET NULL; |
added crates/hub/migrations/0100_board_iterations.sql +15 −0
| 1 | + | -- Iterations on project boards (oxygenhub#343): a named span of days. A |
|
| 2 | + | -- board has at most one open at a time; closing it archives the cards in |
|
| 3 | + | -- its Done column under it, so the board shows the current iteration and |
|
| 4 | + | -- each past one keeps its cards and its burn-up. |
|
| 5 | + | CREATE TABLE project_iterations ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | start_ms INTEGER NOT NULL, |
|
| 10 | + | end_ms INTEGER NOT NULL, |
|
| 11 | + | closed_ms INTEGER |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX project_iterations_by_project ON project_iterations (project_id, start_ms); |
|
| 14 | + | -- The iteration a card was archived under when that iteration closed. |
|
| 15 | + | ALTER TABLE project_cards ADD COLUMN iteration_id INTEGER REFERENCES project_iterations(id) ON DELETE SET NULL; |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Iterations on project boards (#343).** A board can run an iteration: a name and its first and last days. The board shows it above the columns, day N of M and the cards done so far, with a burn-up of cards done per day. Closing it archives the cards in the Done column (the one that takes finished work, or else the last) under it; its page, linked from "past iterations", lists them, restorable, with its burn-up. One iteration is open at a time. Migration 0100. |
|
| 8 | + | ||
| 7 | 9 | - **`oh` on Haiku (#344).** `oh` builds and runs on Haiku R1/beta6 (x86_64); `oh-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.9.9, with its own `SHA256SUMS-haiku`, and the README says how to install it. |
|
| 8 | 10 | ||
| 9 | 11 | ## 0.9.9 (2026-10-27) |
|
| … | 370 unchanged lines |
modified crates/hub/src/db/projects.rs +118 −1
| … | 44 unchanged lines |
||
| 45 | 45 | pub archived_ms: Option<i64>, |
|
| 46 | 46 | /// When it joined its column. |
|
| 47 | 47 | pub moved_ms: i64, |
|
| 48 | + | /// The iteration it was archived under when that closed (#343). |
|
| 49 | + | pub iteration_id: Option<i64>, |
|
| 48 | 50 | } |
|
| 49 | 51 | ||
| 52 | + | /// A named span of a board's days (#343). A board has one open at most; |
|
| 53 | + | /// closing it archives its Done column's cards under it. |
|
| 54 | + | #[derive(Clone, Debug, FromRow)] |
|
| 55 | + | pub struct Iteration { |
|
| 56 | + | pub id: i64, |
|
| 57 | + | pub project_id: i64, |
|
| 58 | + | pub name: String, |
|
| 59 | + | pub start_ms: i64, |
|
| 60 | + | /// The end, exclusive: the midnight after its last day. |
|
| 61 | + | pub end_ms: i64, |
|
| 62 | + | pub closed_ms: Option<i64>, |
|
| 63 | + | } |
|
| 64 | + | ||
| 50 | 65 | /// A day, for the archive's ages. |
|
| 51 | 66 | const DAY_MS: i64 = 86_400_000; |
|
| 52 | 67 | ||
| … | 282 unchanged lines |
||
| 335 | 350 | .fetch_one(&mut *tx) |
|
| 336 | 351 | .await?; |
|
| 337 | 352 | let now = now_ms(); |
|
| 353 | + | // Back on the board, it no longer counts as done in a past iteration. |
|
| 338 | 354 | let r = sqlx::query( |
|
| 339 | - | "UPDATE project_cards SET archived_ms = NULL, moved_ms = $1, position = $2 WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL", |
|
| 355 | + | "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \ |
|
| 356 | + | WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL", |
|
| 340 | 357 | ) |
|
| 341 | 358 | .bind(now) |
|
| 342 | 359 | .bind(pos) |
|
| … | 9 unchanged lines |
||
| 352 | 369 | /// Whether `card` joined its column more than `days` ago, at `now`. |
|
| 353 | 370 | pub fn older_than(card: &Card, days: i64, now: i64) -> bool { |
|
| 354 | 371 | card.moved_ms < now - days * DAY_MS |
|
| 372 | + | } |
|
| 373 | + | ||
| 374 | + | /// A board's iterations, newest first (#343). |
|
| 375 | + | pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> { |
|
| 376 | + | sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC") |
|
| 377 | + | .bind(project_id) |
|
| 378 | + | .fetch_all(pool) |
|
| 379 | + | .await |
|
| 380 | + | } |
|
| 381 | + | ||
| 382 | + | /// Start an iteration of `[start_ms, end_ms)`; `None` while another is open. |
|
| 383 | + | pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> { |
|
| 384 | + | let mut tx = super::begin_write(pool).await?; |
|
| 385 | + | let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL") |
|
| 386 | + | .bind(project_id) |
|
| 387 | + | .fetch_one(&mut *tx) |
|
| 388 | + | .await?; |
|
| 389 | + | if open > 0 { |
|
| 390 | + | return Ok(None); |
|
| 391 | + | } |
|
| 392 | + | let it: Iteration = |
|
| 393 | + | sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *") |
|
| 394 | + | .bind(project_id) |
|
| 395 | + | .bind(name) |
|
| 396 | + | .bind(start_ms) |
|
| 397 | + | .bind(end_ms) |
|
| 398 | + | .fetch_one(&mut *tx) |
|
| 399 | + | .await?; |
|
| 400 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?; |
|
| 401 | + | tx.commit().await?; |
|
| 402 | + | Ok(Some(it)) |
|
| 403 | + | } |
|
| 404 | + | ||
| 405 | + | /// The column whose cards are done: the one that takes finished work, or |
|
| 406 | + | /// else the last. |
|
| 407 | + | pub fn done_column(columns: &[Column]) -> Option<i64> { |
|
| 408 | + | columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id) |
|
| 409 | + | } |
|
| 410 | + | ||
| 411 | + | /// Close an open iteration: the cards on the board in `done` (its Done |
|
| 412 | + | /// column) are archived under it. How many, or `None` when it was not open. |
|
| 413 | + | pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> { |
|
| 414 | + | let mut tx = super::begin_write(pool).await?; |
|
| 415 | + | let now = now_ms(); |
|
| 416 | + | let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL") |
|
| 417 | + | .bind(now) |
|
| 418 | + | .bind(iteration_id) |
|
| 419 | + | .bind(project_id) |
|
| 420 | + | .execute(&mut *tx) |
|
| 421 | + | .await?; |
|
| 422 | + | if closed.rows_affected() == 0 { |
|
| 423 | + | return Ok(None); |
|
| 424 | + | } |
|
| 425 | + | let mut n = 0; |
|
| 426 | + | if let Some(done) = done { |
|
| 427 | + | n = sqlx::query( |
|
| 428 | + | "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL", |
|
| 429 | + | ) |
|
| 430 | + | .bind(now) |
|
| 431 | + | .bind(iteration_id) |
|
| 432 | + | .bind(project_id) |
|
| 433 | + | .bind(done) |
|
| 434 | + | .execute(&mut *tx) |
|
| 435 | + | .await? |
|
| 436 | + | .rows_affected(); |
|
| 437 | + | } |
|
| 438 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?; |
|
| 439 | + | tx.commit().await?; |
|
| 440 | + | Ok(Some(n)) |
|
| 441 | + | } |
|
| 442 | + | ||
| 443 | + | /// When each card done in `it` reached the Done column `done`: those |
|
| 444 | + | /// archived under it, and while it is open those in `done` now or archived |
|
| 445 | + | /// from there during it. |
|
| 446 | + | pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> { |
|
| 447 | + | sqlx::query_scalar( |
|
| 448 | + | "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \ |
|
| 449 | + | OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms", |
|
| 450 | + | ) |
|
| 451 | + | .bind(it.project_id) |
|
| 452 | + | .bind(it.id) |
|
| 453 | + | .bind(it.closed_ms.is_none()) |
|
| 454 | + | .bind(done.unwrap_or(-1)) |
|
| 455 | + | .bind(it.start_ms) |
|
| 456 | + | .fetch_all(pool) |
|
| 457 | + | .await |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | /// The burn-up of an iteration: cards done by the end of each of its days |
|
| 461 | + | /// so far, from `times` (when each reached Done; earlier than the start |
|
| 462 | + | /// counts on the first day). One entry per day up to `now` or its end. |
|
| 463 | + | pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> { |
|
| 464 | + | let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1); |
|
| 465 | + | let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days); |
|
| 466 | + | (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect() |
|
| 467 | + | } |
|
| 468 | + | ||
| 469 | + | /// An iteration's length in days. |
|
| 470 | + | pub fn iteration_days(it: &Iteration) -> i64 { |
|
| 471 | + | ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1) |
|
| 355 | 472 | } |
|
| 356 | 473 | ||
| 357 | 474 | /// Archive a column's cards this many days after they join it; `None` |
|
| … | 139 unchanged lines |
modified crates/hub/src/web/projects.rs +138 −2
| … | 221 unchanged lines |
||
| 222 | 222 | /// The archived cards instead (`?archived=1`). |
|
| 223 | 223 | #[serde(default)] |
|
| 224 | 224 | archived: Option<String>, |
|
| 225 | + | /// A past iteration: the cards archived under it, and its burn-up (#343). |
|
| 226 | + | #[serde(default)] |
|
| 227 | + | iteration: Option<i64>, |
|
| 225 | 228 | } |
|
| 226 | 229 | ||
| 227 | 230 | impl BoardQuery { |
|
| … | 53 unchanged lines |
||
| 281 | 284 | col.total = col.cards.len(); |
|
| 282 | 285 | if !filter.is_empty() { |
|
| 283 | 286 | col.cards.retain(|c| filter.keeps(c)); |
|
| 284 | - | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() { |
|
| 287 | + | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() { |
|
| 285 | 288 | // Cards join a column at its end: the newest are the last. |
|
| 286 | 289 | let cut = col.cards.len() - COLUMN_SHOWN; |
|
| 287 | 290 | col.cards.drain(..cut); |
|
| 288 | 291 | } |
|
| 289 | 292 | col.hidden = col.total - col.cards.len(); |
|
| 290 | 293 | } |
|
| 294 | + | } |
|
| 295 | + | ||
| 296 | + | /// An iteration as the board shows it (#343). |
|
| 297 | + | pub struct IterationView { |
|
| 298 | + | pub it: pdb::Iteration, |
|
| 299 | + | /// Its first and last days, YYYY-MM-DD. |
|
| 300 | + | pub first: String, |
|
| 301 | + | pub last: String, |
|
| 302 | + | pub days: i64, |
|
| 303 | + | /// Its days so far (all of them once it has ended). |
|
| 304 | + | pub so_far: usize, |
|
| 305 | + | /// Cards done in it so far. |
|
| 306 | + | pub done: usize, |
|
| 307 | + | /// The burn-up as SVG polyline points in a [`BURN_W`] × [`BURN_H`] box, |
|
| 308 | + | /// one point per day so far; `top` is the count at the top of the box. |
|
| 309 | + | pub points: String, |
|
| 310 | + | pub top: usize, |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | pub const BURN_W: i64 = 280; |
|
| 314 | + | pub const BURN_H: i64 = 64; |
|
| 315 | + | ||
| 316 | + | /// How many past iterations the board lists. |
|
| 317 | + | const PAST_SHOWN: usize = 12; |
|
| 318 | + | ||
| 319 | + | const DAY_MS: i64 = 86_400_000; |
|
| 320 | + | ||
| 321 | + | /// `YYYY-MM-DD` to the midnight (UTC) that starts it. |
|
| 322 | + | fn day_ms(s: &str) -> Option<i64> { |
|
| 323 | + | let mut parts = s.trim().splitn(3, '-').map(|p| p.parse::<i32>().ok()); |
|
| 324 | + | let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??); |
|
| 325 | + | let month = time::Month::try_from(u8::try_from(m).ok()?).ok()?; |
|
| 326 | + | let date = time::Date::from_calendar_date(y, month, u8::try_from(d).ok()?).ok()?; |
|
| 327 | + | Some(date.midnight().assume_utc().unix_timestamp() * 1000) |
|
| 328 | + | } |
|
| 329 | + | ||
| 330 | + | /// The day (UTC) holding `ms`, as `YYYY-MM-DD`. |
|
| 331 | + | fn day_of(ms: i64) -> String { |
|
| 332 | + | let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
|
| 333 | + | format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) |
|
| 291 | 334 | } |
|
| 292 | 335 | ||
| 336 | + | async fn iteration_view(app: &Shared, it: pdb::Iteration, done: Option<i64>, now: i64) -> sqlx::Result<IterationView> { |
|
| 337 | + | let times = pdb::done_times(&app.db, &it, done).await?; |
|
| 338 | + | let days = pdb::iteration_days(&it); |
|
| 339 | + | let up = pdb::burn_up(×, it.start_ms, it.end_ms, it.closed_ms.unwrap_or(now)); |
|
| 340 | + | let top = up.last().copied().unwrap_or(0).max(1); |
|
| 341 | + | // From nothing at the start to each day's end. |
|
| 342 | + | let points = std::iter::once((0, 0)) |
|
| 343 | + | .chain(up.iter().enumerate().map(|(d, &n)| (d as i64 + 1, n))) |
|
| 344 | + | .map(|(d, n)| format!("{},{}", d * BURN_W / days, BURN_H - n as i64 * BURN_H / top as i64)) |
|
| 345 | + | .collect::<Vec<_>>() |
|
| 346 | + | .join(" "); |
|
| 347 | + | Ok(IterationView { |
|
| 348 | + | first: day_of(it.start_ms), |
|
| 349 | + | last: day_of(it.end_ms - 1), |
|
| 350 | + | days, |
|
| 351 | + | so_far: up.len(), |
|
| 352 | + | done: times.len(), |
|
| 353 | + | points, |
|
| 354 | + | top, |
|
| 355 | + | it, |
|
| 356 | + | }) |
|
| 357 | + | } |
|
| 358 | + | ||
| 293 | 359 | #[derive(Template)] |
|
| 294 | 360 | #[template(path = "project.html")] |
|
| 295 | 361 | pub struct BoardPage { |
|
| … | 19 unchanged lines |
||
| 315 | 381 | archived: bool, |
|
| 316 | 382 | /// Archived cards on the whole board. |
|
| 317 | 383 | archived_total: usize, |
|
| 384 | + | /// The open iteration, the past ones (newest first), and the one the |
|
| 385 | + | /// page shows the cards of (#343). |
|
| 386 | + | current: Option<IterationView>, |
|
| 387 | + | past: Vec<IterationView>, |
|
| 388 | + | shown_iteration: Option<IterationView>, |
|
| 389 | + | /// A new iteration's suggested first and last days: today and 13 days on. |
|
| 390 | + | next_first: String, |
|
| 391 | + | next_last: String, |
|
| 318 | 392 | } |
|
| 319 | 393 | ||
| 320 | 394 | #[allow(clippy::too_many_arguments)] |
|
| … | 14 unchanged lines |
||
| 335 | 409 | .map_err(e)? |
|
| 336 | 410 | .filter(|p| p.public || r.member) |
|
| 337 | 411 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 338 | - | let archived = view.archived(); |
|
| 412 | + | let now = db::now_ms(); |
|
| 413 | + | let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?; |
|
| 414 | + | let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned()); |
|
| 415 | + | // A past iteration's page is the archive of what was done in it. |
|
| 416 | + | let archived = view.archived() || shown.is_some(); |
|
| 339 | 417 | let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?; |
|
| 418 | + | if let Some(it) = &shown { |
|
| 419 | + | for col in columns.iter_mut() { |
|
| 420 | + | col.cards.retain(|c| c.card.iteration_id == Some(it.id)); |
|
| 421 | + | } |
|
| 422 | + | } |
|
| 340 | 423 | shape(&mut columns, view); |
|
| 341 | 424 | let archived_total = columns.iter().map(|c| c.archived).sum(); |
|
| 425 | + | let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>()); |
|
| 426 | + | let (mut current, mut past) = (None, Vec::new()); |
|
| 427 | + | for it in iterations { |
|
| 428 | + | if it.closed_ms.is_none() { |
|
| 429 | + | current = Some(iteration_view(app, it, done, now).await.map_err(e)?); |
|
| 430 | + | } else if past.len() < PAST_SHOWN { |
|
| 431 | + | past.push(iteration_view(app, it, done, now).await.map_err(e)?); |
|
| 432 | + | } |
|
| 433 | + | } |
|
| 434 | + | let shown_iteration = match shown { |
|
| 435 | + | Some(it) => Some(iteration_view(app, it, done, now).await.map_err(e)?), |
|
| 436 | + | None => None, |
|
| 437 | + | }; |
|
| 342 | 438 | let can_edit = r.edit && !project.closed; |
|
| 343 | 439 | let mut transfer_to = Vec::new(); |
|
| 344 | 440 | if let (true, Some(a)) = (r.edit, viewer.account()) { |
|
| … | 19 unchanged lines |
||
| 364 | 460 | filtered, |
|
| 365 | 461 | archived, |
|
| 366 | 462 | archived_total, |
|
| 463 | + | current, |
|
| 464 | + | past, |
|
| 465 | + | shown_iteration, |
|
| 466 | + | next_first: day_of(now), |
|
| 467 | + | next_last: day_of(now + 13 * DAY_MS), |
|
| 367 | 468 | })) |
|
| 368 | 469 | } |
|
| 369 | 470 | ||
| … | 163 unchanged lines |
||
| 533 | 634 | /// Days, for archiving a column's older cards (#293); empty is never. |
|
| 534 | 635 | #[serde(default)] |
|
| 535 | 636 | days: String, |
|
| 637 | + | /// The iteration to close, and a new one's first and last days as |
|
| 638 | + | /// YYYY-MM-DD (#343). |
|
| 639 | + | #[serde(default)] |
|
| 640 | + | iteration: Option<i64>, |
|
| 641 | + | #[serde(default)] |
|
| 642 | + | start: String, |
|
| 643 | + | #[serde(default)] |
|
| 644 | + | end: String, |
|
| 536 | 645 | } |
|
| 537 | 646 | ||
| 538 | 647 | /// `repo#12` or `#12` (with a repository chosen) to (repository, number). |
|
| … | 179 unchanged lines |
||
| 718 | 827 | let detail = format!("{n} card(s) of {name} older than {days} days"); |
|
| 719 | 828 | db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); |
|
| 720 | 829 | notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" })); |
|
| 830 | + | } |
|
| 831 | + | } |
|
| 832 | + | "start_iteration" => { |
|
| 833 | + | let name: String = f.name.trim().chars().take(60).collect(); |
|
| 834 | + | match (day_ms(&f.start), day_ms(&f.end)) { |
|
| 835 | + | _ if name.is_empty() => error = Some("Name the iteration.".into()), |
|
| 836 | + | (Some(start), Some(last)) if last >= start && last - start < 366 * DAY_MS => { |
|
| 837 | + | match pdb::start_iteration(&app.db, project.id, &name, start, last + DAY_MS).await.map_err(e)? { |
|
| 838 | + | Some(_) => notice = Some(format!("{name} started.")), |
|
| 839 | + | None => error = Some("Close the open iteration before starting another.".into()), |
|
| 840 | + | } |
|
| 841 | + | } |
|
| 842 | + | (Some(_), Some(_)) => error = Some("An iteration ends on or after its first day, within a year.".into()), |
|
| 843 | + | _ => error = Some("Give the first and last days as dates.".into()), |
|
| 844 | + | } |
|
| 845 | + | } |
|
| 846 | + | "close_iteration" => { |
|
| 847 | + | let Some(id) = f.iteration else { return Err(Error::bad(shell, "no iteration")) }; |
|
| 848 | + | match pdb::close_iteration(&app.db, project.id, id, pdb::done_column(&columns)).await.map_err(e)? { |
|
| 849 | + | Some(n) => { |
|
| 850 | + | let detail = format!("iteration {id}: {n} done card(s) archived"); |
|
| 851 | + | db::accounts::audit(&app.db, viewer.account(), "project.iteration_closed", &format!("{owner}/{number}"), &detail) |
|
| 852 | + | .await |
|
| 853 | + | .ok(); |
|
| 854 | + | notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" })); |
|
| 855 | + | } |
|
| 856 | + | None => error = Some("That iteration is not open.".into()), |
|
| 721 | 857 | } |
|
| 722 | 858 | } |
|
| 723 | 859 | "add_column" => { |
|
| … | 199 unchanged lines |
modified crates/hub/tests/issues.rs +92 −0
| … | 1217 unchanged lines |
||
| 1218 | 1218 | assert!(page.contains("value=\"finished\" selected"), "the column says so"); |
|
| 1219 | 1219 | } |
|
| 1220 | 1220 | ||
| 1221 | + | /// #343: a board's iteration shows its days and burn-up; one is open at a |
|
| 1222 | + | /// time; closing it archives the Done column's cards under it, and its page |
|
| 1223 | + | /// shows them; a card restored from it no longer counts as done in it. |
|
| 1221 | 1224 | #[tokio::test(flavor = "multi_thread")] |
|
| 1225 | + | async fn board_iterations_close_into_the_archive() { |
|
| 1226 | + | let h = hub().await; |
|
| 1227 | + | let app = &h.app; |
|
| 1228 | + | let andy = register(app, "andy").await; |
|
| 1229 | + | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 1230 | + | post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await; |
|
| 1231 | + | let column = |name: &'static str| async move { |
|
| 1232 | + | sqlx::query_scalar::<_, i64>("SELECT id FROM project_columns WHERE name = $1").bind(name).fetch_one(&app.db).await.unwrap() |
|
| 1233 | + | }; |
|
| 1234 | + | let (backlog, done) = (column("Backlog").await, column("Done").await); |
|
| 1235 | + | let project: i64 = sqlx::query_scalar("SELECT id FROM projects").fetch_one(&app.db).await.unwrap(); |
|
| 1236 | + | let day = 86_400_000i64; |
|
| 1237 | + | let now = db::now_ms(); |
|
| 1238 | + | for (i, (note, col, age)) in [("done-a", done, 2), ("done-b", done, 0), ("todo-c", backlog, 5)].into_iter().enumerate() { |
|
| 1239 | + | sqlx::query( |
|
| 1240 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms, moved_ms) VALUES ($1, $2, $3, 'note', $4, 0, $5)", |
|
| 1241 | + | ) |
|
| 1242 | + | .bind(project) |
|
| 1243 | + | .bind(col) |
|
| 1244 | + | .bind(i as i64) |
|
| 1245 | + | .bind(note) |
|
| 1246 | + | .bind(now - age * day) |
|
| 1247 | + | .execute(&app.db) |
|
| 1248 | + | .await |
|
| 1249 | + | .unwrap(); |
|
| 1250 | + | } |
|
| 1251 | + | let date = |ms: i64| { |
|
| 1252 | + | let t = time::OffsetDateTime::from_unix_timestamp(ms / 1000).unwrap(); |
|
| 1253 | + | format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) |
|
| 1254 | + | }; |
|
| 1255 | + | let board = "/projects/andy/1"; |
|
| 1256 | + | let (who, token) = (&andy, &csrf); |
|
| 1257 | + | let act = |body: String| async move { post(app, board, Some(who), &format!("_csrf={token}&{body}")).await }; |
|
| 1258 | + | ||
| 1259 | + | // No iteration yet: the board offers to start one. |
|
| 1260 | + | assert!(get(app, board, Some(&andy)).await.body.contains("start an iteration")); |
|
| 1261 | + | let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end=nonsense", date(now))).await; |
|
| 1262 | + | assert!(r.body.contains("Give the first and last days as dates."), "{}", r.body); |
|
| 1263 | + | let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end={}", date(now), date(now - 2 * day))).await; |
|
| 1264 | + | assert!(r.body.contains("ends on or after its first day"), "{}", r.body); |
|
| 1265 | + | let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end={}", date(now - 3 * day), date(now + 10 * day))).await; |
|
| 1266 | + | assert!(r.body.contains("Sprint 1 started."), "{}", r.body); |
|
| 1267 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1268 | + | assert!(page.contains("day 4 of 14 · 2 cards done"), "{page}"); |
|
| 1269 | + | // Four days so far of fourteen (20 units each): done-a reached Done on |
|
| 1270 | + | // the second, done-b today. |
|
| 1271 | + | assert!(page.contains("<polyline points=\"0,64 20,64 40,32 60,32 80,0\""), "{page}"); |
|
| 1272 | + | assert!(page.contains("value=\"close_iteration\"")); |
|
| 1273 | + | let r = act(format!("action=start_iteration&name=Sprint+2&start={}&end={}", date(now), date(now + day))).await; |
|
| 1274 | + | assert!(r.body.contains("Close the open iteration before starting another."), "{}", r.body); |
|
| 1275 | + | ||
| 1276 | + | // Closing it archives what is done; the rest stays on the board. |
|
| 1277 | + | let it: i64 = sqlx::query_scalar("SELECT id FROM project_iterations").fetch_one(&app.db).await.unwrap(); |
|
| 1278 | + | let r = act(format!("action=close_iteration&iteration={it}")).await; |
|
| 1279 | + | assert!(r.body.contains("Closed: 2 done cards archived under it."), "{}", r.body); |
|
| 1280 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1281 | + | assert!(!page.contains("done-a") && !page.contains("done-b") && page.contains("todo-c"), "{page}"); |
|
| 1282 | + | assert!(page.contains(&format!("?iteration={it}\">Sprint 1</a>")) && page.contains("start an iteration"), "{page}"); |
|
| 1283 | + | let r = act(format!("action=close_iteration&iteration={it}")).await; |
|
| 1284 | + | assert!(r.body.contains("That iteration is not open."), "{}", r.body); |
|
| 1285 | + | ||
| 1286 | + | // Its page: the cards done in it, restorable, with its burn-up. |
|
| 1287 | + | let past = get(app, &format!("{board}?iteration={it}"), Some(&andy)).await.body; |
|
| 1288 | + | assert!(past.contains("done-a") && past.contains("done-b") && !past.contains("todo-c"), "{past}"); |
|
| 1289 | + | assert!(past.contains("2 cards done, archived when it closed") && past.contains("class=\"burnup\""), "{past}"); |
|
| 1290 | + | let a: i64 = sqlx::query_scalar("SELECT id FROM project_cards WHERE note = 'done-a'").fetch_one(&app.db).await.unwrap(); |
|
| 1291 | + | act(format!("action=restore&card={a}")).await; |
|
| 1292 | + | let past = get(app, &format!("{board}?iteration={it}"), Some(&andy)).await.body; |
|
| 1293 | + | assert!(!past.contains("done-a") && past.contains("1 card done"), "{past}"); |
|
| 1294 | + | assert!(get(app, board, Some(&andy)).await.body.contains("done-a"), "back on the board"); |
|
| 1295 | + | } |
|
| 1296 | + | ||
| 1297 | + | /// The burn-up counts cards done by the end of each day so far (#343). |
|
| 1298 | + | #[test] |
|
| 1299 | + | fn a_burn_up_counts_each_days_done_cards() { |
|
| 1300 | + | use oxygenhub::db::projects::burn_up; |
|
| 1301 | + | let day = 86_400_000i64; |
|
| 1302 | + | let start = 10 * day; |
|
| 1303 | + | // Done before the start, on day one, twice on day three; the iteration |
|
| 1304 | + | // has five days and it is midway through the fourth. |
|
| 1305 | + | let times = [start - day, start + 1000, start + 2 * day + 5, start + 2 * day + 9]; |
|
| 1306 | + | assert_eq!(burn_up(×, start, start + 5 * day, start + 3 * day + day / 2), [2, 2, 4, 4]); |
|
| 1307 | + | // After its end: every day, no more. |
|
| 1308 | + | assert_eq!(burn_up(×, start, start + 5 * day, start + 40 * day).len(), 5); |
|
| 1309 | + | // Before its start: nothing yet. |
|
| 1310 | + | assert!(burn_up(×, start, start + 5 * day, start - day).is_empty()); |
|
| 1311 | + | } |
|
| 1312 | + | ||
| 1313 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1222 | 1314 | async fn issue_markdown_follows_the_default_bookmark() { |
|
| 1223 | 1315 | let h = hub().await; |
|
| 1224 | 1316 | let app = &h.app; |
|
| … | 298 unchanged lines |
modified templates/project.html +27 −2
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}{{ project.title }} · Projects · {{ project.owner }}{% endblock %} |
|
| 3 | 3 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% macro burnup(v) %}<svg class="burnup" viewBox="0 0 {{ crate::web::projects::BURN_W }} {{ crate::web::projects::BURN_H }}" preserveAspectRatio="none" role="img" aria-label="Burn-up: {{ v.done }} card{% if v.done != 1 %}s{% endif %} done over {{ v.so_far }} of {{ v.days }} days"><line x1="0" y1="{{ crate::web::projects::BURN_H }}" x2="{{ crate::web::projects::BURN_W }}" y2="{{ crate::web::projects::BURN_H }}"></line><polyline points="{{ v.points }}"></polyline></svg>{% endmacro %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>{{ project.title }} <small><a href="/projects/{{ project.owner }}">{{ project.owner }}'s projects</a> · #{{ project.number }}{% if !project.public %} · private{% endif %}{% if project.closed %} · closed{% endif %}</small></h1> |
|
| 6 | 7 | {% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div> |
|
| 7 | 8 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | 9 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | - | {% if archived %}<p class="note board-archive-note">Archived cards: off the board, kept and searchable{% if can_edit %}; restore one to put it back at the end of its column{% endif %}. <a href="/projects/{{ project.owner }}/{{ project.number }}">Back to the board</a></p>{% endif %} |
|
| 10 | - | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">{% if archived %}<input type="hidden" name="archived" value="1">{% endif %} |
|
| 10 | + | {% if let Some(v) = shown_iteration %} |
|
| 11 | + | <section class="iteration past" aria-label="Iteration {{ v.it.name }}"> |
|
| 12 | + | <div><h2>{{ v.it.name }}</h2><p class="small mute">{{ v.first }} to {{ v.last }} · {{ v.done }} card{% if v.done != 1 %}s{% endif %} done, archived when it closed{% if can_edit %}; restore one to put it back on the board{% endif %} · <a href="/projects/{{ project.owner }}/{{ project.number }}">back to the board</a></p></div> |
|
| 13 | + | {% call burnup(v) %} |
|
| 14 | + | </section> |
|
| 15 | + | {% else if archived %}<p class="note board-archive-note">Archived cards: off the board, kept and searchable{% if can_edit %}; restore one to put it back at the end of its column{% endif %}. <a href="/projects/{{ project.owner }}/{{ project.number }}">Back to the board</a></p> |
|
| 16 | + | {% else %} |
|
| 17 | + | <section class="iteration" aria-label="Iteration"> |
|
| 18 | + | {% if let Some(v) = current %} |
|
| 19 | + | <div><h2>{{ v.it.name }}</h2><p class="small mute">{{ v.first }} to {{ v.last }} · day {{ v.so_far }} of {{ v.days }} · {{ v.done }} card{% if v.done != 1 %}s{% endif %} done</p> |
|
| 20 | + | {% if can_edit %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="iteration" value="{{ v.it.id }}"><button name="action" value="close_iteration" class="small" data-confirm="Close {{ v.it.name }}? The cards in the Done column are archived under it.">CLOSE ITERATION</button></form>{% endif %}</div> |
|
| 21 | + | {% call burnup(v) %} |
|
| 22 | + | {% else if can_edit %} |
|
| 23 | + | <details class="iteration-new"><summary class="small">start an iteration</summary> |
|
| 24 | + | <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="start_iteration"> |
|
| 25 | + | <label for="it-name">Name</label><input type="text" id="it-name" name="name" maxlength="60" placeholder="Sprint 1" required> |
|
| 26 | + | <div class="inline-fields"><label>First day <input type="date" name="start" value="{{ next_first }}" required></label><label>Last day <input type="date" name="end" value="{{ next_last }}" required></label></div> |
|
| 27 | + | <p class="small mute">Closing it archives the cards in the Done column under it.</p> |
|
| 28 | + | <button type="submit" class="small">START</button> |
|
| 29 | + | </form> |
|
| 30 | + | </details> |
|
| 31 | + | {% endif %} |
|
| 32 | + | {% if !past.is_empty() %}<details class="iteration-past"><summary class="small">past iterations ({{ past.len() }})</summary><ul>{% for v in past %}<li><a href="/projects/{{ project.owner }}/{{ project.number }}?iteration={{ v.it.id }}">{{ v.it.name }}</a> <span class="small mute">{{ v.first }} to {{ v.last }} · {{ v.done }} done</span></li>{% endfor %}</ul></details>{% endif %} |
|
| 33 | + | </section> |
|
| 34 | + | {% endif %} |
|
| 35 | + | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">{% if let Some(v) = shown_iteration %}<input type="hidden" name="iteration" value="{{ v.it.id }}">{% else if archived %}<input type="hidden" name="archived" value="1">{% endif %} |
|
| 11 | 36 | <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, label:bug, assignee:andy" title="Words in the title or reference; label:NAME, assignee:NAME and state:STATE as on the issue list"> |
|
| 12 | 37 | <label class="visually-hidden" for="board-state">State</label><select id="board-state" name="state"><option value="">any state</option>{% for s in ["open", "closed", "landed", "draft"] %}<option{% if state == *s %} selected{% endif %}>{{ s }}</option>{% endfor %}</select> |
|
| 13 | 38 | <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}{% if archived %}?archived=1{% endif %}">clear</a>{% endif %} |
|
| … | 80 unchanged lines |
modified templates/style.css +11 −0
| … | 388 unchanged lines |
||
| 389 | 389 | .col-archives { margin: 0 0 6px; } |
|
| 390 | 390 | .board-archived-link { margin-left: auto; } |
|
| 391 | 391 | .col-archive { margin-top: 8px; } |
|
| 392 | + | /* A board's iteration (#343): its name and days beside its burn-up. */ |
|
| 393 | + | .iteration { display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; margin: 0 0 10px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); } |
|
| 394 | + | .iteration h2 { font-size: 14px; margin: 0 0 2px; } |
|
| 395 | + | .iteration p { margin: 0 0 4px; } |
|
| 396 | + | .iteration > div { min-width: 0; flex: 1 1 16em; } |
|
| 397 | + | .iteration .burnup { flex: 0 1 280px; width: 280px; max-width: 100%; height: 64px; } |
|
| 398 | + | .burnup line { stroke: var(--line); stroke-width: 1; } |
|
| 399 | + | .burnup polyline { fill: none; stroke: var(--acc); stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; } |
|
| 400 | + | .iteration-new form { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; max-width: 26em; } |
|
| 401 | + | .iteration-new .inline-fields { display: flex; flex-wrap: wrap; gap: 8px; } |
|
| 402 | + | .iteration-past ul { margin: 6px 0 0; padding-left: 18px; } |
|
| 392 | 403 | .col-archive .inline-fields { align-items: center; } |
|
| 393 | 404 | .col-archive input[type=number] { width: 6em; margin: 0; } |
|
| 394 | 405 | .refs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; max-width: 100%; } |
|
| … | 1076 unchanged lines |