OXYGENHUB

Board views: saved views, filter bar, lanes, WIP limits, table, keys, card history (refs #361) 0bc95135954d

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key fccab701eeea
When
· 2026-11-03 23:28 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
0bc95135954de9e9011169e25ec4dcdfbf6b7746800ffa79d0b580e0f08f2d88
Depends on
046e3352cdfd 1000b40a2629 119fd17d213f 17a3a84b00d1 18e381ea88c2 306aec1839aa 3b1bc27fe805 439ecd8708a1 456f4197ade3 50928fab5ffc 5c6e92fd8d71 5d90e0e045e2 64e5964e9f13 657e7d5c9da9 6a306cd03fc1 93be9ce4653e 946fe7a09faa a1b9869565aa a7aa76ac52d7 b5668108487f d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
Depended on by
fb8f35d025ab 40a43921ef42 f2d2776b2a14
In bookmarks
main
In tags
v0.9.24
Edits
92 atoms, 2 tree operations, 13 files

Browse the tree at this change

added crates/hub/migrations-pg/0105_board_views.sql +34 −0
1 +
-- Board views and history (oxygenhub#361, #357): a column's work-in-progress
2 +
-- limit; a board's saved views, each a layout with its filter, lanes and
3 +
-- sort; and every card's moves (added, moved, archived, restored, removed),
4 +
-- from which cumulative flow, cycle time and forecasts are drawn. A move to
5 +
-- no column (NULL) takes the card off the board. Cards already on boards
6 +
-- start their history where they are now.
7 +
ALTER TABLE project_columns ADD COLUMN wip_limit BIGINT;
8 +
9 +
CREATE TABLE project_views (
10 +
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
11 +
    project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
12 +
    name       TEXT NOT NULL,
13 +
    layout     TEXT NOT NULL DEFAULT 'board',
14 +
    q          TEXT NOT NULL DEFAULT '',
15 +
    lane       TEXT NOT NULL DEFAULT '',
16 +
    sort       TEXT NOT NULL DEFAULT '',
17 +
    position   BIGINT NOT NULL,
18 +
    created_ms BIGINT NOT NULL,
19 +
    UNIQUE (project_id, name)
20 +
);
21 +
22 +
CREATE TABLE project_card_moves (
23 +
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
24 +
    project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
25 +
    card_id    BIGINT NOT NULL,
26 +
    column_id  BIGINT,
27 +
    at_ms      BIGINT NOT NULL
28 +
);
29 +
CREATE INDEX project_card_moves_by_project ON project_card_moves (project_id, at_ms);
30 +
31 +
INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms)
32 +
    SELECT project_id, id, column_id, moved_ms FROM project_cards;
33 +
INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms)
34 +
    SELECT project_id, id, NULL, archived_ms FROM project_cards WHERE archived_ms IS NOT NULL;
added crates/hub/migrations/0105_board_views.sql +34 −0
1 +
-- Board views and history (oxygenhub#361, #357): a column's work-in-progress
2 +
-- limit; a board's saved views, each a layout with its filter, lanes and
3 +
-- sort; and every card's moves (added, moved, archived, restored, removed),
4 +
-- from which cumulative flow, cycle time and forecasts are drawn. A move to
5 +
-- no column (NULL) takes the card off the board. Cards already on boards
6 +
-- start their history where they are now.
7 +
ALTER TABLE project_columns ADD COLUMN wip_limit INTEGER;
8 +
9 +
CREATE TABLE project_views (
10 +
    id         INTEGER PRIMARY KEY,
11 +
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
12 +
    name       TEXT NOT NULL,
13 +
    layout     TEXT NOT NULL DEFAULT 'board',
14 +
    q          TEXT NOT NULL DEFAULT '',
15 +
    lane       TEXT NOT NULL DEFAULT '',
16 +
    sort       TEXT NOT NULL DEFAULT '',
17 +
    position   INTEGER NOT NULL,
18 +
    created_ms INTEGER NOT NULL,
19 +
    UNIQUE (project_id, name)
20 +
);
21 +
22 +
CREATE TABLE project_card_moves (
23 +
    id         INTEGER PRIMARY KEY,
24 +
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
25 +
    card_id    INTEGER NOT NULL,
26 +
    column_id  INTEGER,
27 +
    at_ms      INTEGER NOT NULL
28 +
);
29 +
CREATE INDEX project_card_moves_by_project ON project_card_moves (project_id, at_ms);
30 +
31 +
INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms)
32 +
    SELECT project_id, id, column_id, moved_ms FROM project_cards;
33 +
INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms)
34 +
    SELECT project_id, id, NULL, archived_ms FROM project_cards WHERE archived_ms IS NOT NULL;
modified CHANGELOG.md +11 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **Board views (#361):** a board shows what matters now and other views of the same cards.
8 +
  - **Saved views** as tabs across the top: a layout with its filter, lanes and order, saved under a name and shared by its address (`?view=NAME`).
9 +
  - **One filter bar** for every view: words, `repo:`, `assignee:`, `label:`, `milestone:` (quote a title with spaces), `state:`, `is:open|closed`, `age:>14d` and `age:<7d`.
10 +
  - **Board**: swimlanes by repository, assignee, label or milestone; a **work-in-progress limit** per column, whose header turns to the warning colour past it; **Done shows this iteration's work** while one is open, saying how many cards finished before it; and **compact cards** (labels as dots).
11 +
  - **Table**: every card as a row (title, reference, state, column, assignees, labels, milestone, opened), sortable, grouped, a column changed in place, 50 rows a page.
12 +
  - **Keys**: `j`/`k` between cards, `Enter` opens one, `m` moves it, `/` filters.
13 +
  - **History**: every card's moves (added, moved, archived, restored, removed) are kept from now on, for the charts and forecasts of #357.
14 +
  - The API answers `GET /projects/{owner}/{n}?q=` or `?view=NAME` with the filter applied, and lists the board's views and each column's limit; `oh project view N --filter TEXT` or `--view NAME`.
15 +
5 16
## 0.9.23 (2026-11-03)
6 17
7 18
- **A push no longer stops main's running run (#369):** a push cancels its bookmark's older push runs, and those already running too unless Settings › OxygenFlow keeps them; on the default bookmark, where releases are cut from the run of their commit, a running push run now always finishes, and only queued ones are cancelled. A release commit's run lost to the next push is what prompted it.
…
454 unchanged lines
modified assets/hub.js +51 −0
…
1019 unchanged lines
1020 1020
    setTimeout(ask, 1500);
1021 1021
  })();
1022 1022
})();
1023 +
1024 +
// Board keys (oxygenhub#361): j/k move between cards, Enter opens one, m
1025 +
// moves it (focuses its column choice), / goes to the filter. Not while
1026 +
// typing in a field.
1027 +
(function () {
1028 +
  var filter = document.querySelector("[data-board-filter]");
1029 +
  if (!filter) return;
1030 +
  function cards() {
1031 +
    return Array.prototype.slice.call(document.querySelectorAll(".card-item[data-card]"));
1032 +
  }
1033 +
  function typing(el) {
1034 +
    return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable);
1035 +
  }
1036 +
  document.addEventListener("keydown", function (e) {
1037 +
    if (e.ctrlKey || e.metaKey || e.altKey || typing(document.activeElement)) return;
1038 +
    var list = cards();
1039 +
    var at = list.indexOf(document.activeElement && document.activeElement.closest ? document.activeElement.closest(".card-item[data-card]") : null);
1040 +
    if (e.key === "/") {
1041 +
      e.preventDefault();
1042 +
      filter.focus();
1043 +
      filter.select();
1044 +
    } else if (e.key === "j" || e.key === "k") {
1045 +
      if (!list.length) return;
1046 +
      e.preventDefault();
1047 +
      var next = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + (e.key === "j" ? 1 : -1)));
1048 +
      list[next].focus();
1049 +
      list[next].scrollIntoView({ block: "nearest", inline: "nearest" });
1050 +
    } else if (e.key === "Enter" && at >= 0) {
1051 +
      var href = list[at].getAttribute("data-href");
1052 +
      if (href) {
1053 +
        e.preventDefault();
1054 +
        window.location.href = href;
1055 +
      }
1056 +
    } else if (e.key === "m" && at >= 0) {
1057 +
      var sel = list[at].querySelector("select[name=column]");
1058 +
      if (sel) {
1059 +
        e.preventDefault();
1060 +
        if (list[at].closest(".compact")) list[at].querySelector(".card-actions").style.display = "flex";
1061 +
        sel.focus();
1062 +
      }
1063 +
    }
1064 +
  });
1065 +
  // A table row's column choice moves the card when changed.
1066 +
  document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) {
1067 +
    sel.addEventListener("change", function () {
1068 +
      var form = sel.form;
1069 +
      var btn = form && form.querySelector("button[name=action][value=move]");
1070 +
      if (btn) btn.click();
1071 +
    });
1072 +
  });
1073 +
})();
modified crates/hub-api/src/lib.rs +24 −0
…
1078 unchanged lines
1079 1079
    pub id: i64,
1080 1080
    pub name: String,
1081 1081
    pub cards: Vec<ProjectCard>,
1082 +
    /// Its work-in-progress limit, when it has one (#361).
1083 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1084 +
    pub wip_limit: Option<i64>,
