Move discussion timeline grouping into lib

The rules for grouping a person's consecutive activity and when to repeat
their name lived in an untested loop in the component.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 18:20:24 +02:00
parent ca3b059a62
commit 6e1176ffcf
3 changed files with 269 additions and 108 deletions
+3 -108
View File
@@ -14,6 +14,7 @@
import partial from "lodash/partial";
import * as roles from "@app/lib/roles";
import { mergeTimeline, timelineRuns } from "@app/lib/timelineRuns";
import { authorForNodeId } from "@app/lib/utils";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
@@ -77,116 +78,10 @@
let focusReply: boolean = $state(false);
let commentFormKey = $state(0);
type TimelineEntry =
| { kind: "thread"; key: string; timestamp: number; thread: Thread }
| {
kind: "activity";
key: string;
timestamp: number;
data: A;
standalone: boolean;
};
const timeline: TimelineEntry[] = $derived(
[
...commentThreads.map(
thread =>
({
kind: "thread",
key: thread.root.id,
timestamp: thread.root.edits[0].timestamp,
thread,
}) satisfies TimelineEntry,
),
...(activityItems ?? []).map(
item =>
({
kind: "activity",
key: item.key,
timestamp: item.timestamp,
data: item.data,
standalone: item.standalone === true,
}) satisfies TimelineEntry,
),
].sort((a, b) => a.timestamp - b.timestamp),
const runs = $derived(
timelineRuns(mergeTimeline(commentThreads, activityItems ?? []), authorOf),
);
function entryAuthor(entry: TimelineEntry): Author | undefined {
if (entry.kind === "thread") {
return entry.thread.root.author;
}
return authorOf?.(entry.data);
}
type Run =
| { kind: "thread"; entry: Extract<TimelineEntry, { kind: "thread" }> }
| {
kind: "single";
entry: Extract<TimelineEntry, { kind: "activity" }>;
repeatsAuthor: boolean;
}
| {
kind: "group";
author: Author;
entries: Extract<TimelineEntry, { kind: "activity" }>[];
repeatsAuthor: boolean;
};
function runAuthor(run: Run): Author | undefined {
if (run.kind === "thread") return run.entry.thread.root.author;
if (run.kind === "single") return entryAuthor(run.entry);
return run.author;
}
const runs: Run[] = $derived.by(() => {
const result: Run[] = [];
for (const entry of timeline) {
if (entry.kind === "thread") {
result.push({ kind: "thread", entry });
continue;
}
const author = entryAuthor(entry);
const last = result[result.length - 1];
const groupable =
!entry.standalone &&
!(last?.kind === "single" && last.entry.standalone);
if (
groupable &&
author &&
last &&
((last.kind === "single" &&
entryAuthor(last.entry)?.did === author.did) ||
(last.kind === "group" && last.author.did === author.did))
) {
if (last.kind === "single") {
result[result.length - 1] = {
kind: "group",
author,
entries: [last.entry, entry],
repeatsAuthor: last.repeatsAuthor,
};
} else {
last.entries.push(entry);
}
} else {
result.push({ kind: "single", entry, repeatsAuthor: false });
}
}
// A run only names its author when the run before it was someone else's.
// A comment breaks a run, so without this the same person's actions are
// re-attributed on the other side of their own comment.
for (let i = 1; i < result.length; i++) {
const run = result[i];
if (run.kind === "thread") continue;
const previous = runAuthor(result[i - 1]);
const current = runAuthor(run);
run.repeatsAuthor = Boolean(
previous && current && previous.did === current.did,
);
}
return result;
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cobId;
+116
View File
@@ -0,0 +1,116 @@
import type { Author } from "@bindings/cob/Author";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { ActivityItem } from "@app/lib/cobActivity";
export type TimelineEntry<A> =
| { kind: "thread"; key: string; timestamp: number; thread: Thread }
| {
kind: "activity";
key: string;
timestamp: number;
data: A;
standalone: boolean;
};
type ActivityEntry<A> = Extract<TimelineEntry<A>, { kind: "activity" }>;
export type Run<A> =
| { kind: "thread"; entry: Extract<TimelineEntry<A>, { kind: "thread" }> }
| { kind: "single"; entry: ActivityEntry<A>; repeatsAuthor: boolean }
| {
kind: "group";
author: Author;
entries: ActivityEntry<A>[];
repeatsAuthor: boolean;
};
/// Comment threads and activity in one list, oldest first.
export function mergeTimeline<A>(
threads: Thread[],
activity: ActivityItem<A>[],
): TimelineEntry<A>[] {
return [
...threads.map(
thread =>
({
kind: "thread",
key: thread.root.id,
timestamp: thread.root.edits[0].timestamp,
thread,
}) satisfies TimelineEntry<A>,
),
...activity.map(
item =>
({
kind: "activity",
key: item.key,
timestamp: item.timestamp,
data: item.data,
standalone: item.standalone === true,
}) satisfies TimelineEntry<A>,
),
].sort((a, b) => a.timestamp - b.timestamp);
}
/// Groups consecutive activity by the same author under one heading.
///
/// A thread or a standalone entry breaks a run. A run only names its author
/// when the run before it was someone else's (`repeatsAuthor` is false), so the
/// same person's actions aren't re-attributed on the other side of their own
/// comment.
export function timelineRuns<A>(
timeline: TimelineEntry<A>[],
authorOf: (data: A) => Author | undefined = () => undefined,
): Run<A>[] {
const entryAuthor = (entry: TimelineEntry<A>) =>
entry.kind === "thread" ? entry.thread.root.author : authorOf(entry.data);
const runAuthor = (run: Run<A>) => {
if (run.kind === "thread") return run.entry.thread.root.author;
if (run.kind === "single") return entryAuthor(run.entry);
return run.author;
};
const result: Run<A>[] = [];
for (const entry of timeline) {
if (entry.kind === "thread") {
result.push({ kind: "thread", entry });
continue;
}
const author = entryAuthor(entry);
const last = result[result.length - 1];
const groupable =
!entry.standalone && !(last?.kind === "single" && last.entry.standalone);
if (
groupable &&
author &&
last &&
((last.kind === "single" &&
entryAuthor(last.entry)?.did === author.did) ||
(last.kind === "group" && last.author.did === author.did))
) {
if (last.kind === "single") {
result[result.length - 1] = {
kind: "group",
author,
entries: [last.entry, entry],
repeatsAuthor: last.repeatsAuthor,
};
} else {
last.entries.push(entry);
}
} else {
result.push({ kind: "single", entry, repeatsAuthor: false });
}
}
for (let i = 1; i < result.length; i++) {
const run = result[i];
if (run.kind === "thread") continue;
const previous = runAuthor(result[i - 1]);
const current = runAuthor(run);
run.repeatsAuthor = Boolean(
previous && current && previous.did === current.did,
);
}
return result;
}
+150
View File
@@ -0,0 +1,150 @@
import type { Author } from "@bindings/cob/Author";
import type { Thread } from "@bindings/cob/thread/Thread";
import { describe, expect, test } from "vitest";
import type { Run, TimelineEntry } from "@app/lib/timelineRuns";
import { mergeTimeline, timelineRuns } from "@app/lib/timelineRuns";
import { author, comment } from "./support/cobs";
const alice = author("alice");
const bob = author("bob");
type Data = { by: Author };
function activity(key: string, by: Author, standalone = false) {
return {
kind: "activity" as const,
key,
timestamp: 0,
data: { by },
standalone,
};
}
function threadEntry(key: string, by: Author): TimelineEntry<Data> {
return {
kind: "thread",
key,
timestamp: 0,
thread: {
root: comment({ id: key, author: by }),
replies: [],
} as unknown as Thread,
};
}
// `a:x` for a single entry, `a:[x,y]` for a group, `thread:x` for a thread;
// `+` marks a run that repeats the previous run's author.
function shape(runs: Run<Data>[]): string[] {
return runs.map(run => {
if (run.kind === "thread") return `thread:${run.entry.key}`;
const repeat = run.repeatsAuthor ? "+" : "";
if (run.kind === "single") return `${repeat}${run.entry.key}`;
return `${repeat}[${run.entries.map(e => e.key).join(",")}]`;
});
}
const authorOf = (data: Data) => data.by;
describe("mergeTimeline", () => {
test("merges threads and activity, oldest first", () => {
const merged = mergeTimeline<Data>(
[
{
root: comment({ id: "t", timestamp: 2 }),
replies: [],
} as unknown as Thread,
],
[
{ key: "late", timestamp: 3, data: { by: alice } },
{ key: "early", timestamp: 1, data: { by: alice }, standalone: true },
],
);
expect(merged.map(e => [e.kind, e.key])).toEqual([
["activity", "early"],
["thread", "t"],
["activity", "late"],
]);
expect(merged[0]).toMatchObject({ standalone: true });
expect(merged[2]).toMatchObject({ standalone: false });
});
});
describe("timelineRuns", () => {
test("groups consecutive activity by the same author", () => {
expect(
shape(
timelineRuns(
[activity("a1", alice), activity("a2", alice), activity("a3", alice)],
authorOf,
),
),
).toEqual(["[a1,a2,a3]"]);
});
test("starts a new run for another author", () => {
expect(
shape(
timelineRuns(
[activity("a1", alice), activity("b1", bob), activity("a2", alice)],
authorOf,
),
),
).toEqual(["a1", "b1", "a2"]);
});
test("a thread breaks a run, and the author isn't named again after it", () => {
const thread = threadEntry("t", alice);
const runs = timelineRuns(
[
activity("a1", alice),
thread,
activity("a2", alice),
activity("a3", alice),
],
authorOf,
);
expect(shape(runs)).toEqual(["a1", "thread:t", "+[a2,a3]"]);
expect(runs[1]).toEqual({ kind: "thread", entry: thread });
});
test("standalone entries are never grouped", () => {
expect(
shape(
timelineRuns(
[
activity("a1", alice),
activity("card", alice, true),
activity("a2", alice),
],
authorOf,
),
),
).toEqual(["a1", "+card", "+a2"]);
});
test("a group keeps the repeat flag of the entry it grew from", () => {
expect(
shape(
timelineRuns(
[
activity("card", alice, true),
activity("a1", alice),
activity("a2", alice),
],
authorOf,
),
),
).toEqual(["card", "+[a1,a2]"]);
});
test("activity without an author is never grouped", () => {
expect(
shape(timelineRuns([activity("x", alice), activity("y", alice)])),
).toEqual(["x", "y"]);
});
});