Skip to content

Commit bdae1d6

Browse files
JohnMcLearclaude
andcommitted
fix(admin/pads): apply filter chip server-side, before pagination
Before: PadPage's filter chip (`active`/`recent`/`empty`/`stale`) ran on the client AFTER the 12-row page slice was already on screen. On a deployment with hundreds of pads it produced obviously wrong results — click "empty pads" on page 1 with 100 empties and only the 0–12 empties within the current page passed the filter. thm reported this on a 3.1.0 deployment. Move the filter into `PadSearchQuery` so the `/settings` socket can apply it before slicing: 1. pattern filter on names (cheap) 2. hydrate metadata for the matching pad universe iff a non-`all` filter is set or a non-`padName` sort is requested 3. apply filter chip on the hydrated set 4. sort + slice → `total` reflects the filtered universe so the pagination footer makes sense The original handler also had a 4-way `if/else if` that duplicated the hydrate-and-sort loop per `sortBy`. Folded those into one pipeline with a single comparator switch. Client side, `PadPage.tsx`: - drop the client-side `filteredResults` filter (server already filters) - chip click writes `filter` into searchParams (debounced refetch) and resets `currentPage` to 0 - older clients that don't send `filter` keep working — server defaults to `all` Stats cards (totalUsers/activeCount/emptyCount) still count the visible page only — that's a pre-existing UI limitation tracked separately. Closes the regression thm reported. Test plan - `tsc --noEmit` clean (server + admin) - New backend spec `padLoadFilter.ts` exercises filter:empty with small `limit` to lock in the bug-fix, plus all/active/omitted cases - `5 passing` locally on Node 25 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 228b49f commit bdae1d6

5 files changed

Lines changed: 326 additions & 142 deletions

File tree

admin/src/pages/PadPage.tsx

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
import {Trans, useTranslation} from "react-i18next";
22
import {useEffect, useMemo, useState} from "react";
33
import {useStore} from "../store/store.ts";
4-
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
4+
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
55
import {useDebounce} from "../utils/useDebounce.ts";
66
import * as Dialog from "@radix-ui/react-dialog";
77
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
88
import {useForm} from "react-hook-form";
99
import type {TFunction} from "i18next";
1010

1111
type PadCreateProps = { padName: string }
12-
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
1312

14-
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
15-
16-
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
17-
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
13+
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']
1814

