[object Object]

← 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

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 →