Implement topic metadata columns

Add lastSeen and followerCount to topicSummaries across the indexer, DB,
and client. The indexer now tracks the newest post seen time, updates
follower counts idempotently on follow/unfollow, and preserves post
metadata. The DB exposes lastSeen and followerCount from GET /topics and
rebuilds both from the rooms store in the backfill. The client renders the
four-column topics page with relative-time labels.

By coder.
This commit is contained in:
Chris Troutner
2026-09-18 05:46:58 -07:00
parent a599b34e38
commit 8da0f09f46
18 changed files with 727 additions and 81 deletions
@@ -0,0 +1,48 @@
/*
Unit tests for the relative-time formatter used by the topics page.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { relativeTime } = require('../../src/services/relative-time')
const NOW = 1800000000000
const HOUR = 60 * 60 * 1000
const DAY = 24 * HOUR
test('returns "No posts" when there is no last-seen time', () => {
assert.equal(relativeTime(0, NOW), 'No posts')
assert.equal(relativeTime(null, NOW), 'No posts')
assert.equal(relativeTime(undefined, NOW), 'No posts')
})
test('returns "Less than an hour ago" for under an hour', () => {
assert.equal(relativeTime(NOW - 1, NOW), 'Less than an hour ago')
assert.equal(relativeTime(NOW - (HOUR - 1), NOW), 'Less than an hour ago')
})
test('returns "1 hour ago" at exactly one hour', () => {
assert.equal(relativeTime(NOW - HOUR, NOW), '1 hour ago')
})
test('returns whole hours under a day', () => {
assert.equal(relativeTime(NOW - 2 * HOUR, NOW), '2 hours ago')
assert.equal(relativeTime(NOW - 5 * HOUR, NOW), '5 hours ago')
assert.equal(relativeTime(NOW - 23 * HOUR, NOW), '23 hours ago')
})
test('returns "1 day ago" at exactly one day', () => {
assert.equal(relativeTime(NOW - DAY, NOW), '1 day ago')
})
test('returns whole days at or beyond a day', () => {
assert.equal(relativeTime(NOW - 2 * DAY, NOW), '2 days ago')
assert.equal(relativeTime(NOW - 30 * DAY, NOW), '30 days ago')
})
test('floors partial hours and days', () => {
assert.equal(relativeTime(NOW - (5 * HOUR + 59 * 60 * 1000), NOW), '5 hours ago')
assert.equal(relativeTime(NOW - (2 * DAY + 23 * HOUR), NOW), '2 days ago')
})
@@ -110,6 +110,34 @@ test('getTopic returns null when the topic is not loaded', async () => {
assert.equal(page.getTopic('bitcoin'), null)
})
test('getLastSeenLabel formats the topic last-seen time', async () => {
const page = new TopicDiscoveryPage({
memoDb: makeMemoDb({ topics: [{ room: 'bitcoin', postCount: 1, lastSeen: 1799998200000 }] })
})
await page.load()
assert.equal(page.getLastSeenLabel('bitcoin', 1800000000000), 'Less than an hour ago')
})
test('getLastSeenLabel returns "No posts" for a topic with no posts', async () => {
const page = new TopicDiscoveryPage({
memoDb: makeMemoDb({ topics: [{ room: 'lone', postCount: 0, lastSeen: 0 }] })
})
await page.load()
assert.equal(page.getLastSeenLabel('lone', 1800000000000), 'No posts')
})
test('getLastSeenLabel returns null for an unknown topic', async () => {
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
await page.load()
assert.equal(page.getLastSeenLabel('missing', 1800000000000), null)
})
test('openTopic navigates to the encoded topic feed path', () => {
const calls = []
const page = new TopicDiscoveryPage({