1082 1085
}
1083 1086
1087 +
/// A board's saved view (#361): `GET /projects/{owner}/{n}?view=NAME`
1088 +
/// gives the board with its filter applied.
1089 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1090 +
pub struct ProjectView {
1091 +
    pub name: String,
1092 +
    /// `board` or `table`.
1093 +
    pub layout: String,
1094 +
    pub q: String,
1095 +
    #[serde(default)]
1096 +
    pub lane: String,
1097 +
    #[serde(default)]
1098 +
    pub sort: String,
1099 +
}
1100 +
1084 1101
/// `POST /projects/{owner}/{n}/columns` (#366): a column at the right.
1085 1102
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1086 1103
pub struct NewColumn {
…
16 unchanged lines
1103 1120
pub struct Board {
1104 1121
    pub project: Project,
1105 1122
    pub columns: Vec<ProjectColumn>,
1123 +
    /// Its saved views (#361).
1124 +
    #[serde(default)]
1125 +
    pub views: Vec<ProjectView>,
1126 +
    /// The filter applied (`?q=`, or the `?view=`'s), when one was.
1127 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
1128 +
    pub filter: String,
1106 1129
}
1107 1130
1108 1131
/// `POST /projects/{owner}`.
…
521 unchanged lines
1630 1653
        ("NewProject", schemars::schema_for!(NewProject)),
1631 1654
        ("ProjectEdit", schemars::schema_for!(ProjectEdit)),
1632 1655
        ("NewColumn", schemars::schema_for!(NewColumn)),
1656 +
        ("ProjectView", schemars::schema_for!(ProjectView)),
1633 1657
        ("ColumnEdit", schemars::schema_for!(ColumnEdit)),
1634 1658
        ("NewCard", schemars::schema_for!(NewCard)),
1635 1659
        ("CardMove", schemars::schema_for!(CardMove)),
…
334 unchanged lines
modified crates/hub/src/api/projects.rs +52 −4
…
108 unchanged lines
109 109
    Ok((StatusCode::CREATED, Json(project_of(&app, &p))))
110 110
}
111 111
112 +
/// `GET /projects/{owner}/{n}`'s filter (#361): `q` as the board's filter
113 +
/// bar takes it, or the `view` saved under that name.
114 +
#[derive(serde::Deserialize, Default)]
115 +
pub struct BoardFilter {
116 +
    #[serde(default)]
117 +
    q: Option<String>,
118 +
    #[serde(default)]
119 +
    view: Option<String>,
120 +
}
121 +
112 122
/// `GET /projects/{owner}/{n}`.
113 123
pub async fn board(
114 124
    State(app): State<Shared>,
115 125
    headers: HeaderMap,
116 126
    Path((owner, number)): Path<(String, i64)>,
127 +
    Query(f): Query<BoardFilter>,
117 128
) -> Result<Json<Board>, ApiError> {
118 129
    let c = caller(&app, &headers).await?;
119 -
    board_of(&app, &c, &owner, number).await
130 +
    let (_, p) = visible(&app, &c, &owner, number).await?;
131 +
    let q = match (f.q, f.view) {
132 +
        (Some(q), _) => q,
133 +
        (None, Some(name)) => pdb::views(&app.db, p.id)
134 +
            .await?
135 +
            .into_iter()
136 +
            .find(|v| v.name == name)
137 +
            .map(|v| v.q)
138 +
            .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no view {name}")))?,
139 +
        (None, None) => String::new(),
140 +
    };
141 +
    let Json(mut b) = board_of(&app, &c, &owner, number).await?;
142 +
    if !q.trim().is_empty() {
143 +
        let filter = wp::CardFilter::parse(&q);
144 +
        let now = crate::db::now_ms();
145 +
        let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?;
146 +
        for (out, col) in b.columns.iter_mut().zip(columns.iter()) {
147 +
            out.cards = col.cards.iter().filter(|card| filter.keeps(card, now)).map(card_of).collect();
148 +
        }
149 +
        b.filter = q.trim().to_string();
150 +
    }
151 +
    Ok(Json(b))
120 152
}
121 153
122 154
/// `PATCH /projects/{owner}/{n}`.
…
41 unchanged lines
164 196
        project: project_of(app, &p),
165 197
        columns: columns
166 198
            .iter()
167 -
            .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() })
199 +
            .map(|col| ProjectColumn {
200 +
                id: col.column.id,
201 +
                name: col.column.name.clone(),
202 +
                cards: col.cards.iter().map(card_of).collect(),
203 +
                wip_limit: col.column.wip_limit,
204 +
            })
205 +
            .collect(),
206 +
        views: pdb::views(&app.db, p.id)
207 +
            .await?
208 +
            .into_iter()
209 +
            .map(|v| hub_api::ProjectView { name: v.name, layout: v.layout, q: v.q, lane: v.lane, sort: v.sort })
168 210
            .collect(),
211 +
        filter: String::new(),
169 212
    }))
170 213
}
171 214
…
90 unchanged lines
262 305
    Query(q): Query<CardsQuery>,
263 306
) -> Result<Json<Vec<ProjectColumn>>, ApiError> {
264 307
    if !q.archived {
265 -
        return Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns));
308 +
        return Ok(Json(board(State(app), headers, Path((owner, number)), Query(BoardFilter::default())).await?.0.columns));
266 309
    }
267 310
    let c = caller(&app, &headers).await?;
268 311
    let (_, p) = visible(&app, &c, &owner, number).await?;
…
1 unchanged lines
270 313
    Ok(Json(
271 314
        columns
272 315
            .iter()
273 -
            .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() })
316 +
            .map(|col| ProjectColumn {
317 +
                id: col.column.id,
318 +
                name: col.column.name.clone(),
319 +
                cards: col.cards.iter().map(card_of).collect(),
320 +
                wip_limit: col.column.wip_limit,
321 +
            })
274 322
            .collect(),
275 323
    ))
276 324
}
…
156 unchanged lines
modified crates/hub/src/db/projects.rs +188 −18
…
26 unchanged lines
27 27
    /// Cards whose issue closes or whose proposal lands or closes move here
28 28
    /// (#325); one column of a board at most.
29 29
    pub takes_finished: bool,
30 +
    /// Work in progress past this many cards turns its header amber (#361).
31 +
    #[sqlx(default)]
32 +
    pub wip_limit: Option<i64>,
30 33
}
31 34
32 35
#[derive(Clone, Debug, FromRow)]
…
205 unchanged lines
238 241
        .bind(to.id)
239 242
        .fetch_one(&mut *tx)
240 243
        .await?;
244 +
    log_moves(&mut *tx, Some(to.id), now_ms(), "column_id = $1 AND archived_ms IS NULL", &[column_id]).await?;
241 245
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
242 246
        .bind(to.id)
243 247
        .bind(base)
…
6 unchanged lines
250 254
    Ok(true)
251 255
}
252 256
257 +
/// Record, for every card `select` picks (`SELECT project_id, id FROM
258 +
/// project_cards WHERE …`, bound from `$1` on), that it moved to `column`
259 +
/// (`None`: off the board) at `at` (#357). Called before the update that
260 +
/// moves them, with the same conditions.
261 +
async fn log_moves<'e, E: sqlx::Executor<'e, Database = super::Db>>(
262 +
    ex: E,
263 +
    column: Option<i64>,
264 +
    at: i64,
265 +
    select: &str,
266 +
    binds: &[i64],
267 +
) -> sqlx::Result<()> {
268 +
    let sql = format!(
269 +
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, {}, {at} FROM project_cards WHERE {select}",
270 +
        match column {
271 +
            Some(c) => c.to_string(),
272 +
            None => "NULL".to_string(),
273 +
        }
274 +
    );
275 +
    let mut q = sqlx::query(&sql);
276 +
    for b in binds {
277 +
        q = q.bind(*b);
278 +
    }
279 +
    q.execute(ex).await?;
280 +
    Ok(())
281 +
}
282 +
283 +
/// A board's card moves since `since`, oldest first: (card, column or
284 +
/// `None` off the board, when).
285 +
pub async fn moves(pool: &Pool, project_id: i64, since: i64) -> sqlx::Result<Vec<(i64, Option<i64>, i64)>> {
286 +
    sqlx::query_as("SELECT card_id, column_id, at_ms FROM project_card_moves WHERE project_id = $1 AND at_ms >= $2 ORDER BY at_ms, id")
287 +
        .bind(project_id)
288 +
        .bind(since)
289 +
        .fetch_all(pool)
290 +
        .await
291 +
}
292 +
253 293
pub struct NewCard<'a> {
254 294
    pub column_id: i64,
255 295
    pub kind: &'a str,
…
10 unchanged lines
266 306
        .bind(c.column_id)
267 307
        .fetch_one(pool)
268 308
        .await?;
269 -
    let r = sqlx::query(
270 -
        "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING",
309 +
    let now = now_ms();
310 +
    let id: Option<i64> = sqlx::query_scalar(
311 +
        "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING RETURNING id",
271 312
    )
272 313
    .bind(project_id)
273 314
    .bind(c.column_id)
…
3 unchanged lines
277 318
    .bind(c.number)
278 319
    .bind(c.note)
279 320
    .bind(c.added_by)
280 -
    .bind(now_ms())
281 -
    .execute(pool)
321 +
    .bind(now)
322 +
    .fetch_optional(pool)
282 323
    .await?;
324 +
    if let Some(id) = id {
325 +
        log_moves(pool, Some(c.column_id), now, "id = $1", &[id]).await?;
326 +
    }
283 327
    touch(pool, project_id).await?;
284 -
    Ok(r.rows_affected() == 1)
328 +
    Ok(id.is_some())
285 329
}
286 330
287 331
/// Move a card to the bottom of another column of the same project.
…
10 unchanged lines
298 342
        .bind(column_id)
299 343
        .fetch_one(pool)
300 344
        .await?;
345 +
    let now = now_ms();
346 +
    log_moves(
347 +
        pool,
348 +
        Some(column_id),
349 +
        now,
350 +
        "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
351 +
        &[card_id, project_id, column_id],
352 +
    )
353 +
    .await?;
301 354
    sqlx::query("UPDATE project_cards SET moved_ms = CASE WHEN column_id = $1 THEN moved_ms ELSE $3 END, column_id = $1, position = $2 WHERE id = $4 AND project_id = $5")
302 355
        .bind(column_id)
303 356
        .bind(pos)
304 -
        .bind(now_ms())
357 +
        .bind(now)
305 358
        .bind(card_id)
306 359
        .bind(project_id)
307 360
        .execute(pool)
…
11 unchanged lines
319 372
        .await?;
320 373
    let now = now_ms();
321 374
    for (i, id) in card_ids.iter().enumerate() {
375 +
        log_moves(
376 +
            &mut *tx,
377 +
            Some(column_id),
378 +
            now,
379 +
            "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
380 +
            &[*id, project_id, column_id],
381 +
        )
382 +
        .await?;
322 383
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
323 384
            .bind(column_id)
324 385
            .bind(pos + i as i64)
…
13 unchanged lines
338 399
    let now = now_ms();
339 400
    let mut n = 0;
340 401
    for id in card_ids {
402 +
        log_moves(&mut *tx, None, now, "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[*id, project_id]).await?;
341 403
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
342 404
            .bind(now)
343 405
            .bind(id)
…
19 unchanged lines
363 425
    .fetch_one(&mut *tx)
364 426
    .await?;
365 427
    let now = now_ms();
428 +
    sqlx::query(&format!(
429 +
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, column_id, {now} FROM project_cards \
430 +
         WHERE id = $1 AND project_id = $2 AND archived_ms IS NOT NULL"
431 +
    ))
432 +
    .bind(card_id)
433 +
    .bind(project_id)
434 +
    .execute(&mut *tx)
435 +
    .await?;
366 436
    // Back on the board, it no longer counts as done in a past iteration.
367 437
    let r = sqlx::query(
368 438
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
…
68 unchanged lines
437 507
    }
438 508
    let mut n = 0;
439 509
    if let Some(done) = done {
510 +
        log_moves(&mut *tx, None, now, "project_id = $1 AND column_id = $2 AND archived_ms IS NULL", &[project_id, done]).await?;
440 511
        n = sqlx::query(
441 512
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
442 513
        )
…
57 unchanged lines
500 571
/// Days after which a column with no setting of its own archives its
501 572
/// finished cards (#293): a board shows the last fortnight's done work.
502 573
pub const FINISHED_KEPT_DAYS: i64 = 14;
574 +
575 +
/// The finished cards due for archiving: in a column with no setting of
576 +
/// its own, joined before `$1`, and their issue closed or proposal closed
577 +
/// or landed.
578 +
const FINISHED_DUE: &str = "archived_ms IS NULL AND moved_ms < $1 \
579 +
     AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
580 +
     AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
581 +
           AND i.number = project_cards.number AND i.state = 'closed')) \
582 +
       OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
583 +
           AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))";
