search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

persist stats to turso, add popular searches

- stats table: single row for lifetime search/error counts
- popular_searches table: track query counts
- /popular endpoint: returns top N searched queries
- frontend: show trending searches in empty state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 1fdf9916 41b27dd5

+186 -16
+1 -1
backend/src/dashboard.zig
··· 90 90 try w.print("{d}", .{searches}); 91 91 try w.writeAll( 92 92 \\</div> 93 - \\ <div class="stat-label">searches (this session)</div> 93 + \\ <div class="stat-label">searches</div> 94 94 \\ </div> 95 95 \\ <div class="stat"> 96 96 \\ <div class="stat-value">
+60 -6
backend/src/db/mod.zig
··· 307 307 return try output.toOwnedSlice(); 308 308 } 309 309 310 - pub fn getStats() struct { documents: i64, publications: i64 } { 311 - var c = &(client orelse return .{ .documents = 0, .publications = 0 }); 310 + pub fn getStats() struct { documents: i64, publications: i64, searches: i64, errors: i64 } { 311 + var c = &(client orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0 }); 312 312 313 313 var res = c.query( 314 314 \\SELECT 315 315 \\ (SELECT COUNT(*) FROM documents) as docs, 316 - \\ (SELECT COUNT(*) FROM publications) as pubs 317 - , &.{}) catch return .{ .documents = 0, .publications = 0 }; 316 + \\ (SELECT COUNT(*) FROM publications) as pubs, 317 + \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 318 + \\ (SELECT total_errors FROM stats WHERE id = 1) as errors 319 + , &.{}) catch return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0 }; 318 320 defer res.deinit(); 319 321 320 - const row = res.first() orelse return .{ .documents = 0, .publications = 0 }; 321 - return .{ .documents = row.int(0), .publications = row.int(1) }; 322 + const row = res.first() orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0 }; 323 + return .{ .documents = row.int(0), .publications = row.int(1), .searches = row.int(2), .errors = row.int(3) }; 324 + } 325 + 326 + pub fn recordSearch(query: []const u8) void { 327 + var c = &(client orelse return); 328 + c.exec("UPDATE stats SET total_searches = total_searches + 1 WHERE id = 1", &.{}) catch {}; 329 + 330 + // track popular searches (skip empty/very short queries) 331 + if (query.len >= 2) { 332 + c.exec( 333 + "INSERT INTO popular_searches (query, count) VALUES (?, 1) ON CONFLICT(query) DO UPDATE SET count = count + 1", 334 + &.{query}, 335 + ) catch {}; 336 + } 337 + } 338 + 339 + pub fn recordError() void { 340 + var c = &(client orelse return); 341 + c.exec("UPDATE stats SET total_errors = total_errors + 1 WHERE id = 1", &.{}) catch {}; 342 + } 343 + 344 + pub fn getPopular(alloc: Allocator, limit: usize) ![]const u8 { 345 + var c = &(client orelse return error.NotInitialized); 346 + 347 + var output: std.Io.Writer.Allocating = .init(alloc); 348 + errdefer output.deinit(); 349 + 350 + var buf: [8]u8 = undefined; 351 + const limit_str = std.fmt.bufPrint(&buf, "{d}", .{limit}) catch "3"; 352 + 353 + var res = c.query( 354 + "SELECT query, count FROM popular_searches ORDER BY count DESC LIMIT ?", 355 + &.{limit_str}, 356 + ) catch { 357 + try output.writer.writeAll("[]"); 358 + return try output.toOwnedSlice(); 359 + }; 360 + defer res.deinit(); 361 + 362 + var jw: json.Stringify = .{ .writer = &output.writer }; 363 + try jw.beginArray(); 364 + 365 + for (res.rows) |row| { 366 + try jw.beginObject(); 367 + try jw.objectField("query"); 368 + try jw.write(row.text(0)); 369 + try jw.objectField("count"); 370 + try jw.write(row.int(1)); 371 + try jw.endObject(); 372 + } 373 + 374 + try jw.endArray(); 375 + return try output.toOwnedSlice(); 322 376 } 323 377 324 378 /// Build FTS5 query with prefix on last word only: "cat dog" -> "cat dog*"
+20
backend/src/db/schema.zig
··· 60 60 "CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag)", 61 61 &.{}, 62 62 ) catch {}; 63 + 64 + // stats table: single row for lifetime counters 65 + try client.exec( 66 + \\CREATE TABLE IF NOT EXISTS stats ( 67 + \\ id INTEGER PRIMARY KEY CHECK (id = 1), 68 + \\ total_searches INTEGER DEFAULT 0, 69 + \\ total_errors INTEGER DEFAULT 0 70 + \\) 71 + , &.{}); 72 + 73 + // ensure the single row exists 74 + client.exec("INSERT OR IGNORE INTO stats (id) VALUES (1)", &.{}) catch {}; 75 + 76 + // popular searches tracking 77 + try client.exec( 78 + \\CREATE TABLE IF NOT EXISTS popular_searches ( 79 + \\ query TEXT PRIMARY KEY, 80 + \\ count INTEGER DEFAULT 1 81 + \\) 82 + , &.{}); 63 83 } 64 84 65 85 fn runMigrations(client: *turso.Client) !void {
+15 -4
backend/src/http.zig
··· 53 53 try handleStats(request); 54 54 } else if (mem.eql(u8, target, "/health")) { 55 55 try sendJson(request, "{\"status\":\"ok\"}"); 56 + } else if (mem.eql(u8, target, "/popular")) { 57 + try handlePopular(request); 56 58 } else if (mem.eql(u8, target, "/dashboard")) { 57 59 try handleDashboard(request); 58 60 } else { ··· 76 78 77 79 // perform FTS search - arena handles cleanup 78 80 const results = db.search(alloc, query, tag_filter) catch |err| { 79 - stats.get().recordError(); 81 + db.recordError(); 80 82 return err; 81 83 }; 82 - stats.get().recordSearch(); 84 + db.recordSearch(query); 83 85 try sendJson(request, results); 84 86 } 85 87 ··· 90 92 91 93 const tags = try db.getTags(alloc); 92 94 try sendJson(request, tags); 95 + } 96 + 97 + fn handlePopular(request: *http.Server.Request) !void { 98 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 99 + defer arena.deinit(); 100 + const alloc = arena.allocator(); 101 + 102 + const popular = try db.getPopular(alloc, 5); 103 + try sendJson(request, popular); 93 104 } 94 105 95 106 fn parseQueryParam(alloc: std.mem.Allocator, target: []const u8, param: []const u8) ![]const u8 { ··· 169 180 const html = dashboard.render( 170 181 alloc, 171 182 s.getUptime(), 172 - s.getSearches(), 173 - s.getErrors(), 183 + @intCast(db_stats.searches), 184 + @intCast(db_stats.errors), 174 185 db_stats.documents, 175 186 db_stats.publications, 176 187 tags_json,
+90 -5
site/index.html
··· 173 173 174 174 .empty-state p { margin-bottom: 0.5rem; } 175 175 176 + .popular { 177 + margin-top: 1.5rem; 178 + } 179 + 180 + .popular-label { 181 + font-size: 10px; 182 + text-transform: uppercase; 183 + letter-spacing: 1px; 184 + color: #444; 185 + margin-bottom: 0.75rem; 186 + display: flex; 187 + align-items: center; 188 + justify-content: center; 189 + gap: 0.5rem; 190 + } 191 + 192 + .popular-label::before, 193 + .popular-label::after { 194 + content: ''; 195 + height: 1px; 196 + width: 2rem; 197 + background: #333; 198 + } 199 + 200 + .popular-list { 201 + display: flex; 202 + flex-wrap: wrap; 203 + justify-content: center; 204 + gap: 0.5rem; 205 + } 206 + 207 + .popular-item { 208 + font-size: 12px; 209 + padding: 0.4rem 0.75rem; 210 + background: #151515; 211 + border: 1px solid #252525; 212 + border-radius: 3px; 213 + cursor: pointer; 214 + color: #888; 215 + transition: all 0.15s; 216 + } 217 + 218 + .popular-item:hover { 219 + background: #1a1a1a; 220 + border-color: #1B7340; 221 + color: #2a9d5c; 222 + } 223 + 176 224 .stats { 177 225 font-size: 11px; 178 226 color: #555; ··· 276 324 277 325 let currentTag = null; 278 326 let allTags = []; 327 + let popularSearches = []; 279 328 280 329 async function search(query, tag = null) { 281 330 if (!query.trim() && !tag) return; ··· 408 457 if (queryInput.value.trim()) { 409 458 search(queryInput.value, null); 410 459 } else { 411 - resultsDiv.innerHTML = ` 412 - <div class="empty-state"> 413 - <p>search for <a href="https://leaflet.pub" target="_blank">leaflet.pub</a></p> 414 - </div> 415 - `; 460 + renderEmptyState(); 416 461 } 417 462 } 418 463 ··· 455 500 } 456 501 } 457 502 503 + async function loadPopular() { 504 + try { 505 + const res = await fetch(`${API_URL}/popular`); 506 + const data = await res.json(); 507 + if (!data.error) { 508 + popularSearches = data.slice(0, 5); 509 + renderEmptyState(); 510 + } 511 + } catch (e) { 512 + console.error('failed to load popular', e); 513 + } 514 + } 515 + 516 + function renderEmptyState() { 517 + const popularHtml = popularSearches.length > 0 ? ` 518 + <div class="popular"> 519 + <div class="popular-label">trending</div> 520 + <div class="popular-list"> 521 + ${popularSearches.map(p => ` 522 + <span class="popular-item" onclick="searchPopular('${escapeHtml(p.query)}')">${escapeHtml(p.query)}</span> 523 + `).join('')} 524 + </div> 525 + </div> 526 + ` : ''; 527 + 528 + resultsDiv.innerHTML = ` 529 + <div class="empty-state"> 530 + <p>search for <a href="https://leaflet.pub" target="_blank">leaflet.pub</a></p> 531 + ${popularHtml} 532 + </div> 533 + `; 534 + } 535 + 536 + function searchPopular(query) { 537 + queryInput.value = query; 538 + doSearch(); 539 + } 540 + 458 541 searchBtn.addEventListener('click', doSearch); 459 542 queryInput.addEventListener('keydown', e => { 460 543 if (e.key === 'Enter') doSearch(); ··· 479 562 480 563 if (initialQuery || initialTag) { 481 564 search(initialQuery || '', initialTag); 565 + } else { 566 + loadPopular(); 482 567 } 483 568 484 569 loadTags();