From 1a11c7a61e177638558ccbcdb82c5c18d1385b5a Mon Sep 17 00:00:00 2001 From: eti Date: Fri, 25 Sep 2026 11:08:03 +0200 Subject: [PATCH] web/timeline: summarise grouped events and merge across interleaved kinds Signed-off-by: eti --- .../TimelineFollowEvent.stories.svelte | 6 +- .../timeline/TimelineFollowEvent.svelte | 9 +-- .../components/timeline/TimelineGroup.svelte | 19 ++---- .../timeline/TimelineRepoEvent.stories.svelte | 4 +- .../timeline/TimelineRepoEvent.svelte | 12 ++-- .../timeline/TimelineStarEvent.stories.svelte | 2 +- .../timeline/TimelineStarEvent.svelte | 2 +- web/src/lib/components/timeline/group.test.ts | 59 +++++++++++++------ web/src/lib/components/timeline/group.ts | 22 +++++-- 9 files changed, 78 insertions(+), 57 deletions(-) diff --git a/web/src/lib/components/timeline/TimelineFollowEvent.stories.svelte b/web/src/lib/components/timeline/TimelineFollowEvent.stories.svelte index 94d2db746..07ae3acfa 100644 --- a/web/src/lib/components/timeline/TimelineFollowEvent.stories.svelte +++ b/web/src/lib/components/timeline/TimelineFollowEvent.stories.svelte @@ -57,7 +57,7 @@ args={{ items: items.slice(0, 2) }} play={async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("and")).toBeInTheDocument(); + await expect(canvas.getByText("2 users")).toBeInTheDocument(); await expect(canvas.queryByRole("button", { name: /more/ })).toBeNull(); }} /> @@ -67,7 +67,7 @@ args={{ items: items.slice(0, 3) }} play={async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("and 1 other")).toBeInTheDocument(); + await expect(canvas.getByText("3 users")).toBeInTheDocument(); await expect(canvas.queryByRole("button", { name: /more/ })).toBeNull(); }} /> @@ -77,7 +77,7 @@ args={{ items }} play={async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("and 5 others")).toBeInTheDocument(); + await expect(canvas.getByText("7 users")).toBeInTheDocument(); await expect(canvas.queryByText("roomy.space")).toBeNull(); await userEvent.click(canvas.getByRole("button", { name: "Show 4 more events" })); await expect(canvas.getByText("boltless.me")).toBeInTheDocument(); diff --git a/web/src/lib/components/timeline/TimelineFollowEvent.svelte b/web/src/lib/components/timeline/TimelineFollowEvent.svelte index d288957d7..03b7ac38a 100644 --- a/web/src/lib/components/timeline/TimelineFollowEvent.svelte +++ b/web/src/lib/components/timeline/TimelineFollowEvent.svelte @@ -18,14 +18,9 @@ } = $props(); - + {#snippet name(event)} - + {/snippet} {#snippet row(event)} []; name: Snippet<[E]>; row: Snippet<[E]>; @@ -28,8 +29,6 @@ let expanded = $state(false); - const named = $derived(items.length > NAMED_COUNT ? items.slice(0, NAMED_COUNT) : items); - const others = $derived(items.length - named.length); const visible = $derived(expanded ? items : items.slice(0, COLLAPSED_COUNT)); const hidden = $derived(items.length - visible.length); @@ -40,16 +39,10 @@ {verb} - {#each named as item, i (item.event.uri)} - {#if i > 0 && i === named.length - 1 && others === 0} - and - {:else if i > 0} - , - {/if} - {@render name(item.event)} - {/each} - {#if others > 0} - and {others} {others === 1 ? "other" : "others"} + {#if items.length === 1} + {@render name(items[0].event)} + {:else} + {items.length} {plural} {/if} {/snippet} diff --git a/web/src/lib/components/timeline/TimelineRepoEvent.stories.svelte b/web/src/lib/components/timeline/TimelineRepoEvent.stories.svelte index fc5c1b130..bfae053f2 100644 --- a/web/src/lib/components/timeline/TimelineRepoEvent.stories.svelte +++ b/web/src/lib/components/timeline/TimelineRepoEvent.stories.svelte @@ -55,7 +55,7 @@ play={async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("created")).toBeInTheDocument(); - await expect(canvas.getByText("and 1 other")).toBeInTheDocument(); + await expect(canvas.getByText("3 repositories")).toBeInTheDocument(); }} /> @@ -65,6 +65,6 @@ play={async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("forked")).toBeInTheDocument(); - await expect(canvas.queryByText("to")).toBeNull(); + await expect(canvas.getByText("2 repositories")).toBeInTheDocument(); }} /> diff --git a/web/src/lib/components/timeline/TimelineRepoEvent.svelte b/web/src/lib/components/timeline/TimelineRepoEvent.svelte index 38ecd580a..0da392053 100644 --- a/web/src/lib/components/timeline/TimelineRepoEvent.svelte +++ b/web/src/lib/components/timeline/TimelineRepoEvent.svelte @@ -19,18 +19,16 @@ const forked = $derived(items[0].event.source !== undefined); - + {#snippet name(event)} {#if event.source} {event.source.owner.handle}/{event.source.slug} - {#if items.length === 1} - to - {event.repo.slug} - {/if} + to + {event.repo.slug} {:else} {event.repo.slug} { const canvas = within(canvasElement); - await expect(canvas.getByText("and 2 others")).toBeInTheDocument(); + await expect(canvas.getByText("4 repositories")).toBeInTheDocument(); await userEvent.click(canvas.getByRole("button", { name: "Show 1 more event" })); await expect(canvas.queryByRole("button", { name: /more/ })).toBeNull(); }} diff --git a/web/src/lib/components/timeline/TimelineStarEvent.svelte b/web/src/lib/components/timeline/TimelineStarEvent.svelte index aa428e891..097dab9b4 100644 --- a/web/src/lib/components/timeline/TimelineStarEvent.svelte +++ b/web/src/lib/components/timeline/TimelineStarEvent.svelte @@ -17,7 +17,7 @@ } = $props(); - + {#snippet name(event)} {event.repo.owner.handle}/{event.repo.slug} { ]); }); - it("splits when an hour or more separates two events", () => { + it("merges an uninterrupted run by one actor regardless of time", () => { expect( shape([ star(alice, "a", "2026-01-01T12:00:00Z"), - star(alice, "b", "2026-01-01T11:00:00Z") + star(alice, "b", "2026-01-01T08:00:00Z") + ]) + ).toEqual([["star", "alice.test", "a", "b"]]); + }); + + it("merges across another actor's event within an hour", () => { + expect( + shape([ + star(alice, "a", "2026-01-01T12:00:00Z"), + star(bob, "b", "2026-01-01T11:50:00Z"), + star(alice, "c", "2026-01-01T11:30:00Z") + ]) + ).toEqual([ + ["star", "alice.test", "a", "c"], + ["star", "bob.test", "b"] + ]); + }); + + it("splits across another actor's event after an hour or more", () => { + expect( + shape([ + star(alice, "a", "2026-01-01T12:00:00Z"), + star(bob, "b", "2026-01-01T11:30:00Z"), + star(alice, "c", "2026-01-01T11:00:00Z") ]) ).toEqual([ ["star", "alice.test", "a"], - ["star", "alice.test", "b"] + ["star", "bob.test", "b"], + ["star", "alice.test", "c"] + ]); + }); + + it("merges across the same actor's events of another kind", () => { + expect( + shape([ + follow(alice, "a", "2026-01-01T12:00:00Z"), + star(alice, "b", "2026-01-01T09:00:00Z"), + follow(alice, "c", "2026-01-01T08:00:00Z"), + star(alice, "d", "2026-01-01T07:00:00Z"), + star(alice, "e", "2026-01-01T06:00:00Z") + ]) + ).toEqual([ + ["follow", "alice.test", "a", "c"], + ["star", "alice.test", "b", "d", "e"] ]); }); @@ -140,20 +179,6 @@ describe("groupTimeline", () => { ]); }); - it("splits on an interleaved event of another kind", () => { - expect( - shape([ - follow(alice, "a", "2026-01-01T12:00:00Z"), - star(alice, "b", "2026-01-01T11:59:00Z"), - follow(alice, "c", "2026-01-01T11:58:00Z") - ]) - ).toEqual([ - ["follow", "alice.test", "a"], - ["star", "alice.test", "b"], - ["follow", "alice.test", "c"] - ]); - }); - it("keys each entry by its first event uri", () => { const feed = [ follow(alice, "a", "2026-01-01T12:00:00Z"), diff --git a/web/src/lib/components/timeline/group.ts b/web/src/lib/components/timeline/group.ts index 030c682bd..51056ebbe 100644 --- a/web/src/lib/components/timeline/group.ts +++ b/web/src/lib/components/timeline/group.ts @@ -53,18 +53,28 @@ const toEntry = ({ event, eventAt }: TimelineItem): TimelineEntry | undefined => export const groupTimeline = (feed: TimelineItem[]): TimelineEntry[] => { const entries: TimelineEntry[] = []; - for (const item of feed) { + const open = new Map(); + let runActor: string | undefined; + let runStart = 0; + for (const [index, item] of feed.entries()) { const next = toEntry(item); if (!next) continue; - const last = entries.at(-1); + if (next.actor.did !== runActor) { + runActor = next.actor.did; + runStart = index; + } + const groupKey = `${next.actor.did} ${next.kind}`; + const group = open.get(groupKey); if ( - last?.kind === next.kind && - last.actor.did === next.actor.did && - withinWindow(last.items.at(-1)!.eventAt, item.eventAt) + group && + (group.lastIndex >= runStart || + withinWindow(group.entry.items.at(-1)!.eventAt, item.eventAt)) ) { - (last.items as unknown[]).push(...next.items); + (group.entry.items as unknown[]).push(...next.items); + group.lastIndex = index; } else { entries.push(next); + open.set(groupKey, { entry: next, lastIndex: index }); } } return entries; -- 2.51.2