Wuensche bekommen einen Ausgang statt nur einer Punktzahl
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Voting hatten wir. Was fehlte, war alles danach: ein Wunsch sammelte zwoelf
Stimmen und dann passierte sichtbar nie wieder etwas damit. Genau das nennt der
Artikel, der die Idee angestossen hat, "letting the board become a graveyard" —
und das war der Zustand.

Jeder Wunsch hat jetzt einen Stand: wird geprueft, geplant, in Arbeit,
umgesetzt, nicht geplant — mit Begruendung. Die steht oeffentlich unter dem
Wunsch auf der Roadmap und wird in den Discord-Post zurueckgeschrieben, wo
abgestimmt wurde. Ein abgelehnter Wunsch mit einem Satz Begruendung ist mehr
wert als einer, der ewig oben schwebt.

Springt ein Wunsch auf "umgesetzt", bekommt jeder eine Direktnachricht, der
dafuer gestimmt hat. Das ist der Punkt, an dem unser Aufbau einem fertigen
Voting-Dienst ueberlegen ist: der Draht zu jedem Einzelnen ist ohnehin offen.

## Zwei Sachen, die kaputt waren

Doppelt abstimmen ging. Der Zuspruch wurde von zwei Wegen hochgezaehlt — 👍 in
Discord und der Web-Knopf — und nur der Web-Weg merkte sich, wer geklickt hat.
Dieselbe Person zaehlte zweimal. Jetzt wird nicht mehr hochgezaehlt, sondern
gezaehlt: eine Zeile je Person, egal woher der Klick kam. Der zusammengesetzte
Schluessel schliesst den Fall aus, statt ihn nachtraeglich zu korrigieren.

Die Discord-Nachrichten-ID war der Primaerschluessel. Damit konnte ein Wunsch
nur existieren, solange seine Nachricht existiert, das Team konnte keinen von
Hand eintragen, und zwei Doppler liessen sich nicht zusammenfuehren. Wuensche
haben jetzt eine eigene ID; die Nachrichten-ID ist nur noch ein Verweis.

Die Migration erhaelt den Zuspruch: was vom alten Punktestand nicht auf
gespeicherte Web-Stimmen zurueckgeht, waren Reaktionen ohne Namen — die Zahl
bleibt als Sockel stehen, weil rueckwirkend niemand mehr feststellen kann, wer
das war. Durchgespielt gegen eine Datenbank im alten Aufbau: Punktestand
erhalten, verwaiste Stimmen fallen raus, ein zweiter Start migriert nicht
nochmal, und der Fall, in dem der alte Zaehler hinter den echten Stimmen
zurueckhing, zaehlt jetzt richtig.

## Dazu

Doppler zusammenfuehren: die Stimmen wandern zum Original, wer fuer beide
gestimmt hat, zaehlt dort weiterhin einmal. Beim Tippen im Wunsch-Feld zeigt die
Seite, was es schon gibt — ein Klick darauf stimmt mit, statt einen zweiten
gleichen Wunsch anzulegen. Und die Liste laesst sich nach Stand filtern.

