docker-fobi/docker-compose/logintrainer/src/app/login/success/success-stats.tsx
Jesko Anschütz ea52d39e6c logintrainer
2025-11-04 21:35:45 +01:00

436 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState, type ReactElement, type CSSProperties } from "react";
type DurationPoint = { dateISO: string; ms: number };
type SuccessStatsProps = {
displayName: string;
days: number;
percent: number;
totalDays: number;
currentDurationMs: number | null;
averageDurationMs: number | null;
bestDurationMs: number | null;
durationSeries: DurationPoint[];
measurementCount: number;
showShortPasswordAlert: boolean;
shortPasswordMessage: string;
metricSuffix: string;
currentSubtitle: string;
chartTitle: string;
isPasswordShort: boolean;
passwordPolicyViolation: boolean;
passwordLength: number;
passwordHasUpper: boolean;
passwordHasLower: boolean;
passwordHasDigit: boolean;
passwordHasSpecial: boolean;
policyMinLength: number;
};
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
timeZone: "UTC",
});
const disqualStampWrapperStyle: CSSProperties = {
position: "absolute",
top: "24px",
left: "50%",
transform: "translateX(-50%) rotate(-12deg)",
pointerEvents: "none",
zIndex: 50,
};
const disqualStampStyle: CSSProperties = {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: "360px",
padding: "18px 54px",
border: "5px solid rgba(239,68,68,0.95)",
background: "rgba(220,38,38,0.18)",
color: "#fee2e2",
fontSize: "1.25rem",
fontWeight: 900,
letterSpacing: "0.58em",
textTransform: "uppercase",
textShadow: "0 16px 38px rgba(220,38,38,0.6)",
boxShadow: "0 20px 46px rgba(220,38,38,0.4)",
whiteSpace: "nowrap",
};
const disqualStampInnerStyle: CSSProperties = {
position: "absolute",
inset: "6px",
border: "2px dashed rgba(248,113,113,0.7)",
};
export default function SuccessStats({
displayName,
days,
percent,
totalDays,
currentDurationMs,
averageDurationMs,
bestDurationMs,
durationSeries,
measurementCount,
showShortPasswordAlert,
shortPasswordMessage,
metricSuffix,
currentSubtitle,
chartTitle,
isPasswordShort,
passwordPolicyViolation,
passwordLength,
passwordHasUpper,
passwordHasLower,
passwordHasDigit,
passwordHasSpecial,
policyMinLength,
}: SuccessStatsProps): ReactElement {
const [dAnim, setDAnim] = useState(0);
const [pAnim, setPAnim] = useState(0);
const cardRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
animateTo(days, setDAnim, 800);
animateTo(percent, setPAnim, 900);
cardRef.current?.animate(
[
{ boxShadow: "0 10px 32px rgba(0,0,0,.45)" },
{ boxShadow: "0 16px 40px rgba(89,240,168,.18)" },
{ boxShadow: "0 10px 32px rgba(0,0,0,.45)" },
],
{ duration: 800, easing: "ease-in-out" }
);
}, [days, percent]);
const hasMeasurements = measurementCount > 0;
const hasAverage = averageDurationMs !== null;
const currentTone = useMemo(() => {
if (!hasAverage || currentDurationMs === null) return "neutral" as const;
if (currentDurationMs > averageDurationMs!) return "slower" as const;
if (currentDurationMs < averageDurationMs!) return "faster" as const;
return "neutral" as const;
}, [hasAverage, currentDurationMs, averageDurationMs]);
const chartStats = useMemo(() => {
if (!hasMeasurements) return null;
const values = durationSeries.map((point) => point.ms);
const max = Math.max(...values);
const min = Math.min(...values);
return { max, min };
}, [durationSeries, hasMeasurements]);
const passwordChecks = useMemo(() => {
const lengthOk = passwordLength >= policyMinLength;
const upperOk = passwordHasUpper;
const lowerOk = passwordHasLower;
const digitOk = passwordHasDigit;
const specialOk = passwordHasSpecial;
return {
lengthOk,
upperOk,
lowerOk,
digitOk,
specialOk,
allOk: lengthOk && upperOk && lowerOk && digitOk && specialOk,
};
}, [passwordLength, passwordHasUpper, passwordHasLower, passwordHasDigit, passwordHasSpecial, policyMinLength]);
const stampNode = passwordPolicyViolation ? (
<div aria-hidden="true" style={disqualStampWrapperStyle}>
<div style={disqualStampStyle}>
<span style={disqualStampInnerStyle} />
<span style={{ position: "relative", display: "inline-block" }}>Disqualifiziert</span>
</div>
</div>
) : null;
const headerNode = (
<div className="dw-stack">
<h1
className={`text-3xl md:text-4xl font-extrabold tracking-wide ${passwordPolicyViolation ? "text-red-200" : ""}`}
style={{ color: passwordPolicyViolation ? undefined : "var(--dw-accent)" }}
>
{passwordPolicyViolation
? "Login erfolgreich, aber das Passwort ist nicht gut!"
: "Login erfolgreich!"}
</h1>
<p className={`dw-hint ${passwordPolicyViolation ? "text-red-200/80" : ""}`}>
{isPasswordShort ? "Dein Passwort ist zu kurz" : "Gut gemacht"},{" "}
<span className="font-mono glow-red">{displayName}</span>.
</p>
</div>
);
const alertNode = showShortPasswordAlert ? (
<div className="dw-alert dw-alert--warn" role="alert">
{shortPasswordMessage}
</div>
) : null;
const passwordTile = (
<div
className="dw-tile space-y-2"
style={{
textAlign: "left",
borderColor: passwordChecks.allOk ? undefined : "rgba(248,113,113,0.4)",
background: passwordChecks.allOk ? "linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01))" : "rgba(248,113,113,0.08)",
}}
>
<div className="dw-tile-title">Passwort-Check</div>
<div className="mt-2 flex flex-col gap-2">
<SecurityRow
ok={passwordChecks.lengthOk}
label={`Passwort-Länge ≥ ${policyMinLength} Zeichen`}
/>
<SecurityRow ok={passwordChecks.upperOk} label="Großbuchstaben" />
<SecurityRow ok={passwordChecks.lowerOk} label="Kleinbuchstaben" />
<SecurityRow ok={passwordChecks.digitOk} label="Ziffern" />
<SecurityRow ok={passwordChecks.specialOk} label="Sonderzeichen" />
</div>
<p className="mt-4 text-xs uppercase tracking-widest text-emerald-100/60">
{passwordChecks.allOk
? "Starke Passwort-Anforderungen erfüllt."
: "Bitte verbessere dein Passwort, um alle Kriterien zu erfüllen."}
</p>
</div>
);
if (passwordPolicyViolation) {
return (
<div ref={cardRef} className="relative dw-stack dw-stack-lg" style={{ paddingTop: "120px" }}>
{stampNode}
{headerNode}
{alertNode}
<div className="dw-stats-layout">
{passwordTile}
</div>
<div className="flex items-center gap-2 pt-1">
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
</div>
</div>
);
}
return (
<div ref={cardRef} className="relative dw-stack dw-stack-lg">
{stampNode}
{headerNode}
{alertNode}
<div className="dw-stats-layout">
<div className="dw-tiles dw-stats-tiles">
<div className="dw-tile">
<div className="dw-tile-title">Letzte {totalDays} Tage</div>
<div className="dw-tile-number" aria-live="polite">{dAnim}</div>
<div className="dw-tile-sub">{dAnim === 1 ? "Tag mit Erfolg" : "Tage mit Erfolg"}</div>
</div>
<div className="dw-tile">
<div className="dw-tile-title">Rating</div>
<div className="dw-tile-number" aria-live="polite">{pAnim}%</div>
<div className="dw-tile-sub">Durchhaltequote</div>
</div>
<div className="dw-tile">
<div className="dw-tile-title">Aktueller Login</div>
<div
className={`dw-tile-number ${currentTone === "faster" ? "dw-tile-number--better" : ""}${currentTone === "slower" ? " dw-tile-number--slower" : ""}`}
aria-live="polite"
>
{renderDuration(currentDurationMs, metricSuffix)}
</div>
<div className="dw-tile-sub">{currentSubtitle}</div>
{bestDurationMs !== null && (
<div className="dw-tile-peer">Deine Bestzeit: {renderDuration(bestDurationMs, metricSuffix)}</div>
)}
</div>
{passwordTile}
</div>
<section className="dw-stack dw-stats-chart">
<header className="flex flex-wrap items-end gap-2">
<h2 className="text-base font-semibold uppercase tracking-wide text-emerald-200">
{chartTitle}
</h2>
<span className="text-xs text-emerald-100/70">
Werte in Sekunden{metricSuffix ? ` ${metricSuffix}` : ""},{" "}
{measurementCount === 1 ? "1 Messung" : `${measurementCount} Messungen`}
</span>
</header>
{hasMeasurements && chartStats ? (
<DurationChart
series={durationSeries}
minMs={chartStats.min}
maxMs={chartStats.max}
valueSuffix={metricSuffix}
/>
) : (
<p className="text-sm text-emerald-100/70">
Noch keine Messwerte vorhanden. Melde dich erneut an, um Daten zu sammeln.
</p>
)}
</section>
</div>
<div className="flex items-center gap-2 pt-1">
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
</div>
</div>
);
}
function animateTo(target: number, set: (v: number) => void, duration = 800): void {
const start = performance.now();
const from = 0;
const to = Math.max(0, Math.round(target));
const ease = (t: number): number => 1 - Math.pow(1 - t, 3);
function frame(now: number): void {
const p = Math.min(1, (now - start) / duration);
const val = Math.round(from + (to - from) * ease(p));
set(val);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function formatDuration(ms: number | null): string {
if (ms === null || ms === undefined) return "";
const seconds = ms / 1000;
if (seconds < 60) {
return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`;
}
const minutes = Math.floor(seconds / 60);
const rest = seconds - minutes * 60;
const restStr = rest >= 10 ? rest.toFixed(0) : rest.toFixed(1);
return `${minutes} min ${restStr} s`;
}
function renderDuration(ms: number | null, suffix = ""): ReactElement {
if (ms === null || ms === undefined) return <></>;
return (
<>
<span>{formatDuration(ms)}</span>
{suffix && <span className="ml-1 align-baseline text-xs uppercase tracking-widest">{suffix}</span>}
</>
);
}
function SecurityRow({ ok, label }: { ok: boolean; label: string }): ReactElement {
return (
<div className="flex items-center gap-2 text-sm">
<span className={ok ? "text-emerald-300" : "text-red-300"}>{ok ? "✅" : "🚨"}</span>
<span className={ok ? "text-emerald-100" : "text-red-100"}>{label}</span>
</div>
);
}
function DurationChart({
series,
minMs,
maxMs,
valueSuffix,
}: {
series: DurationPoint[];
minMs: number;
maxMs: number;
valueSuffix: string;
}): ReactElement {
const padding = 8;
const width = 100;
const height = 80;
const usableWidth = width - padding * 2;
const usableHeight = height - padding * 2;
const range = Math.max(1, maxMs - minMs);
const points = series.map((entry, index) => {
const x = series.length === 1
? width / 2
: padding + (usableWidth * index) / (series.length - 1);
const y = height - (padding + ((entry.ms - minMs) / range) * usableHeight);
return { ...entry, x, y };
});
const polyline = points.length === 1
? `${points[0].x},${points[0].y}`
: points.map((p) => `${p.x},${p.y}`).join(" ");
return (
<div className="w-full overflow-hidden rounded-xl border border-emerald-400/20 bg-[#08111f] px-3 py-4">
<svg
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="Verlauf der Login-Zeiten"
className="h-40 w-full"
>
<defs>
<linearGradient id="login-time-gradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(16, 185, 129, 0.8)" />
<stop offset="100%" stopColor="rgba(16, 185, 129, 0.1)" />
</linearGradient>
</defs>
{points.length > 1 && (
<polygon
points={`${points
.map((p) => `${p.x},${p.y}`)
.join(" ")} ${points[points.length - 1].x},${height - padding} ${points[0].x},${height - padding}`}
fill="url(#login-time-gradient)"
opacity={0.35}
/>
)}
<line
x1={padding}
x2={width - padding}
y1={height - padding}
y2={height - padding}
stroke="rgba(74, 222, 128, 0.3)"
strokeWidth={0.8}
/>
<polyline
points={polyline}
fill="none"
stroke="rgba(16, 185, 129, 0.8)"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
/>
{points.map((p, idx) => (
<circle
key={`point-${idx}`}
cx={p.x}
cy={p.y}
r={2.4}
fill="rgba(45, 212, 191, 0.9)"
stroke="rgba(8, 145, 178, 0.8)"
strokeWidth={0.8}
>
<title>
{`${dateFormatter.format(new Date(p.dateISO))} · ${(p.ms / 1000).toFixed(2)} s${
valueSuffix ? ` ${valueSuffix}` : ""
}`}
</title>
</circle>
))}
</svg>
<div className="mt-2 flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-emerald-100/60">
<span>
{(minMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
</span>
<span>
Max {(maxMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[0.65rem] font-mono text-emerald-100/50">
{points.map((p, idx) => (
<span key={`label-${idx}`}>{dateFormatter.format(new Date(p.dateISO))}</span>
))}
</div>
</div>
);
}