Files
d4rkbot/frontend/src/pages/Roadmap.jsx
T
D4rkst3randClaude Opus 4.8 bc000aaa9f Feature-Voting, Giveaways und Contribution-Heatmap
- /wunsch: Voting-Embed mit 👍, Reaction-Tracking (add/remove) in SQLite,
  Top-20-Rangliste öffentlich auf der Roadmap-Seite
- /giveaway (Admin): Preis/Dauer/Gewinnerzahl, 🎉-Teilnahme-Button (toggle),
  Minuten-Scheduler zieht Gewinner, schließt das Embed ab und pingt sie
- Contribution-Heatmap: 52-Wochen-Grid in Brand-Gelb aus dem Commit-Archiv
  (/api/heatmap) auf der Roadmap-Seite
- Setting voting_channel_id auf der Setup-Seite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:20:44 +02:00

152 lines
6.0 KiB
React
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.
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
const dueFmt = new Intl.DateTimeFormat('de-DE', { 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: '// In Arbeit', empty: 'Gerade nichts in Arbeit.' },
{ id: 'planned', tag: '// Geplant', empty: null },
{ id: 'done', tag: '// Fertig', empty: null },
];
function Milestone({ m }) {
const total = m.open + m.closed;
const pct = total > 0 ? Math.round((m.closed / total) * 100) : m.state === 'closed' ? 100 : 0;
return (
<article className="card milestone">
<div className="card-meta">
<span className="milestone-title">{m.title}</span>
{m.due_on && m.state !== 'closed' && (
<span className="card-rel">Ziel: {dueFmt.format(new Date(m.due_on))}</span>
)}
</div>
{m.description && (
<div className="card-body">
<Markdown text={m.description} />
</div>
)}
<div className="progress">
<div className="progress-bar" style={{ width: `${pct}%` }} />
</div>
<div className="progress-info">
<span>{pct}%</span>
{total > 0 && <span>{m.closed} / {total} Aufgaben</span>}
</div>
</article>
);
}
/** GitHub-Style-Heatmap: 52 Wochen × 7 Tage */
function Heatmap({ days }) {
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(
<span
key={d}
className={`hm-cell hm-${level}`}
title={`${key}: ${n} Commit${n === 1 ? '' : 's'}`}
/>
);
}
weeks.push(<span className="hm-week" key={w}>{col}</span>);
}
const total = days.reduce((sum, d) => sum + d.n, 0);
return (
<div className="roadmap-group">
<h2 className="settings-title">// Aktivität — {total} Commits in 12 Monaten</h2>
<div className="hm-wrap">{weeks}</div>
</div>
);
}
export default function Roadmap() {
const [data, setData] = useState(null);
const [wishes, setWishes] = useState([]);
const [heatmap, setHeatmap] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
window.scrollTo(0, 0);
}, []);
return (
<>
<section className="page-header">
<div className="page-bg-text">ROADMAP</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Wohin die Reise geht</div>
<h1 className="page-title">Roadmap</h1>
<p className="page-subtitle">
Meilensteine direkt aus der Entwicklung live aus dem Issue-Tracker.
</p>
</div>
</section>
<section className="content">
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{data?.milestones.length === 0 && (
<p className="notice">Noch keine Meilensteine angelegt.</p>
)}
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
{wishes.length > 0 && (
<div className="roadmap-group">
<h2 className="settings-title">// Community-Wünsche</h2>
{wishes.map((w, i) => (
<div className="wish-row" key={`${w.created_at}-${i}`}>
<span className="wish-score">👍 {w.score}</span>
<span className="wish-idea">{w.idea}</span>
<span className="wish-author">{w.author}</span>
</div>
))}
<p className="notice" style={{ padding: '.6rem 0 0' }}>
Eigene Idee? Im Discord einfach <code>/wunsch</code> benutzen.
</p>
</div>
)}
{GROUPS.map(({ id, tag, empty }) => {
const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id);
if (items.length === 0 && !empty) return null;
return (
<div className="roadmap-group" key={id}>
<h2 className="settings-title">{tag}</h2>
{items.length === 0 && <p className="notice">{empty}</p>}
{items.map((m) => (
<Milestone m={m} key={m.title} />
))}
</div>
);
})}
</section>
</>
);
}