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>
This commit is contained in:
2026-07-23 13:20:44 +02:00
co-authored by Claude Opus 4.8
parent 075ea36bd2
commit bc000aaa9f
12 changed files with 391 additions and 4 deletions
+59
View File
@@ -43,12 +43,53 @@ function Milestone({ m }) {
);
}
/** 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);
}, []);
@@ -73,6 +114,24 @@ export default function Roadmap() {
<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;
+10
View File
@@ -355,6 +355,16 @@ export default function Settings({ me }) {
{channelOptions}
</select>
</div>
<div className="field">
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf der Roadmap-Seite</span></label>
<select
value={form.voting_channel_id ?? ''}
onChange={(e) => setForm({ ...form, voting_channel_id: e.target.value })}
>
<option value=""> deaktiviert </option>
{channelOptions}
</select>
</div>
</div>
{/* Moderation & Kontakt */}