fix: restore costs page styling, preserve upstash addition (#655)

restored original styling that was accidentally replaced:
- chart bars now use margin-top: auto (bars grow upward)
- heart icon in support section restored
- time range toggle uses font-family: inherit
- correct CSS variables (--bg-tertiary, --border-subtle)

kept the minimal upstash additions from previous commit.

🤖 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 52a86047 93d46c29

Changed files
+298 -219
frontend
src
routes
costs
+298 -219
frontend/src/routes/costs/+page.svelte
··· 103 103 : 1 104 104 ); 105 105 106 - // derive max requests for the daily chart based on filtered data 107 - let maxRequests = $derived( 108 - filteredDaily.length > 0 ? Math.max(...filteredDaily.map((d) => d.requests), 1) : 1 109 - ); 106 + let maxRequests = $derived.by(() => { 107 + return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 108 + }); 110 109 111 - function formatCurrency(amount: number): string { 112 - return `$${amount.toFixed(2)}`; 113 - } 110 + onMount(async () => { 111 + try { 112 + const response = await fetch(`${API_URL}/stats/costs`); 113 + if (!response.ok) { 114 + throw new Error(`failed to load cost data: ${response.status}`); 115 + } 116 + data = await response.json(); 117 + } catch (e) { 118 + console.error('failed to load costs:', e); 119 + error = e instanceof Error ? e.message : 'failed to load cost data'; 120 + } finally { 121 + loading = false; 122 + } 123 + }); 114 124 115 125 function formatDate(isoString: string): string { 116 - return new Date(isoString).toLocaleString('en-US', { 126 + const date = new Date(isoString); 127 + return date.toLocaleDateString('en-US', { 117 128 month: 'short', 118 129 day: 'numeric', 130 + year: 'numeric', 119 131 hour: 'numeric', 120 132 minute: '2-digit' 121 133 }); 122 134 } 123 135 124 - function formatShortDate(isoString: string): string { 125 - return new Date(isoString).toLocaleString('en-US', { 126 - month: 'short', 127 - day: 'numeric' 128 - }); 136 + function formatCurrency(amount: number): string { 137 + return `$${amount.toFixed(2)}`; 129 138 } 130 139 140 + // calculate bar width as percentage of max 131 141 function barWidth(amount: number, max: number): number { 132 - if (max === 0) return 0; 133 - return Math.max((amount / max) * 100, 2); // minimum 2% for visibility 142 + return Math.max(5, (amount / max) * 100); 134 143 } 135 - 136 - onMount(async () => { 137 - try { 138 - const res = await fetch(`${API_URL}/stats/costs`); 139 - if (!res.ok) throw new Error('failed to load costs'); 140 - data = await res.json(); 141 - } catch (e) { 142 - error = e instanceof Error ? e.message : 'unknown error'; 143 - } finally { 144 - loading = false; 145 - } 146 - }); 147 144 148 145 async function logout() { 149 146 await auth.logout(); ··· 264 261 265 262 <!-- audd details --> 266 263 <section class="audd-section"> 267 - <h2>copyright detection (audd)</h2> 264 + <div class="audd-header"> 265 + <h2>api requests (audd)</h2> 266 + <div class="time-range-toggle"> 267 + <button 268 + class:active={timeRange === 'day'} 269 + onclick={() => (timeRange = 'day')} 270 + > 271 + 24h 272 + </button> 273 + <button 274 + class:active={timeRange === 'week'} 275 + onclick={() => (timeRange = 'week')} 276 + > 277 + 7d 278 + </button> 279 + <button 280 + class:active={timeRange === 'month'} 281 + onclick={() => (timeRange = 'month')} 282 + > 283 + 30d 284 + </button> 285 + </div> 286 + </div> 268 287 269 288 <div class="audd-stats"> 270 289 <div class="stat"> 271 - <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 272 - <span class="stat-label">tracks scanned</span> 273 - </div> 274 - <div class="stat"> 275 - <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 276 - <span class="stat-label">api requests</span> 290 + <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 291 + <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 277 292 </div> 278 293 <div class="stat"> 279 294 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 280 295 <span class="stat-label">free remaining</span> 281 296 </div> 282 297 <div class="stat"> 283 - <span class="stat-value">{data.costs.audd.flag_rate}%</span> 284 - <span class="stat-label">flag rate</span> 298 + <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 299 + <span class="stat-label">tracks scanned</span> 285 300 </div> 286 301 </div> 287 302 288 - <!-- daily chart with time range toggle --> 289 - {#if data.costs.audd.daily.length > 0} 290 - <div class="daily-chart-container"> 291 - <div class="chart-header"> 292 - <h3>daily requests</h3> 293 - <div class="time-toggle"> 294 - <button 295 - class="toggle-btn" 296 - class:active={timeRange === 'day'} 297 - onclick={() => (timeRange = 'day')} 298 - > 299 - 24h 300 - </button> 301 - <button 302 - class="toggle-btn" 303 - class:active={timeRange === 'week'} 304 - onclick={() => (timeRange = 'week')} 305 - > 306 - 7d 307 - </button> 308 - <button 309 - class="toggle-btn" 310 - class:active={timeRange === 'month'} 311 - onclick={() => (timeRange = 'month')} 312 - > 313 - 30d 314 - </button> 315 - </div> 316 - </div> 317 - <div class="chart-summary"> 318 - <span>{filteredTotals.requests.toLocaleString()} requests</span> 319 - <span class="separator">·</span> 320 - <span>{filteredTotals.scans.toLocaleString()} scans</span> 321 - </div> 322 - <div class="daily-chart"> 303 + <p class="audd-explainer"> 304 + 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 305 + then ${(5).toFixed(2)}/1k requests. 306 + {#if data.costs.audd.billable_requests > 0} 307 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 308 + {/if} 309 + </p> 310 + 311 + {#if filteredDaily.length > 0} 312 + <div class="daily-chart"> 313 + <h3>daily requests</h3> 314 + <div class="chart-bars"> 323 315 {#each filteredDaily as day} 324 - <div class="day-bar-container"> 316 + <div class="chart-bar-container"> 325 317 <div 326 - class="day-bar" 327 - style="height: {barWidth(day.requests, maxRequests)}%" 328 - title="{formatShortDate(day.date)}: {day.requests} requests, {day.scans} scans" 318 + class="chart-bar" 319 + style="height: {Math.max(4, (day.requests / maxRequests) * 100)}%" 320 + title="{day.date}: {day.requests} requests ({day.scans} tracks)" 329 321 ></div> 330 - <span class="day-label">{formatShortDate(day.date)}</span> 322 + <span class="chart-label">{day.date.slice(5)}</span> 331 323 </div> 332 324 {/each} 333 325 </div> 334 326 </div> 327 + {:else} 328 + <p class="no-data">no requests in this time range</p> 335 329 {/if} 336 330 </section> 337 331 338 332 <!-- support cta --> 339 333 <section class="support-section"> 340 - <p>{data.support.message}</p> 341 - <a href={data.support.url} target="_blank" rel="noopener noreferrer" class="support-link"> 342 - become a supporter → 343 - </a> 334 + <div class="support-card"> 335 + <div class="support-icon"> 336 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 337 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 338 + </svg> 339 + </div> 340 + <div class="support-text"> 341 + <h3>support {APP_NAME}</h3> 342 + <p>{data.support.message}</p> 343 + </div> 344 + <a href={data.support.url} target="_blank" rel="noopener" class="support-button"> 345 + support 346 + </a> 347 + </div> 344 348 </section> 349 + 350 + <!-- footer note --> 351 + <p class="footer-note"> 352 + {APP_NAME} is an open-source project. 353 + <a href="https://github.com/zzstoatzz/plyr.fm" target="_blank" rel="noopener">view source</a> 354 + </p> 345 355 {/if} 346 356 </main> 347 357 ··· 349 359 main { 350 360 max-width: 600px; 351 361 margin: 0 auto; 352 - padding: 2rem 1rem 6rem; 362 + padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)); 353 363 } 354 364 355 365 .page-header { ··· 357 367 } 358 368 359 369 .page-header h1 { 360 - font-size: 1.5rem; 361 - font-weight: 600; 362 - margin: 0 0 0.25rem; 370 + font-size: var(--text-page-heading); 371 + margin: 0 0 0.5rem; 363 372 } 364 373 365 374 .subtitle { 366 - color: var(--text-secondary); 367 - font-size: 0.875rem; 375 + color: var(--text-tertiary); 376 + font-size: 0.9rem; 368 377 margin: 0; 369 378 } 370 379 371 - .loading, 380 + .loading { 381 + display: flex; 382 + justify-content: center; 383 + padding: 4rem 0; 384 + } 385 + 372 386 .error-state { 373 387 text-align: center; 374 388 padding: 3rem 1rem; 389 + color: var(--text-secondary); 375 390 } 376 391 377 - .error-state p { 378 - margin: 0.5rem 0; 379 - } 380 - 381 - .hint { 382 - color: var(--text-secondary); 383 - font-size: 0.875rem; 392 + .error-state .hint { 393 + color: var(--text-tertiary); 394 + font-size: 0.85rem; 395 + margin-top: 0.5rem; 384 396 } 385 397 386 398 /* total section */ 387 399 .total-section { 388 - text-align: center; 389 400 margin-bottom: 2rem; 390 401 } 391 402 392 403 .total-card { 393 - background: var(--surface); 394 - border: 1px solid var(--border); 395 - border-radius: 12px; 396 - padding: 1.5rem; 397 404 display: flex; 398 405 flex-direction: column; 399 - gap: 0.5rem; 406 + align-items: center; 407 + padding: 2rem; 408 + background: var(--bg-tertiary); 409 + border: 1px solid var(--border-subtle); 410 + border-radius: 12px; 400 411 } 401 412 402 413 .total-label { 403 - font-size: 0.875rem; 404 - color: var(--text-secondary); 405 - text-transform: lowercase; 414 + font-size: 0.8rem; 415 + text-transform: uppercase; 416 + letter-spacing: 0.08em; 417 + color: var(--text-tertiary); 418 + margin-bottom: 0.5rem; 406 419 } 407 420 408 421 .total-amount { 409 - font-size: 2.5rem; 422 + font-size: 3rem; 410 423 font-weight: 700; 411 424 color: var(--accent); 412 425 } 413 426 414 427 .updated { 428 + text-align: center; 415 429 font-size: 0.75rem; 416 - color: var(--text-secondary); 417 - margin-top: 0.5rem; 430 + color: var(--text-tertiary); 431 + margin-top: 0.75rem; 418 432 } 419 433 420 434 /* breakdown section */ ··· 422 436 margin-bottom: 2rem; 423 437 } 424 438 425 - .breakdown-section h2 { 426 - font-size: 1rem; 427 - font-weight: 600; 428 - margin: 0 0 1rem; 429 - text-transform: lowercase; 439 + .breakdown-section h2, 440 + .audd-section h2 { 441 + font-size: 0.8rem; 442 + text-transform: uppercase; 443 + letter-spacing: 0.08em; 444 + color: var(--text-tertiary); 445 + margin-bottom: 1rem; 430 446 } 431 447 432 448 .cost-bars { ··· 436 452 } 437 453 438 454 .cost-item { 439 - display: flex; 440 - flex-direction: column; 441 - gap: 0.25rem; 455 + background: var(--bg-tertiary); 456 + border: 1px solid var(--border-subtle); 457 + border-radius: 8px; 458 + padding: 1rem; 442 459 } 443 460 444 461 .cost-header { 445 462 display: flex; 446 463 justify-content: space-between; 447 - align-items: baseline; 464 + align-items: center; 465 + margin-bottom: 0.5rem; 448 466 } 449 467 450 468 .cost-name { 451 - font-weight: 500; 452 - font-size: 0.875rem; 469 + font-weight: 600; 470 + color: var(--text-primary); 453 471 } 454 472 455 473 .cost-amount { 456 474 font-weight: 600; 457 - font-size: 0.875rem; 475 + color: var(--accent); 476 + font-variant-numeric: tabular-nums; 458 477 } 459 478 460 479 .cost-bar-bg { 461 480 height: 8px; 462 - background: var(--surface); 481 + background: var(--bg-primary); 463 482 border-radius: 4px; 464 483 overflow: hidden; 484 + margin-bottom: 0.5rem; 465 485 } 466 486 467 487 .cost-bar { ··· 472 492 } 473 493 474 494 .cost-bar.audd { 475 - background: linear-gradient(90deg, var(--accent), var(--accent-hover)); 495 + background: var(--warning); 476 496 } 477 497 478 498 .cost-note { 479 499 font-size: 0.75rem; 480 - color: var(--text-secondary); 500 + color: var(--text-tertiary); 481 501 } 482 502 483 503 /* audd section */ ··· 485 505 margin-bottom: 2rem; 486 506 } 487 507 488 - .audd-section h2 { 489 - font-size: 1rem; 490 - font-weight: 600; 491 - margin: 0 0 1rem; 492 - text-transform: lowercase; 508 + .audd-header { 509 + display: flex; 510 + justify-content: space-between; 511 + align-items: center; 512 + margin-bottom: 1rem; 513 + gap: 1rem; 493 514 } 494 515 495 - .audd-stats { 496 - display: grid; 497 - grid-template-columns: repeat(2, 1fr); 498 - gap: 1rem; 499 - margin-bottom: 1.5rem; 516 + .audd-header h2 { 517 + margin-bottom: 0; 500 518 } 501 519 502 - .stat { 503 - background: var(--surface); 504 - border: 1px solid var(--border); 505 - border-radius: 8px; 506 - padding: 1rem; 520 + .time-range-toggle { 507 521 display: flex; 508 - flex-direction: column; 509 522 gap: 0.25rem; 523 + background: var(--bg-tertiary); 524 + border: 1px solid var(--border-subtle); 525 + border-radius: 6px; 526 + padding: 0.25rem; 510 527 } 511 528 512 - .stat-value { 513 - font-size: 1.25rem; 514 - font-weight: 600; 515 - } 516 - 517 - .stat-label { 529 + .time-range-toggle button { 530 + padding: 0.35rem 0.75rem; 531 + font-family: inherit; 518 532 font-size: 0.75rem; 533 + font-weight: 500; 534 + background: transparent; 535 + border: none; 536 + border-radius: 4px; 519 537 color: var(--text-secondary); 520 - text-transform: lowercase; 538 + cursor: pointer; 539 + transition: all 0.15s; 521 540 } 522 541 523 - /* daily chart */ 524 - .daily-chart-container { 525 - background: var(--surface); 526 - border: 1px solid var(--border); 527 - border-radius: 8px; 528 - padding: 1rem; 542 + .time-range-toggle button:hover { 543 + color: var(--text-primary); 529 544 } 530 545 531 - .daily-chart-container h3 { 532 - font-size: 0.875rem; 533 - font-weight: 500; 534 - margin: 0 0 0.75rem; 535 - text-transform: lowercase; 546 + .time-range-toggle button.active { 547 + background: var(--accent); 548 + color: white; 536 549 } 537 550 538 - .chart-header { 539 - display: flex; 540 - justify-content: space-between; 541 - align-items: center; 542 - margin-bottom: 0.5rem; 551 + .no-data { 552 + text-align: center; 553 + color: var(--text-tertiary); 554 + font-size: 0.85rem; 555 + padding: 2rem; 556 + background: var(--bg-tertiary); 557 + border: 1px solid var(--border-subtle); 558 + border-radius: 8px; 543 559 } 544 560 545 - .chart-header h3 { 546 - margin: 0; 561 + .audd-stats { 562 + display: grid; 563 + grid-template-columns: repeat(3, 1fr); 564 + gap: 1rem; 565 + margin-bottom: 1rem; 547 566 } 548 567 549 - .time-toggle { 550 - display: flex; 551 - gap: 0.25rem; 552 - background: var(--background); 553 - border-radius: 6px; 554 - padding: 2px; 568 + .audd-explainer { 569 + font-size: 0.8rem; 570 + color: var(--text-secondary); 571 + margin-bottom: 1.5rem; 572 + line-height: 1.5; 555 573 } 556 574 557 - .toggle-btn { 558 - background: transparent; 559 - border: none; 560 - padding: 0.25rem 0.5rem; 561 - font-size: 0.75rem; 562 - color: var(--text-secondary); 563 - cursor: pointer; 564 - border-radius: 4px; 565 - transition: 566 - background 0.15s, 567 - color 0.15s; 575 + .audd-explainer strong { 576 + color: var(--warning); 568 577 } 569 578 570 - .toggle-btn:hover { 571 - color: var(--text-primary); 579 + .stat { 580 + display: flex; 581 + flex-direction: column; 582 + align-items: center; 583 + padding: 1rem; 584 + background: var(--bg-tertiary); 585 + border: 1px solid var(--border-subtle); 586 + border-radius: 8px; 572 587 } 573 588 574 - .toggle-btn.active { 575 - background: var(--surface); 589 + .stat-value { 590 + font-size: 1.25rem; 591 + font-weight: 700; 576 592 color: var(--text-primary); 577 - font-weight: 500; 593 + font-variant-numeric: tabular-nums; 578 594 } 579 595 580 - .chart-summary { 581 - font-size: 0.75rem; 582 - color: var(--text-secondary); 583 - margin-bottom: 0.75rem; 596 + .stat-label { 597 + font-size: 0.7rem; 598 + color: var(--text-tertiary); 599 + text-align: center; 600 + margin-top: 0.25rem; 584 601 } 585 602 586 - .chart-summary .separator { 587 - margin: 0 0.5rem; 603 + /* daily chart */ 604 + .daily-chart { 605 + background: var(--bg-tertiary); 606 + border: 1px solid var(--border-subtle); 607 + border-radius: 8px; 608 + padding: 1rem; 609 + overflow: hidden; 610 + } 611 + 612 + .daily-chart h3 { 613 + font-size: 0.75rem; 614 + text-transform: uppercase; 615 + letter-spacing: 0.05em; 616 + color: var(--text-tertiary); 617 + margin: 0 0 1rem; 588 618 } 589 619 590 - .daily-chart { 620 + .chart-bars { 591 621 display: flex; 592 - gap: 2px; 593 - height: 80px; 594 622 align-items: flex-end; 595 - overflow-x: auto; 623 + gap: 2px; 624 + height: 100px; 625 + width: 100%; 596 626 } 597 627 598 - .day-bar-container { 599 - flex: 1; 600 - min-width: 20px; 628 + .chart-bar-container { 629 + flex: 1 1 0; 630 + min-width: 0; 601 631 display: flex; 602 632 flex-direction: column; 603 633 align-items: center; 604 - gap: 4px; 605 634 height: 100%; 606 635 } 607 636 608 - .day-bar { 637 + .chart-bar { 609 638 width: 100%; 610 639 background: var(--accent); 611 640 border-radius: 2px 2px 0 0; 612 - min-height: 2px; 613 - cursor: help; 614 - transition: opacity 0.15s; 641 + min-height: 4px; 642 + margin-top: auto; 643 + transition: height 0.3s ease; 615 644 } 616 645 617 - .day-bar:hover { 646 + .chart-bar:hover { 618 647 opacity: 0.8; 619 648 } 620 649 621 - .day-label { 622 - font-size: 0.625rem; 623 - color: var(--text-secondary); 650 + .chart-label { 651 + font-size: 0.55rem; 652 + color: var(--text-tertiary); 653 + margin-top: 0.25rem; 624 654 white-space: nowrap; 655 + overflow: hidden; 656 + text-overflow: ellipsis; 657 + max-width: 100%; 625 658 } 626 659 627 660 /* support section */ 628 661 .support-section { 662 + margin-bottom: 2rem; 663 + } 664 + 665 + .support-card { 666 + display: flex; 667 + flex-direction: column; 668 + align-items: center; 629 669 text-align: center; 630 - padding: 1.5rem; 631 - background: var(--surface); 632 - border: 1px solid var(--border); 670 + padding: 2rem; 671 + background: linear-gradient(135deg, 672 + color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)), 673 + var(--bg-tertiary) 674 + ); 675 + border: 1px solid var(--border-subtle); 633 676 border-radius: 12px; 634 677 } 635 678 636 - .support-section p { 637 - margin: 0 0 1rem; 679 + .support-icon { 680 + color: var(--accent); 681 + margin-bottom: 1rem; 682 + } 683 + 684 + .support-text h3 { 685 + margin: 0 0 0.5rem; 686 + font-size: 1.1rem; 687 + color: var(--text-primary); 688 + } 689 + 690 + .support-text p { 691 + margin: 0 0 1.5rem; 638 692 color: var(--text-secondary); 639 - font-size: 0.875rem; 693 + font-size: 0.9rem; 640 694 } 641 695 642 - .support-link { 643 - display: inline-block; 696 + .support-button { 697 + display: inline-flex; 698 + align-items: center; 699 + gap: 0.5rem; 700 + padding: 0.75rem 1.5rem; 701 + background: var(--accent); 702 + color: white; 703 + border-radius: 8px; 704 + text-decoration: none; 705 + font-weight: 600; 706 + font-size: 0.9rem; 707 + transition: transform 0.15s, box-shadow 0.15s; 708 + } 709 + 710 + .support-button:hover { 711 + transform: translateY(-2px); 712 + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 713 + } 714 + 715 + /* footer */ 716 + .footer-note { 717 + text-align: center; 718 + font-size: 0.8rem; 719 + color: var(--text-tertiary); 720 + padding-bottom: 1rem; 721 + } 722 + 723 + .footer-note a { 644 724 color: var(--accent); 645 - font-weight: 500; 646 725 text-decoration: none; 647 726 } 648 727 649 - .support-link:hover { 728 + .footer-note a:hover { 650 729 text-decoration: underline; 651 730 } 652 731 653 732 @media (max-width: 480px) { 654 733 .total-amount { 655 - font-size: 2rem; 734 + font-size: 2.5rem; 656 735 } 657 736 658 737 .audd-stats { 659 - grid-template-columns: 1fr 1fr; 738 + grid-template-columns: 1fr; 660 739 } 661 740 662 - .day-label { 741 + .chart-label { 663 742 display: none; 664 743 } 665 744 }