503 584
504 585
/// A column's `archive_after_days` that means never.
505 586
pub const NEVER: i64 = -1;
…
4 unchanged lines
510 591
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
511 592
/// stops growing while open work and notes stay. [`NEVER`]: none.
512 593
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
594 +
    log_moves(
595 +
        pool,
596 +
        None,
597 +
        now,
598 +
        "archived_ms IS NULL AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id \
599 +
         AND c.archive_after_days >= 0 AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
600 +
        &[now, DAY_MS],
601 +
    )
602 +
    .await?;
513 603
    let set = sqlx::query(
514 604
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
515 605
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
…
3 unchanged lines
519 609
    .bind(DAY_MS)
520 610
    .execute(pool)
521 611
    .await?;
522 -
    let finished = sqlx::query(
523 -
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
524 -
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
525 -
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
526 -
               AND i.number = project_cards.number AND i.state = 'closed')) \
527 -
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
528 -
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
529 -
    )
530 -
    .bind(now)
531 -
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
532 -
    .execute(pool)
533 -
    .await?;
612 +
    log_moves(pool, None, now, FINISHED_DUE, &[now - FINISHED_KEPT_DAYS * DAY_MS]).await?;
613 +
    let finished = sqlx::query(&format!("UPDATE project_cards SET archived_ms = $2 WHERE {FINISHED_DUE}"))
614 +
        .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
615 +
        .bind(now)
616 +
        .execute(pool)
617 +
        .await?;
534 618
    Ok(set.rows_affected() + finished.rows_affected())
535 619
}
536 620
…
25 unchanged lines
562 646
}
563 647
564 648
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
649 +
    log_moves(pool, None, now_ms(), "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[card_id, project_id]).await?;
565 650
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
566 651
    touch(pool, project_id).await
567 652
}
…
55 unchanged lines
623 708
        }
624 709
    }
625 710
    Ok(out)
711 +
}
712 +
713 +
/// A board's saved view (#361): a layout with its filter, lanes and sort.
714 +
#[derive(Clone, Debug, FromRow)]
715 +
pub struct View {
716 +
    pub id: i64,
717 +
    pub project_id: i64,
718 +
    pub name: String,
719 +
    /// `board` or `table`.
720 +
    pub layout: String,
721 +
    pub q: String,
722 +
    /// Swimlanes (board) or groups (table): `repo`, `assignee`, `label`,
723 +
    /// `milestone`, or empty.
724 +
    pub lane: String,
725 +
    /// The table's order: `title`, `reference`, `state`, `column`, `age`,
726 +
    /// a `-` before it for the reverse; empty is the board's order.
727 +
    pub sort: String,
728 +
    pub position: i64,
729 +
    pub created_ms: i64,
730 +
}
731 +
732 +
pub async fn views(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<View>> {
733 +
    sqlx::query_as("SELECT * FROM project_views WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
734 +
}
735 +
736 +
/// Save a view under `name`, replacing the one of that name.
737 +
pub async fn save_view(pool: &Pool, project_id: i64, name: &str, layout: &str, q: &str, lane: &str, sort: &str) -> sqlx::Result<View> {
738 +
    let mut tx = super::begin_write(pool).await?;
739 +
    let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM project_views WHERE project_id = $1 AND name = $2")
740 +
        .bind(project_id)
741 +
        .bind(name)
742 +
        .fetch_optional(&mut *tx)
743 +
        .await?;
744 +
    let v: View = match existing {
745 +
        Some(id) => {
746 +
            sqlx::query_as("UPDATE project_views SET layout = $1, q = $2, lane = $3, sort = $4 WHERE id = $5 RETURNING *")
747 +
                .bind(layout)
748 +
                .bind(q)
749 +
                .bind(lane)
750 +
                .bind(sort)
751 +
                .bind(id)
752 +
                .fetch_one(&mut *tx)
753 +
                .await?
754 +
        }
755 +
        None => {
756 +
            let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_views WHERE project_id = $1")
757 +
                .bind(project_id)
758 +
                .fetch_one(&mut *tx)
759 +
                .await?;
760 +
            sqlx::query_as(
761 +
                "INSERT INTO project_views (project_id, name, layout, q, lane, sort, position, created_ms) \
762 +
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
763 +
            )
764 +
            .bind(project_id)
765 +
            .bind(name)
766 +
            .bind(layout)
767 +
            .bind(q)
768 +
            .bind(lane)
769 +
            .bind(sort)
770 +
            .bind(pos)
771 +
            .bind(now_ms())
772 +
            .fetch_one(&mut *tx)
773 +
            .await?
774 +
        }
775 +
    };
776 +
    tx.commit().await?;
777 +
    touch(pool, project_id).await?;
778 +
    Ok(v)
779 +
}
780 +
781 +
pub async fn delete_view(pool: &Pool, project_id: i64, view_id: i64) -> sqlx::Result<bool> {
782 +
    let r = sqlx::query("DELETE FROM project_views WHERE id = $1 AND project_id = $2").bind(view_id).bind(project_id).execute(pool).await?;
783 +
    touch(pool, project_id).await?;
784 +
    Ok(r.rows_affected() == 1)
785 +
}
786 +
787 +
/// A column's work-in-progress limit (#361); `None` for none.
788 +
pub async fn set_wip_limit(pool: &Pool, project_id: i64, column_id: i64, limit: Option<i64>) -> sqlx::Result<()> {
789 +
    sqlx::query("UPDATE project_columns SET wip_limit = $1 WHERE id = $2 AND project_id = $3")
790 +
        .bind(limit)
791 +
        .bind(column_id)
792 +
        .bind(project_id)
793 +
        .execute(pool)
794 +
        .await?;
795 +
    touch(pool, project_id).await
626 796
}
modified crates/hub/src/web/projects.rs +529 −16
…
174 unchanged lines
175 175
}
176 176
177 177
/// A card as the board shows it.
178 +
#[derive(Clone)]
178 179
pub struct CardView {
179 180
    pub card: pdb::Card,
180 181
    /// `owner/name#N`, for issue and proposal cards.
…
5 unchanged lines
186 187
    /// The issue's or proposal's labels, and an issue's assignees (#293).
187 188
    pub labels: Vec<idb::Label>,
188 189
    pub assignees: Vec<String>,
190 +
    /// Its repository's name, its milestone's title, and when the issue or
191 +
    /// proposal was opened (a note: when it was added), for the filter's
192 +
    /// `repo:`, `milestone:` and `age:` and for lanes (#361).
193 +
    pub repo: String,
194 +
    pub milestone: String,
195 +
    pub opened_ms: i64,
189 196
}
190 197
198 +
#[derive(Clone)]
191 199
pub struct ColumnView {
192 200
    pub column: pdb::Column,
193 201
    pub cards: Vec<CardView>,
…
6 unchanged lines
200 208
    /// Its cards are all finished work (a Done column): its note says
201 209
    /// they are archived after a fortnight unless it says otherwise.
202 210
    pub finished: bool,
211 +
    /// More cards than its work-in-progress limit (#361).
212 +
    pub over_wip: bool,
213 +
    /// Done cards that reached it before the open iteration began, left
214 +
    /// out: the board shows this iteration's done work (#361).
215 +
    pub before_iteration: usize,
203 216
}
204 217
205 218
/// A column longer than this shows its newest cards only, with "show all"
…
1 unchanged lines
207 220
pub const COLUMN_SHOWN: usize = 30;
208 221
209 222
/// How the board page shows its cards (#293).
210 -
#[derive(Deserialize, Default)]
223 +
#[derive(Deserialize, Default, Clone)]
211 224
pub struct BoardQuery {
212 225
    /// The column shown whole.
213 226
    #[serde(default)]
…
11 unchanged lines
225 238
    /// A past iteration: the cards archived under it, and its burn-up (#343).
226 239
    #[serde(default)]
227 240
    iteration: Option<i64>,
241 +
    /// A saved view, by name (#361): its layout, filter, lanes and sort;
242 +
    /// what the address says besides wins.
243 +
    #[serde(default)]
244 +
    pub view: Option<String>,
245 +
    /// `board` (the default) or `table`.
246 +
    #[serde(default)]
247 +
    pub layout: Option<String>,
248 +
    /// Swimlanes or groups: `repo`, `assignee`, `label`, `milestone`.
249 +
    #[serde(default)]
250 +
    pub lane: Option<String>,
251 +
    /// The table's order (`title`, `reference`, `state`, `column`, `age`;
252 +
    /// `-` first to reverse).
253 +
    #[serde(default)]
254 +
    pub sort: Option<String>,
255 +
    /// Compact cards: title, reference, avatars, labels as dots.
256 +
    #[serde(default)]
257 +
    pub compact: Option<String>,
258 +
    /// The table's page, from 1.
259 +
    #[serde(default)]
260 +
    pub page: Option<usize>,
228 261
}
229 262
230 263
impl BoardQuery {
264 +
    /// This query over the saved view it names (#361): what the address
265 +
    /// gives wins, the view fills the rest.
266 +
    pub fn over(&self, saved: Option<&pdb::View>) -> BoardQuery {
267 +
        let mut q = self.clone();
268 +
        if let Some(v) = saved {
269 +
            let fill = |x: &mut Option<String>, from: &str| {
270 +
                if x.is_none() && !from.is_empty() {
271 +
                    *x = Some(from.to_string());
272 +
                }
273 +
            };
274 +
            fill(&mut q.q, &v.q);
275 +
            fill(&mut q.layout, &v.layout);
276 +
            fill(&mut q.lane, &v.lane);
277 +
            fill(&mut q.sort, &v.sort);
278 +
        }
279 +
        q
280 +
    }
281 +
    pub fn layout(&self) -> &str {
282 +
        match self.layout.as_deref() {
283 +
            Some("table") => "table",
284 +
            _ => "board",
285 +
        }
286 +
    }
287 +
    pub fn lane(&self) -> &str {
288 +
        self.lane.as_deref().filter(|l| matches!(*l, "repo" | "assignee" | "label" | "milestone")).unwrap_or("")
289 +
    }
290 +
    fn compact(&self) -> bool {
291 +
        self.compact.as_deref().is_some_and(|c| !c.is_empty() && c != "0")
292 +
    }
231 293
    fn text(&self) -> String {
232 294
        self.q.as_deref().map(str::trim).unwrap_or("").to_string()
233 295
    }
…
7 unchanged lines
241 303
242 304
/// The board filter's `label:`, `assignee:` and `state:` terms, and its words.
243 305
#[derive(Default)]
244 -
struct CardFilter {
306 +
pub struct CardFilter {
245 307
    labels: Vec<String>,
246 308
    assignees: Vec<String>,
247 309
    state: String,
248 310
    words: Vec<String>,
311 +
    /// `repo:NAME`, `milestone:TITLE` (#361).
312 +
    repos: Vec<String>,
313 +
    milestones: Vec<String>,
314 +
    /// `is:open` (open or draft) or `is:closed` (closed or landed).
315 +
    is: String,
316 +
    /// `age:>14d` and `age:<7d`: opened more than, or less than, so many
317 +
    /// days ago.
318 +
    older_than_days: Option<i64>,
319 +
    newer_than_days: Option<i64>,
249 320
}
250 321
251 322
impl CardFilter {
252 323
    fn of(view: &BoardQuery) -> CardFilter {
253 -
        let mut f = CardFilter { state: view.state(), ..Default::default() };
254 -
        for tok in view.text().split_whitespace() {
324 +
        let mut f = CardFilter::parse(&view.text());
325 +
        if f.state.is_empty() {
326 +
            f.state = view.state();
327 +
        }
328 +
        f
329 +
    }
330 +
331 +
    /// The filter bar's text (#361): words, and `label:`, `assignee:`,
332 +
    /// `state:`, `repo:`, `milestone:`, `is:open|closed` and `age:>Nd` or
333 +
    /// `age:<Nd`. A value with spaces takes quotes: `milestone:"Release 1"`.
334 +
    pub fn parse(text: &str) -> CardFilter {
335 +
        let mut f = CardFilter::default();
336 +
        for tok in tokens(text) {
255 337
            match tok.split_once(':') {
256 338
                Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()),
257 339
                Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()),
258 340
                Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(),
341 +
                Some(("repo", v)) if !v.is_empty() => f.repos.push(v.rsplit('/').next().unwrap_or(v).to_lowercase()),
342 +
                Some(("milestone", v)) if !v.is_empty() => f.milestones.push(v.to_lowercase()),
343 +
                Some(("is", v)) if matches!(v, "open" | "closed") => f.is = v.to_string(),
344 +
                Some(("age", v)) if age_days(v).is_some() => match age_days(v) {
345 +
                    Some((true, d)) => f.older_than_days = Some(d),
346 +
                    Some((false, d)) => f.newer_than_days = Some(d),
347 +
                    None => {}
348 +
                },
259 349
                _ => f.words.push(tok.to_lowercase()),
260 350
            }
261 351
        }
262 352
        f
263 353
    }
264 354
265 -
    fn is_empty(&self) -> bool {
266 -
        self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty()
355 +
    pub fn is_empty(&self) -> bool {
356 +
        self.labels.is_empty()
357 +
            && self.assignees.is_empty()
358 +
            && self.state.is_empty()
359 +
            && self.words.is_empty()
360 +
            && self.repos.is_empty()
361 +
            && self.milestones.is_empty()
362 +
            && self.is.is_empty()
363 +
            && self.older_than_days.is_none()
364 +
            && self.newer_than_days.is_none()
267 365
    }
268 366
269 367
    /// Every term holds: each word in the title or reference, each label
270 -
    /// and assignee among the card's, and its state.
271 -
    fn keeps(&self, c: &CardView) -> bool {
368 +
    /// and assignee among the card's, its state, repository, milestone and
369 +
    /// age.
370 +
    pub fn keeps(&self, c: &CardView, now: i64) -> bool {
272 371
        let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase());
372 +
        let age_days = (now - c.opened_ms) / DAY_MS;
273 373
        self.words.iter().all(|w| title.contains(w) || reference.contains(w))
274 374
            && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l)))
275 375
            && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a)))
