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 |