OXYGENHUB

Boards that keep growing stay readable (refs #293) 657e7d5c9da9

A column of more than 30 cards shows its newest 30 with 'show all', and a
filter above the board finds cards by title, reference and state in every
column. The rest of the design is on the issue.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b3a7f64549db
When
· 2026-10-24 23:26 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
657e7d5c9da9735f993d9c5f14afe1f35fd07065cf9b30a0ab089c46cc42b823
Depends on
18e381ea88c2 4f8404093d30 a7e6e8e6094c b1e92a6233ee b5668108487f c79a1111dd56 d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
Depended on by
0bc95135954d 046e3352cdfd 439ecd8708a1 6a306cd03fc1 ecac6e8d9b41
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
21 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Boards that keep growing stay readable (#293, first part):** a column of more than 30 cards shows its newest 30 (cards join a column at its end), says how many older ones it leaves out and links to "show all"; a filter above the board finds cards by title or reference, and by state (open, closed, landed, draft), in every column, counting "3 of 312".
7 8
- **An issue's references come in a box per day (#292):** an issue many changes refer to listed a line per change and ran on down the page. Two or more on one day are now one box, "referenced by 12 changes on 21 Oct 2026", their changes as wrapping chips, collapsible, the newest day's open.
8 9
- **Move a board's column at once (#289):** `oh project card move N --from Doing -c Done` moves every card of a column, `--except 317,#320` leaves some, `--state closed,landed` takes only cards whose issue or proposal is done, and `--dry-run` lists what would move. One call (`POST …/projects/{owner}/{n}/cards/move`), all or none, one audit entry: tidying a board of 115 cards took 115 commands.
9 10
- **Expediting a run moves the queue at once (#291):** a run's jobs kept the place they had ("9 of 9 jobs come before") until the next placement pass and some other change on the page. Expediting now places every queued job again straight away, every run whose jobs' notes changed updates live, and a job an expedited run moved down says so ("2 ready jobs come first (after expedited run andy/demo #12)").
…
290 unchanged lines
modified crates/hub/src/web/projects.rs +83 −9
…
187 unchanged lines
188 188
pub struct ColumnView {
189 189
    pub column: pdb::Column,
190 190
    pub cards: Vec<CardView>,
191 +
    /// Cards the page leaves out (#293): the oldest of a long column, or
192 +
    /// those a filter does not match; `total` is how many it holds.
193 +
    pub hidden: usize,
194 +
    pub total: usize,
191 195
}
192 196
197 +
/// A column longer than this shows its newest cards only, with "show all"
198 +
/// (#293): a board's Done column otherwise grows without end.
199 +
pub const COLUMN_SHOWN: usize = 30;
200 +
201 +
/// How the board page shows its cards (#293).
202 +
#[derive(Deserialize, Default)]
203 +
pub struct BoardQuery {
204 +
    /// The column shown whole.
205 +
    #[serde(default)]
206 +
    all: Option<i64>,
207 +
    /// Only cards whose title or reference holds this.
208 +
    #[serde(default)]
209 +
    q: Option<String>,
210 +
    /// Only cards in this state (`open`, `closed`, `landed`…).
211 +
    #[serde(default)]
212 +
    state: Option<String>,
213 +
}
214 +
215 +
impl BoardQuery {
216 +
    fn text(&self) -> String {
217 +
        self.q.as_deref().map(str::trim).unwrap_or("").to_string()
218 +
    }
219 +
    fn state(&self) -> String {
220 +
        self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase()
221 +
    }
222 +
}
223 +
224 +
/// Filter and cut the columns as `view` says.
225 +
fn shape(columns: &mut [ColumnView], view: &BoardQuery) {
226 +
    let (q, state) = (view.text().to_lowercase(), view.state());
227 +
    for col in columns.iter_mut() {
228 +
        col.total = col.cards.len();
229 +
        if !q.is_empty() || !state.is_empty() {
230 +
            col.cards.retain(|c| {
231 +
                (q.is_empty() || c.title.to_lowercase().contains(&q) || c.reference.to_lowercase().contains(&q))
232 +
                    && (state.is_empty() || c.state.eq_ignore_ascii_case(&state))
233 +
            });
234 +
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) {
235 +
            // Cards join a column at its end: the newest are the last.
236 +
            let cut = col.cards.len() - COLUMN_SHOWN;
237 +
            col.cards.drain(..cut);
238 +
        }
239 +
        col.hidden = col.total - col.cards.len();
240 +
    }
241 +
}
242 +
193 243
#[derive(Template)]
194 244
#[template(path = "project.html")]
195 245
pub struct BoardPage {
…
11 unchanged lines
207 257
    /// Owners the viewer may move the project to (#125): themselves and the
208 258
    /// organisations they belong to, but not its owner now.
209 259
    transfer_to: Vec<String>,
260 +
    /// The filter the page shows (#293), and whether one is set.
261 +
    q: String,
262 +
    state: String,
263 +
    filtered: bool,
210 264
}
211 265
266 +
#[allow(clippy::too_many_arguments)]
212 267
async fn board(
213 268
    app: &Shared,
214 269
    shell: Shell,
…
2 unchanged lines
217 272
    number: i64,
218 273
    notice: Option<String>,
219 274
    error: Option<String>,
275 +
    view: &BoardQuery,
220 276
) -> Result<Page<BoardPage>, Error> {
221 277
    let e = |e| Error::from_db(shell.clone(), e);
222 278
    let r = rights(app, &shell, viewer, owner).await?;
…
2 unchanged lines
225 281
        .map_err(e)?
226 282
        .filter(|p| p.public || r.member)
227 283
        .ok_or_else(|| Error::not_found(shell.clone()))?;
228 -
    let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
284 +
    let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
285 +
    shape(&mut columns, view);
229 286
    let can_edit = r.edit && !project.closed;
230 287
    let mut transfer_to = Vec::new();
231 288
    if let (true, Some(a)) = (r.edit, viewer.account()) {
…
2 unchanged lines
234 291
        transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name));
235 292
        transfer_to.retain(|o| o != owner);
236 293
    }
237 -
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to }))
294 +
    let (q, state) = (view.text(), view.state());
