Ideen bekommen Bereiche und einen Thread zum Reden
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Nach dem Vorbild von windrose.support: Bereiche zum Einsortieren, Sortierung
nach Top / Bewegung / Neu, und eine Zahl daneben, wie viel darueber geredet
wird.

Der Unterschied zu so einem Dienst ist, wo geredet wird. Zu jedem Wunsch macht
der Bot jetzt einen Thread unter dem Post auf. Der Kommentar-Zaehler auf der
Roadmap zaehlt die Beitraege darin und verlinkt hinein — es gibt also einen
Zaehler wie bei einem Ideen-Board, aber ohne ein zweites Kommentarsystem neben
Discord zu stellen. Genau das hatte ich beim letzten Mal als das Falsche
bezeichnet, und dabei bleibt es.

Eingereicht wird jetzt auch ueber einen Aufruf-Post: /wunsch-setup postet ihn,
und mit Bereichen wird daraus ein Auswahlmenue — erst wohin, dann das
Formular. Ohne Bereiche bleibt es beim einen Knopf. Derselbe Aufbau wie bei den
Ticket-Anliegen, bis hin zur Emoji-Pruefung, die ich mir dort schon geschrieben
hatte.

Das Anlegen selbst ist nach bot/wishes.js gewandert. /wunsch, der Knopf und die
Webseite gehen jetzt denselben Weg — vorher hatte die Webseite ihr eigenes
Embed zusammengebaut, das dem aus dem Befehl nur aehnlich sah und keinen Thread
bekam.

"Bewegung" sind die Stimmen der letzten sieben Tage. Die Rangliste allein
zementiert alte Wuensche: was einmal oben steht, bleibt oben, egal ob noch
jemand hinschaut. Sortiert wird auf dem Server, weil die Zeitstempel der
Stimmen im Browser gar nicht ankommen.

Bewusst nicht uebernommen: Gegenstimmen. Windrose hat sie, aber bei einer
Community dieser Groesse laden sie zum Nachtreten ein und schrecken vom
Einreichen ab — und eine Zahl, die aus zwei Richtungen kommt, sagt am Ende
weniger als eine, die nur zaehlt, wer etwas will.

