// frontend\src\components\ui\FinishedReportModal.tsx 'use client' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { ArchiveBoxIcon, ArrowPathIcon, CalendarDaysIcon, ChartBarIcon, ClockIcon, FireIcon, StarIcon, } from '@heroicons/react/24/outline' import type { FinishedReportDailyItem, FinishedReportMode, FinishedReportStats, FinishedReportTopItem, RecordJob, } from '../../types' import Modal from './Modal' import Button from './Button' import ButtonGroup from './ButtonGroup' import LoadingSpinner from './LoadingSpinner' import { autoTagsForJob, ratingStarsForJob } from './autoTags' import { SegmentLabelIcon } from './Icons' const REPORT_PAGE_SIZE = 1000 const nf = new Intl.NumberFormat('de-DE') function fmtInt(value: number | undefined | null) { if (value == null || !Number.isFinite(value)) return '—' return nf.format(value) } function fmtBytes(value: number | undefined | null) { if (value == null || !Number.isFinite(value)) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = value let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const digits = i <= 1 ? 0 : 1 return `${v.toFixed(digits)} ${units[i]}` } function fmtDuration(totalSeconds: number | undefined | null) { if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds <= 0) { return '—' } const s = Math.floor(totalSeconds) const h = Math.floor(s / 3600) const m = Math.floor((s % 3600) / 60) const sec = s % 60 if (h > 0) { return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } return `${m}:${String(sec).padStart(2, '0')}` } function parseMeta(metaRaw: unknown): Record | null { if (!metaRaw) return null if (typeof metaRaw === 'string') { try { const parsed = JSON.parse(metaRaw) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null } catch { return null } } return typeof metaRaw === 'object' && !Array.isArray(metaRaw) ? metaRaw as Record : null } function readNumberLike(...values: unknown[]): number | null { for (const value of values) { const n = Number(value) if (Number.isFinite(n) && n > 0) return n } return null } function readJobDurationSeconds(job: RecordJob): number | null { const meta = parseMeta((job as any)?.meta) return readNumberLike( (job as any)?.durationSeconds, (job as any)?.durationSec, meta?.media?.durationSeconds, meta?.media?.durationSec, meta?.file?.durationSeconds, meta?.file?.durationSec, meta?.durationSeconds, meta?.durationSec ) } function readJobSizeBytes(job: RecordJob): number | null { const meta = parseMeta((job as any)?.meta) return readNumberLike( (job as any)?.sizeBytes, (job as any)?.fileSizeBytes, (job as any)?.bytes, (job as any)?.size, meta?.file?.sizeBytes, meta?.file?.bytes, meta?.sizeBytes, meta?.bytes, meta?.size ) } function baseName(path: string) { return String(path || '').split(/[\\/]/).pop() || '' } function stripHotPrefix(name: string) { return String(name || '').startsWith('HOT ') ? String(name || '').slice(4) : String(name || '') } function isHotOutput(output: string) { return baseName(output).startsWith('HOT ') } function isKeepOutput(output: string) { return String(output || '').replaceAll('\\', '/').includes('/keep/') } function modelNameFromOutput(output?: string) { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) if (m?.[1]) return m[1] const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } function readJobDate(job: RecordJob): Date | null { const raw = (job as any)?.endedAt ?? (job as any)?.completedAt ?? (job as any)?.startedAt ?? null if (!raw) return null const d = new Date(raw) return Number.isFinite(d.getTime()) ? d : null } function startOfDay(value: Date) { const d = new Date(value) d.setHours(0, 0, 0, 0) return d } function addDays(value: Date, days: number) { const d = new Date(value) d.setDate(d.getDate() + days) return d } function startOfWeekMonday(value: Date) { const d = startOfDay(value) const day = d.getDay() const diff = day === 0 ? -6 : 1 - day d.setDate(d.getDate() + diff) return d } function reportPeriod(mode: FinishedReportMode, now = new Date()) { if (mode === 'week') { const start = startOfWeekMonday(now) return { start, end: addDays(start, 7), } } const start = startOfDay(now) return { start, end: addDays(start, 1), } } function dateKey(value: Date) { return [ value.getFullYear(), String(value.getMonth() + 1).padStart(2, '0'), String(value.getDate()).padStart(2, '0'), ].join('-') } function shortDateLabel(value: Date) { return value.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', }) } function longDateLabel(value: Date) { return value.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric', }) } function rangeLabel(start: Date, end: Date) { const last = addDays(end, -1) if (dateKey(start) === dateKey(last)) { return longDateLabel(start) } return `${start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', })} – ${last.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', })}` } function incrementMap(map: Map, label: string, iconLabel?: string) { const clean = String(label || '').trim() if (!clean) return const key = clean.toLowerCase() const current = map.get(key) if (current) { current.count += 1 return } map.set(key, { label: clean, iconLabel, count: 1, }) } function topItems(map: Map, limit = 8) { return Array.from(map.values()) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de')) .slice(0, limit) } function buildReportStats(rows: RecordJob[], mode: FinishedReportMode): FinishedReportStats { const { start, end } = reportPeriod(mode) const modelCounts = new Map() const tagCounts = new Map() const dailyMap = new Map() for (let d = new Date(start); d < end; d = addDays(d, 1)) { const key = dateKey(d) dailyMap.set(key, { key, label: shortDateLabel(d), count: 0, durationSeconds: 0, sizeBytes: 0, }) } const ratingCounts: Record<1 | 2 | 3 | 4 | 5, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, } let totalCount = 0 let totalDurationSeconds = 0 let totalSizeBytes = 0 let knownSizeCount = 0 let hotCount = 0 let keepCount = 0 let ratedCount = 0 for (const job of rows) { const d = readJobDate(job) if (!d || d < start || d >= end) continue totalCount++ const output = String((job as any)?.output ?? '') if (isHotOutput(output)) hotCount++ if (isKeepOutput(output)) keepCount++ const duration = readJobDurationSeconds(job) if (duration != null) { totalDurationSeconds += duration } const sizeBytes = readJobSizeBytes(job) if (sizeBytes != null) { totalSizeBytes += sizeBytes knownSizeCount++ } const bucket = dailyMap.get(dateKey(d)) if (bucket) { bucket.count += 1 bucket.durationSeconds += duration ?? 0 bucket.sizeBytes += sizeBytes ?? 0 } const model = modelNameFromOutput(output) if (model && model !== '—') { incrementMap(modelCounts, model) } for (const tag of autoTagsForJob(job)) { incrementMap(tagCounts, tag, tag) } const stars = ratingStarsForJob(job) if (stars != null) { ratedCount++ ratingCounts[stars as 1 | 2 | 3 | 4 | 5] += 1 } } return { mode, start, end, totalCount, totalDurationSeconds, totalSizeBytes, knownSizeCount, hotCount, keepCount, ratedCount, ratingCounts, topModels: topItems(modelCounts, 8), topTags: topItems(tagCounts, 10), daily: Array.from(dailyMap.values()), } } function ReportMetric({ label, value, sub, icon, tone = 'slate', }: { label: string value: ReactNode sub?: ReactNode icon: ReactNode tone?: 'slate' | 'sky' | 'amber' | 'emerald' | 'rose' }) { const toneCls = tone === 'sky' ? 'border-sky-200 bg-sky-50/80 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200' : tone === 'amber' ? 'border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200' : tone === 'emerald' ? 'border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200' : tone === 'rose' ? 'border-rose-200 bg-rose-50/80 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200' : 'border-gray-200 bg-gray-50/90 text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' return (
{label}
{value}
{sub ? (
{sub}
) : null}
{icon}
) } function ReportBarList({ title, items, empty, icons = false, }: { title: string items: FinishedReportTopItem[] empty: string icons?: boolean }) { const max = Math.max(1, items[0]?.count ?? 1) const total = items.reduce((sum, item) => sum + item.count, 0) return (
{title}
{items.length > 0 ? `${fmtInt(total)} Treffer gesamt` : 'Keine Daten'}
Top {fmtInt(items.length)}
{items.length === 0 ? (
{empty}
) : (
{items.map((item, index) => { const width = `${Math.max(8, Math.round((item.count / max) * 100))}%` const share = total > 0 ? Math.round((item.count / total) * 100) : 0 return (
#{index + 1} {icons ? ( ) : null}
{item.label}
{share}% Anteil
{fmtInt(item.count)}
) })}
)}
) } function DailyBars({ items }: { items: FinishedReportDailyItem[] }) { const max = Math.max(1, ...items.map((item) => item.count)) const total = items.reduce((sum, item) => sum + item.count, 0) return (
Verlauf
Downloads pro Tag
{items.map((item) => { const width = `${Math.max(item.count > 0 ? 8 : 0, Math.round((item.count / max) * 100))}%` const share = total > 0 ? Math.round((item.count / total) * 100) : 0 return (
{item.label} {fmtInt(item.count)} · {fmtDuration(item.durationSeconds)} · {share}%
) })}
) } function RatingDistribution({ stats }: { stats: FinishedReportStats }) { const max = Math.max(1, ...([1, 2, 3, 4, 5] as const).map((n) => stats.ratingCounts[n])) return (
Bewertung
{fmtInt(stats.ratedCount)} bewertete Downloads
{([5, 4, 3, 2, 1] as const).map((stars) => { const count = stats.ratingCounts[stars] const width = `${Math.max(count > 0 ? 8 : 0, Math.round((count / max) * 100))}%` return (
{'★'.repeat(stars)} {'★'.repeat(5 - stars)} {fmtInt(count)}
) })}
) } export default function FinishedReportModal({ open, onClose, }: { open: boolean onClose: () => void }) { const [mode, setMode] = useState('day') const [rows, setRows] = useState([]) const [availableCount, setAvailableCount] = useState(0) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const loadReportRows = useCallback(async () => { setLoading(true) setError('') try { const url = `/api/record/done?page=1&pageSize=${REPORT_PAGE_SIZE}` + `&sort=completed_desc&includeKeep=1&withCount=1` const r = await fetch(url, { cache: 'no-store' }) const data = await r.json().catch(() => null) if (!r.ok) { throw new Error(String(data?.error || data?.message || `HTTP ${r.status}`)) } const items = Array.isArray(data?.items) ? data.items as RecordJob[] : [] const count = Number(data?.count ?? items.length) setRows(items) setAvailableCount(Number.isFinite(count) ? count : items.length) } catch (err: any) { setError(err?.message || 'Report konnte nicht geladen werden.') setRows([]) setAvailableCount(0) } finally { setLoading(false) } }, []) useEffect(() => { if (!open) return void loadReportRows() }, [open, loadReportRows]) const stats = useMemo(() => { return buildReportStats(rows, mode) }, [rows, mode]) const partialHint = availableCount > rows.length ? `Es werden die neuesten ${fmtInt(rows.length)} von ${fmtInt(availableCount)} Downloads berücksichtigt.` : `${fmtInt(rows.length)} Downloads geladen.` const avgDuration = stats.totalCount > 0 ? stats.totalDurationSeconds / stats.totalCount : 0 const avgSize = stats.knownSizeCount > 0 ? stats.totalSizeBytes / stats.knownSizeCount : 0 const reportModeSwitch = ( setMode(id as FinishedReportMode)} size="md" ariaLabel="Report-Zeitraum" items={[ { id: 'day', label: 'Heute', icon: , }, { id: 'week', label: 'Diese Woche', icon: , }, ]} /> ) const refreshButton = ( ) return ( {reportModeSwitch} {refreshButton} } >
{reportModeSwitch} {refreshButton}
{mode === 'day' ? 'Tagesreport' : 'Wochenreport'}

{mode === 'day' ? 'Heute' : 'Diese Woche'}

{rangeLabel(stats.start, stats.end)}
{loading ? ( Lädt… ) : ( {partialHint} )} Ø Dauer: {fmtDuration(avgDuration)} Ø Größe: {avgSize > 0 ? fmtBytes(avgSize) : '—'}
{error ? (
{error}
) : null}
} tone="sky" /> } /> 0 ? fmtBytes(stats.totalSizeBytes) : '—'} sub={stats.knownSizeCount > 0 ? `${fmtInt(stats.knownSizeCount)} Dateien` : 'keine Größen'} icon={} tone="emerald" /> } tone="amber" />
) }