295 +
    let filtered = !q.is_empty() || !state.is_empty();
296 +
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to, q, state, filtered }))
238 297
}
239 298
240 299
/// A project's columns with the cards `account` may see, and the owner's
…
50 unchanged lines
291 350
                _ => {}
292 351
            }
293 352
        }
294 -
        columns.push(ColumnView { column, cards: views });
353 +
        let total = views.len();
354 +
        columns.push(ColumnView { column, cards: views, hidden: 0, total });
295 355
    }
296 356
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
297 357
    repos.sort();
298 358
    Ok((columns, repos))
299 359
}
300 360
301 -
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Response, Error> {
361 +
pub async fn show(
362 +
    State(app): State<Shared>,
363 +
    viewer: Viewer,
364 +
    Path((owner, number)): Path<(String, i64)>,
365 +
    Query(view): Query<BoardQuery>,
366 +
) -> Result<Response, Error> {
302 367
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
303 368
    // A board transferred away (#125): its old address goes to its new one.
304 369
    let e = |e| Error::from_db(shell.clone(), e);
…
2 unchanged lines
307 372
            return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response());
308 373
        }
309 374
    }
310 -
    Ok(board(&app, shell, &viewer, &owner, number, None, None).await?.into_response())
375 +
    Ok(board(&app, shell, &viewer, &owner, number, None, None, &view).await?.into_response())
311 376
}
312 377
313 378
#[derive(Deserialize)]
…
112 unchanged lines
426 491
        return Err(Error::not_found(shell));
427 492
    }
428 493
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") {
429 -
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
430 -
            .await?
431 -
            .into_response());
494 +
        return Ok(board(
495 +
            &app,
496 +
            shell,
497 +
            &viewer,
498 +
            &owner,
499 +
            number,
500 +
            None,
501 +
            Some("This project is closed; reopen it to change it.".into()),
502 +
            &BoardQuery::default(),
503 +
        )
504 +
        .await?
505 +
        .into_response());
432 506
    }
433 507
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
434 508
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
…
80 unchanged lines
515 589
        // Back to the board, so a reload does not repeat the action.
516 590
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
517 591
    }
518 -
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
592 +
    Ok(board(&app, shell, &viewer, &owner, number, notice, error, &BoardQuery::default()).await?.into_response())
