OXYGENHUB

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(&times, 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(&times, 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(&times, start, start + 5 * day, start + 40 * day).len(), 5);
1309 +
    // Before its start: nothing yet.
1310 +
    assert!(burn_up(&times, 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