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();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { page } from '$app/state';
import { ae_loc } from '$lib/stores/ae_stores';
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 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>
</div>
{: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">
<a
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" />
<span class="hidden sm:inline">Badges</span>
</a>
<div class="flex flex-col">
<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
</h2>
{#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}
</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' : ''})` : ''}
</div>
</header>
<!-- Report selector -->
<div class="mb-4 flex flex-row flex-wrap gap-2">
<!-- Report selector — styled like pres_mgmt reports for visual consistency -->
<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
type="button"
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-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" />
Long Names
</button>
<button
type="button"
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-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" />
Print Throughput
</button>
@@ -111,6 +119,6 @@ let reprint_count = $derived(
{:else if active_report === 'print_throughput'}
<Reports_badge_print_throughput badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} />
{: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}

View File

@@ -12,6 +12,8 @@ type NameField = 'given' | 'family' | 'full';
let name_field: NameField = $state('full');
let threshold: number = $state(20);
const MAX_DISPLAY = 500;
function get_display_name(badge: any): string {
switch (name_field) {
case 'given':
@@ -29,7 +31,7 @@ function get_display_name(badge: any): 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 {
@@ -41,79 +43,83 @@ interface BadgeRow {
has_override: boolean;
}
let results: BadgeRow[] = $derived.by(() => {
let all_results: BadgeRow[] = $derived.by(() => {
if (!badge_li?.length) return [];
return badge_li
.map((b) => {
const display_name = get_display_name(b);
const has_override =
name_field === 'full'
? !!b.full_name_override
: false;
return {
event_badge_id: b.event_badge_id,
display_name,
name_len: display_name.length,
badge_type: b.badge_type_override ?? b.badge_type ?? 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)
.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>
<div class="space-y-3">
<!-- Controls -->
<div class="flex flex-row flex-wrap items-center gap-3">
<!-- Field selector -->
<div class="flex flex-row items-center gap-1 text-sm">
<span class="text-gray-500">Field:</span>
<div class="flex flex-row items-center gap-1">
<span class="text-sm text-surface-600-400">Field:</span>
{#each (['full', 'given', 'family'] as NameField[]) as field}
<button
type="button"
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-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)}
</button>
{/each}
</div>
<!-- Threshold stepper -->
<div class="flex flex-row items-center gap-1 text-sm">
<span class="text-gray-500">Min length:</span>
<div class="flex flex-row items-center gap-1">
<span class="text-sm text-surface-600-400">Min length:</span>
<button
type="button"
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>
<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
type="button"
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>
</div>
<!-- Result count -->
<span class="text-sm text-gray-500">
{results.length} badge{results.length !== 1 ? 's' : ''} with {get_field_label(name_field).toLowerCase()}{threshold} chars
<span class="text-sm text-surface-600-400">
{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>
</div>
<!-- Results table -->
{#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.
</p>
{: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">
<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 text-right">Len</th>
<th class="px-3 py-2">Badge Type</th>
@@ -123,32 +129,36 @@ let results: BadgeRow[] = $derived.by(() => {
</thead>
<tbody>
{#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">
{row.display_name}
{#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}
</td>
<td class="px-3 py-2 text-right font-mono">
<span
class="rounded px-1 font-semibold"
class:text-red-700={row.name_len >= 30}
class:bg-red-100={row.name_len >= 30}
class:text-amber-700={row.name_len >= 25 && row.name_len < 30}
class:bg-amber-100={row.name_len >= 25 && row.name_len < 30}
class:text-gray-600={row.name_len < 25}>
class:text-error-600={row.name_len >= 30}
class:dark:text-error-400={row.name_len >= 30}
class:bg-error-100={row.name_len >= 30}
class:dark:bg-error-900={row.name_len >= 30}
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}
</span>
</td>
<td class="px-3 py-2 text-gray-600">{row.badge_type ?? '—'}</td>
<td class="max-w-48 truncate px-3 py-2 text-gray-600" title={row.affiliations ?? ''}>
<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-surface-600-400" title={row.affiliations ?? ''}>
{row.affiliations ?? '—'}
</td>
<td class="px-3 py-2">
<a
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}`}>
<UserRoundPen size="0.9em" />
Edit

View File

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