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
{t('roadmap.tag')}
{t('nav.roadmap')}
{t('roadmap.subtitle')}
{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) => (
))}
);
})}
>
);
}