Boards keep the last fortnight of finished work (refs #293) 946fe7a09faa
Andy (2026-10-25): a board stacking every done card grows without end. By default a column now archives its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, hourly; open work and notes stay. A column can archive every card after N days, or never. Archived cards stay findable and restorable on the archive view.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 4b543e8d54e9
- When
- · 2026-10-25 21:51 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
946fe7a09faac0e57edbde6c7e68444d0ada282ddf8658f94d9a2f59bba4bb5f- Depends on
- 18e381ea88c2 439ecd8708a1 456f4197ade3 8bb7baeb4557 93be9ce4653e a7e6e8e6094c b5668108487f e1ec9fbf538e
- Depended on by
- 0bc95135954d e4203e141e90 a3ba7cc10673
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- 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 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 24 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Boards keep the last fortnight of finished work (#293):** by default every column archives its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, hourly, so a Done column stops growing however long a project runs; open work and notes stay. A column can instead archive every card after N days, or never ("Archive automatically" in its menu). Archived cards stay on the board's archive view, searchable and restorable. On the next hourly pass, boards with years of Done cards (Super Mario Land 2's 327) shrink to their last two weeks. |
|
| 7 | 8 | - **An issue's references: no line through the chips, each change once (#318):** an opened "referenced by N changes" box drew the proposal timeline's vertical rule through its first column (the events shared its class); and a change was recorded again for every bookmark it was pushed on, and for every mention in its message, so one change filled several chips. Now one reference per change per issue or proposal, and the page shows each change once, including those recorded before. |
|
| 8 | 9 | - **Claims say how long a job is expected to take (oxygenflow#76):** `expected_ms`, the median of the job's last five passing runs in the repository (reused passes left out), so a runner leaving with notice finishes a job that fits it and hands back the rest. Pins OxygenFlow's main. |
|
| 9 | 10 | - **The hub's CI writes a test report and coverage (#307):** `test` runs under cargo-nextest and keeps a JUnit report (whether or not the tests pass); a new `coverage` job runs the tests instrumented beside it, with its own cached target, and keeps LCOV coverage. `coverage` is not a required check. |
|
| … | 323 unchanged lines |
modified crates/hub/src/db/projects.rs +30 −6
| … | 349 unchanged lines |
||
| 350 | 350 | card.moved_ms < now - days * DAY_MS |
|
| 351 | 351 | } |
|
| 352 | 352 | ||
| 353 | - | /// Archive a column's cards this many days after they join it, or never. |
|
| 353 | + | /// Archive a column's cards this many days after they join it; `None` |
|
| 354 | + | /// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`] |
|
| 355 | + | /// none. |
|
| 354 | 356 | pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> { |
|
| 355 | 357 | sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3") |
|
| 356 | 358 | .bind(days) |
|
| … | 4 unchanged lines |
||
| 361 | 363 | touch(pool, project_id).await |
|
| 362 | 364 | } |
|
| 363 | 365 | ||
| 364 | - | /// Archive, on every board, the cards of columns that archive after some |
|
| 365 | - | /// days which joined them longer ago than that (#293); how many. |
|
| 366 | + | /// Days after which a column with no setting of its own archives its |
|
| 367 | + | /// finished cards (#293): a board shows the last fortnight's done work. |
|
| 368 | + | pub const FINISHED_KEPT_DAYS: i64 = 14; |
|
| 369 | + | ||
| 370 | + | /// A column's `archive_after_days` that means never. |
|
| 371 | + | pub const NEVER: i64 = -1; |
|
| 372 | + | ||
| 373 | + | /// Archive, on every board, the cards their columns keep no longer (#293); |
|
| 374 | + | /// how many. A column that archives after N days: every card that joined it |
|
| 375 | + | /// longer ago. A column with no setting: finished cards (a closed issue, a |
|
| 376 | + | /// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column |
|
| 377 | + | /// stops growing while open work and notes stay. [`NEVER`]: none. |
|
| 366 | 378 | pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> { |
|
| 367 | - | let r = sqlx::query( |
|
| 379 | + | let set = sqlx::query( |
|
| 368 | 380 | "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \ |
|
| 369 | - | (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NOT NULL \ |
|
| 381 | + | (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \ |
|
| 370 | 382 | AND project_cards.moved_ms < $1 - c.archive_after_days * $2)", |
|
| 371 | 383 | ) |
|
| 372 | 384 | .bind(now) |
|
| 373 | 385 | .bind(DAY_MS) |
|
| 386 | + | .execute(pool) |
|
| 387 | + | .await?; |
|
| 388 | + | let finished = sqlx::query( |
|
| 389 | + | "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \ |
|
| 390 | + | AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \ |
|
| 391 | + | AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \ |
|
| 392 | + | AND i.number = project_cards.number AND i.state = 'closed')) \ |
|
| 393 | + | OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \ |
|
| 394 | + | AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))", |
|
| 395 | + | ) |
|
| 396 | + | .bind(now) |
|
| 397 | + | .bind(now - FINISHED_KEPT_DAYS * DAY_MS) |
|
| 374 | 398 | .execute(pool) |
|
| 375 | 399 | .await?; |
|
| 376 | - | Ok(r.rows_affected()) |
|
| 400 | + | Ok(set.rows_affected() + finished.rows_affected()) |
|
| 377 | 401 | } |
|
| 378 | 402 | ||
| 379 | 403 | /// Swap a card with its neighbour in its column (`-1` up, `1` down). |
|
| … | 49 unchanged lines |
modified crates/hub/src/web/projects.rs +15 −5
| … | 196 unchanged lines |
||
| 197 | 197 | pub total: usize, |
|
| 198 | 198 | /// Its archived cards (#293), shown on the archive view. |
|
| 199 | 199 | pub archived: usize, |
|
| 200 | + | /// Its cards are all finished work (a Done column): its note says |
|
| 201 | + | /// they are archived after a fortnight unless it says otherwise. |
|
| 202 | + | pub finished: bool, |
|
| 200 | 203 | } |
|
| 201 | 204 | ||
| 202 | 205 | /// A column longer than this shows its newest cards only, with "show all" |
|
| … | 226 unchanged lines |
||
| 429 | 432 | } |
|
| 430 | 433 | let total = views.len(); |
|
| 431 | 434 | let archived = archived_in(column.id); |
|
| 432 | - | columns.push(ColumnView { column, cards: views, hidden: 0, total, archived }); |
|
| 435 | + | let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect(); |
|
| 436 | + | let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed")); |
|
| 437 | + | columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished }); |
|
| 433 | 438 | } |
|
| 434 | 439 | let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
|
| 435 | 440 | repos.sort(); |
|
| … | 75 unchanged lines |
||
| 511 | 516 | repo: String, |
|
| 512 | 517 | #[serde(default)] |
|
| 513 | 518 | note: String, |
|
| 519 | + | /// Automatic archiving: `finished` (the default), `days` or `never`. |
|
| 514 | 520 | #[serde(default)] |
|
| 521 | + | mode: String, |
|
| 522 | + | #[serde(default)] |
|
| 515 | 523 | title: String, |
|
| 516 | 524 | #[serde(default)] |
|
| 517 | 525 | description: String, |
|
| … | 149 unchanged lines |
||
| 667 | 675 | } |
|
| 668 | 676 | "archive_older" | "archive_after" => { |
|
| 669 | 677 | let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; |
|
| 670 | - | let days = match f.days.trim() { |
|
| 671 | - | "" if f.action == "archive_after" => None, |
|
| 672 | - | d => match d.parse::<i64>() { |
|
| 678 | + | let days = match (f.action.as_str(), f.mode.as_str(), f.days.trim()) { |
|
| 679 | + | ("archive_after", "finished", _) | ("archive_after", "", "") => None, |
|
| 680 | + | ("archive_after", "never", _) => Some(pdb::NEVER), |
|
| 681 | + | (_, _, d) => match d.parse::<i64>() { |
|
| 673 | 682 | Ok(n) if (0..=3650).contains(&n) => Some(n), |
|
| 674 | 683 | _ => { |
|
| 675 | 684 | error = Some("Give a number of days, 0 to 3650.".into()); |
|
| … | 5 unchanged lines |
||
| 681 | 690 | if error.is_none() && f.action == "archive_after" { |
|
| 682 | 691 | pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?; |
|
| 683 | 692 | notice = Some(match days { |
|
| 693 | + | Some(pdb::NEVER) => format!("{name} keeps its cards until you archive them."), |
|
| 684 | 694 | Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }), |
|
| 685 | - | None => format!("{name} keeps its cards until you archive them."), |
|
| 695 | + | None => format!("{name} archives finished cards {} days after they reach it.", pdb::FINISHED_KEPT_DAYS), |
|
| 686 | 696 | }); |
|
| 687 | 697 | } else if let (None, Some(days)) = (&error, days) { |
|
| 688 | 698 | let now = db::now_ms(); |
|
| … | 213 unchanged lines |
modified crates/hub/tests/issues.rs +41 −1
| … | 1172 unchanged lines |
||
| 1173 | 1173 | let page = get(app, board, Some(&andy)).await.body; |
|
| 1174 | 1174 | assert!(page.contains("aged-40") && !page.contains("aged-10") && page.contains("aged-1<"), "{page}"); |
|
| 1175 | 1175 | assert!(page.contains("2 archived cards")); |
|
| 1176 | - | let r = act(format!("action=archive_after&column={done}&days=")).await; |
|
| 1176 | + | let r = act(format!("action=archive_after&column={done}&mode=never")).await; |
|
| 1177 | 1177 | assert!(r.body.contains("Done keeps its cards until you archive them.")); |
|
| 1178 | 1178 | assert_eq!(db::projects::archive_due(&app.db, db::now_ms() + 100 * day).await.unwrap(), 0, "never, now"); |
|
| 1179 | + | ||
| 1180 | + | // The default (#293, Andy 2026-10-25): finished work, a closed issue, |
|
| 1181 | + | // goes a fortnight after it reached its column; open work and notes stay. |
|
| 1182 | + | let r = act(format!("action=archive_after&column={done}&mode=finished")).await; |
|
| 1183 | + | assert!(r.body.contains("Done archives finished cards 14 days after they reach it."), "{}", r.body); |
|
| 1184 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 1185 | + | let repo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 1186 | + | let mut cards = vec![]; |
|
| 1187 | + | for title in ["shipped", "still open"] { |
|
| 1188 | + | let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: repo.id, title, body: "", author_id: None, author: "andy" }) |
|
| 1189 | + | .await |
|
| 1190 | + | .unwrap(); |
|
| 1191 | + | if title == "shipped" { |
|
| 1192 | + | db::issues::set_state(&app.db, i.id, "closed", "completed").await.unwrap(); |
|
| 1193 | + | } |
|
| 1194 | + | let id: i64 = sqlx::query_scalar( |
|
| 1195 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, created_ms, moved_ms) \ |
|
| 1196 | + | VALUES ($1, $2, 10, 'issue', $3, $4, '', 0, $5) RETURNING id", |
|
| 1197 | + | ) |
|
| 1198 | + | .bind(project) |
|
| 1199 | + | .bind(done) |
|
| 1200 | + | .bind(repo.id) |
|
| 1201 | + | .bind(i.number) |
|
| 1202 | + | .bind(now - 20 * day) |
|
| 1203 | + | .fetch_one(&app.db) |
|
| 1204 | + | .await |
|
| 1205 | + | .unwrap(); |
|
| 1206 | + | cards.push(id); |
|
| 1207 | + | } |
|
| 1208 | + | assert_eq!(db::projects::archive_due(&app.db, db::now_ms()).await.unwrap(), 1, "the closed issue only"); |
|
| 1209 | + | let archived_ms = |id: i64| async move { |
|
| 1210 | + | sqlx::query_scalar::<_, Option<i64>>("SELECT archived_ms FROM project_cards WHERE id = $1") |
|
| 1211 | + | .bind(id) |
|
| 1212 | + | .fetch_one(&app.db) |
|
| 1213 | + | .await |
|
| 1214 | + | .unwrap() |
|
| 1215 | + | }; |
|
| 1216 | + | assert!(archived_ms(cards[0]).await.is_some() && archived_ms(cards[1]).await.is_none()); |
|
| 1217 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1218 | + | assert!(page.contains("value=\"finished\" selected"), "the column says so"); |
|
| 1179 | 1219 | } |
|
| 1180 | 1220 | ||
| 1181 | 1221 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 245 unchanged lines |
modified docs/spec.md +1 −1
| … | 452 unchanged lines |
||
| 453 | 453 | - The board works without JavaScript: each card has a column picker and up and down buttons; cards are added by reference (`#12` in a named repository, or `repo#12`) or as a note; an issue or proposal is on a project at most once. Issue and proposal pages list the projects they are on, with their column. |
|
| 454 | 454 | - Columns can be added, renamed, moved and removed (removing one moves its cards to the first column). Closing a project keeps it read-only. |
|
| 455 | 455 | - Long boards (oxygenhub#293): a column of more than 30 cards shows its newest 30, with "show all". A card shows its issue's or proposal's labels and an issue's assignees, and the filter above the board takes words (title or reference) with `label:NAME`, `assignee:NAME` and `state:STATE`, every term holding, as the issue list does. |
|
| 456 | - | - Archive (oxygenhub#293): a card can be archived, off the board but kept, found on the board's archive view (`?archived=1`, filtered the same way) and restored to the end of its column. A column archives its cards that joined it more than N days ago at once, or does so on its own once set to (Done after 14 days, say; checked hourly). An issue's or proposal's page says when its card is archived. |
|
| 456 | + | - Archive (oxygenhub#293): a card can be archived, off the board but kept, found on the board's archive view (`?archived=1`, filtered the same way) and restored to the end of its column. A column archives its cards that joined it more than N days ago at once, or on its own (checked hourly): by default its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, so a Done column shows the last fortnight's work and an ongoing project's board stays its size; or every card after N days; or never. Open work and notes are never archived by the default. An issue's or proposal's page says when its card is archived. |
|
| 457 | 457 | ||
| 458 | 458 | ## 9. Releases and tags |
|
| 459 | 459 | ||
| … | 435 unchanged lines |
modified templates/project.html +3 −3
| … | 17 unchanged lines |
||
| 18 | 18 | <section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}"> |
|
| 19 | 19 | <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{% if filtered %}{{ col.cards.len() }} of {{ col.total }}{% else %}{{ col.total }}{% endif %}</span></h2> |
|
| 20 | 20 | {% if can_edit && !archived %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header> |
|
| 21 | - | {% if let Some(d) = col.column.archive_after_days %}{% if !archived %}<p class="small mute col-archives">archives cards {{ d }} day{% if *d != 1 %}s{% endif %} after they reach it</p>{% endif %}{% endif %} |
|
| 21 | + | {% if !archived %}{% match col.column.archive_after_days %}{% when Some(-1) %}{% when Some(d) %}<p class="small mute col-archives">archives cards {{ d }} day{% if *d != 1 %}s{% endif %} after they reach it</p>{% when None %}{% if col.finished %}<p class="small mute col-archives">finished cards are archived after {{ crate::db::projects::FINISHED_KEPT_DAYS }} days</p>{% endif %}{% endmatch %}{% endif %} |
|
| 22 | 22 | {% if col.hidden > 0 && !filtered && !archived %}<p class="small mute col-hidden">{{ col.hidden }} older card{% if col.hidden != 1 %}s{% endif %} not shown · <a href="/projects/{{ project.owner }}/{{ project.number }}?all={{ col.column.id }}#col-{{ col.column.id }}">show all {{ col.total }}</a></p>{% endif %} |
|
| 23 | 23 | <ul class="cards"> |
|
| 24 | 24 | {% for c in col.cards %} |
|
| … | 32 unchanged lines |
||
| 57 | 57 | <div class="inline-fields"><input type="number" id="older-{{ col.column.id }}" name="days" min="0" max="3650" value="14" required><span class="small mute">days</span><button name="action" value="archive_older" class="small">ARCHIVE</button></div> |
|
| 58 | 58 | </form> |
|
| 59 | 59 | <form class="col-archive" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 60 | - | <label for="after-{{ col.column.id }}">Archive automatically after</label> |
|
| 61 | - | <div class="inline-fields"><input type="number" id="after-{{ col.column.id }}" name="days" min="0" max="3650" placeholder="never"{% if let Some(d) = col.column.archive_after_days %} value="{{ d }}"{% endif %}><span class="small mute">days</span><button name="action" value="archive_after" class="small">SAVE</button></div> |
|
| 60 | + | <label for="mode-{{ col.column.id }}">Archive automatically</label> |
|
| 61 | + | <div class="inline-fields"><select id="mode-{{ col.column.id }}" name="mode" aria-label="Archive automatically"><option value="finished"{% if col.column.archive_after_days.is_none() %} selected{% endif %}>finished cards after {{ crate::db::projects::FINISHED_KEPT_DAYS }} days</option><option value="days"{% if col.column.archive_after_days.unwrap_or(-1) >= 0 %} selected{% endif %}>every card after</option><option value="never"{% if col.column.archive_after_days == Some(-1) %} selected{% endif %}>never</option></select><input type="number" id="after-{{ col.column.id }}" name="days" min="0" max="3650" placeholder="days" aria-label="Days"{% if let Some(d) = col.column.archive_after_days %}{% if *d >= 0 %} value="{{ d }}"{% endif %}{% endif %}><button name="action" value="archive_after" class="small">SAVE</button></div> |
|
| 62 | 62 | </form> |
|
| 63 | 63 | </details> |
|
| 64 | 64 | {% endif %} |
|
| … | 26 unchanged lines |