style(badges): reports — consistent buttons, dark mode, 500-row cap

- Report selector now uses a tonal-primary container matching the pres_mgmt
  reports pattern, making Long Names / Print Throughput clearly clickable
- All hardcoded gray-* colors replaced with surface-*-* tokens and
  dark: variants for proper light/dark mode support
- Control buttons (field selector, threshold, window size) use btn-sm with
  visible borders matching the rest of the events module
- Long Names table uses surface tokens on rows, header, borders
- Print Throughput bar rows use surface tokens; expanded badge chips link
  to /print instead of /review
- Long Names caps display at 500 rows with a warning note when hit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-08 19:51:31 -04:00
parent 6b3fb36926
commit 24b52b8027
3 changed files with 101 additions and 83 deletions

View File

@@ -6,13 +6,12 @@ interface Props {
} }
let { data, log_lvl = 0 }: Props = $props(); let { data, log_lvl = 0 }: Props = $props();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { page } from '$app/state'; import { page } from '$app/state';
import { ae_loc } from '$lib/stores/ae_stores'; import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events'; import { db_events } from '$lib/ae_events/db_events';
import { events_loc, events_slct } from '$lib/stores/ae_events_stores'; import { events_loc } from '$lib/stores/ae_events_stores';
import { ArrowLeft, TrendingUp, Type, Gauge, LoaderCircle } from '@lucide/svelte'; import { ArrowLeft, TrendingUp, Type, Gauge, LoaderCircle } from '@lucide/svelte';
import Reports_badge_long_names from './reports_badge_long_names.svelte'; import Reports_badge_long_names from './reports_badge_long_names.svelte';
@@ -56,46 +55,55 @@ let reprint_count = $derived(
<p class="text-error-500 font-semibold">Trusted access required for badge reports.</p> <p class="text-error-500 font-semibold">Trusted access required for badge reports.</p>
</div> </div>
{:else} {:else}
<header class="mb-4 flex w-full flex-row flex-wrap items-center justify-between gap-2 border-b border-gray-300 pb-2"> <header class="mb-3 flex w-full flex-row flex-wrap items-center justify-between gap-2 border-b border-surface-300 dark:border-surface-700 pb-2">
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<a <a
href={`/events/${event_id}/badges`} href={`/events/${event_id}/badges`}
class="btn btn-sm preset-tonal-surface flex items-center gap-1"> class="btn btn-sm preset-tonal-surface border border-surface-300-700 flex items-center gap-1">
<ArrowLeft size="1em" /> <ArrowLeft size="1em" />
<span class="hidden sm:inline">Badges</span> <span class="hidden sm:inline">Badges</span>
</a> </a>
<div class="flex flex-col"> <div class="flex flex-col">
<h2 class="flex items-center gap-1 text-base font-bold"> <h2 class="flex items-center gap-1 text-base font-bold">
<TrendingUp size="1em" class="shrink-0" /> <TrendingUp size="1em" class="shrink-0 text-primary-500" />
Badge Reports Badge Reports
</h2> </h2>
{#if $lq__event_obj?.name} {#if $lq__event_obj?.name}
<p class="text-sm text-gray-500">{$lq__event_obj.name}</p> <p class="text-sm text-surface-600-400">{$lq__event_obj.name}</p>
{/if} {/if}
</div> </div>
</div> </div>
<div class="text-sm text-gray-500"> <div class="text-sm text-surface-600-400">
{badge_count} badges · {printed_count} printed{reprint_count > 0 ? ` (${reprint_count} reprint${reprint_count !== 1 ? 's' : ''})` : ''} {badge_count} badges · {printed_count} printed{reprint_count > 0 ? ` (${reprint_count} reprint${reprint_count !== 1 ? 's' : ''})` : ''}
</div> </div>
</header> </header>
<!-- Report selector --> <!-- Report selector — styled like pres_mgmt reports for visual consistency -->
<div class="mb-4 flex flex-row flex-wrap gap-2"> <div class="preset-tonal-primary border-primary-300 dark:border-primary-700 my-2 flex flex-row flex-wrap items-center gap-2 rounded-md border p-2">
<span class="text-xs font-semibold uppercase tracking-wide text-primary-700 dark:text-primary-300 pr-1">
Reports:
</span>
<button <button
type="button" type="button"
onclick={() => (active_report = 'long_names')} onclick={() => (active_report = 'long_names')}
class="btn btn-sm flex items-center gap-1 transition-all" class="btn btn-sm border transition-all"
class:preset-filled-primary={active_report === 'long_names'} class:preset-filled-primary={active_report === 'long_names'}
class:preset-tonal-surface={active_report !== 'long_names'}> class:border-primary-600={active_report === 'long_names'}
class:preset-tonal-primary={active_report !== 'long_names'}
class:border-primary-400={active_report !== 'long_names'}
title="List badges with the longest given, family, or full names">
<Type size="1em" /> <Type size="1em" />
Long Names Long Names
</button> </button>
<button <button
type="button" type="button"
onclick={() => (active_report = 'print_throughput')} onclick={() => (active_report = 'print_throughput')}
class="btn btn-sm flex items-center gap-1 transition-all" class="btn btn-sm border transition-all"
class:preset-filled-primary={active_report === 'print_throughput'} class:preset-filled-primary={active_report === 'print_throughput'}
class:preset-tonal-surface={active_report !== 'print_throughput'}> class:border-primary-600={active_report === 'print_throughput'}
class:preset-tonal-primary={active_report !== 'print_throughput'}
class:border-primary-400={active_report !== 'print_throughput'}
title="Show number of badges printed per time window">
<Gauge size="1em" /> <Gauge size="1em" />
Print Throughput Print Throughput
</button> </button>
@@ -111,6 +119,6 @@ let reprint_count = $derived(
{:else if active_report === 'print_throughput'} {:else if active_report === 'print_throughput'}
<Reports_badge_print_throughput badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} /> <Reports_badge_print_throughput badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} />
{:else} {:else}
<div class="text-gray-500 py-4 text-sm">Select a report above to get started.</div> <p class="py-4 text-sm text-surface-600-400">Select a report above to view results.</p>
{/if} {/if}
{/if} {/if}

View File

@@ -12,6 +12,8 @@ type NameField = 'given' | 'family' | 'full';
let name_field: NameField = $state('full'); let name_field: NameField = $state('full');
let threshold: number = $state(20); let threshold: number = $state(20);
const MAX_DISPLAY = 500;
function get_display_name(badge: any): string { function get_display_name(badge: any): string {
switch (name_field) { switch (name_field) {
case 'given': case 'given':
@@ -29,7 +31,7 @@ function get_display_name(badge: any): string {
} }
function get_field_label(field: NameField): string { function get_field_label(field: NameField): string {
return field === 'given' ? 'Given Name' : field === 'family' ? 'Family Name' : 'Full Name'; return field === 'given' ? 'Given' : field === 'family' ? 'Family' : 'Full Name';
} }
interface BadgeRow { interface BadgeRow {
@@ -41,79 +43,83 @@ interface BadgeRow {
has_override: boolean; has_override: boolean;
} }
let results: BadgeRow[] = $derived.by(() => { let all_results: BadgeRow[] = $derived.by(() => {
if (!badge_li?.length) return []; if (!badge_li?.length) return [];
return badge_li return badge_li
.map((b) => { .map((b) => {
const display_name = get_display_name(b); const display_name = get_display_name(b);
const has_override =
name_field === 'full'
? !!b.full_name_override
: false;
return { return {
event_badge_id: b.event_badge_id, event_badge_id: b.event_badge_id,
display_name, display_name,
name_len: display_name.length, name_len: display_name.length,
badge_type: b.badge_type_override ?? b.badge_type ?? null, badge_type: b.badge_type_override ?? b.badge_type ?? null,
affiliations: b.affiliations_override ?? b.affiliations ?? null, affiliations: b.affiliations_override ?? b.affiliations ?? null,
has_override has_override: name_field === 'full' ? !!b.full_name_override : false
}; };
}) })
.filter((r) => r.name_len >= threshold) .filter((r) => r.name_len >= threshold)
.sort((a, b) => b.name_len - a.name_len); .sort((a, b) => b.name_len - a.name_len);
}); });
let results = $derived(all_results.slice(0, MAX_DISPLAY));
let is_truncated = $derived(all_results.length > MAX_DISPLAY);
</script> </script>
<div class="space-y-3"> <div class="space-y-3">
<!-- Controls --> <!-- Controls -->
<div class="flex flex-row flex-wrap items-center gap-3"> <div class="flex flex-row flex-wrap items-center gap-3">
<!-- Field selector --> <!-- Field selector -->
<div class="flex flex-row items-center gap-1 text-sm"> <div class="flex flex-row items-center gap-1">
<span class="text-gray-500">Field:</span> <span class="text-sm text-surface-600-400">Field:</span>
{#each (['full', 'given', 'family'] as NameField[]) as field} {#each (['full', 'given', 'family'] as NameField[]) as field}
<button <button
type="button" type="button"
onclick={() => (name_field = field)} onclick={() => (name_field = field)}
class="btn btn-xs transition-all" class="btn btn-sm border transition-all"
class:preset-filled-primary={name_field === field} class:preset-filled-primary={name_field === field}
class:preset-tonal-surface={name_field !== field}> class:border-primary-600={name_field === field}
class:preset-tonal-surface={name_field !== field}
class:border-surface-300-700={name_field !== field}>
{get_field_label(field)} {get_field_label(field)}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Threshold stepper --> <!-- Threshold stepper -->
<div class="flex flex-row items-center gap-1 text-sm"> <div class="flex flex-row items-center gap-1">
<span class="text-gray-500">Min length:</span> <span class="text-sm text-surface-600-400">Min length:</span>
<button <button
type="button" type="button"
onclick={() => (threshold = Math.max(5, threshold - 1))} onclick={() => (threshold = Math.max(5, threshold - 1))}
class="btn btn-xs preset-tonal-surface w-7" class="btn btn-sm preset-tonal-surface border border-surface-300-700 w-8"
aria-label="Decrease threshold"></button> aria-label="Decrease threshold"></button>
<span class="w-6 text-center font-mono font-semibold">{threshold}</span> <span class="w-7 text-center font-mono font-semibold">{threshold}</span>
<button <button
type="button" type="button"
onclick={() => (threshold = Math.min(60, threshold + 1))} onclick={() => (threshold = Math.min(60, threshold + 1))}
class="btn btn-xs preset-tonal-surface w-7" class="btn btn-sm preset-tonal-surface border border-surface-300-700 w-8"
aria-label="Increase threshold">+</button> aria-label="Increase threshold">+</button>
</div> </div>
<!-- Result count --> <!-- Result count -->
<span class="text-sm text-gray-500"> <span class="text-sm text-surface-600-400">
{results.length} badge{results.length !== 1 ? 's' : ''} with {get_field_label(name_field).toLowerCase()}{threshold} chars {all_results.length} badge{all_results.length !== 1 ? 's' : ''} with {get_field_label(name_field).toLowerCase()}{threshold} chars
{#if is_truncated}
<span class="text-warning-600 dark:text-warning-400">(showing first {MAX_DISPLAY})</span>
{/if}
</span> </span>
</div> </div>
<!-- Results table --> <!-- Results table -->
{#if results.length === 0} {#if results.length === 0}
<p class="py-4 text-sm text-gray-500"> <p class="py-4 text-sm text-surface-600-400">
No badges found with a {get_field_label(name_field).toLowerCase()} of {threshold} or more characters. No badges found with a {get_field_label(name_field).toLowerCase()} of {threshold} or more characters.
</p> </p>
{:else} {:else}
<div class="overflow-x-auto rounded-md border border-gray-200"> <div class="overflow-x-auto rounded-md border border-surface-200 dark:border-surface-700">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-gray-200 bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500"> <tr class="border-b border-surface-200 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 text-left text-xs uppercase tracking-wide text-surface-600-400">
<th class="px-3 py-2">Name ({get_field_label(name_field)})</th> <th class="px-3 py-2">Name ({get_field_label(name_field)})</th>
<th class="px-3 py-2 text-right">Len</th> <th class="px-3 py-2 text-right">Len</th>
<th class="px-3 py-2">Badge Type</th> <th class="px-3 py-2">Badge Type</th>
@@ -123,32 +129,36 @@ let results: BadgeRow[] = $derived.by(() => {
</thead> </thead>
<tbody> <tbody>
{#each results as row (row.event_badge_id)} {#each results as row (row.event_badge_id)}
<tr class="border-b border-gray-100 hover:bg-gray-50"> <tr class="border-b border-surface-100 dark:border-surface-800 hover:bg-surface-50 dark:hover:bg-surface-900/50 transition-colors">
<td class="px-3 py-2 font-medium"> <td class="px-3 py-2 font-medium">
{row.display_name} {row.display_name}
{#if row.has_override} {#if row.has_override}
<span class="ml-1 rounded bg-amber-100 px-1 text-xs text-amber-700" title="Override value">override</span> <span class="ml-1 rounded bg-warning-100 dark:bg-warning-900/30 px-1 text-xs text-warning-700 dark:text-warning-300" title="Staff override value">override</span>
{/if} {/if}
</td> </td>
<td class="px-3 py-2 text-right font-mono"> <td class="px-3 py-2 text-right font-mono">
<span <span
class="rounded px-1 font-semibold" class="rounded px-1 font-semibold"
class:text-red-700={row.name_len >= 30} class:text-error-600={row.name_len >= 30}
class:bg-red-100={row.name_len >= 30} class:dark:text-error-400={row.name_len >= 30}
class:text-amber-700={row.name_len >= 25 && row.name_len < 30} class:bg-error-100={row.name_len >= 30}
class:bg-amber-100={row.name_len >= 25 && row.name_len < 30} class:dark:bg-error-900={row.name_len >= 30}
class:text-gray-600={row.name_len < 25}> class:text-warning-700={row.name_len >= 25 && row.name_len < 30}
class:dark:text-warning-400={row.name_len >= 25 && row.name_len < 30}
class:bg-warning-100={row.name_len >= 25 && row.name_len < 30}
class:dark:bg-warning-900={row.name_len >= 25 && row.name_len < 30}
class:text-surface-600-400={row.name_len < 25}>
{row.name_len} {row.name_len}
</span> </span>
</td> </td>
<td class="px-3 py-2 text-gray-600">{row.badge_type ?? '—'}</td> <td class="px-3 py-2 text-surface-600-400">{row.badge_type ?? '—'}</td>
<td class="max-w-48 truncate px-3 py-2 text-gray-600" title={row.affiliations ?? ''}> <td class="max-w-48 truncate px-3 py-2 text-surface-600-400" title={row.affiliations ?? ''}>
{row.affiliations ?? '—'} {row.affiliations ?? '—'}
</td> </td>
<td class="px-3 py-2"> <td class="px-3 py-2">
<a <a
href={`/events/${event_id}/badges/${row.event_badge_id}/print`} href={`/events/${event_id}/badges/${row.event_badge_id}/print`}
class="btn btn-xs preset-tonal-primary flex items-center gap-1" class="btn btn-sm preset-tonal-primary border border-primary-300-700 flex items-center gap-1"
title={`Edit badge · ${row.event_badge_id} · ${row.display_name}`}> title={`Edit badge · ${row.event_badge_id} · ${row.display_name}`}>
<UserRoundPen size="0.9em" /> <UserRoundPen size="0.9em" />
Edit Edit

View File

@@ -13,7 +13,7 @@ let expanded_bucket: number | null = $state(null);
interface Bucket { interface Bucket {
start_ms: number; start_ms: number;
label: string; label: string;
date_label: string | null; // shown when day changes date_label: string | null;
count: number; count: number;
badges: any[]; badges: any[];
} }
@@ -96,22 +96,24 @@ function get_effective_name(badge: any): string {
<div class="space-y-3"> <div class="space-y-3">
<!-- Controls --> <!-- Controls -->
<div class="flex flex-row flex-wrap items-center gap-3"> <div class="flex flex-row flex-wrap items-center gap-3">
<div class="flex flex-row items-center gap-1 text-sm"> <div class="flex flex-row items-center gap-1">
<span class="text-gray-500">Window:</span> <span class="text-sm text-surface-600-400">Window:</span>
{#each ([5, 15, 30, 60] as BucketMin[]) as sz} {#each ([5, 15, 30, 60] as BucketMin[]) as sz}
<button <button
type="button" type="button"
onclick={() => { bucket_size = sz; expanded_bucket = null; }} onclick={() => { bucket_size = sz; expanded_bucket = null; }}
class="btn btn-xs transition-all" class="btn btn-sm border transition-all"
class:preset-filled-primary={bucket_size === sz} class:preset-filled-primary={bucket_size === sz}
class:preset-tonal-surface={bucket_size !== sz}> class:border-primary-600={bucket_size === sz}
class:preset-tonal-surface={bucket_size !== sz}
class:border-surface-300-700={bucket_size !== sz}>
{sz} min {sz} min
</button> </button>
{/each} {/each}
</div> </div>
{#if stats.total_printed > 0} {#if stats.total_printed > 0}
<span class="text-sm text-gray-500"> <span class="text-sm text-surface-600-400">
{stats.total_printed} printed · {stats.buckets.length} window{stats.buckets.length !== 1 ? 's' : ''} with activity {stats.total_printed} printed · {stats.buckets.length} window{stats.buckets.length !== 1 ? 's' : ''} with activity
{#if stats.span_label}· {stats.span_label}{/if} {#if stats.span_label}· {stats.span_label}{/if}
</span> </span>
@@ -120,68 +122,72 @@ function get_effective_name(badge: any): string {
<!-- Chart / table --> <!-- Chart / table -->
{#if stats.total_printed === 0} {#if stats.total_printed === 0}
<p class="py-4 text-sm text-gray-500">No printed badges found for this event.</p> <p class="py-4 text-sm text-surface-600-400">No printed badges found for this event.</p>
{:else if stats.buckets.length === 0} {:else if stats.buckets.length === 0}
<p class="py-4 text-sm text-gray-500">No valid print timestamps found.</p> <p class="py-4 text-sm text-surface-600-400">No valid print timestamps found.</p>
{:else} {:else}
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each stats.buckets as bucket (bucket.start_ms)} {#each stats.buckets as bucket (bucket.start_ms)}
<!-- Date separator when the day changes --> <!-- Date separator when the day changes -->
{#if bucket.date_label} {#if bucket.date_label}
<div class="pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"> <div class="pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-surface-500-400">
{bucket.date_label} {bucket.date_label}
</div> </div>
{/if} {/if}
<!-- Bar row --> <!-- Bar row -->
<div class="group"> <div class="group rounded border border-transparent hover:border-surface-200 dark:hover:border-surface-700 transition-colors">
<button <button
type="button" type="button"
onclick={() => (expanded_bucket = expanded_bucket === bucket.start_ms ? null : bucket.start_ms)} onclick={() => (expanded_bucket = expanded_bucket === bucket.start_ms ? null : bucket.start_ms)}
class="flex w-full flex-row items-center gap-2 rounded px-2 py-1 text-left hover:bg-gray-50 transition-colors"> class="flex w-full flex-row items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-surface-50 dark:hover:bg-surface-900/50 transition-colors">
<!-- Time label --> <!-- Time label -->
<span class="w-14 shrink-0 font-mono text-sm text-gray-600"> <span class="w-14 shrink-0 font-mono text-sm">
{bucket.label} {bucket.label}
</span> </span>
<!-- Bar --> <!-- Bar -->
<div class="flex-1 overflow-hidden rounded-sm bg-gray-100 h-5"> <div class="flex-1 overflow-hidden rounded-sm bg-surface-200 dark:bg-surface-700 h-5">
<div <div
class="h-full rounded-sm bg-primary-400 transition-all duration-300" class="h-full rounded-sm transition-all duration-300"
class:bg-primary-500={bucket.count < stats.max_count}
class:bg-primary-400={bucket.count === stats.max_count && stats.max_count > 1}
class:dark:bg-primary-400={bucket.count < stats.max_count}
class:dark:bg-primary-300={bucket.count === stats.max_count && stats.max_count > 1}
style:width="{Math.round((bucket.count / stats.max_count) * 100)}%"> style:width="{Math.round((bucket.count / stats.max_count) * 100)}%">
</div> </div>
</div> </div>
<!-- Count badge --> <!-- Count -->
<span <span
class="w-8 shrink-0 text-right text-sm font-semibold" class="w-8 shrink-0 text-right text-sm font-semibold"
class:text-primary-600={bucket.count === stats.max_count} class:text-primary-600={bucket.count === stats.max_count && stats.max_count > 1}
class:text-gray-700={bucket.count !== stats.max_count}> class:dark:text-primary-400={bucket.count === stats.max_count && stats.max_count > 1}>
{bucket.count} {bucket.count}
</span> </span>
<!-- Peak indicator --> <!-- Peak chip -->
{#if bucket.count === stats.max_count && stats.max_count > 1} <span class="w-10 shrink-0 text-right">
<span class="shrink-0 rounded bg-primary-100 px-1 text-xs text-primary-700">peak</span> {#if bucket.count === stats.max_count && stats.max_count > 1}
{:else} <span class="rounded bg-primary-100 dark:bg-primary-900/40 px-1 text-xs text-primary-700 dark:text-primary-300">peak</span>
<span class="w-10 shrink-0"></span> {/if}
{/if} </span>
</button> </button>
<!-- Expanded badge list --> <!-- Expanded badge list -->
{#if expanded_bucket === bucket.start_ms} {#if expanded_bucket === bucket.start_ms}
<div class="mx-2 mb-1 rounded border border-gray-100 bg-gray-50 px-3 py-2"> <div class="mx-2 mb-2 rounded border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-900/30 px-3 py-2">
<div class="flex flex-row flex-wrap gap-1"> <div class="flex flex-row flex-wrap gap-1">
{#each bucket.badges as b (b.event_badge_id)} {#each bucket.badges as b (b.event_badge_id)}
<a <a
href={`/events/${event_id}/badges/${b.event_badge_id}/review`} href={`/events/${event_id}/badges/${b.event_badge_id}/print`}
class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs hover:bg-primary-50 hover:border-primary-200 transition-colors"> class="rounded border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-2 py-0.5 text-xs hover:border-primary-300 dark:hover:border-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
{get_effective_name(b)} {get_effective_name(b)}
</a> </a>
{/each} {/each}
</div> </div>
<p class="mt-1 text-right text-xs text-gray-400"> <p class="mt-1.5 text-right text-xs text-surface-500-400">
{bucket.count} badge{bucket.count !== 1 ? 's' : ''} printed in this {bucket_size}-min window · click badge to review {bucket.count} badge{bucket.count !== 1 ? 's' : ''} printed in this {bucket_size}-min window · click to open
</p> </p>
</div> </div>
{/if} {/if}
@@ -190,16 +196,10 @@ function get_effective_name(badge: any): string {
</div> </div>
<!-- Summary row --> <!-- Summary row -->
<div class="mt-3 flex flex-row flex-wrap gap-4 border-t border-gray-200 pt-3 text-sm"> <div class="mt-3 flex flex-row flex-wrap gap-4 border-t border-surface-200 dark:border-surface-700 pt-3 text-sm text-surface-600-400">
<span class="text-gray-500"> <span>Total printed: <span class="font-semibold text-inherit">{stats.total_printed}</span></span>
Total printed: <span class="font-semibold text-gray-800">{stats.total_printed}</span> <span>Peak window: <span class="font-semibold text-inherit">{stats.max_count}</span> in {bucket_size} min</span>
</span> <span>Avg per window: <span class="font-semibold text-inherit">{stats.buckets.length ? (stats.total_printed / stats.buckets.length).toFixed(1) : '—'}</span></span>
<span class="text-gray-500">
Peak window: <span class="font-semibold text-gray-800">{stats.max_count}</span> in {bucket_size} min
</span>
<span class="text-gray-500">
Avg per window: <span class="font-semibold text-gray-800">{stats.buckets.length ? (stats.total_printed / stats.buckets.length).toFixed(1) : '—'}</span>
</span>
</div> </div>
{/if} {/if}
</div> </div>