Implement topic recency ordering and pagination

- Indexer maintains topicSummaries and topicRecency for topic messages and
  follows, idempotently, moving each room's recency record to its newest post.
- DB adds the two index stores and serves GET /topics from them with
  limit/offset pagination, without iterating the rooms store. Adds
  util/room/backfill-topic-indexes.js to build the indexes idempotently.
- Client topics page loads 50 per page with Previous/Next.

By coder.
This commit is contained in:
Chris Troutner
2026-09-17 07:24:45 -07:00
parent e2c0bbe523
commit e02bea5460
28 changed files with 1397 additions and 134 deletions
+55 -2
View File
@@ -361,13 +361,18 @@ function makeMemoDb () {
async getMuteState (muterAddr, muteeAddr) {
return muteState[`${muterAddr}:${muteeAddr}`] || false
},
async getTopics () {
async getTopics ({ limit = 50, offset = 0 } = {}) {
const list = []
for (const [room, postCount] of topicCounts.entries()) {
list.push({ room, postCount })
}
list.sort((a, b) => a.room.localeCompare(b.room))
return { topics: list }
const total = list.length
const page = list.slice(offset, offset + limit)
return {
topics: page,
pagination: { limit, offset, total, hasMore: offset + page.length < total }
}
},
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
let all = topicPosts[room] || []
@@ -2141,6 +2146,54 @@ const handlers = [
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
}
},
{
name: 'open topics page at offset',
pattern: /^I open the topics page at offset (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const offset = parseInt(resolveParam(m[1], example), 10)
await world.topicDiscoveryPage.load({ limit: 50, offset })
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
}
},
{
name: 'API serves N topics',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) topics$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.addTopic(`topic-${String(i + 1).padStart(3, '0')}`, 1)
}
}
},
{
name: 'topics page shows N topics',
pattern: /^the topics page shows (<[A-Za-z0-9_]+>) topics$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const actual = world.topicDiscoveryPage.topics.length
if (actual !== expected) {
throw new Error(`Expected ${expected} topics on the topics page, got ${actual}.`)
}
}
},
{
name: 'topics page can load more topics',
pattern: /^the topics page can load more topics$/,
run (m, example, world) {
if (!world.topicDiscoveryPage.canLoadMore()) {
throw new Error('Expected the topics page to have more topics, but pagination says there are none.')
}
}
},
{
name: 'topics page has no more topics',
pattern: /^the topics page has no more topics$/,
run (m, example, world) {
if (world.topicDiscoveryPage.canLoadMore()) {
throw new Error('Expected the topics page to have no more topics, but pagination says there are more.')
}
}
},
{
name: 'topics page shows topic count',
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
@@ -1,10 +1,10 @@
/*
Display the list of Memo topics served by psf-memo-db.
Display a page of Memo topics served by psf-memo-db.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner, ListGroup } from 'react-bootstrap'
import { Container, Row, Col, Spinner, ListGroup, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
// Local libraries
@@ -12,11 +12,15 @@ import MemoDb from '../../../services/memo-db'
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
import '../../../App.css'
const PAGE_SIZE = 50
function Topics (props) {
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [topics, setTopics] = useState([])
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
useEffect(() => {
const loadTopics = async () => {
@@ -26,23 +30,36 @@ function Topics (props) {
try {
const memoDb = new MemoDb()
const page = new TopicDiscoveryPage({ memoDb, navigate })
const result = await page.load()
const result = await page.load({ limit: PAGE_SIZE, offset })
setTopics(result.topics || [])
setPagination(result.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load topics')
setTopics([])
setPagination(null)
}
setLoading(false)
}
loadTopics()
}, [navigate])
}, [navigate, offset])
const handleClick = (room) => {
navigate(TopicDiscoveryPage.topicFeedPath(room))
}
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const handlePrevious = () => {
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
}
const handleNext = () => {
setOffset((prev) => prev + PAGE_SIZE)
}
return (
<Container className='topics-page'>
<Row className='justify-content-center'>
@@ -50,6 +67,13 @@ function Topics (props) {
<header className='topics-heading'>
<h1>Topics</h1>
<p>Discover Memo conversations organized by topic.</p>
{pagination && topics.length > 0 && (
<span className='topics-count'>
Showing {pagination.offset + 1}
{pagination.offset + topics.length} of {pagination.total}
</span>
)}
</header>
{error && (
@@ -84,6 +108,26 @@ function Topics (props) {
))}
</ListGroup>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='topics-pagination mt-3'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
</Container>
+2 -2
View File
@@ -44,8 +44,8 @@ class MemoDb {
return this._getList(`/mute/muted/${encodeURIComponent(muterAddr)}`, 'getMuted', 'muted')
}
async getTopics () {
return this.getRecent('/topics', 'getTopics', {})
async getTopics ({ limit = 50, offset = 0 } = {}) {
return this.getRecent('/topics', 'getTopics', { limit, offset })
}
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
@@ -1,29 +1,23 @@
/*
Topic Discovery Page behavior: load and display the list of Memo topics.
Topic Discovery Page behavior: load and display a page of Memo topics.
This is the testable controller behind the React "Topics" page. It wraps
the MemoDb client and exposes the loaded topics so the view can render each
topic name and post count.
the MemoDb client and exposes the loaded topics and pagination so the view
can render each topic name and post count and move between pages.
*/
const PaginatedPage = require('./paginated-page')
const TOPICS_PATH = '/topics'
class TopicDiscoveryPage {
class TopicDiscoveryPage extends PaginatedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
super(deps, {
listField: 'topics',
loadMethod: 'getTopics',
errorMessage: 'Topic discovery page requires a memo db client.'
})
this.navigate = deps.navigate || (() => {})
this.topics = []
}
async load () {
if (!this.memoDb) {
throw new Error('Topic discovery page requires a memo db client.')
}
const data = await this.memoDb.getTopics()
this.topics = data.topics || []
return { topics: this.topics }
}
getTopic (room) {
@@ -8,10 +8,13 @@ const test = require('node:test')
const assert = require('node:assert/strict')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
function makeMemoDb (topics) {
function makeMemoDb ({ topics = [], pagination = null } = {}) {
const calls = []
return {
async getTopics () {
return { topics }
calls,
async getTopics (opts) {
calls.push(opts)
return { topics, pagination }
}
}
}
@@ -21,13 +24,56 @@ test('load returns topics with post counts', async () => {
{ room: 'bitcoin', postCount: 2 },
{ room: 'cash', postCount: 1 }
]
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
const result = await page.load()
assert.deepEqual(result.topics, topics)
})
test('load defaults to a page of 50 from offset 0', async () => {
const memoDb = makeMemoDb({ topics: [] })
const page = new TopicDiscoveryPage({ memoDb })
await page.load()
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 0 })
})
test('load forwards limit and offset and stores pagination', async () => {
const memoDb = makeMemoDb({
topics: [{ room: 'bitcoin', postCount: 2 }],
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
})
const page = new TopicDiscoveryPage({ memoDb })
const result = await page.load({ limit: 50, offset: 50 })
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 50 })
assert.deepEqual(result.pagination, { limit: 50, offset: 50, total: 60, hasMore: false })
assert.equal(page.pagination.offset, 50)
})
test('canLoadMore reflects the pagination hasMore flag', async () => {
const more = new TopicDiscoveryPage({
memoDb: makeMemoDb({
topics: [{ room: 'a', postCount: 0 }],
pagination: { limit: 50, offset: 0, total: 60, hasMore: true }
})
})
await more.load()
assert.equal(more.canLoadMore(), true)
const last = new TopicDiscoveryPage({
memoDb: makeMemoDb({
topics: [{ room: 'a', postCount: 0 }],
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
})
})
await last.load()
assert.equal(last.canLoadMore(), false)
})
test('load throws when no memo db client is provided', async () => {
const page = new TopicDiscoveryPage({})
@@ -39,7 +85,7 @@ test('load throws when no memo db client is provided', async () => {
test('stores the provided navigate function', () => {
const navigate = () => {}
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]), navigate })
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }), navigate })
assert.equal(page.navigate, navigate)
})
@@ -49,7 +95,7 @@ test('getTopic returns the matching topic', async () => {
{ room: 'bitcoin', postCount: 2 },
{ room: 'cash', postCount: 1 }
]
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
await page.load()
@@ -57,7 +103,7 @@ test('getTopic returns the matching topic', async () => {
})
test('getTopic returns null when the topic is not loaded', async () => {
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]) })
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
await page.load()