feat: add time range toggle to costs dashboard for audd requests (#647)

- export script now queries 30 days of daily data (independent of billing cycle)
- frontend adds toggle buttons (24h / 7d / 30d) to filter the chart
- billing period stats (free remaining, billable) still use AudD cycle for cost accuracy
- defaults to 30d view

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub be06e6a3 2ee1360e

Changed files
+116 -14
frontend
src
routes
costs
scripts
+111 -12
frontend/src/routes/costs/+page.svelte
··· 59 59 let loading = $state(true); 60 60 let error = $state<string | null>(null); 61 61 let data = $state<CostData | null>(null); 62 + let timeRange = $state<'day' | 'week' | 'month'>('month'); 63 + 64 + // filter daily data based on selected time range 65 + let filteredDaily = $derived.by(() => { 66 + if (!data?.costs.audd.daily.length) return []; 67 + const now = Date.now(); 68 + let cutoffMs: number; 69 + if (timeRange === 'day') { 70 + cutoffMs = now - 24 * 60 * 60 * 1000; 71 + } else if (timeRange === 'week') { 72 + cutoffMs = now - 7 * 24 * 60 * 60 * 1000; 73 + } else { 74 + cutoffMs = now - 30 * 24 * 60 * 60 * 1000; 75 + } 76 + return data.costs.audd.daily.filter((d) => new Date(d.date).getTime() >= cutoffMs); 77 + }); 78 + 79 + // calculate totals for selected time range 80 + let filteredTotals = $derived.by(() => { 81 + return { 82 + requests: filteredDaily.reduce((sum, d) => sum + d.requests, 0), 83 + scans: filteredDaily.reduce((sum, d) => sum + d.scans, 0) 84 + }; 85 + }); 62 86 63 87 // derived values for bar chart scaling 64 88 let maxCost = $derived( ··· 72 96 : 1 73 97 ); 74 98 75 - let maxRequests = $derived( 76 - data?.costs.audd.daily.length 77 - ? Math.max(...data.costs.audd.daily.map((d) => d.requests)) 78 - : 1 79 - ); 99 + let maxRequests = $derived.by(() => { 100 + return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 101 + }); 80 102 81 103 onMount(async () => { 82 104 try { ··· 216 238 217 239 <!-- audd details --> 218 240 <section class="audd-section"> 219 - <h2>copyright scanning (audd)</h2> 241 + <div class="audd-header"> 242 + <h2>api requests (audd)</h2> 243 + <div class="time-range-toggle"> 244 + <button 245 + class:active={timeRange === 'day'} 246 + onclick={() => (timeRange = 'day')} 247 + > 248 + 24h 249 + </button> 250 + <button 251 + class:active={timeRange === 'week'} 252 + onclick={() => (timeRange = 'week')} 253 + > 254 + 7d 255 + </button> 256 + <button 257 + class:active={timeRange === 'month'} 258 + onclick={() => (timeRange = 'month')} 259 + > 260 + 30d 261 + </button> 262 + </div> 263 + </div> 264 + 220 265 <div class="audd-stats"> 221 266 <div class="stat"> 222 - <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 223 - <span class="stat-label">API requests</span> 267 + <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 268 + <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 224 269 </div> 225 270 <div class="stat"> 226 271 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 227 272 <span class="stat-label">free remaining</span> 228 273 </div> 229 274 <div class="stat"> 230 - <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 275 + <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 231 276 <span class="stat-label">tracks scanned</span> 232 277 </div> 233 278 </div> ··· 236 281 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 237 282 then ${(5).toFixed(2)}/1k requests. 238 283 {#if data.costs.audd.billable_requests > 0} 239 - <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period. 284 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 240 285 {/if} 241 286 </p> 242 287 243 - {#if data.costs.audd.daily.length > 0} 288 + {#if filteredDaily.length > 0} 244 289 <div class="daily-chart"> 245 290 <h3>daily requests</h3> 246 291 <div class="chart-bars"> 247 - {#each data.costs.audd.daily as day} 292 + {#each filteredDaily as day} 248 293 <div class="chart-bar-container"> 249 294 <div 250 295 class="chart-bar" ··· 256 301 {/each} 257 302 </div> 258 303 </div> 304 + {:else} 305 + <p class="no-data">no requests in this time range</p> 259 306 {/if} 260 307 </section> 261 308 ··· 433 480 /* audd section */ 434 481 .audd-section { 435 482 margin-bottom: 2rem; 483 + } 484 + 485 + .audd-header { 486 + display: flex; 487 + justify-content: space-between; 488 + align-items: center; 489 + margin-bottom: 1rem; 490 + gap: 1rem; 491 + } 492 + 493 + .audd-header h2 { 494 + margin-bottom: 0; 495 + } 496 + 497 + .time-range-toggle { 498 + display: flex; 499 + gap: 0.25rem; 500 + background: var(--bg-tertiary); 501 + border: 1px solid var(--border-subtle); 502 + border-radius: 6px; 503 + padding: 0.25rem; 504 + } 505 + 506 + .time-range-toggle button { 507 + padding: 0.35rem 0.75rem; 508 + font-size: 0.75rem; 509 + font-weight: 500; 510 + background: transparent; 511 + border: none; 512 + border-radius: 4px; 513 + color: var(--text-secondary); 514 + cursor: pointer; 515 + transition: all 0.15s; 516 + } 517 + 518 + .time-range-toggle button:hover { 519 + color: var(--text-primary); 520 + } 521 + 522 + .time-range-toggle button.active { 523 + background: var(--accent); 524 + color: white; 525 + } 526 + 527 + .no-data { 528 + text-align: center; 529 + color: var(--text-tertiary); 530 + font-size: 0.85rem; 531 + padding: 2rem; 532 + background: var(--bg-tertiary); 533 + border: 1px solid var(--border-subtle); 534 + border-radius: 8px; 436 535 } 437 536 438 537 .audd-stats {
+5 -2
scripts/costs/export_costs.py
··· 116 116 import asyncpg 117 117 118 118 billing_start = get_billing_period_start() 119 + # 30 days of history for the daily chart (independent of billing cycle) 120 + history_start = datetime.now() - timedelta(days=30) 119 121 120 122 conn = await asyncpg.connect(db_url) 121 123 try: 122 124 # get totals: scans, flagged, and derived API requests from duration 125 + # uses billing period for accurate cost calculation 123 126 row = await conn.fetchrow( 124 127 """ 125 128 SELECT ··· 139 142 total_requests = row["total_requests"] 140 143 total_seconds = row["total_seconds"] 141 144 142 - # daily breakdown for chart - now includes requests derived from duration 145 + # daily breakdown for chart - 30 days of history for flexible views 143 146 daily = await conn.fetch( 144 147 """ 145 148 SELECT ··· 153 156 GROUP BY DATE(cs.scanned_at) 154 157 ORDER BY date 155 158 """, 156 - billing_start, 159 + history_start, 157 160 AUDD_SECONDS_PER_REQUEST, 158 161 ) 159 162