import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; import { IconThumbsUp, IconMessage } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; const ZIEL = { month: 'long', year: 'numeric' }; /** Milestone-Gruppe bestimmen: Fertig / In Arbeit / Geplant */ function groupOf(m) { if (m.state === 'closed') return 'done'; return m.closed > 0 ? 'active' : 'planned'; } const GROUPS = [ { id: 'active', tag: 'roadmap.active', empty: 'roadmap.nothingActive' }, { id: 'planned', tag: 'roadmap.planned', empty: null }, { id: 'done', tag: 'roadmap.done', empty: null }, ]; // Stand eines Wunsches. „offen" braucht kein Schild — das ist der Normalfall. const WISH_STATUS = { offen: 'wish.offen', geplant: 'wish.geplant', in_arbeit: 'wish.inArbeit', umgesetzt: 'wish.umgesetzt', abgelehnt: 'wish.abgelehnt', }; const WISH_SORT = [ { id: 'top', key: 'roadmap.sortTop' }, { id: 'bewegung', key: 'roadmap.sortTrend' }, { id: 'neu', key: 'roadmap.sortNew' }, ]; const WISH_FILTER = [ { id: 'alle', key: 'wish.alle' }, { id: 'offen', key: 'wish.offen' }, { id: 'geplant', key: 'wish.geplant' }, { id: 'in_arbeit', key: 'wish.inArbeit' }, { id: 'umgesetzt', key: 'wish.umgesetzt' }, { id: 'abgelehnt', key: 'wish.abgelehnt' }, ]; function Milestone({ m }) { const { t } = useT(); const { datum } = useFmt(); const total = m.open + m.closed; const pct = total > 0 ? Math.round((m.closed / total) * 100) : m.state === 'closed' ? 100 : 0; return (
{m.title} {m.due_on && m.state !== 'closed' && ( {t('roadmap.due', datum(m.due_on, ZIEL))} )}
{m.description && (
)}
{pct}% {total > 0 && {t('roadmap.tasks', m.closed, total)}}
); } /** GitHub-Style-Heatmap: 52 Wochen × 7 Tage */ function Heatmap({ days }) { const { t } = useT(); const byDay = new Map(days.map((d) => [d.day, d.n])); const max = Math.max(1, ...days.map((d) => d.n)); const today = new Date(); // Start: Montag vor ~52 Wochen const start = new Date(today.getTime() - 364 * 86400000); start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); const weeks = []; for (let w = 0; w < 53; w++) { const col = []; for (let d = 0; d < 7; d++) { const date = new Date(start.getTime() + (w * 7 + d) * 86400000); if (date > today) break; const key = date.toISOString().slice(0, 10); const n = byDay.get(key) ?? 0; const level = n === 0 ? 0 : Math.min(4, Math.ceil((n / max) * 4)); col.push( ); } weeks.push({col}); } const total = days.reduce((sum, d) => sum + d.n, 0); return (

{t('roadmap.activity', total)}

{weeks}
); } export default function Roadmap() { const { t } = useT(); const [data, setData] = useState(null); const [wishes, setWishes] = useState([]); const [canVote, setCanVote] = useState(false); const [idea, setIdea] = useState(''); const [submitMsg, setSubmitMsg] = useState(''); const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); const [filter, setFilter] = useState('alle'); const [bereich, setBereich] = useState('alle'); const [bereiche, setBereiche] = useState([]); const [neuerBereich, setNeuerBereich] = useState(''); const [sort, setSort] = useState('top'); const [aehnliche, setAehnliche] = useState([]); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {}); window.scrollTo(0, 0); }, []); // Sortiert wird auf dem Server — „Bewegung" braucht die Zeitstempel der // Stimmen, die hier gar nicht ankommen useEffect(() => { apiGet(`/api/wishes?sort=${sort}`) .then((d) => { setWishes(d.wishes); setCanVote(d.canVote); setBereiche(d.bereiche ?? []); }) .catch(() => {}); }, [sort]); // Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten, // damit nicht jeder Tastendruck eine Anfrage auslöst. useEffect(() => { const text = idea.trim(); if (text.length < 3) { setAehnliche([]); return undefined; } const timer = setTimeout(() => { apiGet(`/api/wishes/suche?q=${encodeURIComponent(text)}`) .then((d) => setAehnliche(d.treffer ?? [])) .catch(() => setAehnliche([])); }, 350); return () => clearTimeout(timer); }, [idea]); const sichtbareWuensche = wishes .filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter) .filter((w) => bereich === 'alle' || w.category_id === bereich); async function vote(id) { try { const res = await apiPost(`/api/wishes/${id}/vote`); setWishes((old) => old.map((w) => w.id !== id ? w : { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) })); setAehnliche((old) => old.map((w) => (w.id === id ? { ...w, score: w.score + 1 } : w))); } catch (e) { if (e.status === 403) setSubmitMsg(t('roadmap.voteMembersOnly')); } } async function submitWish() { const text = idea.trim(); if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; } try { await apiPost('/api/wishes', { idea: text, category_id: neuerBereich ? Number(neuerBereich) : null, }); setIdea(''); setSubmitMsg(t('roadmap.submitted')); apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {}); } catch (e) { setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed')); } } return ( <>
ROADMAP
{error &&

{t('roadmap.error')}

} {!error && !data && } {data?.milestones.length === 0 && (

{t('roadmap.noMilestones')}

)} {heatmap && heatmap.length > 0 && } {(wishes.length > 0 || canVote) && (

{t('roadmap.wishes')}

{/* Sortierung: die Rangliste allein zementiert alte Wünsche — „Bewegung" zeigt, wo diese Woche etwas passiert, „Neu" das Frischeste. */}
{t('roadmap.sort')} {WISH_SORT.map(({ id, key }) => ( ))}
{t('roadmap.status')} {WISH_FILTER.map(({ id, key }) => { const n = id === 'alle' ? wishes.length : wishes.filter((w) => (w.status ?? 'offen') === id).length; if (n === 0 && id !== 'alle') return null; return ( ); })}
{bereiche.length > 0 && (
{t('roadmap.area')} {bereiche.map((b) => ( ))}
)}
{sichtbareWuensche.length === 0 && (

{t('roadmap.noneInFilter')}

)} {sichtbareWuensche.map((w, i) => { const status = w.status ?? 'offen'; return (
{canVote && w.id ? ( ) : ( {w.score} )} {status !== 'offen' && ( {t(WISH_STATUS[status])} )} {w.kategorie && ( {w.kategorie_emoji} {w.kategorie} )} {w.idea} {w.status_grund && ( {w.status_grund} )} {/* Geredet wird im Thread unter dem Post — der Zähler führt direkt dorthin */} {w.thread_url && ( {w.kommentare ?? 0} )} {w.author}
); })} {canVote ? (
{bereiche.length > 0 && ( )} setIdea(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} />
{/* Beim Tippen zeigen, was es schon gibt — ein Klick auf einen Treffer ist besser als ein Doppler */} {aehnliche.length > 0 && (
{t('roadmap.alreadyThere')} {aehnliche.map((w) => ( ))}
)}
) : (

{t('roadmap.hint').split('%s')[0]}/wunsch{t('roadmap.hint').split('%s')[1]}

)} {submitMsg &&

{submitMsg}

}
)} {GROUPS.map(({ id, tag, empty }) => { const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id); if (items.length === 0 && !empty) return null; return (

{t(tag)}

{items.length === 0 &&

{t(empty)}

} {items.map((m) => ( ))}
); })}
); }