diff --git a/apps/web/core/claims/browse/activity-row-tag.tsx b/apps/web/core/claims/browse/activity-row-tag.tsx
new file mode 100644
index 0000000000..378173e6bd
--- /dev/null
+++ b/apps/web/core/claims/browse/activity-row-tag.tsx
@@ -0,0 +1,33 @@
+import cx from 'classnames';
+
+const LABELS = { debate: 'Debate', claim: 'Claim', comment: 'Comment' } as const;
+
+/**
+ * What kind of thing a row is, said in a word.
+ *
+ * The feed draws three kinds of row that carry the same furniture — a face or a keyframe, a name, a
+ * sentence, a vote, a comment count — and mean different things. The differences that exist (a
+ * timecode, a position badge) are conditional, so a claim with no assertable moment and a comment
+ * from someone holding no position are indistinguishable at a glance.
+ *
+ * All three go through here. The debate row used to spell its own chip inline with different padding
+ * and a different type size, so the one label a reader was meant to compare across rows was the one
+ * thing that did not match between them.
+ *
+ * Neutral on purpose. The coloured chip beside it is a *claim* about a person's position; this one
+ * is a label for the row, and two coloured chips in one header would compete for the same meaning.
+ */
+export function ActivityRowTag({ kind }: { kind: keyof typeof LABELS }) {
+ return (
+
+ {LABELS[kind]}
+
+ );
+}
diff --git a/apps/web/core/claims/browse/claim-activity-count.test.ts b/apps/web/core/claims/browse/claim-activity-count.test.ts
new file mode 100644
index 0000000000..f80962b1d9
--- /dev/null
+++ b/apps/web/core/claims/browse/claim-activity-count.test.ts
@@ -0,0 +1,251 @@
+import { QueryClient } from '@tanstack/react-query';
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ adjustClaimActivityTotal,
+ claimActivityCountsQueryKey,
+ decodeClaimActivityCounts,
+} from './claim-activity-count';
+import type { ClaimActivityCount } from './claim-activity-fields';
+
+function debate(comments: number, extracted: Array) {
+ return {
+ fromEntity: {
+ id: `debate${comments}${extracted.join('')}`,
+ comments: { totalCount: comments },
+ extracted: extracted.map((n, index) => ({
+ fromEntity: { id: `extracted${index}`, comments: { totalCount: n } },
+ })),
+ },
+ };
+}
+
+describe('decodeClaimActivityCounts', () => {
+ it('adds up everything the feed shows by default', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [
+ {
+ id: 'claim1',
+ comments: { totalCount: 3 },
+ debates: [debate(2, [0, 1]), debate(0, [4])],
+ },
+ ],
+ });
+
+ // 3 claim comments + 2 debates + 3 extracted claims + (2 + 0) debate comments + (0 + 1 + 4)
+ // comments on extracted claims.
+ expect(counts.get('claim1')).toEqual({
+ comments: 3,
+ debates: 2,
+ extractedClaims: 3,
+ debateComments: 7,
+ total: 15,
+ });
+ });
+
+ it('keys by canonical id so a hyphenated lookup finds it', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [{ id: '3e4a0955-699a-4c8f-813f-cc803a3335ba', comments: { totalCount: 1 }, debates: [] }],
+ });
+
+ expect(counts.get('3e4a0955699a4c8f813fcc803a3335ba')?.total).toBe(1);
+ });
+
+ it('counts a claim nobody has touched as zero rather than leaving it out', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [] }],
+ });
+
+ expect(counts.get('claim1')?.total).toBe(0);
+ });
+
+ // A debate with no transcript yet is still a debate — it counts, its absent claims do not.
+ it('counts a debate that produced no claims', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [debate(0, [])] }],
+ });
+
+ expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 0, total: 1 });
+ });
+
+ it('survives nulls anywhere the API may return them', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [
+ null,
+ { id: null, comments: null, debates: null },
+ {
+ id: 'claim1',
+ comments: null,
+ debates: [null, { fromEntity: null }, { fromEntity: { id: 'd', comments: null, extracted: [null] } }],
+ },
+ ],
+ });
+
+ expect([...counts.keys()]).toEqual(['claim1']);
+ expect(counts.get('claim1')).toMatchObject({ comments: 0, debates: 1, extractedClaims: 0, total: 1 });
+ });
+
+ // A nested list inside a batched `entities(filter: { id: { in: … } })` query stops at ten rows
+ // without erroring, so the list is the wrong thing to measure. The aggregate beside it is not
+ // capped: a debate with 22 extracted claims returns `totalCount: 22` next to a ten-item list.
+ it('takes the extracted total from the aggregate, not the length of a truncated list', () => {
+ const counts = decodeClaimActivityCounts({
+ entities: [
+ {
+ id: 'claim1',
+ comments: { totalCount: 0 },
+ debates: [
+ {
+ fromEntity: {
+ id: 'debate1',
+ comments: { totalCount: 0 },
+ extractedCount: { totalCount: 22 },
+ extracted: Array.from({ length: 10 }, (_, index) => ({
+ fromEntity: { id: `extracted${index}`, comments: { totalCount: 0 } },
+ })),
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 22, total: 23 });
+ });
+
+ // Testnet has a debate carrying two `Claims` relations to the same claim. Each is its own backlink
+ // row, but the feed reads debates as entities and draws one — so counting rows double-counts the
+ // whole subtree under it.
+ it('counts a debate once however many times it links the claim', () => {
+ const twice = {
+ fromEntity: {
+ id: 'debate1',
+ comments: { totalCount: 2 },
+ extractedCount: { totalCount: 22 },
+ extracted: [],
+ },
+ };
+
+ const counts = decodeClaimActivityCounts({
+ entities: [{ id: 'claim1', comments: { totalCount: 0 }, debates: [twice, twice] }],
+ });
+
+ expect(counts.get('claim1')).toMatchObject({ debates: 1, extractedClaims: 22, debateComments: 2, total: 25 });
+ });
+
+ it('is empty when nothing came back', () => {
+ expect(decodeClaimActivityCounts({}).size).toBe(0);
+ expect(decodeClaimActivityCounts({ entities: null }).size).toBe(0);
+ });
+});
+
+/**
+ * The reader's own comment, against a refetch that was already on its way.
+ *
+ * This aggregate is held for a minute, so a mount past that — or a reconnect — starts a background
+ * fetch. That request answers with a number from before the comment existed, and if it lands after the
+ * optimistic write it puts that number back: the heading drops a comment still sitting in the list
+ * underneath it. Timestamps cannot separate the two, because the response was asked for before the
+ * publish and arrives after it, so the write cancels first instead.
+ */
+describe('adjustClaimActivityTotal', () => {
+ const CLAIM = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ const key = claimActivityCountsQueryKey([CLAIM]);
+
+ function seeded(total: number) {
+ return new Map([[CLAIM, { total, comments: 1, debates: 0, extractedClaims: 0 } as ClaimActivityCount]]);
+ }
+
+ function totalIn(client: QueryClient) {
+ return client.getQueryData