Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions web/board/src/board/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,40 @@ describe("the gate rail is a stack of self-contained cards", () => {
});
});

describe("project lanes and scroll stability", () => {
const DAY = 86400 * 1e9;

it("orders project lanes by MOST-RECENT activity, not by gate count", () => {
const now = Date.now() * 1e6;
const items = [
// "stale" project: touched a week ago, and carries a gate — which used to
// float it to the top under the old gated-first order.
makeItem({ id: "s1", project: "stale", status: "waiting", waitingType: "gate", gateMsgId: "m", gate: "design", updatedAt: now - 8 * DAY }),
// "fresh" project: touched yesterday, no gate.
makeItem({ id: "f1", project: "fresh", status: "active", updatedAt: now - 1 * DAY }),
];
ws = mountBoardWorkspace(container, items, {
boards: [{ coord: "stale", title: "stale" }, { coord: "fresh", title: "fresh" }],
});
const laneNames = [...container.querySelectorAll(".swimlane .lane-name")].map((n) => n.textContent);
expect(laneNames.indexOf("fresh")).toBeLessThan(laneNames.indexOf("stale"));
expect(laneNames[0]).toBe("fresh");
});

it("preserves the page scroll position across a live re-render", () => {
const items = Array.from({ length: 30 }, (_, i) =>
makeItem({ id: `i${i}`, status: "active", project: "ready", updatedAt: Date.now() * 1e6 - i * 1000 }),
);
ws = mountBoardWorkspace(container, items, { boards: [{ coord: "ready", title: "ready" }] });
const scroller = document.scrollingElement ?? document.documentElement;
scroller.scrollTop = 250;
// a live fold arrives — the board rebuilds (replaceChildren), but the reader
// stays where they scrolled to instead of being yanked to the top.
ws.setItems([...items, makeItem({ id: "new", status: "active", project: "ready" })]);
expect((document.scrollingElement ?? document.documentElement).scrollTop).toBe(250);
});
});

describe("board identity: names, never coordinates", () => {
const COORD = "30301:a9f766ae56bbf466d2d361e5b1788b7cd689fd8e3b418e35b002b313f478db25:dontguess";

Expand Down
59 changes: 52 additions & 7 deletions web/board/src/board/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,11 @@ export class BoardWorkspace {
*/
setItems(items: Item[]): void {
const focus = this.captureFocusedField();
const scroll = this.captureScroll();
this.items = items;
this.render();
this.restoreFocusedField(focus);
this.restoreScroll(scroll);
}

/**
Expand All @@ -385,11 +387,13 @@ export class BoardWorkspace {
*/
setBoards(boards: BoardRef[]): void {
const focus = this.captureFocusedField();
const scroll = this.captureScroll();
this.boards = boards;
this.boardTitles = new Map(boards.map((b) => [b.coord, b.title]));
this.boardByCoord = new Map(boards.map((b) => [b.coord, b]));
this.render();
this.restoreFocusedField(focus);
this.restoreScroll(scroll);
}

/**
Expand All @@ -401,9 +405,44 @@ export class BoardWorkspace {
*/
setNotice(notice: string | undefined): void {
const focus = this.captureFocusedField();
const scroll = this.captureScroll();
this.notice = notice;
this.render();
this.restoreFocusedField(focus);
this.restoreScroll(scroll);
}

/**
* The board re-renders on every live fold (setItems) and every board-state
* reconcile (setBoards), each of which does render()'s replaceChildren() — a
* fresh .board-center / .left-tree whose scrollTop is 0. During the first
* minute those calls arrive in a stream, so a reader scrolled halfway down the
* board was yanked back to the top again and again (ready-...). Capturing the
* two independent scroll positions before the rebuild and restoring them after
* keeps the view where the reader put it — the page updates under them without
* moving what they are looking at.
*/
private scroller(): Element {
return document.scrollingElement ?? document.documentElement;
}

private captureScroll(): { page: number; center: number; tree: number } {
// The page itself is the scroll container (the shell grows with content), so
// that is the one that must survive replaceChildren — the panes are captured
// too so the fix still holds if the layout is ever made pane-scrolling.
return {
page: this.scroller().scrollTop,
center: this.container.querySelector(".board-center")?.scrollTop ?? 0,
tree: this.container.querySelector(".left-tree")?.scrollTop ?? 0,
};
}

private restoreScroll(s: { page: number; center: number; tree: number }): void {
this.scroller().scrollTop = s.page;
const center = this.container.querySelector<HTMLElement>(".board-center");
const tree = this.container.querySelector<HTMLElement>(".left-tree");
if (center) center.scrollTop = s.center;
if (tree) tree.scrollTop = s.tree;
}

private captureFocusedField(): FocusedField | undefined {
Expand Down Expand Up @@ -440,7 +479,9 @@ export class BoardWorkspace {
const next = [...scope.querySelectorAll("input")].find((n) => n.classList.contains(f.cls));
if (!next) continue;
next.value = f.value;
next.focus();
// preventScroll: refocusing must not scroll the field into view — that
// would fight restoreScroll and yank the board (ready-...).
next.focus({ preventScroll: true });
if (f.start !== null && f.end !== null) next.setSelectionRange(f.start, f.end);
return;
}
Expand Down Expand Up @@ -925,7 +966,7 @@ export class BoardWorkspace {
this.setQuery(find.value);
const next = this.container.querySelector<HTMLInputElement>("input.find");
if (next) {
next.focus();
next.focus({ preventScroll: true });
if (caret !== null) next.setSelectionRange(caret, caret);
}
});
Expand Down Expand Up @@ -1409,12 +1450,16 @@ export class BoardWorkspace {
list.push(item);
groups.set(key, list);
}
// Most gated first, then largest — the prototype's project-lane order:
// the lane that needs a human decision should not be below the fold.
const gateCount = (key: string): number =>
items.filter((i) => this.projectKeyOf(i) === key && gatesFilter()(i)).length;
// MOST RECENTLY ACTIVE FIRST (ready-...). The prototype led with the most-
// gated project so a decision was never below the fold — but gates now live
// in their own banner at the very top, surfaced regardless of lane order, so
// the lanes are free to answer the question a reader actually has scrolling
// the board: "what have I touched lately?". A project stale for a week no
// longer floats to the top just because it carries gates. Ties break on
// name so the order is total (stable across re-renders).
const lastActive = (its: Item[]): number => its.reduce((m, i) => Math.max(m, i.updatedAt), 0);
return [...groups.entries()]
.sort(([a, ia], [b, ib]) => gateCount(b) - gateCount(a) || ib.length - ia.length || a.localeCompare(b))
.sort(([a, ia], [b, ib]) => lastActive(ib) - lastActive(ia) || a.localeCompare(b))
.map(([key, its]) => ({ key, label: this.projectNameOf(key), items: its }));
}
// epic
Expand Down
Loading