276 376
            && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state))
377 +
            && self.repos.iter().all(|r| c.repo.eq_ignore_ascii_case(r))
378 +
            && self.milestones.iter().all(|m| c.milestone.eq_ignore_ascii_case(m))
379 +
            && match self.is.as_str() {
380 +
                "open" => matches!(c.state.as_str(), "open" | "draft"),
381 +
                "closed" => matches!(c.state.as_str(), "closed" | "landed"),
382 +
                _ => true,
383 +
            }
384 +
            && self.older_than_days.is_none_or(|d| age_days > d)
385 +
            && self.newer_than_days.is_none_or(|d| age_days < d)
386 +
    }
387 +
}
388 +
389 +
/// The filter bar's words, a quoted value kept whole (`milestone:"Release 1"`).
390 +
fn tokens(text: &str) -> Vec<String> {
391 +
    let mut out = Vec::new();
392 +
    let mut cur = String::new();
393 +
    let mut quoted = false;
394 +
    for ch in text.chars() {
395 +
        match ch {
396 +
            '"' => quoted = !quoted,
397 +
            c if c.is_whitespace() && !quoted => {
398 +
                if !cur.is_empty() {
399 +
                    out.push(std::mem::take(&mut cur));
400 +
                }
401 +
            }
402 +
            c => cur.push(c),
403 +
        }
277 404
    }
405 +
    if !cur.is_empty() {
406 +
        out.push(cur);
407 +
    }
408 +
    out
278 409
}
279 410
280 -
/// Filter and cut the columns as `view` says.
281 -
fn shape(columns: &mut [ColumnView], view: &BoardQuery) {
411 +
/// `>14d` (older than 14 days: `(true, 14)`), `<7d` (newer: `(false, 7)`);
412 +
/// the `d` may be left off.
413 +
fn age_days(v: &str) -> Option<(bool, i64)> {
414 +
    let (older, rest) = match v.as_bytes().first()? {
415 +
        b'>' => (true, &v[1..]),
416 +
        b'<' => (false, &v[1..]),
417 +
        _ => return None,
418 +
    };
419 +
    let n: i64 = rest.trim_end_matches('d').parse().ok()?;
420 +
    (n >= 0).then_some((older, n))
421 +
}
422 +
423 +
/// Filter and cut the columns as `view` says. With an iteration open,
424 +
/// the Done column `done` shows the cards that reached it since the
425 +
/// iteration began (#361); the rest are counted.
426 +
fn shape(columns: &mut [ColumnView], view: &BoardQuery, now: i64, done: Option<i64>, since: Option<i64>) {
282 427
    let filter = CardFilter::of(view);
283 428
    for col in columns.iter_mut() {
284 429
        col.total = col.cards.len();
430 +
        if let (Some(d), Some(since), false) = (done, since, view.archived() || view.iteration.is_some()) {
431 +
            if col.column.id == d {
432 +
                let before = col.cards.len();
433 +
                col.cards.retain(|c| c.card.moved_ms >= since);
434 +
                col.before_iteration = before - col.cards.len();
435 +
                col.total = col.cards.len();
436 +
            }
437 +
        }
438 +
        col.over_wip = col.column.wip_limit.is_some_and(|w| w >= 0 && col.total as i64 > w);
285 439
        if !filter.is_empty() {
286 -
            col.cards.retain(|c| filter.keeps(c));
440 +
            col.cards.retain(|c| filter.keeps(c, now));
287 441
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() {
288 442
            // Cards join a column at its end: the newest are the last.
289 443
            let cut = col.cards.len() - COLUMN_SHOWN;
…
1 unchanged lines
291 445
        }
292 446
        col.hidden = col.total - col.cards.len();
293 447
    }
448 +
}
449 +
450 +
/// A swimlane (#361): the board's columns with the cards of one
451 +
/// repository, assignee, label or milestone.
452 +
pub struct LaneView {
453 +
    pub name: String,
454 +
    pub columns: Vec<ColumnView>,
455 +
    pub count: usize,
294 456
}
295 457
458 +
/// The lanes a card belongs to under `lane`: one, or one per assignee or
459 +
/// label; those without one share a lane named for it.
460 +
fn lanes_of(c: &CardView, lane: &str) -> Vec<String> {
461 +
    let many = |v: Vec<String>, none: &str| if v.is_empty() { vec![none.to_string()] } else { v };
462 +
    match lane {
463 +
        "repo" => vec![if c.repo.is_empty() { "(notes)".into() } else { c.repo.clone() }],
464 +
        "assignee" => many(c.assignees.iter().map(|a| format!("@{a}")).collect(), "(nobody)"),
465 +
        "label" => many(c.labels.iter().map(|l| l.name.clone()).collect(), "(no label)"),
466 +
        "milestone" => vec![if c.milestone.is_empty() { "(no milestone)".into() } else { c.milestone.clone() }],
467 +
        _ => vec![String::new()],
468 +
    }
469 +
}
470 +
471 +
/// Split shaped columns into swimlanes by `lane`, named in order with
472 +
/// the lane of those without one last.
473 +
pub fn lanes(columns: &[ColumnView], lane: &str) -> Vec<LaneView> {
474 +
    let mut names: Vec<String> = columns.iter().flat_map(|col| col.cards.iter().flat_map(|c| lanes_of(c, lane))).collect();
475 +
    names.sort_by_key(|n| (n.starts_with('('), n.to_lowercase()));
476 +
    names.dedup();
477 +
    names
478 +
        .into_iter()
479 +
        .map(|name| {
480 +
            let columns: Vec<ColumnView> = columns
481 +
                .iter()
482 +
                .map(|col| {
483 +
                    let mut col = col.clone();
484 +
                    col.cards.retain(|c| lanes_of(c, lane).contains(&name));
485 +
                    col.total = col.cards.len();
486 +
                    col.hidden = 0;
487 +
                    col.before_iteration = 0;
488 +
                    col
489 +
                })
490 +
                .collect();
491 +
            let count = columns.iter().map(|c| c.cards.len()).sum();
492 +
            LaneView { name, columns, count }
493 +
        })
494 +
        .collect()
495 +
}
496 +
497 +
/// A card as a table row (#361), with its column and group.
498 +
pub struct RowView {
499 +
    pub c: CardView,
500 +
    pub column: String,
501 +
    pub column_id: i64,
502 +
    pub group: String,
503 +
    /// The first row of its group, which carries the group's heading.
504 +
    pub starts_group: bool,
505 +
}
506 +
507 +
/// Rows a table page shows.
508 +
pub const TABLE_PAGE: usize = 50;
509 +
510 +
/// Every shaped card as a row, grouped by `lane` and ordered by `sort`
511 +
/// (`title`, `reference`, `state`, `column`, `age`; a leading `-`
512 +
/// reverses), then the page `page` (from 1) of them and how many pages.
513 +
pub fn rows(columns: &[ColumnView], lane: &str, sort: &str, page: usize) -> (Vec<RowView>, usize) {
514 +
    let mut all: Vec<RowView> = Vec::new();
515 +
    for (ci, col) in columns.iter().enumerate() {
516 +
        for c in &col.cards {
517 +
            for group in lanes_of(c, lane) {
518 +
                all.push(RowView { c: c.clone(), column: col.column.name.clone(), column_id: ci as i64, group, starts_group: false });
519 +
            }
520 +
        }
521 +
    }
522 +
    let (desc, key) = match sort.strip_prefix('-') {
523 +
        Some(k) => (true, k),
524 +
        None => (false, sort),
525 +
    };
526 +
    all.sort_by(|a, b| {
527 +
        let by = match key {
528 +
            "title" => a.c.title.to_lowercase().cmp(&b.c.title.to_lowercase()),
529 +
            "reference" => a.c.reference.cmp(&b.c.reference),
530 +
            "state" => a.c.state.cmp(&b.c.state),
531 +
            "column" => a.column_id.cmp(&b.column_id),
532 +
            "age" => b.c.opened_ms.cmp(&a.c.opened_ms),
533 +
            _ => a.column_id.cmp(&b.column_id).then(a.c.card.position.cmp(&b.c.card.position)),
534 +
        };
535 +
        let by = if desc { by.reverse() } else { by };
536 +
        (a.group.starts_with('('), a.group.to_lowercase()).cmp(&(b.group.starts_with('('), b.group.to_lowercase())).then(by)
537 +
    });
538 +
    let pages = all.len().div_ceil(TABLE_PAGE).max(1);
539 +
    let page = page.clamp(1, pages);
540 +
    let mut shown: Vec<RowView> = all.into_iter().skip((page - 1) * TABLE_PAGE).take(TABLE_PAGE).collect();
541 +
    let mut last: Option<String> = None;
542 +
    for r in shown.iter_mut() {
543 +
        r.starts_group = !lane.is_empty() && last.as_ref() != Some(&r.group);
544 +
        last = Some(r.group.clone());
545 +
    }
546 +
    (shown, pages)
547 +
}
548 +
296 549
/// An iteration as the board shows it (#343).
297 550
pub struct IterationView {
298 551
    pub it: pdb::Iteration,
…
90 unchanged lines
389 642
    /// A new iteration's suggested first and last days: today and 13 days on.
390 643
    next_first: String,
391 644
    next_last: String,
645 +
    /// The saved views as tabs, and the one shown (#361).
646 +
    views: Vec<pdb::View>,
647 +
    view_name: String,
648 +
    /// `board` or `table`; lanes or groups; the table's order; compact cards.
649 +
    layout: String,
650 +
    lane: String,
651 +
    sort: String,
652 +
    compact: bool,
653 +
    /// The board in swimlanes, when `lane` is set.
654 +
    lanes: Vec<LaneView>,
655 +
    /// The table's rows, page and pages.
656 +
    rows: Vec<RowView>,
657 +
    page: usize,
658 +
    pages: usize,
659 +
    /// The address's query for this look, without the page, for links.
660 +
    look: String,
392 661
}
393 662
394 663
#[allow(clippy::too_many_arguments)]
…
15 unchanged lines
410 679
        .filter(|p| p.public || r.member)
411 680
        .ok_or_else(|| Error::not_found(shell.clone()))?;
412 681
    let now = db::now_ms();
682 +
    let views = pdb::views(&app.db, project.id).await.map_err(e)?;
683 +
    let saved = view.view.as_deref().and_then(|n| views.iter().find(|v| v.name == n));
684 +
    let look = view.over(saved);
685 +
    let view = &look;
413 686
    let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?;
414 687
    let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned());
415 688
    // A past iteration's page is the archive of what was done in it.
…
4 unchanged lines
420 693
            col.cards.retain(|c| c.card.iteration_id == Some(it.id));
421 694
        }
