From fb61e7279dcfff5448a9cde80b4719fb212e7daa Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 23:18:29 +0200 Subject: [PATCH] Wuensche bekommen einen Ausgang statt nur einer Punktzahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 +- frontend/src/locales/de.js | 8 ++ frontend/src/locales/en.js | 8 ++ frontend/src/pages/Roadmap.jsx | 144 ++++++++++++++++++---- frontend/src/pages/Settings.jsx | 153 +++++++++++++++++++++++ frontend/src/style.css | 53 +++++++- src/bot/client.js | 18 ++- src/db.js | 208 ++++++++++++++++++++++++++------ src/web/api.js | 116 ++++++++++++++++-- 9 files changed, 634 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 33296eb..3bc2f66 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 📈 **Level-System** | XP pro Nachricht (Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste | | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen im Best-of-Kanal | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) | -| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster (Idee + „warum wäre das gut?") → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap | +| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster → Voting-Post mit 👍; Wünsche mit **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung auf der Roadmap und im Discord-Post. Wer für einen Wunsch gestimmt hat, bekommt eine DM, sobald er umgesetzt ist. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | | 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | @@ -453,6 +453,7 @@ urllib.request.urlopen(req) | `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) | | `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung | | `GET /api/profile` · `/api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten | +| `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen | | `GET /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles | | `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 78611cb..03b60fa 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -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', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 702b5dd..c34fb58 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -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', diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index 1918924..7073856 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -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) && (

{t('roadmap.wishes')}

- {wishes.map((w, 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. */} +
+ {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 ( - ) : ( - {w.score} - )} - {w.idea} - {w.author} -
- ))} + ); + })} +
+ + {sichtbareWuensche.length === 0 && ( +

{t('roadmap.noneInFilter')}

+ )} + + {sichtbareWuensche.map((w, i) => { + const status = w.status ?? 'offen'; + return ( +
+ {canVote && w.id ? ( + + ) : ( + {w.score} + )} + + {w.idea} + {w.status_grund && ( + {w.status_grund} + )} + + {status !== 'offen' && ( + {t(WISH_STATUS[status])} + )} + {w.author} +
+ ); + })} + {canVote ? ( -
- setIdea(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} - /> - +
+
+ setIdea(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} + /> + +
+ {/* Beim Tippen zeigen, was es schon gibt — ein Klick + auf einen Treffer ist besser als ein Doppler */} + {aehnliche.length > 0 && ( +
+ {t('roadmap.alreadyThere')} + {aehnliche.map((w) => ( + + ))} +
+ )}
) : (

diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index e58c6b2..9309f22 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 = ( <> +

+

// Wünsche

+

+ 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 umgesetzt bekommt jeder eine Nachricht, der + dafür gestimmt hat. +

+ + {wuensche.length === 0 ? ( +

Noch keine Wünsche eingegangen.

+ ) : ( +
+ {wuensche.map((w) => ( +
+
+ 👍 {w.score} + {w.idea} + {w.author} +
+
+ {WUNSCH_STATUS.map(([id, label]) => ( + + ))} + + +
+ {w.status_grund &&

{w.status_grund}

} +
+ ))} +
+ )} +
+ + {statusDialog && ( + setStatusDialog(null)} + footer={ + <> + + + + } + > +

„{statusDialog.wunsch.idea}"

+
+ +