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) {
const radius = 55;
const circumference = 2 * Math.PI * radius;
const all = exact + tendency + wrong || 1;
const all = exact + tendency + wrong;
const hasData = all > 0;
const segments = [
{ value: exact / all, color: 'var(--gold)', label: 'Exakt' },
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz' },
{ value: wrong / all, color: 'var(--error)', label: 'Falsch' },
];
const segments = hasData ? [
{ value: exact / all, color: 'var(--gold)', label: 'Exakt', count: exact },
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz', count: tendency },
{ value: wrong / all, color: 'var(--error)', label: 'Falsch', count: wrong },
] : [];
let offset = 0;
@@ -49,17 +50,19 @@ export default function StatsRing({ exact, tendency, wrong, total }: Props) {
</text>
<text x="70" y="85" textAnchor="middle"
fill="var(--text-secondary)" fontSize="11">
Punkte
{hasData ? 'Punkte' : 'Keine Tipps'}
</text>
</svg>
<div className={styles.legend}>
{segments.map((seg, i) => (
<span key={i} className={styles.legendItem}>
<span className={styles.dot} style={{ background: seg.color }} />
{seg.label}: {Math.round(seg.value * all)}
</span>
))}
</div>
{hasData && (
<div className={styles.legend}>
{segments.map((seg, i) => (
<span key={i} className={styles.legendItem}>
<span className={styles.dot} style={{ background: seg.color }} />
{seg.label}: {seg.count}
</span>
))}
</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';
interface Props {
@@ -8,10 +8,13 @@ interface Props {
}
export default function Toast({ message, onDismiss, duration = 5000 }: Props) {
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
const timer = setTimeout(onDismiss, duration);
const timer = setTimeout(() => onDismissRef.current(), duration);
return () => clearTimeout(timer);
}, [onDismiss, duration]);
}, [duration]);
return (
<div className={`card ${styles.toast}`} onClick={onDismiss}>
+5
View File
@@ -2,11 +2,15 @@ import { useState, useEffect } from 'react';
import { api } from '../api/client';
const RANK_KEY = 'tippspiel_last_rank';
const SHOWN_KEY = 'tippspiel_rank_toast_shown';
export function useRankChange() {
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
// Only show once per session
if (sessionStorage.getItem(SHOWN_KEY)) return;
api.getMyStats().then(stats => {
if (!stats.rank) return;
const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0');
@@ -16,6 +20,7 @@ export function useRankChange() {
} else {
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));
}).catch(() => {});