Was ich bewusst nicht gebaut habe: Kommentare auf der Webseite. Jeder Wunsch
ist schon eine Discord-Nachricht — die Diskussion gehoert in den Thread
darunter und nicht in ein zweites System.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:18:29 +02:00
co-authored by Claude Opus 5
parent 1c5c7d2470
commit fb61e7279d
9 changed files with 634 additions and 77 deletions
+8
View File
@@ -120,6 +120,14 @@ export default {
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
'roadmap.commitsOn': '%s: %s Commits',
'roadmap.commitOn': '%s: 1 Commit',
'wish.alle': 'Alle',
'wish.offen': 'Wird geprüft',
'wish.geplant': 'Geplant',
'wish.inArbeit': 'In Arbeit',
'wish.umgesetzt': 'Umgesetzt',
'wish.abgelehnt': 'Nicht geplant',
'roadmap.noneInFilter': 'Hier steht gerade nichts.',
'roadmap.alreadyThere': 'Gibt es schon — stimm lieber dafür:',
'roadmap.wishes': '// Community-Wünsche',
'roadmap.unvote': 'Vote zurücknehmen',
'roadmap.vote': 'Dafür stimmen',
+8
View File
@@ -119,6 +119,14 @@ export default {
'roadmap.activity': '// Activity — %s commits in 12 months',
'roadmap.commitsOn': '%s: %s commits',
'roadmap.commitOn': '%s: 1 commit',
'wish.alle': 'All',
'wish.offen': 'Under review',
'wish.geplant': 'Planned',
'wish.inArbeit': 'In progress',
'wish.umgesetzt': 'Shipped',
'wish.abgelehnt': 'Not planned',
'roadmap.noneInFilter': 'Nothing here right now.',
'roadmap.alreadyThere': 'Already suggested — vote for it instead:',
'roadmap.wishes': '// Community wishes',
'roadmap.unvote': 'Take back your vote',
'roadmap.vote': 'Vote for this',
+119 -25
View File
@@ -19,6 +19,23 @@ const GROUPS = [
{ 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_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();
@@ -95,6 +112,8 @@ export default function Roadmap() {
const [submitMsg, setSubmitMsg] = useState('');
const [heatmap, setHeatmap] = useState(null);
const [error, setError] = useState(false);
const [filter, setFilter] = useState('alle');
const [aehnliche, setAehnliche] = useState([]);
useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
@@ -103,12 +122,30 @@ export default function Roadmap() {
window.scrollTo(0, 0);
}, []);
async function vote(messageId) {
// 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 = filter === 'alle'
? wishes
: wishes.filter((w) => (w.status ?? 'offen') === filter);
async function vote(id) {
try {
const res = await apiPost(`/api/wishes/${messageId}/vote`);
setWishes((old) => old.map((w) => w.message_id !== messageId
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'));
}
@@ -151,32 +188,89 @@ export default function Roadmap() {
{(wishes.length > 0 || canVote) && (
<div className="roadmap-group">
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
{wishes.map((w, i) => (
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
{canVote && w.message_id ? (
{/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den
niemand mehr einreicht — deshalb steht der Stand vorne
dran und lässt sich filtern. */}
<div className="wish-filter">
{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 (
<button
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
onClick={() => vote(w.message_id)}
key={id}
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
onClick={() => setFilter(id)}
>
<IconThumbsUp size={14} /> {w.score}
{t(key)} <span className="wish-chip-n">{n}</span>
</button>
) : (
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
)}
<span className="wish-idea">{w.idea}</span>
<span className="wish-author">{w.author}</span>
</div>
))}
);
})}
</div>
{sichtbareWuensche.length === 0 && (
<p className="notice">{t('roadmap.noneInFilter')}</p>
)}
{sichtbareWuensche.map((w, i) => {
const status = w.status ?? 'offen';
return (
<div className={`wish-row st-${status}`} key={w.id ?? `${w.created_at}-${i}`}>
{canVote && w.id ? (
<button
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
onClick={() => vote(w.id)}
>
<IconThumbsUp size={14} /> {w.score}
</button>
) : (
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
)}
<span className="wish-idea">
{w.idea}
{w.status_grund && (
<span className="wish-grund">{w.status_grund}</span>
)}
</span>
{status !== 'offen' && (
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
)}
<span className="wish-author">{w.author}</span>
</div>
);
})}
{canVote ? (
<div className="wish-form">
<input
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
value={idea}
onChange={(e) => setIdea(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
/>
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
<div className="wish-form-wrap">
<div className="wish-form">
<input
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
value={idea}
onChange={(e) => setIdea(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
/>
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
</div>
{/* Beim Tippen zeigen, was es schon gibt — ein Klick
auf einen Treffer ist besser als ein Doppler */}
{aehnliche.length > 0 && (
<div className="wish-hits">
<span className="wish-hits-label">{t('roadmap.alreadyThere')}</span>
{aehnliche.map((w) => (
<button
key={w.id}
className="wish-hit"
onClick={() => { setIdea(''); vote(w.id); }}
title={t('roadmap.vote')}
>
<IconThumbsUp size={12} /> {w.score} · {w.idea}
</button>
))}
</div>
)}
</div>
) : (
<p className="notice" style={{ padding: '.6rem 0 0' }}>
+153
View File
@@ -76,6 +76,16 @@ const TABS = [
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
];
// Stand eines Wunsches. Reihenfolge = Weg durchs Verfahren, damit die Knöpfe
// in der Ansicht so nebeneinander liegen, wie man sie durchläuft.
const WUNSCH_STATUS = [
['offen', 'Wird geprüft'],
['geplant', 'Geplant'],
['in_arbeit', 'In Arbeit'],
['umgesetzt', 'Umgesetzt'],
['abgelehnt', 'Nicht geplant'],
];
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
@@ -351,6 +361,10 @@ export default function Settings({ me }) {
const [watchdog, setWatchdog] = useState(null);
const [automod, setAutomod] = useState(null);
const [anliegen, setAnliegen] = useState([]);
const [wuensche, setWuensche] = useState([]);
const [statusDialog, setStatusDialog] = useState(null);
const [statusGrund, setStatusGrund] = useState('');
const [mergeVon, setMergeVon] = useState(null);
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
const [owner, setOwner] = useState(null);
@@ -481,6 +495,7 @@ export default function Settings({ me }) {
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
apiGet('/api/automod').then(setAutomod).catch(() => {});
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
apiGet('/api/wishes').then((d) => setWuensche(d.wishes ?? [])).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
@@ -1754,6 +1769,102 @@ export default function Settings({ me }) {
const tabCommunity = (
<>
<div className="settings-section">
<h2 className="settings-title">// Wünsche</h2>
<p className="section-intro">
Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den beim nächsten Mal
niemand mehr einreicht. Der Stand steht auf der Roadmap und im Discord-Post;
beim Sprung auf <strong>umgesetzt</strong> bekommt jeder eine Nachricht, der
dafür gestimmt hat.
</p>
{wuensche.length === 0 ? (
<p className="notice">Noch keine Wünsche eingegangen.</p>
) : (
<div className="key-list">
{wuensche.map((w) => (
<div className="wu-row card" key={w.id}>
<div className="wu-head">
<span className="wish-score with-icon">👍 {w.score}</span>
<span className="wu-idee">{w.idea}</span>
<span className="key-used">{w.author}</span>
</div>
<div className="wu-aktionen">
{WUNSCH_STATUS.map(([id, label]) => (
<button
key={id}
className={`wish-chip${(w.status ?? 'offen') === id ? ' aktiv' : ''}`}
onClick={() => wunschStatus(w, id)}
>
{label}
</button>
))}
<button
className="btn btn-mini"
style={{ marginLeft: 'auto' }}
onClick={() => setMergeVon(w)}
title="Als Doppler auf einen anderen Wunsch zeigen lassen"
>
Doppler
</button>
<button className="card-delete" title="Wunsch löschen" onClick={() => wunschLoeschen(w)}>✕</button>
</div>
{w.status_grund && <p className="wu-grund">{w.status_grund}</p>}
</div>
))}
</div>
)}
</div>
{statusDialog && (
<Dialog
title={`${statusDialog.label} — kurz begründen`}
onClose={() => setStatusDialog(null)}
footer={
<>
<button className="btn btn-ghost" onClick={() => setStatusDialog(null)}>Abbrechen</button>
<button className="btn btn-save" onClick={statusSpeichern}>Übernehmen</button>
</>
}
>
<p className="section-intro">„{statusDialog.wunsch.idea}"</p>
<div className="field">
<label>
Begründung <span className="field-hint">
steht öffentlich unter dem Wunsch und im Discord-Post leer lassen geht auch
{statusDialog.status === 'umgesetzt' && ', kommt zusätzlich in die Nachricht an alle Stimmen-Geber'}
</span>
</label>
<textarea
rows={3}
maxLength={500}
placeholder={statusDialog.status === 'abgelehnt'
? 'Warum das nicht kommt — das ist die Antwort, die sonst fehlt.'
: 'Ein, zwei Sätze reichen.'}
value={statusGrund}
onChange={(e) => setStatusGrund(e.target.value)}
/>
</div>
</Dialog>
)}
{mergeVon && (
<Dialog title="Doppler zusammenführen" onClose={() => setMergeVon(null)}>
<p className="section-intro">
{mergeVon.idea}" verschwindet aus der Liste; die Stimmen wandern zum
gewählten Wunsch. Wer für beide gestimmt hat, zählt dort weiterhin einmal.
</p>
<div className="dlg-pick">
{wuensche.filter((w) => w.id !== mergeVon.id).map((w) => (
<button key={w.id} onClick={() => zusammenfuehren(mergeVon, w)}>
{w.idea}
<span className="pick-hint">👍 {w.score}</span>
</button>
))}
</div>
</Dialog>
)}
<div className="settings-section">
<h2 className="settings-title">// Geburtstage</h2>
<div className="field" data-setting="birthday_channel_id">
@@ -1969,6 +2080,48 @@ export default function Settings({ me }) {
</>
);
/** Status setzen — mit Begruendung, denn genau die fehlt sonst */
function wunschStatus(wunsch, status) {
const label = Object.fromEntries(WUNSCH_STATUS)[status];
setStatusGrund(wunsch.status === status ? (wunsch.status_grund ?? '') : '');
setStatusDialog({ wunsch, status, label });
}
async function statusSpeichern() {
const { wunsch, status } = statusDialog;
try {
const res = await apiPut(`/api/wishes/${wunsch.id}/status`, { status, grund: statusGrund });
setWuensche(res.wishes ?? []);
setStatusDialog(null);
flash(res.benachrichtigt
? `✓ Gespeichert — ${res.benachrichtigt} benachrichtigt`
: '✓ Gespeichert');
} catch (e) {
flash(`${e.body?.error ?? 'Fehlgeschlagen'}`);
}
}
async function zusammenfuehren(doppler, original) {
try {
const res = await apiPost(`/api/wishes/${doppler.id}/merge`, { original: original.id });
setWuensche(res.wishes ?? []);
setMergeVon(null);
flash('✓ Zusammengeführt');
} catch (e) {
flash(`${e.body?.error ?? 'Fehlgeschlagen'}`);
}
}
async function wunschLoeschen(wunsch) {
if (!window.confirm(`${wunsch.idea}" löschen? Die Stimmen gehen mit.`)) return;
try {
const res = await apiDelete(`/api/wishes/${wunsch.id}`);
setWuensche(res.wishes ?? []);
} catch {
flash('✗ Löschen fehlgeschlagen');
}
}
async function anliegenSpeichern() {
const body = { ...anliegenDraft };
try {
+52 -1
View File
@@ -466,9 +466,50 @@ body::after {
font-family: var(--mono); font-size: .78rem;
color: var(--neon); white-space: nowrap; min-width: 3.5rem;
}
.wish-idea { flex: 1; font-weight: 300; }
.wish-idea { flex: 1; font-weight: 300; min-width: 0; }
.wish-author { font-family: var(--mono); font-size: .62rem; color: var(--muted2); white-space: nowrap; }
/* Begründung des Teams unter der Idee — der Teil, der einen abgelehnten
Wunsch von einem vergessenen unterscheidet */
.wish-grund {
display: block; margin-top: .3rem;
font-size: .82rem; color: var(--muted);
border-left: 2px solid var(--border); padding-left: .6rem;
}
.wish-status {
font-family: var(--mono); font-size: .58rem; letter-spacing: .1em;
text-transform: uppercase; white-space: nowrap;
padding: .18rem .5rem; border-radius: 999px; border: 1px solid;
}
.wish-status.geplant { color: var(--neon); border-color: color-mix(in srgb, var(--neon) 45%, transparent); }
.wish-status.in_arbeit { color: var(--neon2, var(--neon)); border-color: color-mix(in srgb, var(--neon2, var(--neon)) 45%, transparent); }
.wish-status.umgesetzt { color: #3ba55d; border-color: rgba(59, 165, 93, .45); }
.wish-status.abgelehnt { color: var(--muted2); border-color: var(--border); }
/* Erledigtes tritt zurück, ohne zu verschwinden */
.wish-row.st-umgesetzt, .wish-row.st-abgelehnt { opacity: .72; }
.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .9rem; }
.wish-chip {
background: var(--bg2); border: 1px solid var(--border); border-radius: 999px;
color: var(--muted); font-family: var(--mono); font-size: .62rem;
letter-spacing: .06em; padding: .3rem .7rem; cursor: pointer;
transition: border-color .15s, color .15s;
}
.wish-chip:hover { color: var(--text); }
.wish-chip.aktiv { border-color: var(--neon); color: var(--neon); }
.wish-chip-n { opacity: .6; }
/* Treffer beim Tippen: lieber mitstimmen als denselben Wunsch nochmal stellen */
.wish-hits { display: flex; flex-direction: column; gap: .3rem; margin-top: .6rem; }
.wish-hits-label { font-family: var(--mono); font-size: .62rem; color: var(--muted2); }
.wish-hit {
display: flex; align-items: center; gap: .45rem; text-align: left;
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
color: var(--muted); font: inherit; font-size: .84rem;
padding: .4rem .7rem; cursor: pointer;
}
.wish-hit:hover { border-color: var(--neon); color: var(--neon); }
/* ── GALERIE ───────────────────────────────────────── */
.gallery-grid {
display: grid;
@@ -1888,6 +1929,16 @@ label.toggle {
.pt-key { font-family: var(--mono); font-size: .74rem; color: var(--neon); }
.pt-wartet { font-family: var(--mono); font-size: .66rem; color: var(--muted2); }
/* Wunsch-Verwaltung im Panel */
.wu-row { padding: .8rem 1rem; margin-bottom: .6rem; min-width: 0; }
.wu-head { display: flex; align-items: baseline; gap: .8rem; }
.wu-idee { flex: 1; min-width: 0; font-size: .92rem; }
.wu-aktionen { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; margin-top: .6rem; }
.wu-grund {
margin: .55rem 0 0; font-size: .82rem; color: var(--muted);
border-left: 2px solid var(--border); padding-left: .6rem;
}
/* ── DIALOG ────────────────────────────────────────── */
.dlg-backdrop {
position: fixed; inset: 0; z-index: 1200;