add insights to dashboard: match rate, quotes, multi-link

new stats tracked:
- quote_matches: posts that matched via quoted content
- multi_platform: posts with links to 2+ platforms
- match rate: calculated posts/hour

dashboard now shows insights section with these metrics.

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

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

Changed files
+112 -4
src
+26
src/feed/filter.zig
··· 111 111 return false; 112 112 } 113 113 114 + /// returns true if the post matched because it quotes a music post (not direct link) 115 + pub fn isQuoteMatch(record: Record) bool { 116 + const val: json.Value = .{ .object = record }; 117 + 118 + // if post has direct music links, it's not a quote match 119 + if (zat.json.getString(val, "embed.external.uri")) |uri| { 120 + if (containsMusicDomain(uri)) return false; 121 + } 122 + if (zat.json.getArray(val, "facets")) |facets| { 123 + if (checkFacetsForMusic(facets)) return false; 124 + } 125 + 126 + // check if music is in quoted content 127 + if (zat.json.getString(val, "embed.record.value.embed.external.uri")) |uri| { 128 + if (containsMusicDomain(uri)) return true; 129 + } 130 + if (zat.json.getArray(val, "embed.record.value.facets")) |facets| { 131 + if (checkFacetsForMusic(facets)) return true; 132 + } 133 + if (zat.json.getString(val, "embed.record.record.value.embed.external.uri")) |uri| { 134 + if (containsMusicDomain(uri)) return true; 135 + } 136 + 137 + return false; 138 + } 139 + 114 140 fn containsMusicDomain(uri: []const u8) bool { 115 141 for (music_domains) |domain| { 116 142 if (mem.indexOf(u8, uri, domain) != null) return true;
+6 -2
src/jetstream.zig
··· 163 163 return; 164 164 }; 165 165 166 - stats.get().recordMatch(); 166 + const s = stats.get(); 167 + s.recordMatch(); 167 168 168 169 // track all platforms in post 169 170 const platforms = filter.detectAllPlatformsFromRecord(post_record.object); 170 - const s = stats.get(); 171 171 if (platforms.soundcloud) s.recordPlatform(.soundcloud); 172 172 if (platforms.bandcamp) s.recordPlatform(.bandcamp); 173 173 if (platforms.spotify) s.recordPlatform(.spotify); 174 174 if (platforms.plyr) s.recordPlatform(.plyr); 175 175 if (platforms.apple) s.recordPlatform(.apple); 176 + 177 + // track match types 178 + if (filter.isQuoteMatch(post_record.object)) s.recordQuoteMatch(); 179 + if (platforms.count() >= 2) s.recordMultiPlatform(); 176 180 177 181 std.debug.print("added: {s}\n", .{at_uri.string}); 178 182 }
+34
src/server/dashboard.zig
··· 220 220 \\ </div> 221 221 \\ 222 222 \\ <div class="criteria"> 223 + \\ <div class="criteria-title">insights</div> 224 + \\ <div class="criteria-list"> 225 + ); 226 + 227 + // match rate 228 + const match_rate = s.getMatchRate(); 229 + try w.print("{d:.1} posts/hour", .{match_rate}); 230 + 231 + // quote matches 232 + const quote_matches = s.getQuoteMatches(); 233 + const multi_platform = s.getMultiPlatform(); 234 + const matches = s.getMatches(); 235 + 236 + if (matches > 0) { 237 + try w.writeAll(" · "); 238 + if (quote_matches > 0) { 239 + const quote_pct: f64 = @as(f64, @floatFromInt(quote_matches)) / @as(f64, @floatFromInt(matches)) * 100.0; 240 + try w.print("{d} quotes ({d:.1}%)", .{ quote_matches, quote_pct }); 241 + } else { 242 + try w.writeAll("0 quotes"); 243 + } 244 + try w.writeAll(" · "); 245 + if (multi_platform > 0) { 246 + try w.print("{d} multi-link", .{multi_platform}); 247 + } else { 248 + try w.writeAll("0 multi-link"); 249 + } 250 + } 251 + 252 + try w.writeAll( 253 + \\</div> 254 + \\ </div> 255 + \\ 256 + \\ <div class="criteria"> 223 257 \\ <div class="criteria-title">excluded</div> 224 258 \\ <div class="criteria-list">posts with nsfw <a href="https://docs.bsky.app/docs/advanced-guides/moderation">labels</a></div> 225 259 \\ </div>
+46 -2
src/server/stats.zig
··· 26 26 plyr: Atomic(u64), 27 27 apple: Atomic(u64), 28 28 29 + // match type counters 30 + quote_matches: Atomic(u64), // posts quoting music posts 31 + multi_platform: Atomic(u64), // posts with 2+ platforms 32 + 29 33 // for lag trend tracking 30 34 prev_lag_ms: Atomic(i64), 31 35 ··· 44 48 .spotify = Atomic(u64).init(0), 45 49 .plyr = Atomic(u64).init(0), 46 50 .apple = Atomic(u64).init(0), 51 + .quote_matches = Atomic(u64).init(0), 52 + .multi_platform = Atomic(u64).init(0), 47 53 .prev_lag_ms = Atomic(i64).init(0), 48 54 }; 49 55 self.load(); ··· 86 92 }; 87 93 if (root.get("apple")) |v| if (v == .integer) { 88 94 self.apple.store(@intCast(@max(0, v.integer)), .monotonic); 95 + }; 96 + if (root.get("quote_matches")) |v| if (v == .integer) { 97 + self.quote_matches.store(@intCast(@max(0, v.integer)), .monotonic); 98 + }; 99 + if (root.get("multi_platform")) |v| if (v == .integer) { 100 + self.multi_platform.store(@intCast(@max(0, v.integer)), .monotonic); 89 101 }; 90 102 91 103 std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); ··· 99 111 const session_uptime: u64 = @intCast(@max(0, now - self.started_at)); 100 112 const total_uptime = self.prior_uptime + session_uptime; 101 113 102 - var buf: [512]u8 = undefined; 114 + var buf: [768]u8 = undefined; 103 115 const data = std.fmt.bufPrint(&buf, 104 - \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{},"apple":{}}} 116 + \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{},"apple":{},"quote_matches":{},"multi_platform":{}}} 105 117 , .{ 106 118 self.messages.load(.monotonic), 107 119 self.matches.load(.monotonic), ··· 111 123 self.spotify.load(.monotonic), 112 124 self.plyr.load(.monotonic), 113 125 self.apple.load(.monotonic), 126 + self.quote_matches.load(.monotonic), 127 + self.multi_platform.load(.monotonic), 114 128 }) catch return; 115 129 116 130 file.writeAll(data) catch return; ··· 172 186 }; 173 187 } 174 188 189 + pub fn recordQuoteMatch(self: *Stats) void { 190 + _ = self.quote_matches.fetchAdd(1, .monotonic); 191 + } 192 + 193 + pub fn recordMultiPlatform(self: *Stats) void { 194 + _ = self.multi_platform.fetchAdd(1, .monotonic); 195 + } 196 + 197 + pub fn getQuoteMatches(self: *const Stats) u64 { 198 + return self.quote_matches.load(.monotonic); 199 + } 200 + 201 + pub fn getMultiPlatform(self: *const Stats) u64 { 202 + return self.multi_platform.load(.monotonic); 203 + } 204 + 205 + pub fn getMatchRate(self: *Stats) f64 { 206 + const uptime_sec = self.totalUptime(); 207 + if (uptime_sec <= 0) return 0; 208 + const uptime_hours: f64 = @as(f64, @floatFromInt(uptime_sec)) / 3600.0; 209 + const matches: f64 = @floatFromInt(self.matches.load(.monotonic)); 210 + return matches / uptime_hours; 211 + } 212 + 175 213 pub fn recordConnected(self: *Stats) void { 176 214 self.connected_at.store(std.time.milliTimestamp(), .monotonic); 177 215 } ··· 247 285 .spotify = Atomic(u64).init(0), 248 286 .plyr = Atomic(u64).init(0), 249 287 .apple = Atomic(u64).init(0), 288 + .quote_matches = Atomic(u64).init(0), 289 + .multi_platform = Atomic(u64).init(0), 250 290 .prev_lag_ms = Atomic(i64).init(0), 251 291 }; 252 292 ··· 272 312 .spotify = Atomic(u64).init(0), 273 313 .plyr = Atomic(u64).init(0), 274 314 .apple = Atomic(u64).init(0), 315 + .quote_matches = Atomic(u64).init(0), 316 + .multi_platform = Atomic(u64).init(0), 275 317 .prev_lag_ms = Atomic(i64).init(0), 276 318 }; 277 319 ··· 303 345 .spotify = Atomic(u64).init(0), 304 346 .plyr = Atomic(u64).init(0), 305 347 .apple = Atomic(u64).init(0), 348 + .quote_matches = Atomic(u64).init(0), 349 + .multi_platform = Atomic(u64).init(0), 306 350 .prev_lag_ms = Atomic(i64).init(0), 307 351 }; 308 352