422 695
    }
423 -
    shape(&mut columns, view);
424 -
    let archived_total = columns.iter().map(|c| c.archived).sum();
425 696
    let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>());
697 +
    let open_since = iterations.iter().find(|i| i.closed_ms.is_none()).map(|i| i.start_ms);
698 +
    shape(&mut columns, view, now, done, open_since);
699 +
    let archived_total = columns.iter().map(|c| c.archived).sum();
700 +
    let (layout, lane, sort) = (view.layout().to_string(), view.lane().to_string(), view.sort.clone().unwrap_or_default());
701 +
    let lanes = if layout == "board" && !lane.is_empty() { lanes(&columns, &lane) } else { Vec::new() };
702 +
    let (rows, pages) = if layout == "table" { rows(&columns, &lane, &sort, view.page.unwrap_or(1)) } else { (Vec::new(), 1) };
703 +
    let page = view.page.unwrap_or(1).clamp(1, pages);
704 +
    let mut parts: Vec<String> = Vec::new();
705 +
    for (k, v) in [
706 +
        ("view", view.view.clone().unwrap_or_default()),
707 +
        ("layout", if layout == "table" { layout.clone() } else { String::new() }),
708 +
        ("q", view.text()),
709 +
        ("lane", lane.clone()),
710 +
        ("sort", sort.clone()),
711 +
        ("compact", if view.compact() { "1".into() } else { String::new() }),
712 +
    ] {
713 +
        if !v.is_empty() {
714 +
            parts.push(format!("{k}={}", super::settings::urlencode(&v)));
715 +
        }
716 +
    }
717 +
    let look_query = parts.join("&");
426 718
    let (mut current, mut past) = (None, Vec::new());
427 719
    for it in iterations {
428 720
        if it.closed_ms.is_none() {
…
36 unchanged lines
465 757
        shown_iteration,
466 758
        next_first: day_of(now),
467 759
        next_last: day_of(now + 13 * DAY_MS),
760 +
        view_name: view.view.clone().filter(|n| views.iter().any(|v| &v.name == n)).unwrap_or_default(),
761 +
        views,
762 +
        layout,
763 +
        lane,
764 +
        sort,
765 +
        compact: view.compact(),
766 +
        lanes,
767 +
        rows,
768 +
        page,
769 +
        pages,
770 +
        look: look_query,
468 771
    }))
469 772
}
470 773
…
41 unchanged lines
512 815
                    state: String::new(),
513 816
                    labels: Vec::new(),
514 817
                    assignees: Vec::new(),
818 +
                    repo: String::new(),
819 +
                    milestone: String::new(),
820 +
                    opened_ms: card.created_ms,
515 821
                }),
516 822
                (kind, Some(rid), Some(n)) => {
517 823
                    // Cards of repositories the viewer cannot read are not shown at all.
…
8 unchanged lines
526 832
                        state: s.state.clone(),
527 833
                        labels: s.labels.clone(),
528 834
                        assignees: s.assignees.clone(),
835 +
                        repo: row.name.clone(),
836 +
                        milestone: s.milestone.clone(),
837 +
                        opened_ms: s.opened_ms,
529 838
                    });
530 839
                }
531 840
                _ => {}
…
3 unchanged lines
535 844
        let archived = archived_in(column.id);
536 845
        let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect();
537 846
        let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed"));
538 -
        columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished });
847 +
        columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished, over_wip: false, before_iteration: 0 });
539 848
    }
540 849
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
541 850
    repos.sort();
…
6 unchanged lines
548 857
    state: String,
549 858
    labels: Vec<idb::Label>,
550 859
    assignees: Vec<String>,
860 +
    milestone: String,
861 +
    opened_ms: i64,
551 862
}
552 863
553 864
/// A repository's issues (or proposals) as cards show them, by number.
…
9 unchanged lines
563 874
        for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? {
564 875
            assignees.entry(id).or_default().push(u);
565 876
        }
877 +
        let milestones: HashMap<i64, String> = idb::milestones(&app.db, repo_id).await?.into_iter().map(|m| (m.id, m.title)).collect();
566 878
        for i in idb::list(&app.db, repo_id).await? {
567 879
            let mut a = assignees.remove(&i.id).unwrap_or_default();
568 880
            a.sort();
569 -
            let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state };
881 +
            let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()).unwrap_or_default();
882 +
            let s = Shown {
883 +
                labels: labels.remove(&i.id).unwrap_or_default(),
884 +
                assignees: a,
885 +
                title: i.title,
886 +
                state: i.state,
887 +
                milestone,
888 +
                opened_ms: i.created_ms,
889 +
            };
570 890
            out.push((i.number, s));
571 891
        }
572 892
    } else {
…
2 unchanged lines
575 895
            labels.entry(id).or_default().push(l);
576 896
        }
577 897
        for p in db::proposals::list(&app.db, repo_id, &[]).await? {
578 -
            let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state };
898 +
            let s = Shown {
899 +
                labels: labels.remove(&p.id).unwrap_or_default(),
900 +
                assignees: Vec::new(),
901 +
                title: p.title,
902 +
                state: p.state,
903 +
                milestone: String::new(),
904 +
                opened_ms: p.created_ms,
905 +
            };
579 906
            out.push((p.number, s));
580 907
        }
581 908
    }
…
60 unchanged lines
642 969
    start: String,
643 970
    #[serde(default)]
644 971
    end: String,
972 +
    /// A column's work-in-progress limit; empty for none (#361).
973 +
    #[serde(default)]
974 +
    limit: String,
975 +
    /// A view to save (with `name`): its layout, filter, lanes and order;
976 +
    /// or the id of one to delete (#361).
977 +
    #[serde(default)]
978 +
    layout: String,
979 +
    #[serde(default)]
980 +
    q: String,
981 +
    #[serde(default)]
982 +
    lane: String,
983 +
    #[serde(default)]
984 +
    sort: String,
985 +
    #[serde(default)]
986 +
    view: Option<i64>,
645 987
}
646 988
647 989
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
…
206 unchanged lines
854 1196
                    notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" }));
855 1197
                }
856 1198
                None => error = Some("That iteration is not open.".into()),
1199 +
            }
1200 +
        }
1201 +
        "wip" => {
1202 +
            let limit = f.limit.trim();
1203 +
            match (column, limit.parse::<i64>()) {
1204 +
                (Some(col), _) if limit.is_empty() => pdb::set_wip_limit(&app.db, project.id, col, None).await.map_err(e)?,
1205 +
                (Some(col), Ok(n)) if (0..=999).contains(&n) => pdb::set_wip_limit(&app.db, project.id, col, Some(n)).await.map_err(e)?,
1206 +
                _ => error = Some("A work-in-progress limit is a number of cards, 0 to 999, or empty for none.".into()),
1207 +
            }
1208 +
        }