1915
function relativeTime(t: TFunction, ts: number): string {
2016
const d = (Date.now() - ts) / 1000
@@ -58,12 +54,19 @@ function sanitizeLocale(lng?: string): string {
5854
export const PadPage = () => {
5955
const settingsSocket = useStore(state => state.settingsSocket)
6056
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
61-
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
57+
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all',
6258
})
6359
const {t, i18n} = useTranslation()
6460
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
6561
const [searchTerm, setSearchTerm] = useState('')
66-
const [filter, setFilter] = useState<FilterId>('all')
62+
// Read filter off searchParams so chip changes round-trip through
63+
// the server (`filter` is applied before pagination there). Clicking
64+
// a chip used to filter only the current 12-row page slice.
65+
const filter: PadFilter = searchParams.filter ?? 'all'
66+
const setFilter = (f: PadFilter) => {
67+
setCurrentPage(0)
68+
setSearchParams({...searchParams, filter: f, offset: 0})
69+
}
6770
const [selected, setSelected] = useState<Set<string>>(new Set())
6871
const pads = useStore(state => state.pads)
6972
const [currentPage, setCurrentPage] = useState(0)
@@ -78,28 +81,23 @@ export const PadPage = () => {
7881
[pads, searchParams.limit]
7982
)
8083

81-
const filteredResults = useMemo(() => {
82-
const r = pads?.results ?? []
83-
if (filter === 'active') return r.filter(p => p.userCount > 0)
84-
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
85-
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
86-
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
87-
return r
88-
}, [pads, filter])
89-
90-
const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
91-
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
92-
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
84+
// The server applies `filter` before paginating; the page payload is
85+
// already the filtered slice. The stats cards still reflect the
86+
// current page (pre-existing behaviour) — making them global would
87+
// require a separate aggregate query.
88+
const visibleResults = pads?.results ?? []
89+
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
90+
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
91+
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
9392
const lastActivity = useMemo(() => {
94-
const r = pads?.results ?? []
95-
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
93+
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
9694
}, [pads])
9795

98-
const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
96+
const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName))
9997
const toggleAll = () => {
10098
const s = new Set(selected)
101-
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
102-
else filteredResults.forEach(p => s.add(p.padName))
99+
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
100+
else visibleResults.forEach(p => s.add(p.padName))
103101
setSelected(s)
104102
}
105103
const toggleOne = (name: string) => {
@@ -250,7 +248,7 @@ export const PadPage = () => {
250248
<section className="pm-section">
251249
<div className="pm-section-header">
252250
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
253-
<span className="pm-count-badge">{filteredResults.length}</span>
251+
<span className="pm-count-badge">{visibleResults.length}</span>
254252
<div className="pm-spacer"/>
255253
<div className="pm-toolbar">
256254
<div className="pm-search">
@@ -330,7 +328,7 @@ export const PadPage = () => {
330328
</div>
331329
)}
332330

333-
{filteredResults.length > 0 ? (
331+
{visibleResults.length > 0 ? (
334332
<div className="pm-table-wrap">
335333
<table>
336334
<thead>
@@ -348,7 +346,7 @@ export const PadPage = () => {
348346
</tr>
349347
</thead>
350348
<tbody>
351-
{filteredResults.map(pad => {
349+
{visibleResults.map(pad => {
352350
const isEmpty = pad.revisionNumber === 0
353351
const isSel = selected.has(pad.padName)
354352
return (

admin/src/utils/PadSearch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale';
2+
13
export type PadSearchQuery = {
24
pattern: string;
35
offset: number;
46
limit: number;
57
ascending: boolean;
68
sortBy: string;
9+
filter?: PadFilter;
710
}
811

912

src/node/hooks/express/adminsettings.ts

Lines changed: 88 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33

4-
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
4+
import {PAD_FILTERS, PadFilter, PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
55
import log4js from 'log4js';
66

77
const fsp = require('fs').promises;
@@ -110,137 +110,111 @@ exports.socketio = (hookName: string, {io}: any) => {
110110
socket.on('padLoad', async (query: PadSearchQuery) => {
111111
const {padIDs} = await padManager.listAllPads();
112112

113-
const data: {
114-
total: number,
115-
results?: PadQueryResult[]
116-
} = {
117-
total: padIDs.length,
118-
};
119-
let result: string[] = padIDs;
120-
let maxResult;
121-
122-
// Filter out matches
113+
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
114+
let candidateNames: string[] = padIDs;
123115
if (query.pattern) {
124-
result = result.filter((padName: string) => padName.includes(query.pattern));
116+
candidateNames = candidateNames.filter(
117+
(padName: string) => padName.includes(query.pattern));
125118
}
126119

127-
data.total = result.length;
120+
// ── 2. Resolve filter chip ──────────────────────────────────────
121+
// PadPage sends a chip id; "all" (default) means no additional
122+
// filtering. We accept missing values from older clients gracefully.
123+
const filter: PadFilter =
124+
(query.filter && PAD_FILTERS.includes(query.filter)) ? query.filter : 'all';
125+
126+
// ── 3. Decide whether we need full metadata for every candidate ──
127+
// The fast path — name-sort with no filter chip — only needs to
128+
// hydrate metadata for the 12-row page slice. Any other path
129+
// (filter chip OR non-name sort) requires every candidate's revs
130+
// / users / lastEdited up front so we can sort and slice against
131+
// the right universe. The expensive call is `padManager.getPad`;
132+
// user counts come from an in-memory map.
133+
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
134+
135+
const loadMeta = async (padName: string): Promise<PadQueryResult> => {
136+
const pad = await padManager.getPad(padName);
137+
return {
138+
padName,
139+
lastEdited: await pad.getLastEdit(),
140+
userCount: api.padUsersCount(padName).padUsersCount,
141+
revisionNumber: pad.getHeadRevisionNumber(),
142+
};
143+
};
144+
145+
// Lazily lifted so we don't load every pad twice on the fast path.
146+
let hydrated: PadQueryResult[] | null = null;
147+
const hydrateAll = async () => {
148+
if (hydrated == null) {
149+
hydrated = await Promise.all(candidateNames.map(loadMeta));
150+
}
151+
return hydrated;
152+
};
153+
154+
// ── 4. Filter chip — applied to hydrated metadata ────────────────
155+
// Bucket boundaries match the client chips in PadPage.tsx so the
156+
// counts on the stats cards keep meaning the same thing. Compute
157+
// `now` once per request so a pad doesn't slip between buckets
158+
// mid-loop.
159+
const now = Date.now();
160+
const isRecent = (lastEdited: number) => now - lastEdited < 86_400_000 * 7;
161+
const isStale = (lastEdited: number) => now - lastEdited > 86_400_000 * 365;
162+
const matchesFilter = (m: PadQueryResult) => {
163+
switch (filter) {
164+
case 'active': return m.userCount > 0;
165+
case 'recent': return isRecent(Number(m.lastEdited));
166+
case 'empty': return m.revisionNumber === 0;
167+
case 'stale': return isStale(Number(m.lastEdited));
168+
default: return true;
169+
}
170+
};
128171

129-
maxResult = result.length - 1;
130-
if (maxResult < 0) {
131-
maxResult = 0;
172+
// ── 5. Total — i.e. the count the pagination footer reflects ────
173+
// For the fast path this is just the pattern-filtered name list;
174+
// for full-scan we report the post-chip total.
175+
let totalNames: string[] | null = needsFullScan ? null : candidateNames;
176+
let postFilterMetas: PadQueryResult[] | null = null;
177+
if (needsFullScan) {
178+
postFilterMetas = (await hydrateAll()).filter(matchesFilter);
132179
}
180+
const total = needsFullScan ? postFilterMetas!.length : totalNames!.length;
133181

134-
// Reset to default values if out of bounds
182+
// ── 6. Clamp offset/limit ──────────────────────────────────────
183+
const maxOffset = Math.max(total - 1, 0);
135184
if (query.offset && query.offset < 0) {
136185
query.offset = 0;
137-
} else if (query.offset > maxResult) {
138-
query.offset = maxResult;
186+
} else if (query.offset > maxOffset) {
187+
query.offset = maxOffset;
139188
}
140-
141189
if (query.limit && query.limit < 0) {
142-
// Too small
143190
query.limit = 0;
144191
} else if (query.limit > queryPadLimit) {
145-
// Too big
146192
query.limit = queryPadLimit;
147193
}
148194

149-
150-
if (query.sortBy === 'padName') {
151-
result = result.sort((a, b) => {
152-
if (a < b) return query.ascending ? -1 : 1;
153-
if (a > b) return query.ascending ? 1 : -1;
154-
return 0;
155-
}).slice(query.offset, query.offset + query.limit);
156-
157-
data.results = await Promise.all(result.map(async (padName: string) => {
158-
const pad = await padManager.getPad(padName);
159-
const revisionNumber = pad.getHeadRevisionNumber()
160-
const userCount = api.padUsersCount(padName).padUsersCount;
161-
const lastEdited = await pad.getLastEdit();
162-
163-
return {
164-
padName,
165-
lastEdited,
166-
userCount,
167-
revisionNumber
195+
// ── 7. Sort + slice ────────────────────────────────────────────
196+
const dir = query.ascending ? 1 : -1;
197+
const cmpStr = (a: string, b: string) => a < b ? -dir : a > b ? dir : 0;
198+
const cmpNum = (a: number, b: number) => a < b ? -dir : a > b ? dir : 0;
199+
200+
let results: PadQueryResult[];
201+
if (needsFullScan) {
202+
const sorted = postFilterMetas!.sort((a, b) => {
203+
switch (query.sortBy) {
204+
case 'padName': return cmpStr(a.padName, b.padName);
205+
case 'revisionNumber': return cmpNum(a.revisionNumber, b.revisionNumber);
206+
case 'userCount': return cmpNum(a.userCount, b.userCount);
207+
case 'lastEdited': return cmpStr(String(a.lastEdited), String(b.lastEdited));
208+
default: return 0;
168209
}
169-
}));
170-
} else if (query.sortBy === "revisionNumber") {
171-
const currentWinners: PadQueryResult[] = []
172-
const padMapping = [] as {padId: string, revisionNumber: number}[]
173-
for (let res of result) {
174-
const pad = await padManager.getPad(res);
175-
const revisionNumber = pad.getHeadRevisionNumber()
176-
padMapping.push({padId: res, revisionNumber})
177-
}
178-
padMapping.sort((a, b) => {
179-
if (a.revisionNumber < b.revisionNumber) return query.ascending ? -1 : 1;
180-
if (a.revisionNumber > b.revisionNumber) return query.ascending ? 1 : -1;
181-
return 0;
182-
})
183-
184-
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
185-
let pad = await padManager.getPad(padRetrieval.padId);
186-
currentWinners.push({
187-
padName: padRetrieval.padId,
188-
lastEdited: await pad.getLastEdit(),
189-
userCount: api.padUsersCount(pad.padName).padUsersCount,
190-
revisionNumber: padRetrieval.revisionNumber
191-
})
192-
}
193-
194-
data.results = currentWinners;
195-
} else if (query.sortBy === "userCount") {
196-
const currentWinners: PadQueryResult[] = []
197-
const padMapping = [] as {padId: string, userCount: number}[]
198-
for (let res of result) {
199-
const userCount = api.padUsersCount(res).padUsersCount
200-
padMapping.push({padId: res, userCount})
201-
}
202-
padMapping.sort((a, b) => {
203-
if (a.userCount < b.userCount) return query.ascending ? -1 : 1;
204-
if (a.userCount > b.userCount) return query.ascending ? 1 : -1;
205-
return 0;
206-
})
207-
208-
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
209-
let pad = await padManager.getPad(padRetrieval.padId);
210-
currentWinners.push({
211-
padName: padRetrieval.padId,
212-
lastEdited: await pad.getLastEdit(),
213-
userCount: padRetrieval.userCount,
214-
revisionNumber: pad.getHeadRevisionNumber()
215-
})
216-
}
217-
data.results = currentWinners;
218-
} else if (query.sortBy === "lastEdited") {
219-
const currentWinners: PadQueryResult[] = []
220-
const padMapping = [] as {padId: string, lastEdited: string}[]
221-
for (let res of result) {
222-
const pad = await padManager.getPad(res);
223-
const lastEdited = await pad.getLastEdit();
224-
padMapping.push({padId: res, lastEdited})
225-
}
226-
padMapping.sort((a, b) => {
227-
if (a.lastEdited < b.lastEdited) return query.ascending ? -1 : 1;
228-
if (a.lastEdited > b.lastEdited) return query.ascending ? 1 : -1;
229-
return 0;
230-
})
231-
232-
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
233-
let pad = await padManager.getPad(padRetrieval.padId);
234-
currentWinners.push({
235-
padName: padRetrieval.padId,
236-
lastEdited: padRetrieval.lastEdited,
237-
userCount: api.padUsersCount(pad.padName).padUsersCount,
238-
revisionNumber: pad.getHeadRevisionNumber()
239-
})
240-
}
241-
data.results = currentWinners;
210+
});
211+
results = sorted.slice(query.offset, query.offset + query.limit);
212+
} else {
213+
const sliceNames = totalNames!.sort(cmpStr).slice(query.offset, query.offset + query.limit);
214+
results = await Promise.all(sliceNames.map(loadMeta));
242215
}
243216

217+
const data: {total: number, results?: PadQueryResult[]} = {total, results};
244218
socket.emit('results:padLoad', data);
245219
})
246220

src/node/types/PadSearchQuery.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
export type PadFilter = "all" | "active" | "recent" | "empty" | "stale";
2+
3+
export const PAD_FILTERS: PadFilter[] = ["all", "active", "recent", "empty", "stale"];
4+
15
export type PadSearchQuery = {
26
pattern: string;
37
offset: number;
48
limit: number;
59
ascending: boolean;
610
sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber";
11+
// Filter chip. Defaults to "all". Applied server-side so pagination
12+
// reflects the filtered universe — without this, the chip filters only
13+
// the current page slice and "0 empty pads" can appear on page 1 while
14+
// page 2 has nothing but empties.
15+
filter?: PadFilter;
716
}
817

918

0 commit comments

Comments
 (0)