Public: anyone can read this; the crew can change it.

The home timeline draws a day divider before the first post of each UTC day, never twice #6566

Merged opened this

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

Merged

scout/forge:scout/6065-the-home-timeline-draws-a-day-divider-be → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6065-the-home-timeline-draws-a-day-divider-be was merged into main as 87760d3.

Review

Sign in to review this change.
4 files changed +164 −7
docs/design/areas/feed.md +1
@@ -3,3 +3,4 @@
33 - `.feed-head` — the head of the home's feed: a §5 `.bar` above the timeline holding the "What you asked for" link, pushed to the end by a spacer. Drawn by `studioHome` in `src/web/studio/home.ts`, styled in `src/web/studio/feed_style.ts`. Under 900px it is hidden, since that link is a tab on a phone and the first post should come next; a bare `.bar` cannot carry this, as it has no reason to vanish on a narrow screen.
44 - `.look[data-slot="feed"]` — a look post's thumbnail, drawn by `feedCard` in `src/web/studio/feed.ts`: the §5 `.look` frame fixed at 160×100 (the img carries the same width and height, so nothing shifts), cropped from the top like the other thumbs. A bare `.look` is fluid and would resize the post.
55 - `.feed` — the home's timeline list: a one-column grid with no gap that holds the feed's cards, each focusable with a visible outline and removed from layout while `hidden`. Drawn by `drawRegion` in `src/web/studio/feed.ts` (the `#live-feed` section), styled in `src/web/studio/feed_style.ts`.
6 - Day divider — no new class: a §5 `.muted.small` line with `data-day-divider="<label>"`, drawn by `drawRegion` in `src/web/studio/feed.ts` before the first post of each UTC day (labels from `day_divider.ts`). It marks a change of day between neighbours in the order the feed already has; it never reorders. It is `role="separator"`, and hiding a card (dismiss, mute) moves it to the next card showing that day or hides it (`keepDay`). The "Older posts" link and `data-live-src` carry `day=` so a later page does not repeat the divider.
src/web/studio/feed.ts +45 −5
@@ -21,6 +21,7 @@ import { getAppByRepo } from "../../core/apps.ts";
2121 import { getRepo, getRepoById } from "../../core/repos.ts";
2222 import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts";
2323 import { mayFetchLook, pictured } from "../pages/looks.ts";
24 import { dividers } from "./day_divider.ts";
2425 import { html, type Html } from "../html.ts";
2526 import { fragment } from "../layout.ts";
2627 import { outboxJs } from "../outbox.ts";
@@ -300,6 +301,9 @@ function readRegion(user: User, ctx: Ctx) {
300301 ...(cursor === undefined ? {} : { cursor }),
301302 });
302303 const items = page.items;
304 // The day the page before ended on, so a continuing page draws no divider twice (`day_divider.ts`).
305 const day = cursor === undefined ? "" : (ctx.url.searchParams.get("day") ?? "");
306 const lastDayOnPage = Number.isFinite(Date.parse(day)) ? new Date(day).toISOString() : undefined;
303307 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
304308 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
305309 // Who each poster is, asked once for the page (`core/marks.ts`).
@@ -310,7 +314,7 @@ function readRegion(user: User, ctx: Ctx) {
310314 const quiet = page.quiet;
311315 const quietRepo = quiet.repoId === null ? undefined : getRepoById(ctx.app.db, quiet.repoId);
312316 const quietLink = quietRepo === undefined ? "/explore" : `/${quietRepo.ownerName}/${quietRepo.name}/runs?kind=agent`;
313 return { items, next: page.next_cursor, cursor, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
317 return { items, next: page.next_cursor, cursor, lastDayOnPage, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
314318 }
315319
316320 /** The region: cards, or one line saying there is nothing to do. */
@@ -333,18 +337,32 @@ export function caughtUp(items: readonly FeedItem[], next: string | null, cursor
333337 <span>You're caught up to ${time(oldest)}</span>
334338 <span class="spacer"></span>
335339 ${newest}
336 <a href="/?cursor=${encodeURIComponent(next)}">Older posts</a>
340 <a href="/?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a>

Fine as written: the link only renders when next is set, which implies a non-empty page. If caughtUp is ever called with next set and no items, the ! here will throw. A guard costs nothing.

337341 </p>`;
338342 }
339343
340344 function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): Html {
341 const { items, next, cursor, thoughts, placed, marks, pictures, quiet, quietLink } = read;
345 const { items, next, cursor, lastDayOnPage, thoughts, placed, marks, pictures, quiet, quietLink } = read;
346 const now = new Date();
347 // One divider per change of day between neighbours, in the order the feed already has.
348 const posts: Html[] = [];
349 let before = lastDayOnPage;
350 for (const item of items) {
351 const [label] = dividers([item.at], now, before);
352 if (label !== undefined) posts.push(html`<p class="muted small" role="separator" aria-label="${label}" data-day-divider="${label}">${label}</p>`);
353 posts.push(feedCard(item, marks, pictures.get(item.id)));
354 before = item.at;
355 }
356 const liveQuery = [
357 cursor === undefined ? "" : `cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`,
358 cursor === undefined || lastDayOnPage === undefined ? "" : `day=${encodeURIComponent(lastDayOnPage)}`,
359 ].filter(Boolean).join("&");
342360 return html`<section
343361 id="live-feed"
344362 class="feed"
345363 aria-label="Feed"
346364 data-live="${FEED_LIVE}"
347 data-live-src="/live/feed${cursor === undefined ? "" : `?cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`}"
365 data-live-src="/live/feed${liveQuery === "" ? "" : `?${liveQuery}`}"
348366 >
349367 ${chipsBar(user, ctx, items)}
outdated

data-last-day is never read by the client or anything else. Remove it, or use it.

350368 ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))}
@@ -354,7 +372,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
354372 title: "Nothing new",
355373 note: "When something happens in your apps, it lands here.",
356374 })
357 : items.map((item) => feedCard(item, marks, pictures.get(item.id)))}
375 : posts}
358376 ${quiet === 0
359377 ? ""
outdated

The divider is a sibling of the card, so hidden on the card (dismiss or mute) leaves the divider behind. Tie its visibility to the card's.

360378 : html`<p class="small muted"><a href="${quietLink}">${quiet} quiet ${quiet === 1 ? "run" : "runs"} in the last 24 h</a></p>`}
@@ -374,6 +392,26 @@ export function liveFeedFragment(ctx: Ctx): Response {
374392 return response;
375393 }
376394
395 /**
396 * When a card is hidden, the day divider just above it goes with it, unless
397 * a later card of that day is still showing — then the divider moves down to
398 * it, so a day never reads as a heading over nothing.
399 */
400 export const dayKeepJs = /* js */ `
401 function keepDay(card) {
402 var div = card.previousElementSibling;
403 if (!div || !div.hasAttribute('data-day-divider')) return;
404 var next = card.nextElementSibling;
405 while (next && next.hidden) next = next.nextElementSibling;
406 if (next && next.hasAttribute('data-item')) {
407 next.parentNode.insertBefore(div, next);
408 div.hidden = false;
409 } else {
410 div.hidden = true;
411 }
412 }
413 `;
414
377415 /**
378416 * j/k and enter are the shell's (`data-nav`). Here: `x` dismisses the card
379417 * in focus, the buttons dismiss or mute, and opening a card is recorded.
@@ -381,6 +419,7 @@ export function liveFeedFragment(ctx: Ctx): Response {
381419 */
382420 export const feedJs = /* js */ `
383421 ${outboxJs}
422 ${dayKeepJs}
384423 (function () {
385424 var feed = document.getElementById('live-feed');
386425 if (!feed || typeof fetch === 'undefined') return;
@@ -427,6 +466,7 @@ ${outboxJs}
427466 var cards = Array.prototype.slice.call(feed.querySelectorAll('[data-item]'));
428467 var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1];
429468 card.hidden = true;
469 keepDay(card);
430470 if (next) next.focus();
431471 }
432472 // Acting is instant: the card goes, the act is kept and sent.
test/feed-caught-up.test.ts +2 −2
@@ -72,9 +72,9 @@ describe("the timeline's caught-up line", () => {
7272
7373 test("a page after the first carries its own address, so a chip refetch stays on it", async () => {
7474 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&amp;", "&");
75 const cursor = href.slice("/?cursor=".length);
75 const cursor = href.slice("/?cursor=".length).split("&day=")[0];
7676 const older = await home(href);
77 expect(older).toContain(`data-live-src="/live/feed?cursor=${cursor}"`);
77 expect(older).toContain(`data-live-src="/live/feed?cursor=${cursor}&amp;day=`);
7878 expect(await home()).toContain('data-live-src="/live/feed"');
7979 expect(feedJs).toContain("feed.getAttribute('data-live-src')");
8080 });
test/feed-day-divider.test.ts added +116
@@ -0,0 +1,116 @@
1 /** The home timeline draws a day divider before the first post of each UTC day, never twice. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { dayKeepJs } from "../src/web/studio/feed.ts";
12 import { promote } from "./helpers/members.ts";
13
14 const dividerLabels = (text: string): string[] => [...text.matchAll(/data-day-divider="([^"]+)"/g)].map((m) => m[1]!);
15
16 describe("day dividers on the home timeline", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie: string;
21 let repoId = "";
22 let aliceId = "";
23
24 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-day-divider-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 let alice: User | undefined;
31 for (const [name, first] of [["root", true], ["alice", false]] as const) {
32 const r = await createUser(app.db, { origin: "seed", trusted: true, username: name, allowReserved: true, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
33 if (!r.ok) throw new Error(r.error);
34 promote(app.db, r.value);
35 if (name === "alice") alice = r.value;
36 }
37 cookie = `forge_session=${createSession(app.db, alice!.id).token}`;
38 const made = await createRepo(app, alice!, { name: "open", visibility: "public" });
39 if (!made.ok) throw new Error("repo");
40 repoId = made.value.id;
41 aliceId = alice!.id;
42 // Two posts on each of three days, written in the past by hand.
43 const day = 86_400_000;
44 [0, 1, 5].forEach((ago, d) => {
45 for (let i = 0; i < 2; i++) {
46 const e = app.bus.append({ kind: "run.finished", repoId: made.value.id, actorId: alice!.id, payload: { kind: "agent", state: "ok", summary: `work ${d}-${i}`, runId: `r${d}${i}` } });
47 app.db.query("UPDATE event SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - ago * day - i * 1000).toISOString(), e.id);
48 }
49 });
50 });
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("three days give three dividers in order, once each", async () => {
59 const labels = dividerLabels(await get("/"));
60 expect(labels).toHaveLength(3);
61 expect(labels.slice(0, 2)).toEqual(["Today", "Yesterday"]);
62 });
63
64 test("the next page and its live fragment add no divider already on the page", async () => {
65 for (let i = 0; i < 25; i++) {
66 const e = app.bus.append({ kind: "run.finished", repoId: repoId, actorId: aliceId, payload: { kind: "agent", state: "ok", summary: `more ${i}`, runId: `m${i}` } });
67 app.db.query("UPDATE event SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - i * 1000).toISOString(), e.id);
68 }
69 const first = await get("/");
70 expect(dividerLabels(first)[0]).toBe("Today");
71 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(first)![1]!.replaceAll("&amp;", "&");
72 const older = await get(href);
73 expect(older).toContain("data-item=");
74 expect(dividerLabels(older)).not.toContain("Today");
75 const live = await get(href.replace("/?", "/live/feed?"));
76 expect(dividerLabels(live)).toEqual(dividerLabels(older));
77 });
78
79 test("a day that is not a time is ignored", async () => {
80 const text = await get(`/live/feed?cursor=${encodeURIComponent("1|zzz")}&day=%3Cscript%3E`);
81 expect(text).not.toContain("<script>");
82 });
83 });
84
85 describe("a divider goes with its card when the card is hidden", () => {
86 type El = { hasAttribute(a: string): boolean; hidden: boolean; attrs: Set<string>; parentNode: { insertBefore(a: El, b: El): void }; previousElementSibling: El | null; nextElementSibling: El | null };
87 const keepDay = new Function(`${dayKeepJs}; return keepDay;`)() as (card: El) => void;
88 const row = (list: El[], attr: string): El => {
89 const e = { hidden: false, attrs: new Set([attr]), hasAttribute(a: string) { return a === attr; }, previousElementSibling: null, nextElementSibling: null, parentNode: null } as unknown as El;
90 e.parentNode = { insertBefore: (a, b) => { list.splice(list.indexOf(a), 1); list.splice(list.indexOf(b), 0, a); link(list); } };
91 list.push(e);
92 return e;
93 };
94 const link = (list: El[]) => list.forEach((e, i) => { e.previousElementSibling = list[i - 1] ?? null; e.nextElementSibling = list[i + 1] ?? null; });
95
96 test("the only post of a day: its divider is hidden", () => {
97 const l: El[] = [];
98 const d1 = row(l, "data-day-divider"), a = row(l, "data-item"), d2 = row(l, "data-day-divider"), b = row(l, "data-item");
99 link(l);
100 a.hidden = true;
101 keepDay(a);
102 expect(d1.hidden).toBe(true);
103 expect(d2.hidden).toBe(false);
104 expect(b.hidden).toBe(false);
105 });
106
107 test("one of several posts of a day: the divider moves to the next showing", () => {
108 const l: El[] = [];
109 const d = row(l, "data-day-divider"), a = row(l, "data-item"), b = row(l, "data-item");
110 link(l);
111 a.hidden = true;
112 keepDay(a);
113 expect(d.hidden).toBe(false);
114 expect(l.indexOf(d)).toBe(l.indexOf(b) - 1);
115 });
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs