Öffentliche Roadmap-Seite aus Gitea-Milestones

- GET /api/roadmap (public): Milestones des Setting roadmap_repo, 5-min-Cache,
  letzter guter Stand als Fallback bei Gitea-Ausfall
- /roadmap: Gruppen In Arbeit / Geplant / Fertig, Fortschrittsbalken im Brand-Look,
  Ziel-Monat, Markdown-Beschreibungen; Nav-Link öffentlich
- Setup-Seite: Roadmap-Repo konfigurierbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:24:31 +02:00
co-authored by Claude Opus 4.8
parent 39f9fd4f67
commit 6d8f383aed
7 changed files with 183 additions and 2 deletions
+92
View File
@@ -0,0 +1,92 @@
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>
);
}
export default function Roadmap() {
const [data, setData] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
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>
)}
{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>
</>
);
}