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

Merged opened this

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

Merged

scout/forge:scout/5820-issues-one-author-opens-within-two-minut → main 8 commits fork 4 threads · 4 unresolved

  • Merged. scout/5820-issues-one-author-opens-within-two-minut was merged into main as 1cde7a9.

Review

Sign in to review this change.
12 files changed +296 −10
docs/FEED.md +12 −1
@@ -23,7 +23,7 @@ Anything the viewer cannot read is a 404.
2323 only to those who may see owner facts), `pull.opened|merged|reviewed`,
2424 `look.taken` (a `look`: a full set of pictures), `app.serving`,
2525 `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
2727 own words).
2828 - Pending asks (`askLinesFor`), only for what the viewer may decide.
2929 - Decision-ledger outcomes, only for repositories the viewer may write.
@@ -42,6 +42,17 @@ Anything the viewer cannot read is a 404.
4242 Runs already announce themselves on the outbox (`run.*`); looks did not, so
4343 `look.taken` was added.
4444
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
4556 ## Ranking (`src/feed/rank.ts`, `affinity.ts`)
4657
4758 `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.
7373 | `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` | |
7474 | `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` | |
7575 | `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 | |
7777 | `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 |
7878 | `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 |
7979 | `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 @@
33 - `.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.
44 - `.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`.
55 - 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";
1717 import { candidatesFor, quietRunsFor, type Candidate } from "./items.ts";
1818 import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
20 import { foldBursts } from "./fold.ts";
2021 import { rank, type FeedItem } from "./rank.ts";
2122 import { settledOf } from "./settled.ts";
2223
@@ -176,8 +177,10 @@ export function readFeedPage(
176177 c.kind === "choose" ||
177178 !muted.has(c.repoId)),
178179 );
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(

Dismissals were applied above, to the members. The folded post's own id (fold:…, which is what the card's Dismiss button and feed_dismiss record) also needs .filter((c) => !dismissed.has(c.id)) after this, or a folded post can't be dismissed.

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
181184 const cursor = options.cursor;
182185 // Without a cursor only the page and one more (to know there is a next) are placed.
183186 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 readFeedPage filters dismissals on the unfolded event: ids before folding, so a fold: dismissal never takes effect. Key the id on the first member, and filter dismissed again after foldBursts.

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";
3232 import { watchedBy } from "../core/watches.ts";
3333 import { CLOG_NOTE_WINDOW_MS, flattenNote } from "../flow/clog_note.ts";
3434 import { chooseCandidates } from "./choose.ts";
35 import type { Foldable } from "./fold.ts";
3536 import { writeStamp } from "../core/write_clock.ts";
3637 import { Stamped } from "../kernel/stamped.ts";
3738 import { QUIET_WINDOW_MS, quietRunEventIds } from "./quiet.ts";
@@ -77,6 +78,11 @@ export interface Candidate {
7778 readonly said?: string;
7879 /** For an ask: what it asks, the answers, what happens if nobody acts, and its rank. */
7980 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;
8086 /** For a look: the picture's environment and commit; the page finds the thumbnail. */
8187 readonly look?: FeedLook;
8288 }
@@ -659,6 +665,7 @@ function fromEvent(
659665 kind: "tell",
660666 link: `${base(repo)}/issues/${number}`,
661667 what: `opened an issue: ${told === "" ? `#${number}` : clip(told)}`,
668 fold: { group: "issues", title: told === "" ? `#${number}` : clip(told) },
662669 };
663670 }
664671 case "issue.commented":
src/feed/rank.ts +4
@@ -25,6 +25,9 @@ export interface FeedItem {
2525 readonly score: number;
2626 /** For an ask: the decision, options, default and rank. */
2727 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;
2831 /** For a look: which picture it is of. */
2932 readonly look?: FeedLook;
3033 /** 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(
155158 reason: reasonOf(s!.c, aff, now),
156159 score: Math.round(bestScore * 1e6) / 1e6,
157160 ...(s!.c.ask === undefined ? {} : { ask: s!.c.ask }),
161 ...(s!.c.lines === undefined ? {} : { lines: s!.c.lines, more: s!.c.more ?? 0 }),
158162 ...(s!.c.look === undefined ? {} : { look: s!.c.look }),
159163 });
160164 }
src/http/mcp/tools/feed.ts +1 −1
@@ -6,7 +6,7 @@ import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts";
66
77 export const tool: Tool = {
88 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",
1010 description:
1111 "Your ranked home timeline; `cursor` pages; saved filter applies.",
1212 inputSchema: {
src/web/studio/feed.ts +4 −1
@@ -27,7 +27,7 @@ import { html, type Html } from "../html.ts";
2727 import { fragment } from "../layout.ts";
2828 import { draftJs } from "../draft.ts";
2929 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";
3131 import { writableAppsOf } from "./asks.ts";
3232
3333 /** Events that can change what the feed says. */
@@ -120,6 +120,9 @@ export function feedCard(
120120 <span class="spacer"></span>
121121 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
122122 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>`}
123126 ${picture === undefined
124127 ? null
125128 : 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";
44 import { join } from "node:path";
55 import { createOrg } from "../src/core/orgs.ts";
66 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
7 import { createUser } from "../src/core/users.ts";
78 import { openIssue } from "../src/actions/issues.ts";
89 import { affinityFor } from "../src/feed/affinity.ts";
910 import { readFeed } from "../src/feed/feed.ts";
11 import { foldBursts } from "../src/feed/fold.ts";
1012 import { candidatesFor } from "../src/feed/items.ts";
1113 import { rank } from "../src/feed/rank.ts";
1214
@@ -60,13 +62,29 @@ test("a spawned forge's lean home names spawned and steps once each, and reads n
6062 }
6163 });
6264
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 }
6480 const now = Date.now();
6581 const candidates = candidatesFor(f.app.db, f.owner, now, undefined, true);
6682 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);
6886 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));
7088 const page = readFeed(f.app.db, f.owner, { limit: 50, now });
7189 expect(page.items).toEqual(whole.slice(0, 50));
7290 expect(page.next_cursor).not.toBeNull();
test/snappy.test.ts +1 −1
@@ -118,7 +118,7 @@ describe("the caches are exact", () => {
118118
119119 test("one viewer's feed is never another's, and a write is in the next one", async () => {
120120 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");
122122 // Both warm, before the write.
123123 whats(f.owner);
124124 whats(f.peer);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs