OXYGENHUB

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