← back to build-pages
api/search: round-robin merge + by_project breakdown (no single project monopolizes the page)
aafba880b73ba6bb085a3dfe8bb1d7821fe2d7cb · 2026-05-13 13:50:04 -0700 · SteveStudio2
Files touched
M server.jsM test/smoke.js
Diff
commit aafba880b73ba6bb085a3dfe8bb1d7821fe2d7cb
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 13:50:04 2026 -0700
api/search: round-robin merge + by_project breakdown (no single project monopolizes the page)
---
server.js | 47 +++++++++++++++++++++++++++++++++--------------
test/smoke.js | 3 ++-
2 files changed, 35 insertions(+), 15 deletions(-)
diff --git a/server.js b/server.js
index ea3272b..a66dcdf 100644
--- a/server.js
+++ b/server.js
@@ -475,23 +475,42 @@ app.get('/api/search', async (req, res, next) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
if (q.length < 2) return res.json({ q, results: [], note: 'min 2 chars' });
try {
- const hits = [];
- for (const [slug, p] of Object.entries(PROJECTS)) {
- const bundle = await getProjectBundle(slug, p).catch(() => null);
- if (!bundle) continue;
- for (const c of bundle.commits) {
- const blob = (c.subject + ' ' + c.body).toLowerCase();
- if (blob.includes(q)) {
- hits.push({
- slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10), subject: c.subject,
- });
- if (hits.length >= limit) break;
- }
+ // Phase 1 — pull ALL matches per project (no cap), so the "one project
+ // monopolizes the page" failure mode is impossible.
+ const perProject = await Promise.all(
+ Object.entries(PROJECTS).map(async ([slug, p]) => {
+ const bundle = await getProjectBundle(slug, p).catch(() => null);
+ if (!bundle) return { slug, name: p.name, hits: [] };
+ const hits = bundle.commits.filter(c =>
+ (c.subject + ' ' + c.body).toLowerCase().includes(q)
+ ).map(c => ({
+ slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10), subject: c.subject,
+ }));
+ return { slug, name: p.name, hits };
+ })
+ );
+ // Phase 2 — round-robin merge so the first N hits include something from
+ // every project that matched at all, then sort by date desc.
+ const queues = perProject.map(p => [...p.hits]);
+ const interleaved = [];
+ while (interleaved.length < limit && queues.some(q => q.length)) {
+ for (const q of queues) {
+ if (!q.length) continue;
+ interleaved.push(q.shift());
+ if (interleaved.length >= limit) break;
}
- if (hits.length >= limit) break;
}
+ interleaved.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
+ const grandTotal = perProject.reduce((a, p) => a + p.hits.length, 0);
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
- res.json({ q, total: hits.length, limit, results: hits });
+ res.json({
+ q,
+ total: grandTotal,
+ shown: interleaved.length,
+ limit,
+ by_project: perProject.map(p => ({ slug: p.slug, name: p.name, count: p.hits.length })),
+ results: interleaved,
+ });
} catch (e) { next(e); }
});
diff --git a/test/smoke.js b/test/smoke.js
index 5935c4a..a65683f 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -147,11 +147,12 @@ function check(name, cond, hint = '') {
check('404: branded HTML', /build-pages/.test(r.body));
check('404: back-link', /back to all projects/.test(r.body));
- // 14. cross-project search
+ // 14. cross-project search (round-robin merge + per-project breakdown)
r = await fetch('/api/search?q=whisper&limit=10');
check('search: 200', r.status === 200);
check('search: results array', /"results":\[/.test(r.body));
check('search: total field', /"total":\d+/.test(r.body));
+ check('search: by_project', /"by_project":\[/.test(r.body));
r = await fetch('/api/search?q=a');
check('search: <2 chars → empty',/"results":\[\]/.test(r.body));
← 9494934 ui: fleet-wide search input on home (wired to /api/search, 1
·
back to build-pages
·
ui: fleet search now shows by_project breakdown — '26 hits — 4287f44 →