519 593
}
520 594
521 595
/// The projects an issue or proposal is on that the viewer may see, with
…
140 unchanged lines
modified crates/hub/tests/issues.rs +39 −0
…
982 unchanged lines
983 983
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
984 984
}
985 985
986 +
/// #293: a long column shows its newest cards, says how many older ones it
987 +
/// leaves out and links to them all; a filter finds cards in any column.
986 988
#[tokio::test(flavor = "multi_thread")]
989 +
async fn a_long_column_shows_its_newest_cards() {
990 +
    let h = hub().await;
991 +
    let app = &h.app;
992 +
    let andy = register(app, "andy").await;
993 +
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
994 +
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
995 +
    let (project, done): (i64, i64) =
996 +
        sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id WHERE c.name = 'Done'")
997 +
            .fetch_one(&app.db)
998 +
            .await
999 +
            .unwrap();
1000 +
    for i in 1..=35 {
1001 +
        sqlx::query(
1002 +
            "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms) VALUES ($1, $2, $3, 'note', $4, 0)",
1003 +
        )
1004 +
        .bind(project)
1005 +
        .bind(done)
1006 +
        .bind(i)
1007 +
        .bind(format!("card-{i:03}"))
1008 +
        .execute(&app.db)
1009 +
        .await
1010 +
        .unwrap();
1011 +
    }
1012 +
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1013 +
    assert!(board.contains("5 older cards not shown") && board.contains(&format!("?all={done}#col-{done}\">show all 35</a>")), "{board}");
1014 +
    assert!(board.contains("card-006") && board.contains("card-035") && !board.contains("card-005"), "the newest 30");
1015 +
    assert!(board.contains(">35</span></h2>"), "the count is the column's");
1016 +
    let all = get(app, &format!("/projects/andy/1?all={done}"), Some(&andy)).await.body;
1017 +
    assert!(all.contains("card-001") && !all.contains("older cards not shown"), "show all");
1018 +
    let found = get(app, "/projects/andy/1?q=CARD-002", Some(&andy)).await.body;
1019 +
    assert!(found.contains("card-002") && !found.contains("card-030") && found.contains("1 of 35"), "{found}");
1020 +
    assert!(found.contains(">clear</a>"));
1021 +
    let open = get(app, "/projects/andy/1?state=open", Some(&andy)).await.body;
1022 +
    assert!(!open.contains("card-0"), "notes have no state");
1023 +
}
1024 +
1025 +
#[tokio::test(flavor = "multi_thread")]
987 1026
async fn issue_markdown_follows_the_default_bookmark() {
988 1027
    let h = hub().await;
989 1028
    let app = &h.app;
…
242 unchanged lines
modified templates/project.html +7 −1
…
5 unchanged lines
6 6
{% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div>
7 7
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">
10 +
  <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter cards">
11 +
  <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>
12 +
  <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}">clear</a>{% endif %}
13 +
</form>
9 14
<div class="board" role="list" aria-label="Columns">
10 15
{% for col in columns %}
11 16
<section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}">
12 -
  <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{{ col.cards.len() }}</span></h2>
17 +
  <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>
13 18
  {% 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="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>
19 +
  {% if col.hidden > 0 && !filtered %}<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 %}
14 20
  <ul class="cards">
15 21
  {% for c in col.cards %}
16 22
    <li class="card-item{% if c.card.kind == "note" %} note{% endif %}">
…
53 unchanged lines
modified templates/style.css +3 −0
…
377 unchanged lines
378 378
.label { display: inline-block; font-size: 11px; padding: 1px 7px; margin-right: 4px; border: 1px solid var(--c, var(--acc)); color: var(--c, var(--acc)); vertical-align: middle; }
379 379
.timeline { margin: 0 0 8px 14px; }
380 380
.refs-day summary { cursor: pointer; }
381 +
.board-filter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; }
382 +
.board-filter input[type=search] { min-width: 0; flex: 1 1 14em; max-width: 24em; }
383 +
.col-hidden { margin: 0 0 6px; }
381 384
.refs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; max-width: 100%; }
382 385
.refs-chips .chip { overflow-wrap: anywhere; }
383 386
details.inline-edit { display: inline; margin-left: 8px; }
…
1057 unchanged lines