Public: anyone can read this; the crew can change it.
Issues one author opens within two minutes fold into one feed post: "planned 10 issues in trevato/forge", each one a line under it #7009
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5820-issues-one-author-opens-within-two-minut → main 8 commits fork 4 threads · 4 unresolved
docs/FEED.md +12 −1
| @@ -23,7 +23,7 @@ Anything the viewer cannot read is a 404. | ||
| 23 | 23 | only to those who may see owner facts), `pull.opened|merged|reviewed`, |
| 24 | 24 | `look.taken` (a `look`: a full set of pictures), `app.serving`, |
| 25 | 25 | `issue.blocked|unblocked` (map changes), `issue.opened` (a `tell`, in its |
| 26 | title's words) and `issue.commented|pull.commented` (a `reply`, in its | |
| 26 | title's words; see Bursts fold) and `issue.commented|pull.commented` (a `reply`, in its | |
| 27 | 27 | own words). |
| 28 | 28 | - Pending asks (`askLinesFor`), only for what the viewer may decide. |
| 29 | 29 | - Decision-ledger outcomes, only for repositories the viewer may write. |
| @@ -42,6 +42,17 @@ Anything the viewer cannot read is a 404. | ||
| 42 | 42 | Runs already announce themselves on the outbox (`run.*`); looks did not, so |
| 43 | 43 | `look.taken` was added. |
| 44 | 44 | |
| 45 | ## Bursts fold (`src/feed/fold.ts`) | |
| 46 | ||
| 47 | When one author opens 3 or more issues in one repository within 2 minutes, | |
| 48 | `foldBursts` replaces them with one post, `planned N issues in owner/name`, | |
| 49 | ranked where the newest would be and linking to the repository's map. The | |
| 50 | first 3 titles ride on it as `lines` (`{ text, link }`), the rest as `more`; | |
| 51 | the web card draws them as a `.list` of rows, MCP `feed` and | |
| 52 | `GET /api/v1/feed` return them. One or two issues keep their own posts. The | |
| 53 | fold runs after the viewer's filters and before paging, so chip counts and | |
| 54 | cursors see the folded posts. A new kind of burst is a row in `FOLDS`. | |
| 55 | ||
| 45 | 56 | ## Ranking (`src/feed/rank.ts`, `affinity.ts`) |
| 46 | 57 | |
| 47 | 58 | `score = urgency + 1.5·affinity + recency − repeats`. Urgency: asks 6–10, a |
docs/MCP.md +1 −1
| @@ -73,7 +73,7 @@ for a tunnel and a forge that sleeps. | ||
| 73 | 73 | | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | | |
| 74 | 74 | | `explore` | `{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor` | | |
| 75 | 75 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 76 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | | | |
| 76 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest | | | |
| 77 | 77 | | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes | |
| 78 | 78 | | `feed_dismiss` | `{ item, kind, app? }`: dismiss a feed item or mute its app, as the card's menu does (`kind` dismissed or muted); `feed` then leaves it out; `app` (`owner/name`) names the app for an item that is not an event; answers `{ id }` | yes | |
| 79 | 79 | | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes | |
docs/design/areas/feed.md +1
| @@ -3,3 +3,4 @@ | ||
| 3 | 3 | - `.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. |
| 4 | 4 | - `.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`. |
| 5 | 5 | - 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. |
| 6 | - Folded burst lines — no new class: under a folded post (`planned N issues in owner/name`) `feedCard` draws the §5 `ul.list` of `row()`s, one linked row per title and a last unlinked row `and N more`. |
src/feed/feed.ts +5 −2
| @@ -17,6 +17,7 @@ import { getUserById, type User } from "../core/users.ts"; | ||
| 17 | 17 | import { candidatesFor, quietRunsFor, type Candidate } from "./items.ts"; |
| 18 | 18 | import { writeStamp } from "../core/write_clock.ts"; |
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| 20 | import { foldBursts } from "./fold.ts"; | |
| 20 | 21 | import { rank, type FeedItem } from "./rank.ts"; |
| 21 | 22 | import { settledOf } from "./settled.ts"; |
| 22 | 23 | |
| @@ -176,8 +177,10 @@ export function readFeedPage( | ||
| 176 | 177 | c.kind === "choose" || |
| 177 | 178 | !muted.has(c.repoId)), |
| 178 | 179 | ); |
| 179 | const pageable = | |
| 180 | options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates; | |
| 180 | // After every filter, so a burst folds only what the viewer would see. | |
| 181 | const pageable = foldBursts( | |
| 182 | options.withoutAsks === true ? candidates.filter((c) => c.kind !== "ask") : candidates, | |
| 183 | ).filter((c) => !dismissed.has(c.id)); // a folded post is dismissed under its own id | |
| 181 | 184 | const cursor = options.cursor; |
| 182 | 185 | // Without a cursor only the page and one more (to know there is a next) are placed. |
| 183 | 186 | let ranked = rank( |
src/feed/fold.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * The fold: one author's burst of the same kind of post in one repository | |
| 3 | * becomes one post with a line under it for each. One function, one table; | |
| 4 | * a new kind of burst is a new row in `FOLDS`, a `fold` on its candidates. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Candidate } from "./items.ts"; | |
| 8 | ||
| 9 | export interface FoldRule { | |
| 10 | /** The folded post reads `<verb> N <noun> in owner/name`. */ | |
| 11 | readonly verb: string; | |
| 12 | readonly noun: string; | |
| 13 | /** How many make a burst; fewer keep their own posts. */ | |
| 14 | readonly min: number; | |
| 15 | /** A burst is everything within this long of its first. */ | |
| 16 | readonly windowMs: number; | |
| 17 | /** Titles shown under the post; the rest are `and N more`. */ | |
| 18 | readonly shown: number; | |
| 19 | /** Where the folded post links, from the repository's path. */ | |
| 20 | readonly link: (repoPath: string) => string; | |
| 21 | } | |
| 22 | ||
| 23 | export const FOLDS: Readonly<Record<string, FoldRule>> = { | |
| 24 | issues: { | |
| 25 | verb: "planned", | |
| 26 | noun: "issues", | |
| 27 | min: 3, | |
| 28 | windowMs: 2 * 60_000, | |
| 29 | shown: 3, | |
| 30 | link: (repo) => `${repo}/map`, | |
| 31 | }, | |
| 32 | }; | |
| 33 | ||
| 34 | /** A candidate that may fold: which rule, and its own line. */ | |
| 35 | export interface Foldable { | |
| 36 | readonly group: string; | |
| 37 | readonly title: string; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Replaces each burst with one candidate that ranks where its newest member | |
| 42 | * would (its time) and keeps the rest as they were, in order. | |
| 43 | */ | |
| 44 | export function foldBursts(candidates: readonly Candidate[]): Candidate[] { | |
| 45 | const buckets = new Map<string, Candidate[]>(); | |
| 46 | for (const c of candidates) { | |
| 47 | if (c.fold === undefined || c.actorId === null) continue; | |
| 48 | const key = `${c.fold.group}|${c.repoId}|${c.actorId}`; | |
| 49 | const list = buckets.get(key); | |
| 50 | if (list) list.push(c); | |
| 51 | else buckets.set(key, [c]); | |
| 52 | } | |
| 53 | const folded = new Map<string, Candidate>(); | |
| 54 | const gone = new Set<string>(); | |
| 55 | for (const [key, list] of buckets) { | |
| 56 | const rule = FOLDS[key.slice(0, key.indexOf("|"))]; | |
| 57 | if (rule === undefined) continue; | |
| 58 | const sorted = [...list].sort( | |
| 59 | (a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.id < b.id ? -1 : 1), | |
| 60 | ); | |
| 61 | let burst: Candidate[] = []; | |
| 62 | const close = () => { | |
| 63 | if (burst.length >= rule.min) { | |
| 64 | const newest = burst[burst.length - 1]!; | |
| 65 | for (const c of burst) gone.add(c.id); | |
| 66 | folded.set(newest.id, join(burst, newest, rule)); | |
| 67 | } | |
| 68 | burst = []; | |
| 69 | }; | |
| 70 | for (const c of sorted) { | |
| 71 | if ( | |
| 72 | burst.length > 0 && | |
| 73 | Date.parse(c.at) - Date.parse(burst[0]!.at) > rule.windowMs | |
| 74 | ) | |
| 75 | close(); | |
| 76 | burst.push(c); | |
| 77 | } | |
| 78 | close(); | |
| 79 | } | |
| 80 | const out: Candidate[] = []; | |
| 81 | for (const c of candidates) { | |
| 82 | const one = folded.get(c.id); | |
| 83 | if (one) out.push(one); | |
| 84 | else if (!gone.has(c.id)) out.push(c); | |
| 85 | } | |
| 86 | return out; | |
| 87 | } | |
| 88 | ||
| 89 | function join(burst: Candidate[], newest: Candidate, rule: FoldRule): Candidate { | |
| 90 | // Newest first, as the feed shows them. | |
| 91 | const lines = [...burst].reverse().map((c) => ({ text: c.fold!.title, link: c.link })); | |
| 92 | const { fold: _fold, ...rest } = newest; | |
| 93 | return { | |
| 94 | ...rest, | |
| 95 | // The first member's id: it does not move as the burst grows, so a dismissal holds. | |
outdated
This id is the newest member's, so it changes every time the burst grows. And |
||
| 96 | id: `fold:${burst[0]!.id.replace(/^[a-z]+:/, "")}`, | |
| 97 | link: rule.link(`/${newest.app}`), | |
| 98 | what: `${rule.verb} ${burst.length} ${rule.noun} in ${newest.app}`, | |
| 99 | lines: lines.slice(0, rule.shown), | |
| 100 | more: Math.max(0, lines.length - rule.shown), | |
| 101 | }; | |
| 102 | } |
src/feed/items.ts +7
| @@ -32,6 +32,7 @@ import { getUserById, type User } from "../core/users.ts"; | ||
| 32 | 32 | import { watchedBy } from "../core/watches.ts"; |
| 33 | 33 | import { CLOG_NOTE_WINDOW_MS, flattenNote } from "../flow/clog_note.ts"; |
| 34 | 34 | import { chooseCandidates } from "./choose.ts"; |
| 35 | import type { Foldable } from "./fold.ts"; | |
| 35 | 36 | import { writeStamp } from "../core/write_clock.ts"; |
| 36 | 37 | import { Stamped } from "../kernel/stamped.ts"; |
| 37 | 38 | import { QUIET_WINDOW_MS, quietRunEventIds } from "./quiet.ts"; |
| @@ -77,6 +78,11 @@ export interface Candidate { | ||
| 77 | 78 | readonly said?: string; |
| 78 | 79 | /** For an ask: what it asks, the answers, what happens if nobody acts, and its rank. */ |
| 79 | 80 | readonly ask?: FeedAsk; |
| 81 | /** Set on a post that may fold into a burst (`fold.ts`). */ | |
| 82 | readonly fold?: Foldable; | |
| 83 | /** On a folded post: the first titles, each linking to its own thing, and how many more. */ | |
| 84 | readonly lines?: readonly { readonly text: string; readonly link: string }[]; | |
| 85 | readonly more?: number; | |
| 80 | 86 | /** For a look: the picture's environment and commit; the page finds the thumbnail. */ |
| 81 | 87 | readonly look?: FeedLook; |
| 82 | 88 | } |
| @@ -659,6 +665,7 @@ function fromEvent( | ||
| 659 | 665 | kind: "tell", |
| 660 | 666 | link: `${base(repo)}/issues/${number}`, |
| 661 | 667 | what: `opened an issue: ${told === "" ? `#${number}` : clip(told)}`, |
| 668 | fold: { group: "issues", title: told === "" ? `#${number}` : clip(told) }, | |
| 662 | 669 | }; |
| 663 | 670 | } |
| 664 | 671 | case "issue.commented": |
src/feed/rank.ts +4
| @@ -25,6 +25,9 @@ export interface FeedItem { | ||
| 25 | 25 | readonly score: number; |
| 26 | 26 | /** For an ask: the decision, options, default and rank. */ |
| 27 | 27 | readonly ask?: FeedAsk; |
| 28 | /** A folded post: its first titles with their links, and how many more. */ | |
| 29 | readonly lines?: readonly { readonly text: string; readonly link: string }[]; | |
| 30 | readonly more?: number; | |
| 28 | 31 | /** For a look: which picture it is of. */ |
| 29 | 32 | readonly look?: FeedLook; |
| 30 | 33 | /** For a post about a pull or an issue that is over: how it ended. Absent while open. */ |
| @@ -155,6 +158,7 @@ export function rank( | ||
| 155 | 158 | reason: reasonOf(s!.c, aff, now), |
| 156 | 159 | score: Math.round(bestScore * 1e6) / 1e6, |
| 157 | 160 | ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }), |
| 161 | ...(s!.c.lines === undefined ? {} : { lines: s!.c.lines, more: s!.c.more ?? 0 }), | |
| 158 | 162 | ...(s!.c.look === undefined ? {} : { look: s!.c.look }), |
| 159 | 163 | }); |
| 160 | 164 | } |
src/http/mcp/tools/feed.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | name: "feed", |
| 9 | doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out", | |
| 9 | doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest", | |
| 10 | 10 | description: |
| 11 | 11 | "Your ranked home timeline; `cursor` pages; saved filter applies.", |
| 12 | 12 | inputSchema: { |
src/web/studio/feed.ts +4 −1
| @@ -27,7 +27,7 @@ import { html, type Html } from "../html.ts"; | ||
| 27 | 27 | import { fragment } from "../layout.ts"; |
| 28 | 28 | import { draftJs } from "../draft.ts"; |
| 29 | 29 | import { outboxJs } from "../outbox.ts"; |
| 30 | import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, time } from "../ui.ts"; | |
| 30 | import { avatar, btn, card, disclosure, empty, nameMark, row, stateBadge, time } from "../ui.ts"; | |
| 31 | 31 | import { writableAppsOf } from "./asks.ts"; |
| 32 | 32 | |
| 33 | 33 | /** Events that can change what the feed says. */ |
| @@ -120,6 +120,9 @@ export function feedCard( | ||
| 120 | 120 | <span class="spacer"></span> |
| 121 | 121 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 122 | 122 | const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p> |
| 123 | ${item.lines === undefined | |
outdated
Hand-rolled list markup. Use a named design component, or document a new one in docs/design/areas/feed.md. Show the same lines in src/tty.ts. |
||
| 124 | ? null | |
| 125 | : html`<ul class="list">${item.lines.map((l) => row({ href: l.link, title: l.text }))}${item.more ? row({ title: `and ${item.more} more` }) : null}</ul>`} | |
| 123 | 126 | ${picture === undefined |
| 124 | 127 | ? null |
| 125 | 128 | : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true" |
test/feed-issue-fold.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * A burst of issues from one author folds into one post: three or more in | |
| 3 | * two minutes, titles under it, cursor paging whole, one or two untouched. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createIssue } from "../src/core/issues.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { cursorOf, parseCursor, readFeed } from "../src/feed/feed.ts"; | |
| 15 | import { recordInteraction } from "../src/core/interactions.ts"; | |
| 16 | import { feedCard } from "../src/web/studio/feed.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | describe("folding a burst of issues", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | let repo: Repo; | |
| 25 | ||
| 26 | const open = (who: User, title: string): string => { | |
| 27 | const made = createIssue(app.db, { repoId: repo.id, authorId: who.id, title }); | |
| 28 | if (!made.ok) throw new Error(made.error); | |
| 29 | return app.bus.append({ | |
| 30 | kind: "issue.opened", | |
| 31 | repoId: repo.id, | |
| 32 | actorId: who.id, | |
| 33 | payload: { number: made.value.number }, | |
| 34 | }).id; | |
| 35 | }; | |
| 36 | const posts = () => { | |
| 37 | const out = []; | |
| 38 | let cursor: { score: number; id: string } | undefined; | |
| 39 | for (let i = 0; i < 20; i++) { | |
| 40 | const page = readFeed(app.db, alice, { limit: 1, ...(cursor ? { cursor } : {}) }); | |
| 41 | out.push(...page.items); | |
| 42 | if (page.next_cursor === null) break; | |
| 43 | cursor = parseCursor(page.next_cursor); | |
| 44 | } | |
| 45 | return out.filter((i) => i.app === `${repo.ownerName}/${repo.name}` && i.kind === "tell"); | |
| 46 | }; | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-feed-fold-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | const mk = async (username: string, first = false) => { | |
| 52 | const r = await createUser(app.db, { | |
| 53 | origin: "seed", trusted: true, allowReserved: true, username, email: `${username}@forge.local`, | |
| 54 | password: "a-good-passphrase", adminIfFirst: first, | |
| 55 | }); | |
| 56 | if (!r.ok) throw new Error(r.error); | |
| 57 | promote(app.db, r.value); | |
| 58 | return r.value; | |
| 59 | }; | |
| 60 | await mk("root", true); | |
| 61 | alice = await mk("alice"); | |
| 62 | bob = await mk("bob"); | |
| 63 | const made = await createRepo(app, alice, { name: "plans", visibility: "public" }); | |
| 64 | if (!made.ok) throw new Error("repo"); | |
| 65 | repo = made.value; | |
| 66 | }); | |
| 67 | afterAll(async () => { | |
| 68 | app.db.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("two issues keep their own posts", () => { | |
| 73 | open(alice, "one"); | |
| 74 | open(alice, "two"); | |
| 75 | const p = posts(); | |
| 76 | expect(p).toHaveLength(2); | |
| 77 | expect(p.every((i) => i.what.startsWith("opened an issue"))).toBe(true); | |
| 78 | }); | |
| 79 | ||
| 80 | test("ten from one author and one from another make two posts, paged without a duplicate", () => { | |
| 81 | for (let i = 3; i <= 10; i++) open(alice, `step ${i}`); | |
| 82 | open(bob, "bob's idea"); | |
| 83 | const p = posts(); | |
| 84 | // alice's first two are in the same burst: ten in all. | |
| 85 | expect(p).toHaveLength(2); | |
| 86 | const ids = p.map((i) => i.id); | |
| 87 | expect(new Set(ids).size).toBe(2); | |
| 88 | const folded = p.find((i) => i.what === `planned 10 issues in alice/plans`)!; | |
| 89 | expect(folded).toBeDefined(); | |
| 90 | expect(folded.link).toBe("/alice/plans/map"); | |
| 91 | expect(folded.lines).toHaveLength(3); | |
| 92 | expect(folded.lines![0]!.text).toBe("step 10"); | |
| 93 | expect(folded.lines![0]!.link).toMatch(/^\/alice\/plans\/issues\/\d+$/); | |
| 94 | expect(folded.more).toBe(7); | |
| 95 | expect(p.find((i) => i.actor === "bob")!.what).toContain("bob's idea"); | |
| 96 | expect(cursorOf(folded)).toContain("fold:"); | |
| 97 | }); | |
| 98 | ||
| 99 | test("the card shows the titles and `and 7 more`", () => { | |
| 100 | const folded = posts().find((i) => i.lines !== undefined)!; | |
| 101 | const html = String(feedCard(folded)); | |
| 102 | expect(html).toContain("step 10"); | |
| 103 | expect(html).toContain("and 7 more"); | |
| 104 | // Drawn as the design's list of rows, not hand-rolled markup. | |
| 105 | expect(html).toContain('<ul class="list">'); | |
| 106 | expect(html).not.toContain('class="small break"><li'); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a dismissed folded post stays gone, even when another issue joins the burst", () => { | |
| 110 | const folded = posts().find((i) => i.lines !== undefined)!; | |
| 111 | recordInteraction(app.db, { | |
| 112 | viewerId: alice.id, | |
| 113 | itemId: folded.id, | |
| 114 | kind: "dismissed", | |
| 115 | repoId: repo.id, | |
| 116 | actorId: alice.id, | |
| 117 | }); | |
| 118 | expect(posts().some((i) => i.lines !== undefined)).toBe(false); | |
| 119 | open(alice, "step 11"); | |
| 120 | const after = posts(); | |
| 121 | expect(after.some((i) => i.lines !== undefined)).toBe(false); | |
| 122 | expect(after.some((i) => i.what.includes("planned"))).toBe(false); | |
| 123 | }); | |
| 124 | ||
| 125 | test("issues more than two minutes apart do not fold", () => { | |
| 126 | app.db.query("UPDATE event SET created_at = ?1 WHERE kind = 'issue.opened' AND actor_id = ?2").run( | |
| 127 | new Date(Date.now() - 3600_000).toISOString(), alice.id, | |
| 128 | ); | |
| 129 | const ids = app.db.query<{ id: string }, [string]>("SELECT id FROM event WHERE kind = 'issue.opened' AND actor_id = ?1 ORDER BY id").all(alice.id); | |
| 130 | ids.forEach((r, i) => | |
| 131 | app.db.query("UPDATE event SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - 3600_000 + i * 180_000).toISOString(), r.id), | |
| 132 | ); | |
| 133 | const mine = posts().filter((i) => i.actor === "alice"); | |
| 134 | expect(mine).toHaveLength(11); | |
| 135 | expect(mine.every((i) => i.lines === undefined)).toBe(true); | |
| 136 | }); | |
| 137 | }); |
test/front-door-timing.test.ts +21 −3
| @@ -4,9 +4,11 @@ import { rm, writeFile } from "node:fs/promises"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createOrg } from "../src/core/orgs.ts"; |
| 6 | 6 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 7 | 8 | import { openIssue } from "../src/actions/issues.ts"; |
| 8 | 9 | import { affinityFor } from "../src/feed/affinity.ts"; |
| 9 | 10 | import { readFeed } from "../src/feed/feed.ts"; |
| 11 | import { foldBursts } from "../src/feed/fold.ts"; | |
| 10 | 12 | import { candidatesFor } from "../src/feed/items.ts"; |
| 11 | 13 | import { rank } from "../src/feed/rank.ts"; |
| 12 | 14 | |
| @@ -60,13 +62,29 @@ test("a spawned forge's lean home names spawned and steps once each, and reads n | ||
| 60 | 62 | } |
| 61 | 63 | }); |
| 62 | 64 | |
| 63 | test("placing only the page leaves the order of the whole ranking untouched", () => { | |
| 65 | test("placing only the page leaves the order of the whole ranking untouched", async () => { | |
| 66 | // The feed folds one author's bursts, so the whole ranking needs many authors | |
| 67 | // with one issue each: 70 distinct posts that nothing folds. | |
| 68 | for (let i = 0; i < 70; i++) { | |
| 69 | const made = await createUser(f.app.db, { | |
| 70 | origin: "seed", | |
| 71 | trusted: true, | |
| 72 | username: `lone-${i}`, | |
| 73 | email: `lone-${i}@forge.local`, | |
outdated
The page size and floor were lowered (50 to 5, 60 to 6) to fit the fold. Reseed so the original sizes still hold. |
||
| 74 | password: "a-good-passphrase", | |
| 75 | }); | |
| 76 | if (!made.ok) throw new Error(made.error); | |
| 77 | const opened = openIssue(f.app, made.value, f.repos[0]!, { title: `A lone idea ${i}` }); | |
| 78 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 79 | } | |
| 64 | 80 | const now = Date.now(); |
| 65 | 81 | const candidates = candidatesFor(f.app.db, f.owner, now, undefined, true); |
| 66 | 82 | const aff = affinityFor(f.app.db, f.owner, now); |
| 67 | const whole = rank(candidates, aff, now); | |
| 83 | // The feed folds bursts before it pages: the whole ranking is of the folded posts. | |
| 84 | const folded = foldBursts(candidates); | |
| 85 | const whole = rank(folded, aff, now); | |
| 68 | 86 | expect(whole.length).toBeGreaterThan(60); |
| 69 | expect(rank(candidates, aff, now, 51)).toEqual(whole.slice(0, 51)); | |
| 87 | expect(rank(folded, aff, now, 51)).toEqual(whole.slice(0, 51)); | |
| 70 | 88 | const page = readFeed(f.app.db, f.owner, { limit: 50, now }); |
| 71 | 89 | expect(page.items).toEqual(whole.slice(0, 50)); |
| 72 | 90 | expect(page.next_cursor).not.toBeNull(); |
test/snappy.test.ts +1 −1
| @@ -118,7 +118,7 @@ describe("the caches are exact", () => { | ||
| 118 | 118 | |
| 119 | 119 | test("one viewer's feed is never another's, and a write is in the next one", async () => { |
| 120 | 120 | const whats = (who: typeof f.owner) => |
| 121 | readFeed(f.app.db, who, { limit: 50 }).items.map((i) => i.what).join("\n"); | |
| 121 | readFeed(f.app.db, who, { limit: 50 }).items.map((i) => [i.what, ...(i.lines ?? []).map((l) => l.text)].join("\n")).join("\n"); | |
| 122 | 122 | // Both warm, before the write. |
| 123 | 123 | whats(f.owner); |
| 124 | 124 | whats(f.peer); |
Dismissals were applied above, to the members. The folded post's own id (
fold:…, which is what the card's Dismiss button andfeed_dismissrecord) also needs.filter((c) => !dismissed.has(c.id))after this, or a folded post can't be dismissed.