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
+77 -5
View File
@@ -217,6 +217,7 @@ function makeMemoDb () {
const topicPosts = {}
const topicCounts = new Map()
const topicFollow = new Map()
const topicMetadata = new Map()
return {
posts,
@@ -228,6 +229,7 @@ function makeMemoDb () {
topics,
topicPosts,
topicCounts,
topicMetadata,
addPost (post) {
posts.push(post)
},
@@ -274,6 +276,7 @@ function makeMemoDb () {
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicMetadata.set(room, { followerCount: 0, lastSeen: 0 })
topicPosts[room] = []
for (let i = 0; i < postCount; i++) {
const txid = `${room}-post-${i + 1}`.padEnd(64, '0')
@@ -285,6 +288,16 @@ function makeMemoDb () {
})
}
},
setTopicFollowerCount (room, followerCount) {
const meta = topicMetadata.get(room) || {}
meta.followerCount = followerCount
topicMetadata.set(room, meta)
},
setTopicLastSeen (room, lastSeen) {
const meta = topicMetadata.get(room) || {}
meta.lastSeen = lastSeen
topicMetadata.set(room, meta)
},
addTopicPost (room, post) {
if (!topicPosts[room]) topicPosts[room] = []
topicPosts[room].push(post)
@@ -364,7 +377,13 @@ function makeMemoDb () {
async getTopics ({ limit = 50, offset = 0 } = {}) {
const list = []
for (const [room, postCount] of topicCounts.entries()) {
list.push({ room, postCount })
const meta = topicMetadata.get(room) || {}
list.push({
room,
postCount,
lastSeen: meta.lastSeen ?? 0,
followerCount: meta.followerCount ?? 0
})
}
list.sort((a, b) => a.room.localeCompare(b.room))
const total = list.length
@@ -2101,13 +2120,38 @@ const handlers = [
},
{
name: 'API serves topic with post count',
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/,
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\S+) posts?$/,
run (m, example, world) {
const room = m[1]
const count = parseInt(m[2], 10)
const room = resolveParam(m[1], example)
const count = parseInt(resolveParam(m[2], example), 10)
world.memoDb.addTopic(room, count)
}
},
{
name: 'topic has follower count',
pattern: /^the topic "([^"]+)" has (\S+) followers?$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const followerCount = parseInt(resolveParam(m[2], example), 10)
world.memoDb.setTopicFollowerCount(room, followerCount)
}
},
{
name: 'topic was last posted at',
pattern: /^the topic "([^"]+)" was last posted at (\S+)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const lastSeen = parseInt(resolveParam(m[2], example), 10)
world.memoDb.setTopicLastSeen(room, lastSeen)
}
},
{
name: 'set current time',
pattern: /^the current time is (\S+)$/,
run (m, example, world) {
world.currentTime = parseInt(resolveParam(m[1], example), 10)
}
},
{
name: 'API serves post in topic with address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/,
@@ -2196,7 +2240,7 @@ const handlers = [
},
{
name: 'topics page shows topic count',
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) posts$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
@@ -2209,6 +2253,34 @@ const handlers = [
}
}
},
{
name: 'topics page shows topic follower count',
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) followers?$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const topic = world.topicDiscoveryPage.getTopic(room)
if (!topic) {
throw new Error(`Topic ${room} is not shown on the topics page.`)
}
if (topic.followerCount !== expected) {
throw new Error(`Expected ${room} to have ${expected} followers, got ${topic.followerCount}.`)
}
}
},
{
name: 'topics page shows most recent post label',
pattern: /^the topics page shows the most recent post for the topic "([^"]+)" as "(.+)"$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const now = world.currentTime ?? Date.now()
const actual = world.topicDiscoveryPage.getLastSeenLabel(room, now)
if (actual !== expected) {
throw new Error(`Expected ${room} most recent post "${expected}", got "${actual}".`)
}
}
},
{
name: 'click topic',
pattern: /^I click the topic (<topic>)$/,
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
import { relativeTime } from '../../../services/relative-time'
import '../../../App.css'
const PAGE_SIZE = 50
@@ -101,9 +102,12 @@ function Topics (props) {
key={topic.room}
action
onClick={() => handleClick(topic.room)}
className='d-flex justify-content-between align-items-center'
>
#{topic.room}{' '}
<span className='text-muted'>({topic.postCount} posts)</span>
<span className='topic-name'>#{topic.room}</span>
<span className='topic-last-seen text-muted'>{relativeTime(topic.lastSeen, Date.now())}</span>
<span className='topic-post-count text-muted'>{topic.postCount} posts</span>
<span className='topic-follower-count text-muted'>{topic.followerCount} followers</span>
</ListGroup.Item>
))}
</ListGroup>
@@ -0,0 +1,31 @@
/*
Format the time since a topic's most recent post as a short label.
Inputs are epoch milliseconds: `lastSeen` is the room's most recent post
time (0 when the room has no posts) and `now` is the current time. The
labels match the topics page contract:
- no posts -> "No posts"
- less than an hour -> "Less than an hour ago"
- under 24 hours -> "N hours ago" (singular "1 hour ago")
- 24 hours or more -> "N days ago" (singular "1 day ago")
*/
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function relativeTime (lastSeen, now = Date.now()) {
if (!lastSeen) return 'No posts'
const elapsed = now - lastSeen
if (elapsed < HOUR_MS) return 'Less than an hour ago'
if (elapsed < DAY_MS) {
const hours = Math.floor(elapsed / HOUR_MS)
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
}
const days = Math.floor(elapsed / DAY_MS)
return days === 1 ? '1 day ago' : `${days} days ago`
}
module.exports = { relativeTime }
@@ -7,6 +7,7 @@
*/
const PaginatedPage = require('./paginated-page')
const { relativeTime } = require('./relative-time')
const TOPICS_PATH = '/topics'
@@ -24,6 +25,15 @@ class TopicDiscoveryPage extends PaginatedPage {
return this.topics.find((topic) => topic.room === room) || null
}
// Label describing how long ago the room's most recent post was, computed
// from the API's lastSeen timestamp. Returns null when the topic is not
// loaded. `now` is injectable so the label is deterministic in tests.
getLastSeenLabel (room, now = Date.now()) {
const topic = this.getTopic(room)
if (!topic) return null
return relativeTime(topic.lastSeen, now)
}
openTopic (room) {
const path = TopicDiscoveryPage.topicFeedPath(room)
this.navigate(path)
@@ -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({