fix: StatsRing NaN with no data, Toast showing repeatedly, timer stability

- StatsRing: compute all without || 1 fallback, guard segments/legend behind hasData, use seg.count in legend to avoid NaN
- useRankChange: skip toast if already shown this session via sessionStorage
- Toast: use ref for onDismiss to prevent timer reset on every render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ronny
2026-04-11 19:54:32 +02:00
parent 77ee3f9a45
commit dd65f7c4fe
3 changed files with 29 additions and 18 deletions
+18 -15
View File
@@ -10,13 +10,14 @@ interface Props {
export default function StatsRing({ exact, tendency, wrong, total }: Props) { export default function StatsRing({ exact, tendency, wrong, total }: Props) {
const radius = 55; const radius = 55;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const all = exact + tendency + wrong || 1; const all = exact + tendency + wrong;
const hasData = all > 0;
const segments = [ const segments = hasData ? [
{ value: exact / all, color: 'var(--gold)', label: 'Exakt' }, { value: exact / all, color: 'var(--gold)', label: 'Exakt', count: exact },
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz' }, { value: tendency / all, color: 'var(--success)', label: 'Tendenz', count: tendency },
{ value: wrong / all, color: 'var(--error)', label: 'Falsch' }, { value: wrong / all, color: 'var(--error)', label: 'Falsch', count: wrong },
]; ] : [];
let offset = 0; let offset = 0;
@@ -49,17 +50,19 @@ export default function StatsRing({ exact, tendency, wrong, total }: Props) {
</text> </text>
<text x="70" y="85" textAnchor="middle" <text x="70" y="85" textAnchor="middle"
fill="var(--text-secondary)" fontSize="11"> fill="var(--text-secondary)" fontSize="11">
Punkte {hasData ? 'Punkte' : 'Keine Tipps'}
</text> </text>
</svg> </svg>
<div className={styles.legend}> {hasData && (
{segments.map((seg, i) => ( <div className={styles.legend}>
<span key={i} className={styles.legendItem}> {segments.map((seg, i) => (
<span className={styles.dot} style={{ background: seg.color }} /> <span key={i} className={styles.legendItem}>
{seg.label}: {Math.round(seg.value * all)} <span className={styles.dot} style={{ background: seg.color }} />
</span> {seg.label}: {seg.count}
))} </span>
</div> ))}
</div>
)}
</div> </div>
); );
} }
+6 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import styles from './Toast.module.css'; import styles from './Toast.module.css';
interface Props { interface Props {
@@ -8,10 +8,13 @@ interface Props {
} }
export default function Toast({ message, onDismiss, duration = 5000 }: Props) { export default function Toast({ message, onDismiss, duration = 5000 }: Props) {
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => { useEffect(() => {
const timer = setTimeout(onDismiss, duration); const timer = setTimeout(() => onDismissRef.current(), duration);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [onDismiss, duration]); }, [duration]);
return ( return (
<div className={`card ${styles.toast}`} onClick={onDismiss}> <div className={`card ${styles.toast}`} onClick={onDismiss}>
+5
View File
@@ -2,11 +2,15 @@ import { useState, useEffect } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
const RANK_KEY = 'tippspiel_last_rank'; const RANK_KEY = 'tippspiel_last_rank';
const SHOWN_KEY = 'tippspiel_rank_toast_shown';
export function useRankChange() { export function useRankChange() {
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
// Only show once per session
if (sessionStorage.getItem(SHOWN_KEY)) return;
api.getMyStats().then(stats => { api.getMyStats().then(stats => {
if (!stats.rank) return; if (!stats.rank) return;
const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0'); const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0');
@@ -16,6 +20,7 @@ export function useRankChange() {
} else { } else {
setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`); setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`);
} }
sessionStorage.setItem(SHOWN_KEY, '1');
} }
localStorage.setItem(RANK_KEY, String(stats.rank)); localStorage.setItem(RANK_KEY, String(stats.rank));
}).catch(() => {}); }).catch(() => {});