- /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>
152 lines
6.0 KiB
React
152 lines
6.0 KiB
React
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>
|
||
</>
|
||
);
|
||
}
|