1209 +
        "save_view" => {
1210 +
            let name: String = f.name.trim().chars().take(40).collect();
1211 +
            let layout = if f.layout == "table" { "table" } else { "board" };
1212 +
            let lane = if matches!(f.lane.as_str(), "repo" | "assignee" | "label" | "milestone") { f.lane.as_str() } else { "" };
1213 +
            let sort: String = f.sort.trim().chars().take(20).collect();
1214 +
            let q: String = f.q.trim().chars().take(300).collect();
1215 +
            if name.is_empty() {
1216 +
                error = Some("Name the view.".into());
1217 +
            } else {
1218 +
                pdb::save_view(&app.db, project.id, &name, layout, &q, lane, &sort).await.map_err(e)?;
1219 +
                return Ok(Redirect::to(&format!("/projects/{owner}/{number}?view={}", super::settings::urlencode(&name))).into_response());
1220 +
            }
1221 +
        }
1222 +
        "delete_view" => {
1223 +
            if let Some(id) = f.view {
1224 +
                pdb::delete_view(&app.db, project.id, id).await.map_err(e)?;
857 1225
            }
858 1226
        }
859 1227
        "add_column" => {
…
195 unchanged lines
1055 1423
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
1056 1424
            .collect(),
1057 1425
    )
1426 +
}
1427 +
1428 +
#[cfg(test)]
1429 +
mod view_tests {
1430 +
    use super::*;
1431 +
1432 +
    const NOW: i64 = 100 * DAY_MS;
1433 +
1434 +
    #[allow(clippy::too_many_arguments)]
1435 +
    fn card(
1436 +
        id: i64,
1437 +
        title: &str,
1438 +
        repo: &str,
1439 +
        state: &str,
1440 +
        assignees: &[&str],
1441 +
        labels: &[&str],
1442 +
        milestone: &str,
1443 +
        age_days: i64,
1444 +
    ) -> CardView {
1445 +
        CardView {
1446 +
            card: pdb::Card {
1447 +
                id,
1448 +
                project_id: 1,
1449 +
                column_id: 1,
1450 +
                position: id,
1451 +
                kind: "issue".into(),
1452 +
                repo_id: Some(1),
1453 +
                number: Some(id),
1454 +
                note: String::new(),
1455 +
                added_by: String::new(),
1456 +
                created_ms: 0,
1457 +
                archived_ms: None,
1458 +
                moved_ms: NOW - age_days * DAY_MS,
1459 +
                iteration_id: None,
1460 +
            },
1461 +
            reference: format!("andy/{repo}#{id}"),
1462 +
            url: format!("/andy/{repo}/issue/{id}"),
1463 +
            title: title.into(),
1464 +
            state: state.into(),
1465 +
            labels: labels
1466 +
                .iter()
1467 +
                .map(|l| idb::Label { id: 0, repo_id: 1, name: (*l).into(), colour: "#123456".into(), description: String::new() })
1468 +
                .collect(),
1469 +
            assignees: assignees.iter().map(|a| (*a).to_string()).collect(),
1470 +
            repo: repo.into(),
1471 +
            milestone: milestone.into(),
1472 +
            opened_ms: NOW - age_days * DAY_MS,
1473 +
        }
1474 +
    }
1475 +
1476 +
    fn column(id: i64, name: &str, wip: Option<i64>, cards: Vec<CardView>) -> ColumnView {
1477 +
        ColumnView {
1478 +
            column: pdb::Column {
1479 +
                id,
1480 +
                project_id: 1,
1481 +
                name: name.into(),
1482 +
                position: id,
1483 +
                archive_after_days: None,
1484 +
                takes_finished: name == "Done",
1485 +
                wip_limit: wip,
1486 +
            },
1487 +
            total: cards.len(),
1488 +
            cards,
1489 +
            hidden: 0,
1490 +
            archived: 0,
1491 +
            finished: false,
1492 +
            over_wip: false,
1493 +
            before_iteration: 0,
1494 +
        }
1495 +
    }
1496 +
1497 +
    fn board() -> Vec<ColumnView> {
1498 +
        vec![
1499 +
            column(
1500 +
                1,
1501 +
                "Doing",
1502 +
                Some(1),
1503 +
                vec![
1504 +
                    card(1, "Crash on start", "oxy", "open", &["andy"], &["bug"], "Release 1", 20),
1505 +
                    card(2, "Faster pull", "hub", "open", &[], &["perf", "bug"], "", 3),
1506 +
                ],
1507 +
            ),
1508 +
            column(2, "Done", None, vec![card(3, "Docs", "oxy", "closed", &["kim"], &[], "Release 1", 40)]),
1509 +
        ]
1510 +
    }
1511 +
1512 +
    fn keeps(q: &str) -> Vec<i64> {
1513 +
        let f = CardFilter::parse(q);
1514 +
        board().iter().flat_map(|c| c.cards.iter()).filter(|c| f.keeps(c, NOW)).map(|c| c.card.id).collect()
1515 +
    }
1516 +
1517 +
    #[test]
1518 +
    fn the_filter_bar_reads_every_term() {
1519 +
        assert_eq!(keeps(""), [1, 2, 3]);
1520 +
        assert_eq!(keeps("repo:oxy"), [1, 3]);
1521 +
        assert_eq!(keeps("repo:andy/hub"), [2]);
1522 +
        assert_eq!(keeps("assignee:@andy"), [1]);
1523 +
        assert_eq!(keeps("label:bug label:perf"), [2]);
1524 +
        assert_eq!(keeps(r#"milestone:"release 1""#), [1, 3]);
1525 +
        assert_eq!(keeps("is:open"), [1, 2]);
1526 +
        assert_eq!(keeps("is:closed"), [3]);
1527 +
        assert_eq!(keeps("age:>14d"), [1, 3]);
1528 +
        assert_eq!(keeps("age:<7"), [2]);
1529 +
        assert_eq!(keeps("crash age:>14d"), [1]);
1530 +
        assert_eq!(keeps("age:soon"), Vec::<i64>::new(), "an unreadable term is a word, which matches nothing");
1531 +
        assert!(CardFilter::parse("  ").is_empty());
1532 +
    }
1533 +
1534 +
    #[test]
1535 +
    fn wip_limits_and_this_iterations_done() {
1536 +
        let mut cols = board();
1537 +
        cols[1].cards.push(card(4, "Old", "oxy", "closed", &[], &[], "", 1));
1538 +
        cols[1].cards[1].card.moved_ms = NOW - 30 * DAY_MS;
1539 +
        let q = BoardQuery::default();
1540 +
        shape(&mut cols, &q, NOW, Some(2), Some(NOW - 50 * DAY_MS + 1));
1541 +
        assert!(cols[0].over_wip, "two cards over a limit of one");
1542 +
        assert!(!cols[1].over_wip);
1543 +
        // An iteration that began 50 days ago: card 3 (in Done for 40 days)
1544 +
        // and card 4 (30) both reached Done during it.
1545 +
        assert_eq!((cols[1].cards.len(), cols[1].before_iteration), (2, 0));
1546 +
        let mut cols = board();
1547 +
        shape(&mut cols, &q, NOW, Some(2), Some(NOW - 10 * DAY_MS));
1548 +
        assert_eq!((cols[1].cards.len(), cols[1].before_iteration, cols[1].total), (0, 1, 0), "done before it began");
1549 +
    }
1550 +
1551 +
    #[test]
1552 +
    fn lanes_and_table_rows() {
1553 +
        let cols = board();
1554 +
        let names: Vec<(String, usize)> = lanes(&cols, "label").into_iter().map(|l| (l.name, l.count)).collect();
1555 +
        assert_eq!(names, [("bug".to_string(), 2), ("perf".into(), 1), ("(no label)".into(), 1)]);
1556 +
        let by_repo: Vec<String> = lanes(&cols, "repo").into_iter().map(|l| l.name).collect();
1557 +
        assert_eq!(by_repo, ["hub", "oxy"]);
1558 +
        let (shown, pages) = super::rows(&cols, "", "title", 1);
1559 +
        assert_eq!(pages, 1);
1560 +
        assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [1, 3, 2]);
1561 +
        let (shown, _) = super::rows(&cols, "", "-age", 1);
1562 +
        assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [3, 1, 2], "oldest first");
1563 +
        let (shown, _) = super::rows(&cols, "assignee", "", 1);
1564 +
        let groups: Vec<(&str, bool)> = shown.iter().map(|r| (r.group.as_str(), r.starts_group)).collect();
1565 +
        assert_eq!(groups, [("@andy", true), ("@kim", true), ("(nobody)", true)]);
1566 +
        // Paged: 120 cards are three pages of fifty.
1567 +
        let many = vec![column(1, "Doing", None, (0..120).map(|i| card(i, "x", "oxy", "open", &[], &[], "", 1)).collect())];
1568 +
        let (p3, pages) = super::rows(&many, "", "", 3);
1569 +
        assert_eq!((p3.len(), pages), (20, 3));
1570 +
    }
1058 1571
}
modified crates/hub/tests/issues.rs +22 −0
…
970 unchanged lines
971 971
    let issue = get(app, "/andy/demo/issue/1", None).await.body;
972 972
    assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is");
973 973
974 +
    // Views (#361): every card move is history; a WIP limit, a saved
975 +
    // table view grouped by repository, lanes, and the filter's new terms.
976 +
    let moves: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_card_moves").fetch_one(&app.db).await.unwrap();
977 +
    assert_eq!(moves, 4, "three cards added and one moved");
978 +
    act(format!("_csrf={csrf}&action=wip&column={}&limit=0", cols[2])).await;
979 +
    let b = get(app, "/projects/andy/1", Some(&andy)).await.body;
980 +
    assert!(b.contains("class=\"over-wip\"") && b.contains("1 / 0"), "Done is over its limit of none in progress");
981 +
    let r = act(format!("_csrf={csrf}&action=save_view&name=By+repo&layout=table&lane=repo&q=is%3Aopen")).await;
982 +
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1?view=By%20repo"), "{}", r.body);
983 +
    let t = get(app, "/projects/andy/1?view=By%20repo", Some(&andy)).await.body;
984 +
    assert!(t.contains("class=\"board-table\"") && t.contains("aria-current=\"page\">By repo"), "the view's tab and its table");
985 +
    assert!(t.contains("<th colspan=\"8\" scope=\"colgroup\">secret</th>") && t.contains("secret issue"), "{t}");
986 +
    assert!(!t.contains("Write the docs"), "a note is not open work");
