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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user