feat(badges): add Long Names and Print Throughput reports

Two IDB-backed reports under /badges/reports:
- Long Names: filter badges by given/family/full name length (threshold
  adjustable), colour-coded by severity, links to review page
- Print Throughput: bucket print_last_datetime into 5/15/30/60-min
  windows with a horizontal bar chart and expandable badge name list

Also adds a "Badge Reports" nav link on the badges main page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-08 19:32:53 -04:00
parent 88ab5b27d4
commit 8a41f02f0d
4 changed files with 484 additions and 1 deletions

View File

@@ -32,7 +32,7 @@ import Comp_badge_obj_li from './ae_comp__badge_obj_li.svelte';
import Comp_badge_create_form from './ae_comp__badge_create_form.svelte';
import Comp_badge_upload_form from './ae_comp__badge_upload_form.svelte';
import { UserPlus, Printer, Upload, FileText, ChartColumnBig, LayoutTemplate } from '@lucide/svelte';
import { UserPlus, Printer, Upload, FileText, ChartColumnBig, LayoutTemplate, TrendingUp } from '@lucide/svelte';
// Load templates for this event so the create form can show the selector and
// derive badge_type_code_li from whichever template the user picks.
@@ -545,6 +545,11 @@ async function handle_search_refresh(params: any) {
class="btn btn-tertiary">
<ChartColumnBig size="1em" /> Badge Printing Stats
</a>
<a
href={`/events/${$events_slct?.event_id}/badges/reports`}
class="btn btn-tertiary">
<TrendingUp size="1em" /> Badge Reports
</a>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
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 { ArrowLeft, TrendingUp, Type, Gauge, LoaderCircle } from '@lucide/svelte';
import Reports_badge_long_names from './reports_badge_long_names.svelte';
import Reports_badge_print_throughput from './reports_badge_print_throughput.svelte';
let event_id = $derived(page.params.event_id);
type ReportKey = 'long_names' | 'print_throughput';
let active_report: ReportKey | null = $state(null);
let lq__event_obj = $derived(
liveQuery(async () => {
if (!event_id) return null;
return db_events.event.get(event_id);
})
);
let lq__badge_li = $derived(
liveQuery(async () => {
if (!event_id) return [];
return db_events.badge.where('event_id').equals(event_id).toArray();
})
);
let is_trusted = $derived($ae_loc?.trusted_access === true);
let badge_count = $derived($lq__badge_li?.length ?? 0);
let printed_count = $derived($lq__badge_li?.filter((b) => (b.print_count ?? 0) >= 1).length ?? 0);
</script>
<svelte:head>
<title>
Badge Reports — {$lq__event_obj?.name ?? '…'}{$events_loc?.title ?? 'Æ'}
</title>
</svelte:head>
{#if !is_trusted}
<div class="card p-8 text-center">
<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">
<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">
<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" />
Badge Reports
</h2>
{#if $lq__event_obj?.name}
<p class="text-sm text-gray-500">{$lq__event_obj.name}</p>
{/if}
</div>
</div>
<div class="text-sm text-gray-500">
{badge_count} badges · {printed_count} printed
</div>
</header>
<!-- Report selector -->
<div class="mb-4 flex flex-row flex-wrap gap-2">
<button
type="button"
onclick={() => (active_report = 'long_names')}
class="btn btn-sm flex items-center gap-1 transition-all"
class:preset-filled-primary={active_report === 'long_names'}
class:preset-tonal-surface={active_report !== 'long_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:preset-filled-primary={active_report === 'print_throughput'}
class:preset-tonal-surface={active_report !== 'print_throughput'}>
<Gauge size="1em" />
Print Throughput
</button>
</div>
{#if $lq__badge_li === undefined}
<div class="flex items-center gap-2 py-8 opacity-50">
<LoaderCircle size="1.5em" class="animate-spin" />
<span>Loading badge data…</span>
</div>
{:else if active_report === 'long_names'}
<Reports_badge_long_names badge_li={$lq__badge_li} event_id={event_id ?? ''} {log_lvl} />
{: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>
{/if}
{/if}

View File

@@ -0,0 +1,160 @@
<script lang="ts">
interface Props {
badge_li: any[];
event_id: string;
log_lvl?: number;
}
let { badge_li, event_id, log_lvl = 0 }: Props = $props();
type NameField = 'given' | 'family' | 'full';
let name_field: NameField = $state('full');
let threshold: number = $state(20);
function get_display_name(badge: any): string {
switch (name_field) {
case 'given':
return badge.given_name ?? '';
case 'family':
return badge.family_name ?? '';
case 'full':
default:
return (
badge.full_name_override ??
badge.full_name ??
`${badge.given_name ?? ''} ${badge.family_name ?? ''}`.trim()
);
}
}
function get_field_label(field: NameField): string {
return field === 'given' ? 'Given Name' : field === 'family' ? 'Family Name' : 'Full Name';
}
interface BadgeRow {
event_badge_id: string;
display_name: string;
name_len: number;
badge_type: string | null;
affiliations: string | null;
has_override: boolean;
}
let 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
};
})
.filter((r) => r.name_len >= threshold)
.sort((a, b) => b.name_len - a.name_len);
});
</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>
{#each (['full', 'given', 'family'] as NameField[]) as field}
<button
type="button"
onclick={() => (name_field = field)}
class="btn btn-xs transition-all"
class:preset-filled-primary={name_field === field}
class:preset-tonal-surface={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>
<button
type="button"
onclick={() => (threshold = Math.max(5, threshold - 1))}
class="btn btn-xs preset-tonal-surface w-7"
aria-label="Decrease threshold"></button>
<span class="w-6 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"
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>
</div>
<!-- Results table -->
{#if results.length === 0}
<p class="py-4 text-sm text-gray-500">
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">
<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">
<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>
<th class="px-3 py-2">Affiliations</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{#each results as row (row.event_badge_id)}
<tr class="border-b border-gray-100 hover:bg-gray-50">
<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>
{/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}>
{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 ?? ''}>
{row.affiliations ?? '—'}
</td>
<td class="px-3 py-2">
<a
href={`/events/${event_id}/badges/${row.event_badge_id}/review`}
class="btn btn-xs preset-tonal-primary"
title="Review badge">
Edit
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,205 @@
<script lang="ts">
interface Props {
badge_li: any[];
event_id: string;
log_lvl?: number;
}
let { badge_li, event_id, log_lvl = 0 }: Props = $props();
type BucketMin = 5 | 15 | 30 | 60;
let bucket_size: BucketMin = $state(15);
let expanded_bucket: number | null = $state(null);
interface Bucket {
start_ms: number;
label: string;
date_label: string | null; // shown when day changes
count: number;
badges: any[];
}
interface PrintStats {
buckets: Bucket[];
total_printed: number;
max_count: number;
span_label: string;
}
function fmt_time(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function fmt_date(ms: number): string {
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
}
let stats: PrintStats = $derived.by(() => {
const printed = badge_li.filter((b) => (b.print_count ?? 0) >= 1 && b.print_last_datetime);
if (!printed.length) {
return { buckets: [], total_printed: 0, max_count: 1, span_label: '' };
}
const bucket_ms = bucket_size * 60 * 1000;
const times = printed
.map((b) => new Date(b.print_last_datetime).getTime())
.filter((t) => !isNaN(t));
if (!times.length) {
return { buckets: [], total_printed: printed.length, max_count: 1, span_label: '' };
}
const min_raw = Math.min(...times);
const max_raw = Math.max(...times);
const start = Math.floor(min_raw / bucket_ms) * bucket_ms;
const end = Math.ceil((max_raw + 1) / bucket_ms) * bucket_ms;
const buckets: Bucket[] = [];
let prev_day = '';
for (let t = start; t < end; t += bucket_ms) {
const in_bucket = printed.filter((b) => {
const bt = new Date(b.print_last_datetime).getTime();
return bt >= t && bt < t + bucket_ms;
});
if (in_bucket.length === 0) continue;
const day = fmt_date(t);
const date_label = day !== prev_day ? day : null;
prev_day = day;
buckets.push({
start_ms: t,
label: fmt_time(t),
date_label,
count: in_bucket.length,
badges: in_bucket
});
}
const max_count = Math.max(...buckets.map((b) => b.count), 1);
const span_label =
buckets.length > 0
? `${fmt_date(min_raw)}, ${fmt_time(min_raw)} ${fmt_time(max_raw)}`
: '';
return { buckets, total_printed: printed.length, max_count, span_label };
});
function get_effective_name(badge: any): string {
return (
badge.full_name_override ??
badge.full_name ??
(`${badge.given_name ?? ''} ${badge.family_name ?? ''}`.trim() || badge.event_badge_id)
);
}
</script>
<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>
{#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:preset-filled-primary={bucket_size === sz}
class:preset-tonal-surface={bucket_size !== sz}>
{sz} min
</button>
{/each}
</div>
{#if stats.total_printed > 0}
<span class="text-sm text-gray-500">
{stats.total_printed} printed · {stats.buckets.length} window{stats.buckets.length !== 1 ? 's' : ''} with activity
{#if stats.span_label}· {stats.span_label}{/if}
</span>
{/if}
</div>
<!-- Chart / table -->
{#if stats.total_printed === 0}
<p class="py-4 text-sm text-gray-500">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>
{: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">
{bucket.date_label}
</div>
{/if}
<!-- Bar row -->
<div class="group">
<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">
<!-- Time label -->
<span class="w-14 shrink-0 font-mono text-sm text-gray-600">
{bucket.label}
</span>
<!-- Bar -->
<div class="flex-1 overflow-hidden rounded-sm bg-gray-100 h-5">
<div
class="h-full rounded-sm bg-primary-400 transition-all duration-300"
style:width="{Math.round((bucket.count / stats.max_count) * 100)}%">
</div>
</div>
<!-- Count badge -->
<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}>
{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}
</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="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">
{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>
</div>
{/if}
</div>
{/each}
</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>
{/if}
</div>