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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8858-the-cut-line-on-next-up-moves-while-a-ro → main 4 commits fork

  • Merged. scout/8858-the-cut-line-on-next-up-moves-while-a-ro was merged into main as daf5573.

Review

Sign in to review this change.
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 })
6767 return out;
6868 }
6969
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
7089 /**
7190 * Where the week's capacity ends on the ranked list. An issue with no
7291 * 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";
1111 import { canWrite, type Repo } from "../../core/repos.ts";
1212 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1313 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";
1515 import type { Ctx } from "../../http/context.ts";
1616 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
1717 import { backTo, field, readForm } from "../form.ts";
@@ -19,7 +19,7 @@ import { html, type Html } from "../html.ts";
1919 import { flashOrJson, page } from "../layout.ts";
2020 import { badge, btn, card, kbd, row } from "../ui.ts";
2121
22 const KEYS_JS = `
22 export const KEYS_JS = `
2323 (() => {
2424 const list = document.getElementById("next-list");
2525 const remembered = sessionStorage.getItem("next-focus");
@@ -36,10 +36,11 @@ const KEYS_JS = `
3636 const note = document.getElementById("next-flash");
3737 note.textContent = said.error || "That move was refused.";
3838 note.hidden = false;
39 return;
39 return false;
4040 }
4141 sessionStorage.setItem("next-focus", number);
4242 location.reload();
43 return true;
4344 };
4445 // One key, one action, on the row that has focus: the key is shown on the button.
4546 document.addEventListener("keydown", (e) => {
@@ -51,18 +52,61 @@ const KEYS_JS = `
5152 send(new FormData(form), row.dataset.number);
5253 });
5354 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 };
5472 let held = null;
73 let origin = [];
74 let dropped = false;
75 const restore = () => {
76 origin.forEach((r) => list.appendChild(r));
77 place(false);
78 };
5579 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;
6187 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();
66110 });
67111 })();`;
68112
@@ -84,6 +128,7 @@ function line(
84128 back: string,
85129 away: boolean,
86130 priced: readonly Priced[] = [],
131 cost: number | undefined = undefined,
87132 ): Html {
88133 const r = rows[at]!;
89134 const n = r.issue.number;
@@ -105,7 +150,7 @@ function line(
105150 meta: html`${badge(r.role === "blocker" ? "Blocker" : "Enabler", { variant: r.role === "blocker" ? "secondary" : "outline" })}
106151 #${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}`}`,
107152 nav: true,
108 data: { number: String(n) },
153 data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) },
109154 ...(controls === null ? {} : { after: controls }),
110155 });
111156 }
@@ -113,8 +158,8 @@ function line(
113158 /** The cut line: everything above it fits this week's capacity, everything below reads as next week. */
114159 function cutMark(cut: Cut, top: boolean): Html {
115160 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>`;
118163 }
119164
120165 export function nextPage(ctx: Ctx): Response {
@@ -129,12 +174,13 @@ export function nextPage(ctx: Ctx): Response {
129174 const rest = next.rows.length - shown.length;
130175 const back = all ? `${base}/next?all=1` : `${base}/next`;
131176 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);
133179 const rowsHtml = (): Html[] => {
134180 const out: Html[] = [];
135181 shown.forEach((_, at) => {
136182 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]));
138184 });
139185 return out;
140186 };
@@ -154,7 +200,7 @@ export function nextPage(ctx: Ctx): Response {
154200 ${next.rows.length === 0
155201 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
156202 : 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}>
158204 ${rowsHtml()}
159205 </ul>`,
160206 { pad: false },
src/web/style/content.ts +1
@@ -866,6 +866,7 @@ textarea.editor:focus-visible { outline: none; }
866866 .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; }
867867 .valve-range { flex: 1 1 12rem; min-width: 8rem; }
868868 /* The week on Next up (#8704): the cut line and a row's cost range. */
869 .next-week { opacity: 0.55; }
869870 .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); }
870871 .estimate { color: var(--fg); font-variant-numeric: tabular-nums; }
871872 /* 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";
2323 import { recordUsage } from "../src/core/usage.ts";
2424 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2525 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";
2727 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";
2830 import { createForgeApp } from "../src/tui/forge.ts";
2931 import { headlessTerminal } from "../src/tui/term/terminal.ts";
3032 import { quietLogs } from "./helpers/globals.ts";
@@ -147,6 +149,75 @@ describe("the week's doors", () => {
147149 await rm(dataDir, { recursive: true, force: true });
148150 });
149151
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
150221 test("once capacity is read, Next up draws the cut line and the rows below read as next week", async () => {
151222 const view = weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date());
152223 expect(view.cut.unsure).toBeNull();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs