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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6065-the-home-timeline-draws-a-day-divider-be → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +164
−7
docs/design/areas/feed.md +1
| @@ -3,3 +3,4 @@ | ||
| 3 | 3 | - `.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. |
| 4 | 4 | - `.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. |
| 5 | 5 | - `.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"; | ||
| 21 | 21 | import { getRepo, getRepoById } from "../../core/repos.ts"; |
| 22 | 22 | import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; |
| 23 | 23 | import { mayFetchLook, pictured } from "../pages/looks.ts"; |
| 24 | import { dividers } from "./day_divider.ts"; | |
| 24 | 25 | import { html, type Html } from "../html.ts"; |
| 25 | 26 | import { fragment } from "../layout.ts"; |
| 26 | 27 | import { outboxJs } from "../outbox.ts"; |
| @@ -300,6 +301,9 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 300 | 301 | ...(cursor === undefined ? {} : { cursor }), |
| 301 | 302 | }); |
| 302 | 303 | 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; | |
| 303 | 307 | const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString()); |
| 304 | 308 | const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id)); |
| 305 | 309 | // Who each poster is, asked once for the page (`core/marks.ts`). |
| @@ -310,7 +314,7 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 310 | 314 | const quiet = page.quiet; |
| 311 | 315 | const quietRepo = quiet.repoId === null ? undefined : getRepoById(ctx.app.db, quiet.repoId); |
| 312 | 316 | 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 }; | |
| 314 | 318 | } |
| 315 | 319 | |
| 316 | 320 | /** 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 | ||
| 333 | 337 | <span>You're caught up to ${time(oldest)}</span> |
| 334 | 338 | <span class="spacer"></span> |
| 335 | 339 | ${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> | |
| 337 | 341 | </p>`; |
| 338 | 342 | } |
| 339 | 343 | |
| 340 | 344 | 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("&"); | |
| 342 | 360 | return html`<section |
| 343 | 361 | id="live-feed" |
| 344 | 362 | class="feed" |
| 345 | 363 | aria-label="Feed" |
| 346 | 364 | 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}`}" | |
| 348 | 366 | > |
| 349 | 367 | ${chipsBar(user, ctx, items)} |
outdated
|
||
| 350 | 368 | ${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>): | ||
| 354 | 372 | title: "Nothing new", |
| 355 | 373 | note: "When something happens in your apps, it lands here.", |
| 356 | 374 | }) |
| 357 | : items.map((item) => feedCard(item, marks, pictures.get(item.id)))} | |
| 375 | : posts} | |
| 358 | 376 | ${quiet === 0 |
| 359 | 377 | ? "" |
outdated
The divider is a sibling of the card, so |
||
| 360 | 378 | : 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 { | ||
| 374 | 392 | return response; |
| 375 | 393 | } |
| 376 | 394 | |
| 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 | ||
| 377 | 415 | /** |
| 378 | 416 | * j/k and enter are the shell's (`data-nav`). Here: `x` dismisses the card |
| 379 | 417 | * in focus, the buttons dismiss or mute, and opening a card is recorded. |
| @@ -381,6 +419,7 @@ export function liveFeedFragment(ctx: Ctx): Response { | ||
| 381 | 419 | */ |
| 382 | 420 | export const feedJs = /* js */ ` |
| 383 | 421 | ${outboxJs} |
| 422 | ${dayKeepJs} | |
| 384 | 423 | (function () { |
| 385 | 424 | var feed = document.getElementById('live-feed'); |
| 386 | 425 | if (!feed || typeof fetch === 'undefined') return; |
| @@ -427,6 +466,7 @@ ${outboxJs} | ||
| 427 | 466 | var cards = Array.prototype.slice.call(feed.querySelectorAll('[data-item]')); |
| 428 | 467 | var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1]; |
| 429 | 468 | card.hidden = true; |
| 469 | keepDay(card); | |
| 430 | 470 | if (next) next.focus(); |
| 431 | 471 | } |
| 432 | 472 | // 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", () => { | ||
| 72 | 72 | |
| 73 | 73 | test("a page after the first carries its own address, so a chip refetch stays on it", async () => { |
| 74 | 74 | const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&", "&"); |
| 75 | const cursor = href.slice("/?cursor=".length); | |
| 75 | const cursor = href.slice("/?cursor=".length).split("&day=")[0]; | |
| 76 | 76 | 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}&day=`); | |
| 78 | 78 | expect(await home()).toContain('data-live-src="/live/feed"'); |
| 79 | 79 | expect(feedJs).toContain("feed.getAttribute('data-live-src')"); |
| 80 | 80 | }); |
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("&", "&"); | |
| 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 | }); |
new above
offline
Fine as written: the link only renders when
nextis set, which implies a non-empty page. IfcaughtUpis ever called withnextset and no items, the!here will throw. A guard costs nothing.