987 +
    let lanes = get(app, "/projects/andy/1?lane=repo", Some(&andy)).await.body;
988 +
    assert!(lanes.contains("aria-label=\"demo\"") && lanes.contains("aria-label=\"(notes)\""), "{lanes}");
989 +
    let only = get(app, "/projects/andy/1?q=repo%3Asecret", Some(&andy)).await.body;
990 +
    assert!(only.contains("secret issue") && !only.contains("demo issue"), "repo: filters");
991 +
    let view_id: i64 = sqlx::query_scalar("SELECT id FROM project_views").fetch_one(&app.db).await.unwrap();
992 +
    act(format!("_csrf={csrf}&action=delete_view&view={view_id}")).await;
993 +
    assert!(!get(app, "/projects/andy/1", Some(&andy)).await.body.contains(">By repo <"), "deleted");
994 +
    act(format!("_csrf={csrf}&action=wip&column={}&limit=", cols[2])).await;
995 +
974 996
    // Readers see public cards, never those of repositories they cannot read.
975 997
    let anon = get(app, "/projects/andy/1", None).await.body;
976 998
    assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}");
…
638 unchanged lines
modified crates/oh/src/main.rs +27 −4
…
533 unchanged lines
534 534
        /// Its archived cards instead (#293)
535 535
        #[arg(long)]
536 536
        archived: bool,
537 +
        /// Only the cards this filter keeps, as the board's filter bar takes it:
538 +
        /// words, repo:, assignee:, label:, milestone:, is:open|closed, age:>14d (#361)
539 +
        #[arg(long, short = 'q', conflicts_with = "view")]
540 +
        filter: Option<String>,
541 +
        /// A saved view of the board, by name: its filter (#361)
542 +
        #[arg(long)]
543 +
        view: Option<String>,
537 544
    },
538 545
    /// Create a board (owner, or an organisation's members)
539 546
    Create {
…
1839 unchanged lines
2379 2386
                println!("{}", project_line(p));
2380 2387
            }
2381 2388
        }
2382 -
        ProjectCmd::View { number, archived } => {
2383 -
            let path = format!("{base}/{number}");
2384 -
            let cards = format!("{path}/cards?archived=true");
2389 +
        ProjectCmd::View { number, archived, filter, view } => {
2390 +
            let cards = format!("{base}/{number}/cards?archived=true");
2391 +
            let path = match (&filter, &view) {
2392 +
                (Some(q), _) => format!("{base}/{number}?q={}", enc(q)),
2393 +
                (None, Some(v)) => format!("{base}/{number}?view={}", enc(v)),
2394 +
                (None, None) => format!("{base}/{number}"),
2395 +
            };
2385 2396
            if json {
2386 2397
                println!("{}", c.get_text(if archived { &cards } else { &path })?);
2387 2398
                return Ok(0);
…
5 unchanged lines
2393 2404
            println!("{}", project_line(&b.project));
2394 2405
            if !b.project.description.is_empty() {
2395 2406
                println!("{}", b.project.description);
2407 +
            }
2408 +
            if !b.filter.is_empty() {
2409 +
                println!("{}", ui::dim(&format!("filter: {}", b.filter)));
2396 2410
            }
2411 +
            if !b.views.is_empty() && filter.is_none() && view.is_none() {
2412 +
                let names: Vec<&str> = b.views.iter().map(|v| v.name.as_str()).collect();
2413 +
                println!("{}", ui::dim(&format!("views: {} (oh project view {number} --view NAME)", names.join(", "))));
2414 +
            }
2397 2415
            for col in &b.columns {
2398 2416
                println!();
2399 -
                println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}", col.cards.len(), col.id)));
2417 +
                let wip = match col.wip_limit {
2418 +
                    Some(w) if col.cards.len() as i64 > w => format!(" · over its limit of {w}"),
2419 +
                    Some(w) => format!(" · limit {w}"),
2420 +
                    None => String::new(),
2421 +
                };
2422 +
                println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}{wip}", col.cards.len(), col.id)));
2400 2423
                if col.cards.is_empty() {
2401 2424
                    println!("  {}", ui::dim("(empty)"));
2402 2425
                }
…
467 unchanged lines
modified docs/spec.md +1 −1
…
493 unchanged lines
494 494
495 495
### 12.1 API
496 496
497 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
497 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
498 498
499 499
### 12.2 Webhooks
500 500
…
398 unchanged lines
modified templates/project.html +80 −19
…
1 unchanged lines
2 2
{% block title %}{{ project.title }} · Projects · {{ project.owner }}{% endblock %}
3 3
{% block mainclass %} class="wide"{% endblock %}
4 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 %}
5 +
{% macro card(c, colid) %}
6 +
    <li class="card-item{% if c.card.kind == "note" %} note{% endif %}" tabindex="-1" data-card="{{ c.card.id }}"{% if !c.url.is_empty() %} data-href="{{ c.url }}"{% endif %}>
7 +
      {% if c.card.kind == "note" %}<p>{{ c.title }}</p>
8 +
      {% else %}<a href="{{ c.url }}">{{ c.title }}</a>
9 +
      <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span>{% for a in c.assignees %} · <a class="card-who" href="/projects/{{ project.owner }}/{{ project.number }}?q=assignee:{{ a|urlencode }}" title="Cards assigned to {{ a }}">@{{ a }}</a>{% endfor %}</p>
10 +
      {% if compact %}{% if !c.labels.is_empty() %}<p class="card-dots">{% for l in c.labels %}<span class="dot" style="--c: {{ l.colour }}" title="{{ l.name }}"></span>{% endfor %}</p>{% endif %}{% else if !c.labels.is_empty() %}<p class="card-labels">{% for l in c.labels %}<a class="label" style="--c: {{ l.colour }}" href="/projects/{{ project.owner }}/{{ project.number }}?q=label:{{ l.name|urlencode }}" title="{% if l.description.is_empty() %}Cards labelled {{ l.name }}{% else %}{{ l.description }}{% endif %}">{{ l.name }}</a>{% endfor %}</p>{% endif %}{% endif %}
11 +
      {% if let Some(t) = c.card.archived_ms %}<p class="small mute">archived <time title="{{ t|when_i }}">{{ t|ago_i }}</time></p>{% endif %}
12 +
      {% if can_edit && archived %}
13 +
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"><button name="action" value="restore">restore</button></form>
14 +
      {% else if can_edit %}
15 +
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}">
16 +
        <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label>
17 +
        <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == colid %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select>
18 +
        <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="archive" title="Archive: off the board, kept and restorable">archive</button><button name="action" value="remove" aria-label="Remove from the project" title="Remove from the project">✕</button>
19 +
      </form>{% endif %}
20 +
    </li>
21 +
{% endmacro %}
5 22
{% block content %}
6 23
<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>
7 24
{% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div>
8 25
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
9 26
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
27 +
<nav class="board-tabs" aria-label="Views of this board">
28 +
  <a class="board-tab{% if view_name.is_empty() %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}"{% if view_name.is_empty() %} aria-current="page"{% endif %}>Board</a>
29 +
  {% for v in views %}<a class="board-tab{% if v.name == *view_name %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}?view={{ v.name|urlencode }}"{% if v.name == *view_name %} aria-current="page"{% endif %}>{{ v.name }} <span class="small mute">{{ v.layout }}</span></a>{% endfor %}
30 +
</nav>
10 31
{% if let Some(v) = shown_iteration %}
11 32
<section class="iteration past" aria-label="Iteration {{ v.it.name }}">
12 33
  <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>
…
20 unchanged lines
33 54
</section>
34 55
{% endif %}
35 56
<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 %}
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">
57 +
  {% if !view_name.is_empty() %}<input type="hidden" name="view" value="{{ view_name }}">{% endif %}
58 +
  <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, repo:, assignee:, label:, milestone:, is:open, age:>14d" title="Words in the title or reference; repo:NAME, assignee:NAME, label:NAME, milestone:TITLE (quote a title with spaces), state:STATE, is:open or is:closed, age:>14d or age:<7d. Press / to come here." data-board-filter>
59 +
  <label class="visually-hidden" for="board-layout">Layout</label><select id="board-layout" name="layout"><option value="board"{% if layout == "board" %} selected{% endif %}>board</option><option value="table"{% if layout == "table" %} selected{% endif %}>table</option></select>
60 +
  <label class="visually-hidden" for="board-lane">{% if layout == "table" %}Group by{% else %}Swimlanes{% endif %}</label><select id="board-lane" name="lane"><option value="">{% if layout == "table" %}no groups{% else %}no lanes{% endif %}</option>{% for l in ["repo", "assignee", "label", "milestone"] %}<option value="{{ l }}"{% if lane == *l %} selected{% endif %}>by {{ l }}</option>{% endfor %}</select>
61 +
  {% if layout == "table" %}<label class="visually-hidden" for="board-sort">Order</label><select id="board-sort" name="sort"><option value="">board order</option>{% for o in ["title", "reference", "state", "column", "age", "-age"] %}<option value="{{ o }}"{% if sort == *o %} selected{% endif %}>{% if *o == "-age" %}oldest first{% else if *o == "age" %}newest first{% else %}by {{ o }}{% endif %}</option>{% endfor %}</select>{% else %}<label class="small"><input type="checkbox" name="compact" value="1"{% if compact %} checked{% endif %}> compact</label>{% endif %}
37 62
  <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>
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 %}
63 +
  <button type="submit" class="small">SHOW</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}{% if archived %}?archived=1{% endif %}">clear</a>{% endif %}
39 64
  {% if !archived && archived_total > 0 %}<a class="small board-archived-link" href="/projects/{{ project.owner }}/{{ project.number }}?archived=1">{{ archived_total }} archived card{% if archived_total != 1 %}s{% endif %}</a>{% endif %}
40 65
</form>
41 -
<div class="board" role="list" aria-label="Columns">
66 +
{% if can_edit && !archived %}<details class="board-save-view"><summary class="small">{% if view_name.is_empty() %}save as a view{% else %}save view{% endif %}</summary>
67 +
  <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="layout" value="{{ layout }}"><input type="hidden" name="q" value="{{ q }}"><input type="hidden" name="lane" value="{{ lane }}"><input type="hidden" name="sort" value="{{ sort }}">
68 +
    <label for="view-name">Name</label><input type="text" id="view-name" name="name" value="{{ view_name }}" maxlength="40" required placeholder="My open work">
69 +
    <p class="small mute">Keeps this layout, filter{% if !lane.is_empty() %}, {% if layout == "table" %}groups{% else %}lanes{% endif %}{% endif %}{% if !sort.is_empty() %} and order{% endif %} as a tab anyone with the board sees; its address can be shared.</p>
70 +
    <button name="action" value="save_view" class="small">SAVE</button>
71 +
  </form>
72 +
  {% for v in views %}{% if v.name == *view_name %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="view" value="{{ v.id }}"><button name="action" value="delete_view" class="small danger-btn" data-confirm="Delete the view {{ v.name }}? The cards are not touched.">DELETE VIEW</button></form>{% endif %}{% endfor %}
73 +
</details>{% endif %}
74 +
{% if layout == "table" %}
75 +
<div class="table-wrap"><table class="board-table" aria-label="Cards">
76 +
  <thead><tr><th scope="col">Title</th><th scope="col">Reference</th><th scope="col">State</th><th scope="col">Column</th><th scope="col">Assignees</th><th scope="col">Labels</th><th scope="col">Milestone</th><th scope="col">Opened</th></tr></thead>
77 +
  <tbody>
78 +
  {% for r in rows %}
79 +
  {% if r.starts_group %}<tr class="group-row"><th colspan="8" scope="colgroup">{{ r.group }}</th></tr>{% endif %}
80 +
  <tr class="card-item" tabindex="-1" data-card="{{ r.c.card.id }}"{% if !r.c.url.is_empty() %} data-href="{{ r.c.url }}"{% endif %}>
81 +
    <td>{% if r.c.url.is_empty() %}{{ r.c.title }}{% else %}<a href="{{ r.c.url }}">{{ r.c.title }}</a>{% endif %}</td>
82 +
    <td class="small mute">{{ r.c.reference }}</td>
83 +
    <td><span class="state-{{ r.c.state }}">{{ r.c.state }}</span></td>
84 +
    <td>{% 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="card" value="{{ r.c.card.id }}"><label class="visually-hidden" for="tmv-{{ r.c.card.id }}">Column</label><select id="tmv-{{ r.c.card.id }}" name="column" data-autosubmit>{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.name == r.column %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select><button name="action" value="move" class="small">move</button></form>{% else %}{{ r.column }}{% endif %}</td>
85 +
    <td class="small">{% for a in r.c.assignees %}@{{ a }} {% endfor %}</td>
86 +
    <td>{% for l in r.c.labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span>{% endfor %}</td>
87 +
    <td class="small">{{ r.c.milestone }}</td>
88 +
    <td class="small mute"><time title="{{ r.c.opened_ms|when_i }}">{{ r.c.opened_ms|ago_i }}</time></td>
89 +
  </tr>
90 +
  {% endfor %}
91 +
  </tbody>
92 +
</table></div>
93 +
{% if rows.is_empty() %}<p class="mute">No cards{% if filtered %} match this filter{% endif %}.</p>{% endif %}
94 +
{% if pages > 1 %}<nav class="pager small" aria-label="Pages">{% if page > 1 %}<a href="?{{ look }}&layout=table&page={{ page - 1 }}">← previous</a>{% endif %} page {{ page }} of {{ pages }} {% if page < pages %}<a href="?{{ look }}&layout=table&page={{ page + 1 }}">next →</a>{% endif %}</nav>{% endif %}
95 +
{% else if !lanes.is_empty() %}
96 +
{% for ln in lanes %}
97 +
<section class="lane" aria-label="{{ ln.name }}">
98 +
  <h2 class="lane-head">{{ ln.name }} <span class="count-chip">{{ ln.count }}</span></h2>
99 +
  <div class="board{% if compact %} compact{% endif %}" role="list" aria-label="Columns of {{ ln.name }}">
100 +
  {% for col in ln.columns %}
101 +
  <section class="board-col" role="listitem" aria-label="{{ col.column.name }}"><header><h3>{{ col.column.name }} <span class="count-chip">{{ col.cards.len() }}</span></h3></header>
102 +
    <ul class="cards">{% for c in col.cards %}{% call card(c, col.column.id) %}{% endfor %}</ul>
103 +
  </section>
104 +
  {% endfor %}
105 +
  </div>
106 +
</section>
107 +
{% endfor %}
108 +
{% else %}
109 +
<div class="board{% if compact %} compact{% endif %}" role="list" aria-label="Columns">
42 110
{% for col in columns %}
43 111
<section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}">
44 -
  <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>
112 +
  <header{% if col.over_wip %} class="over-wip"{% endif %}><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip"{% if col.over_wip %} title="Over its limit of {% if let Some(w) = col.column.wip_limit %}{{ w }}{% endif %} cards in progress"{% endif %}>{% if filtered %}{{ col.cards.len() }} of {{ col.total }}{% else %}{{ col.total }}{% endif %}{% if let Some(w) = col.column.wip_limit %} / {{ w }}{% endif %}</span></h2>
45 113
  {% 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>
46 114
  {% 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 %}
115 +
  {% if col.before_iteration > 0 %}<p class="small mute col-hidden">{{ col.before_iteration }} done before this iteration, shown when it closes in its archive · <a href="/projects/{{ project.owner }}/{{ project.number }}?archived=1">archived cards</a></p>{% endif %}
47 116
  {% 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 %}
48 117
  <ul class="cards">
49 118
  {% for c in col.cards %}
50 -
    <li class="card-item{% if c.card.kind == "note" %} note{% endif %}">
51 -
      {% if c.card.kind == "note" %}<p>{{ c.title }}</p>
52 -
      {% else %}<a href="{{ c.url }}">{{ c.title }}</a>
53 -
      <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span>{% for a in c.assignees %} · <a class="card-who" href="/projects/{{ project.owner }}/{{ project.number }}?q=assignee:{{ a|urlencode }}" title="Cards assigned to {{ a }}">@{{ a }}</a>{% endfor %}</p>
54 -
      {% if !c.labels.is_empty() %}<p class="card-labels">{% for l in c.labels %}<a class="label" style="--c: {{ l.colour }}" href="/projects/{{ project.owner }}/{{ project.number }}?q=label:{{ l.name|urlencode }}" title="{% if l.description.is_empty() %}Cards labelled {{ l.name }}{% else %}{{ l.description }}{% endif %}">{{ l.name }}</a>{% endfor %}</p>{% endif %}{% endif %}
55 -
      {% if let Some(t) = c.card.archived_ms %}<p class="small mute">archived <time title="{{ t|when_i }}">{{ t|ago_i }}</time></p>{% endif %}
56 -
      {% if can_edit && archived %}
57 -
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"><button name="action" value="restore">restore</button></form>
58 -
      {% else if can_edit %}
59 -
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}">
60 -
        <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label>
61 -
        <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == col.column.id %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select>
62 -
        <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="archive" title="Archive: off the board, kept and restorable">archive</button><button name="action" value="remove" aria-label="Remove from the project" title="Remove from the project">✕</button>
63 -
      </form>{% endif %}
64 -
    </li>
119 +
    {% call card(c, col.column.id) %}
65 120
  {% endfor %}
66 121
  </ul>
67 122
  {% if can_edit && !archived %}
…
8 unchanged lines
76 131
  <details class="add-card"><summary class="small">column</summary>
77 132
    <form 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 }}">
78 133
      <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" data-confirm="Remove this column? Its cards move to the first column.">REMOVE</button>
134 +
    </form>
135 +
    <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 }}">
136 +
      <label for="wip-{{ col.column.id }}">Work in progress limit</label>
137 +
      <div class="inline-fields"><input type="number" id="wip-{{ col.column.id }}" name="limit" min="0" max="999" placeholder="none"{% if let Some(w) = col.column.wip_limit %} value="{{ w }}"{% endif %}><span class="small mute">cards</span><button name="action" value="wip" class="small">SAVE</button></div>
79 138
    </form>
80 139
    <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 }}">
81 140
      <label for="older-{{ col.column.id }}">Archive cards here more than</label>
…
14 unchanged lines
96 155
<section class="board-col add-col"><form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="newcol">New column</label><input type="text" id="newcol" name="name" maxlength="40" required><button name="action" value="add_column" class="small">ADD COLUMN</button></form></section>
97 156
{% endif %}
98 157
</div>
158 +
{% endif %}
159 +
<p class="small mute board-keys">Keys: <kbd>j</kbd>/<kbd>k</kbd> move between cards, <kbd>Enter</kbd> opens one, <kbd>m</kbd> moves it to another column, <kbd>/</kbd> filters.</p>
99 160
{% if can_manage %}
100 161
<details class="panel"><summary>Settings</summary>
101 162
<form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
17 unchanged lines
modified templates/style.css +23 −0
…
643 unchanged lines
644 644
.card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; }
645 645
.card-item.note { border-left: 3px solid var(--warn); }
646 646
.card-item p { margin: 4px 0 0; }
647 +
/* Board views (#361): tabs, WIP limits, lanes, compact cards, the table. */
648 +
.board-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; border-bottom: 1px solid var(--line); }
649 +
.board-tab { padding: 4px 10px; border: 1px solid transparent; border-bottom: none; color: var(--mute); text-decoration: none; }
650 +
.board-tab.on { border-color: var(--line); background: var(--panel); color: var(--ink); margin-bottom: -1px; }
651 +
.board-col > header.over-wip h2, .board-col > header.over-wip .count-chip { color: var(--warn); }
652 +
.board-col > header.over-wip { border-bottom: 2px solid var(--warn); }
653 +
.lane { margin: 0 0 14px; }
654 +
.lane-head { font-size: 14px; margin: 0 0 6px; }
655 +
.lane .board-col h3 { font-size: 13px; margin: 2px 0 6px; }
656 +
.board.compact .card-item { padding: 5px 8px; }
657 +
.board.compact .card-item > p.small, .board.compact .card-actions { display: none; }
658 +
.board.compact .card-item:focus-within .card-actions { display: flex; }
659 +
.card-dots { display: flex; gap: 3px; }
660 +
.card-dots .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
661 +
.card-item:focus { outline: 2px solid var(--acc); outline-offset: 1px; }
662 +
.table-wrap { overflow-x: auto; }
663 +
.board-table { border-collapse: collapse; width: 100%; font-size: 13px; }
664 +
.board-table th, .board-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
665 +
.board-table thead th { color: var(--mute); font-weight: 600; }
666 +
.board-table tr.group-row th { background: var(--soft); padding-top: 8px; }
667 +
.board-table tr.card-item { background: none; border: none; }
668 +
.board-save-view { margin: 0 0 10px; }
669 +
.board-keys { margin-top: 8px; }
647 670
.card-item .card-labels { display: flex; flex-wrap: wrap; gap: 4px; }
648 671
.card-item .card-labels .label { margin-right: 0; text-decoration: none; }
649 672
.card-item .card-labels .label:hover, .card-item .card-labels .label:focus-visible { background: color-mix(in srgb, var(--c, var(--acc)) 14%, transparent); }
…
870 unchanged lines