Public: anyone can read this; the crew can change it.
The cut line on Next up moves while a row is dragged, before the save #8894
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8858-the-cut-line-on-next-up-moves-while-a-ro → main 4 commits fork
5 files
changed +160
−19
docs/design/areas/next-up.md added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | # Next up | |
| 2 | ||
| 3 | - `.next-week`: a row dimmed (opacity only, no layout change) while it is dragged below the cut line, so it reads as next week. Added and removed by the page's script during a drag; absent at rest. | |
| 4 | - Dragging is mouse-only (HTML5 drag does not fire on touch); on a phone a row moves by its Top/Up/Down buttons. A pointer path is a later change. |
src/flow/week.ts +19
| @@ -67,6 +67,25 @@ function fitting(rows: readonly Priced[], cost: (e: Estimate & { known: true }) | ||
| 67 | 67 | return out; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | /** The client's half of the rule, as source: how many rows from the top fit `usd` (the page's drag re-places the line with it). */ | |
| 71 | export const FIT_COUNT_JS = `function fitCount(costs, usd) { | |
| 72 | let used = 0, n = 0; | |
| 73 | for (const c of costs) { used += c; if (used > usd) break; n++; } | |
| 74 | return n; | |
| 75 | }`; | |
| 76 | ||
| 77 | /** The cut line's two sentences, one place for the page and its script. */ | |
| 78 | export const CUT_TOP_WORDS = "Nothing here fits what is left of this week: all of this reads as next week."; | |
| 79 | export const cutWords = (fit: number | string): string => `This week ends here (${fit} fit): below this line reads as next week.`; | |
| 80 | ||
| 81 | /** What each row counts for against the week: its mid, or the median mid when it has no estimate; null when no line can be drawn. */ | |
| 82 | export function rowCosts(rows: readonly Priced[]): number[] | null { | |
| 83 | const known = rows.flatMap((r) => (r.estimate.known ? [r.estimate.mid] : [])); | |
| 84 | if (known.length === 0) return null; | |
| 85 | const fill = known.sort((a, b) => a - b)[Math.floor((known.length - 1) / 2)]!; | |
| 86 | return rows.map((r) => (r.estimate.known ? r.estimate.mid : fill)); | |
| 87 | } | |
| 88 | ||
| 70 | 89 | /** |
| 71 | 90 | * Where the week's capacity ends on the ranked list. An issue with no |
| 72 | 91 | * estimate counts as the median of those that have one; with none at all |
src/web/pages/next.ts +64 −18
| @@ -11,7 +11,7 @@ import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; | ||
| 11 | 11 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | import { rangeWords, weekView, type Cut, type Priced } from "../../flow/week.ts"; | |
| 14 | import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts"; | |
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 16 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; |
| 17 | 17 | import { backTo, field, readForm } from "../form.ts"; |
| @@ -19,7 +19,7 @@ import { html, type Html } from "../html.ts"; | ||
| 19 | 19 | import { flashOrJson, page } from "../layout.ts"; |
| 20 | 20 | import { badge, btn, card, kbd, row } from "../ui.ts"; |
| 21 | 21 | |
| 22 | const KEYS_JS = ` | |
| 22 | export const KEYS_JS = ` | |
| 23 | 23 | (() => { |
| 24 | 24 | const list = document.getElementById("next-list"); |
| 25 | 25 | const remembered = sessionStorage.getItem("next-focus"); |
| @@ -36,10 +36,11 @@ const KEYS_JS = ` | ||
| 36 | 36 | const note = document.getElementById("next-flash"); |
| 37 | 37 | note.textContent = said.error || "That move was refused."; |
| 38 | 38 | note.hidden = false; |
| 39 | return; | |
| 39 | return false; | |
| 40 | 40 | } |
| 41 | 41 | sessionStorage.setItem("next-focus", number); |
| 42 | 42 | location.reload(); |
| 43 | return true; | |
| 43 | 44 | }; |
| 44 | 45 | // One key, one action, on the row that has focus: the key is shown on the button. |
| 45 | 46 | document.addEventListener("keydown", (e) => { |
| @@ -51,18 +52,61 @@ const KEYS_JS = ` | ||
| 51 | 52 | send(new FormData(form), row.dataset.number); |
| 52 | 53 | }); |
| 53 | 54 | if (!list) return; |
| 55 | ${FIT_COUNT_JS} | |
| 56 | const usd = list.dataset.capacity === undefined ? NaN : Number(list.dataset.capacity); | |
| 57 | const numbered = () => [...list.querySelectorAll("li[data-number]")]; | |
| 58 | // Re-place the cut line from the rows' costs in their current order; while dragging, rows below it read as next week. | |
| 59 | const place = (dragging) => { | |
| 60 | if (!(usd >= 0)) return; | |
| 61 | const rows = numbered(); | |
| 62 | const n = fitCount(rows.map((r) => Number(r.dataset.cost)), usd); | |
| 63 | let mark = list.querySelector(".cut-line"); | |
| 64 | if (n >= rows.length) { if (mark) mark.remove(); } | |
| 65 | else { | |
| 66 | if (!mark) { mark = document.createElement("li"); mark.className = "cut-line"; mark.setAttribute("role", "separator"); } | |
| 67 | mark.textContent = n === 0 ? ${JSON.stringify(CUT_TOP_WORDS)} : ${JSON.stringify(cutWords("{n}"))}.replace("{n}", n); | |
| 68 | list.insertBefore(mark, rows[n]); | |
| 69 | } | |
| 70 | rows.forEach((r, i) => r.classList.toggle("next-week", dragging && i >= n)); | |
| 71 | }; | |
| 54 | 72 | let held = null; |
| 73 | let origin = []; | |
| 74 | let dropped = false; | |
| 75 | const restore = () => { | |
| 76 | origin.forEach((r) => list.appendChild(r)); | |
| 77 | place(false); | |
| 78 | }; | |
| 55 | 79 | list.querySelectorAll("li[data-number]").forEach((li) => { li.draggable = true; }); |
| 56 | list.addEventListener("dragstart", (e) => { held = e.target.closest("li[data-number]"); }); | |
| 57 | list.addEventListener("dragover", (e) => { if (held) e.preventDefault(); }); | |
| 58 | list.addEventListener("drop", (e) => { | |
| 59 | const over = e.target.closest("li[data-number]"); | |
| 60 | if (!held || !over || over === held) return; | |
| 80 | list.addEventListener("dragstart", (e) => { | |
| 81 | held = e.target.closest("li[data-number]"); | |
| 82 | origin = numbered(); | |
| 83 | dropped = false; | |
| 84 | }); | |
| 85 | list.addEventListener("dragover", (e) => { | |
| 86 | if (!held) return; | |
| 61 | 87 | e.preventDefault(); |
| 62 | const rows = [...list.querySelectorAll("li[data-number]")]; | |
| 63 | const rest = rows.filter((r) => r !== held); | |
| 64 | const at = rest.indexOf(over) + (rows.indexOf(over) > rows.indexOf(held) ? 1 : 0); | |
| 65 | send({ number: held.dataset.number, to: at === 0 ? "top" : "after:" + rest[at - 1].dataset.number }, held.dataset.number); | |
| 88 | const over = e.target.closest("li[data-number]"); | |
| 89 | if (!over || over === held) return; | |
| 90 | const rows = numbered(); | |
| 91 | list.insertBefore(held, rows.indexOf(over) > rows.indexOf(held) ? over.nextSibling : over); | |
| 92 | place(true); | |
| 93 | }); | |
| 94 | list.addEventListener("dragend", () => { | |
| 95 | if (!dropped) restore(); | |
| 96 | list.querySelectorAll(".next-week").forEach((r) => r.classList.remove("next-week")); | |
| 97 | held = null; | |
| 98 | }); | |
| 99 | list.addEventListener("drop", async (e) => { | |
| 100 | if (!held) return; | |
| 101 | e.preventDefault(); | |
| 102 | const rows = numbered(); | |
| 103 | const at = rows.indexOf(held); | |
| 104 | if (at === origin.indexOf(held)) { restore(); return; } | |
| 105 | dropped = true; | |
| 106 | const before = at === 0 ? "top" : "after:" + rows[at - 1].dataset.number; | |
| 107 | const number = held.dataset.number; | |
| 108 | // On success the dragged order stays until the reload; only a refusal puts the rows back. | |
| 109 | if (!(await send({ number, to: before }, number))) restore(); | |
| 66 | 110 | }); |
| 67 | 111 | })();`; |
| 68 | 112 | |
| @@ -84,6 +128,7 @@ function line( | ||
| 84 | 128 | back: string, |
| 85 | 129 | away: boolean, |
| 86 | 130 | priced: readonly Priced[] = [], |
| 131 | cost: number | undefined = undefined, | |
| 87 | 132 | ): Html { |
| 88 | 133 | const r = rows[at]!; |
| 89 | 134 | const n = r.issue.number; |
| @@ -105,7 +150,7 @@ function line( | ||
| 105 | 150 | meta: html`${badge(r.role === "blocker" ? "Blocker" : "Enabler", { variant: r.role === "blocker" ? "secondary" : "outline" })} |
| 106 | 151 | #${n} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`, |
| 107 | 152 | nav: true, |
| 108 | data: { number: String(n) }, | |
| 153 | data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) }, | |
| 109 | 154 | ...(controls === null ? {} : { after: controls }), |
| 110 | 155 | }); |
| 111 | 156 | } |
| @@ -113,8 +158,8 @@ function line( | ||
| 113 | 158 | /** The cut line: everything above it fits this week's capacity, everything below reads as next week. */ |
| 114 | 159 | function cutMark(cut: Cut, top: boolean): Html { |
| 115 | 160 | return html`<li class="cut-line" role="separator">${top |
| 116 | ? "Nothing here fits what is left of this week: all of this reads as next week." | |
| 117 | : `This week ends here (${cut.fits.length} fit): below this line reads as next week.`}</li>`; | |
| 161 | ? CUT_TOP_WORDS | |
| 162 | : cutWords(cut.fits.length)}</li>`; | |
| 118 | 163 | } |
| 119 | 164 | |
| 120 | 165 | export function nextPage(ctx: Ctx): Response { |
| @@ -129,12 +174,13 @@ export function nextPage(ctx: Ctx): Response { | ||
| 129 | 174 | const rest = next.rows.length - shown.length; |
| 130 | 175 | const back = all ? `${base}/next?all=1` : `${base}/next`; |
| 131 | 176 | const rendering = performance.now(); |
| 132 | const { priced, cut, sentence } = weekView(ctx.app.db, repo.id, next.rows, new Date()); | |
| 177 | const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, next.rows, new Date()); | |
| 178 | const costs = rowCosts(priced); | |
| 133 | 179 | const rowsHtml = (): Html[] => { |
| 134 | 180 | const out: Html[] = []; |
| 135 | 181 | shown.forEach((_, at) => { |
| 136 | 182 | if (at === cut.fits.length && cut.unsure === null) out.push(cutMark(cut, at === 0)); |
| 137 | out.push(line(base, next.rows, at, writer, back, false, priced)); | |
| 183 | out.push(line(base, next.rows, at, writer, back, false, priced, costs?.[at])); | |
| 138 | 184 | }); |
| 139 | 185 | return out; |
| 140 | 186 | }; |
| @@ -154,7 +200,7 @@ export function nextPage(ctx: Ctx): Response { | ||
| 154 | 200 | ${next.rows.length === 0 |
| 155 | 201 | ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>` |
| 156 | 202 | : card( |
| 157 | html`<ul class="list setting-list" id="next-list"> | |
| 203 | html`<ul class="list setting-list" id="next-list"${costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}> | |
| 158 | 204 | ${rowsHtml()} |
| 159 | 205 | </ul>`, |
| 160 | 206 | { pad: false }, |
src/web/style/content.ts +1
| @@ -866,6 +866,7 @@ textarea.editor:focus-visible { outline: none; } | ||
| 866 | 866 | .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; } |
| 867 | 867 | .valve-range { flex: 1 1 12rem; min-width: 8rem; } |
| 868 | 868 | /* The week on Next up (#8704): the cut line and a row's cost range. */ |
| 869 | .next-week { opacity: 0.55; } | |
| 869 | 870 | .cut-line { list-style: none; padding: 6px 16px; font-size: 13px; color: var(--muted-fg); border-top: 2px dashed var(--series-1); border-bottom: 1px solid var(--border); background: var(--bg); } |
| 870 | 871 | .estimate { color: var(--fg); font-variant-numeric: tabular-nums; } |
| 871 | 872 | /* The config form (#7336): nested objects as quiet fieldsets, scalar |
test/week-doors.test.ts +72 −1
| @@ -23,8 +23,10 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 23 | 23 | import { recordUsage } from "../src/core/usage.ts"; |
| 24 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { latestRetro, takenOf, tickWeeks, failureClass } from "../src/flow/retro.ts"; |
| 26 | import { weekView } from "../src/flow/week.ts"; | |
| 26 | import { CUT_TOP_WORDS, cutLine, cutWords, FIT_COUNT_JS, rowCosts, weekView } from "../src/flow/week.ts"; | |
| 27 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | import { KEYS_JS } from "../src/web/pages/next.ts"; | |
| 29 | import { Window } from "happy-dom"; | |
| 28 | 30 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 29 | 31 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 30 | 32 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -147,6 +149,75 @@ describe("the week's doors", () => { | ||
| 147 | 149 | await rm(dataDir, { recursive: true, force: true }); |
| 148 | 150 | }); |
| 149 | 151 | |
| 152 | test("the rows carry their cost and the list the capacity, and the client rule places the same line as weekView", async () => { | |
| 153 | const rows = nextUp(forge.db, repo).rows; | |
| 154 | const view = weekView(forge.db, repo.id, rows, new Date()); | |
| 155 | const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie: aliceCookie } })).text(); | |
| 156 | const doc = new Window().document; | |
| 157 | doc.body.innerHTML = page; | |
| 158 | const list = doc.getElementById("next-list")!; | |
| 159 | const capacity = Number(list.getAttribute("data-capacity")); | |
| 160 | expect(capacity).toBe(view.capacity!.usd); | |
| 161 | const lis = [...list.querySelectorAll("li[data-number]")]; | |
| 162 | expect(lis.length).toBe(Math.min(10, rows.length)); | |
| 163 | const costs = lis.map((li) => Number(li.getAttribute("data-cost"))); | |
| 164 | expect(costs.every((c) => Number.isFinite(c) && c >= 0)).toBe(true); | |
| 165 | const fitCount = new Function(`${FIT_COUNT_JS}; return fitCount;`)() as (c: number[], usd: number) => number; | |
| 166 | expect(fitCount(costs, capacity)).toBe(view.cut.fits.length); | |
| 167 | // Reranked, both sides read the same costs: the full reversed list, one median. | |
| 168 | const reversed = [...view.priced].reverse(); | |
| 169 | expect(fitCount(rowCosts(reversed)!, capacity)).toBe(cutLine(reversed, view.capacity).fits.length); | |
| 170 | }); | |
| 171 | ||
| 172 | test("with no line drawn the list carries no capacity", async () => { | |
| 173 | const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie: aliceCookie } })).text(); | |
| 174 | const without = page.replace(/ data-capacity="[^"]*"/, ""); | |
| 175 | expect(without).not.toContain("data-capacity"); | |
| 176 | // The server and the script say the same sentences. | |
| 177 | expect(KEYS_JS).toContain(JSON.stringify(CUT_TOP_WORDS)); | |
| 178 | expect(KEYS_JS).toContain(JSON.stringify(cutWords("{n}"))); | |
| 179 | }); | |
| 180 | ||
| 181 | test("dragging a row moves the line before any save, and a refusal puts it back", async () => { | |
| 182 | const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie: aliceCookie } })).text(); | |
| 183 | for (const accept of [true, false]) { | |
| 184 | const window = new Window(); | |
| 185 | const doc = window.document; | |
| 186 | doc.body.innerHTML = page; | |
| 187 | const list = doc.getElementById("next-list")!; | |
| 188 | const order = () => [...list.children].map((c) => c.getAttribute("data-number") ?? "|"); | |
| 189 | const before = order(); | |
| 190 | const posted: string[] = []; | |
| 191 | let reloaded = false; | |
| 192 | const fetcher = async (_u: string, init: { body: URLSearchParams }) => { | |
| 193 | posted.push(init.body.toString()); | |
| 194 | return { ok: accept, json: async () => ({ error: "no" }) }; | |
| 195 | }; | |
| 196 | const location = { pathname: "/alice/plan/next", reload: () => { reloaded = true; } }; | |
| 197 | const store = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; | |
| 198 | new Function("document", "sessionStorage", "fetch", "location", "FormData", KEYS_JS)(doc, store, fetcher, location, window.FormData); | |
| 199 | const lis = [...list.querySelectorAll("li[data-number]")]; | |
| 200 | const fire = (el: { dispatchEvent(e: unknown): boolean }, type: string) => el.dispatchEvent(new window.Event(type, { bubbles: true, cancelable: true })); | |
| 201 | fire(lis[0]!, "dragstart"); | |
| 202 | fire(lis[lis.length - 1]!, "dragover"); | |
| 203 | // Before the save: the row sits last and the line was re-placed for the new order. | |
| 204 | expect(list.querySelectorAll("li[data-number]")[lis.length - 1]).toBe(lis[0]!); | |
| 205 | expect(list.querySelectorAll(".cut-line").length).toBeLessThanOrEqual(1); | |
| 206 | expect(posted).toHaveLength(0); | |
| 207 | fire(lis[lis.length - 1]!, "drop"); | |
| 208 | await new Promise((r) => setTimeout(r, 10)); | |
| 209 | fire(lis[0]!, "dragend"); | |
| 210 | expect(posted).toHaveLength(1); | |
| 211 | if (accept) { | |
| 212 | expect(reloaded).toBe(true); | |
| 213 | expect(order()).not.toEqual(before); | |
| 214 | } else { | |
| 215 | expect(reloaded).toBe(false); | |
| 216 | expect(order()).toEqual(before); | |
| 217 | } | |
| 218 | } | |
| 219 | }); | |
| 220 | ||
| 150 | 221 | test("once capacity is read, Next up draws the cut line and the rows below read as next week", async () => { |
| 151 | 222 | const view = weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()); |
| 152 | 223 | expect(view.cut.unsure).toBeNull(); |
new above
offline