Geprueft: Migration erneut durchgespielt (das Schema hat drei Spalten
dazubekommen), Routen, SQL gegen das Schema, und im Browser alle drei
Filterreihen einzeln und kombiniert, der Sortierwechsel, die Thread-Links und
die Bereichs-Pflege im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:31:55 +02:00
co-authored by Claude Opus 5
parent fb61e7279d
commit edf8fa14c5
13 changed files with 657 additions and 128 deletions
+112 -24
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { apiGet, apiPost } from '../api.js';
import { Markdown } from '../markdown.jsx';
import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconThumbsUp } from '../icons.jsx';
import { IconThumbsUp, IconMessage } from '../icons.jsx';
import { useT, useFmt } from '../i18n.jsx';
const ZIEL = { month: 'long', year: 'numeric' };
@@ -27,6 +27,11 @@ const WISH_STATUS = {
umgesetzt: 'wish.umgesetzt',
abgelehnt: 'wish.abgelehnt',
};
const WISH_SORT = [
{ id: 'top', key: 'roadmap.sortTop' },
{ id: 'bewegung', key: 'roadmap.sortTrend' },
{ id: 'neu', key: 'roadmap.sortNew' },
];
const WISH_FILTER = [
{ id: 'alle', key: 'wish.alle' },
{ id: 'offen', key: 'wish.offen' },
@@ -113,15 +118,30 @@ export default function Roadmap() {
const [heatmap, setHeatmap] = useState(null);
const [error, setError] = useState(false);
const [filter, setFilter] = useState('alle');
const [bereich, setBereich] = useState('alle');
const [bereiche, setBereiche] = useState([]);
const [neuerBereich, setNeuerBereich] = useState('');
const [sort, setSort] = useState('top');
const [aehnliche, setAehnliche] = useState([]);
useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {});
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
window.scrollTo(0, 0);
}, []);
// Sortiert wird auf dem Server — „Bewegung" braucht die Zeitstempel der
// Stimmen, die hier gar nicht ankommen
useEffect(() => {
apiGet(`/api/wishes?sort=${sort}`)
.then((d) => {
setWishes(d.wishes);
setCanVote(d.canVote);
setBereiche(d.bereiche ?? []);
})
.catch(() => {});
}, [sort]);
// Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten,
// damit nicht jeder Tastendruck eine Anfrage auslöst.
useEffect(() => {
@@ -135,9 +155,9 @@ export default function Roadmap() {
return () => clearTimeout(timer);
}, [idea]);
const sichtbareWuensche = filter === 'alle'
? wishes
: wishes.filter((w) => (w.status ?? 'offen') === filter);
const sichtbareWuensche = wishes
.filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter)
.filter((w) => bereich === 'alle' || w.category_id === bereich);
async function vote(id) {
try {
@@ -155,10 +175,13 @@ export default function Roadmap() {
const text = idea.trim();
if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; }
try {
await apiPost('/api/wishes', { idea: text });
await apiPost('/api/wishes', {
idea: text,
category_id: neuerBereich ? Number(neuerBereich) : null,
});
setIdea('');
setSubmitMsg(t('roadmap.submitted'));
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {});
} catch (e) {
setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed'));
}
@@ -189,25 +212,62 @@ export default function Roadmap() {
<div className="roadmap-group">
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
{/* 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 (
<div className="wish-bar">
{/* Sortierung: die Rangliste allein zementiert alte
Wünsche — „Bewegung" zeigt, wo diese Woche etwas
passiert, „Neu" das Frischeste. */}
<div className="wish-filter">
<span className="wish-filter-label">{t('roadmap.sort')}</span>
{WISH_SORT.map(({ id, key }) => (
<button
key={id}
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
onClick={() => setFilter(id)}
className={`wish-chip${sort === id ? ' aktiv' : ''}`}
onClick={() => setSort(id)}
>
{t(key)} <span className="wish-chip-n">{n}</span>
{t(key)}
</button>
);
})}
))}
</div>
<div className="wish-filter">
<span className="wish-filter-label">{t('roadmap.status')}</span>
{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
key={id}
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
onClick={() => setFilter(id)}
>
{t(key)} <span className="wish-chip-n">{n}</span>
</button>
);
})}
</div>
{bereiche.length > 0 && (
<div className="wish-filter">
<span className="wish-filter-label">{t('roadmap.area')}</span>
<button
className={`wish-chip${bereich === 'alle' ? ' aktiv' : ''}`}
onClick={() => setBereich('alle')}
>
{t('wish.alle')}
</button>
{bereiche.map((b) => (
<button
key={b.id}
className={`wish-chip${bereich === b.id ? ' aktiv' : ''}`}
onClick={() => setBereich(b.id)}
>
{b.emoji} {b.name}
</button>
))}
</div>
)}
</div>
{sichtbareWuensche.length === 0 && (
@@ -230,13 +290,27 @@ export default function Roadmap() {
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
)}
<span className="wish-idea">
<span className="wish-schilder">
{status !== 'offen' && (
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
)}
{w.kategorie && (
<span className="wish-area">
{w.kategorie_emoji} {w.kategorie}
</span>
)}
</span>
{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>
{/* Geredet wird im Thread unter dem Post — der
Zähler führt direkt dorthin */}
{w.thread_url && (
<a className="wish-komm" href={w.thread_url} target="_blank" rel="noreferrer">
<IconMessage size={13} /> {w.kommentare ?? 0}
</a>
)}
<span className="wish-author">{w.author}</span>
</div>
@@ -246,6 +320,20 @@ export default function Roadmap() {
{canVote ? (
<div className="wish-form-wrap">
<div className="wish-form">
{bereiche.length > 0 && (
<select
className="wish-area-select"
value={neuerBereich}
onChange={(e) => setNeuerBereich(e.target.value)}
>
<option value="">{t('roadmap.areaPick')}</option>
{bereiche.map((b) => (
<option key={b.id} value={b.id}>
{b.emoji} {b.name}
</option>
))}
</select>
)}
<input
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
value={idea}