Abstimmung Ja/Nein statt Links/Rechts

Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe
"Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-29 18:14:57 +02:00
co-authored by Claude Opus 5.5
parent 3d2447fe35
commit b6cf8c7fa0
8 changed files with 35 additions and 35 deletions
+9 -9
View File
@@ -187,8 +187,8 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
}
type tally struct {
Left int `json:"left"`
Right int `json:"right"`
Yes int `json:"yes"`
No int `json:"no"`
Selected string `json:"selected"`
}
@@ -209,16 +209,16 @@ func TestVoting(t *testing.T) {
return tl
}
if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" {
t.Fatalf("alice left: %+v", got)
if got := vote(alice, "yes"); got.Yes != 1 || got.Selected != "yes" {
t.Fatalf("alice yes: %+v", got)
}
if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 {
t.Fatalf("bob right: %+v", got)
if got := vote(bob, "no"); got.Yes != 1 || got.No != 1 {
t.Fatalf("bob no: %+v", got)
}
if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" {
if got := vote(alice, "yes"); got.Yes != 0 || got.Selected != "none" {
t.Fatalf("alice toggle off: %+v", got)
}
if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" {
if got := vote(alice, "no"); got.No != 2 || got.Selected != "no" {
t.Fatalf("alice switch: %+v", got)
}
}
@@ -230,7 +230,7 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
anon := newClient(t)
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
resp := postForm(t, anon, voteURL, url.Values{"mode": {"yes"}})
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
+8 -8
View File
@@ -540,20 +540,20 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func writeTally(w http.ResponseWriter, pid, uid int64) error {
var left, right int64
var yes, no int64
var selected string
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE mode = 'left'),
COUNT(*) FILTER (WHERE mode = 'right'),
`SELECT COUNT(*) FILTER (WHERE mode = 'yes'),
COUNT(*) FILTER (WHERE mode = 'no'),
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
FROM vote WHERE pid = $1`, pid, uid).
Scan(&left, &right, &selected); err != nil {
Scan(&yes, &no, &selected); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"left": left,
"right": right,
"yes": yes,
"no": no,
"selected": selected,
})
return nil
@@ -585,8 +585,8 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context())
mode := r.FormValue("mode")
if mode != "left" && mode != "right" {
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
if mode != "yes" && mode != "no" {
return Invalid("vote.bad_mode", "Ungültiger Modus (yes oder no)").At("mode")
}
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
+3 -3
View File
@@ -47,7 +47,7 @@ export function Votes({ pid }: { pid: number }) {
let counts = "– / –";
if (vote.isError) counts = errMsg(vote.error, "Fehler");
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
else if (t) counts = `${t.left} / ${t.right}`;
else if (t) counts = `${t.yes} / ${t.no}`;
const button = (mode: VoteMode, label: string) => (
<button
@@ -61,9 +61,9 @@ export function Votes({ pid }: { pid: number }) {
return (
<div className="row">
{button("left", "Links")}
{button("yes", "Ja")}
<span className="muted">{counts}</span>
{button("right", "Rechts")}
{button("no", "Nein")}
</div>
);
}
+2 -2
View File
@@ -62,8 +62,8 @@ export function StatsPage() {
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.votes.left} label="Stimmen Links" />
<NumCard value={s.votes.right} label="Stimmen Rechts" />
<NumCard value={s.votes.yes} label="Stimmen Ja" />
<NumCard value={s.votes.no} label="Stimmen Nein" />
</>
)}
</div>
+4 -4
View File
@@ -26,12 +26,12 @@ export interface Thread {
replies: Entry[];
}
export type VoteMode = "left" | "right";
export type VoteMode = "yes" | "no";
export interface VoteTally {
pid: number;
left: number;
right: number;
yes: number;
no: number;
selected: VoteMode | "none";
}
@@ -65,7 +65,7 @@ export interface StatsDetail {
entries: number;
toplevel: number;
replies: number;
votes: { left: number; right: number };
votes: { yes: number; no: number };
impressions_total: number;
visitors_total: number;
daily: { day: string; impressions: number; visitors: number }[];
+3 -3
View File
@@ -85,12 +85,12 @@ Antwort). Ohne passenden Nachfahren fehlt das Feld.
|---------|------|------|---------|--------|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","yes","no","selected"}` (`selected`: `yes`/`no`/`none`) |
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `yes`/`no` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown
@@ -119,7 +119,7 @@ TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
| Methode | Pfad | Erfolg |
|---------|------|--------|
| GET | `/api/stats` | `200 {"users","entries"}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
---
+1 -1
View File
@@ -11,4 +11,4 @@
- RSS / Atom Feed
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
- robots.txt / Sitemap
- Links/Rechts Auswertung
- Ja/Nein Auswertung
+5 -5
View File
@@ -240,14 +240,14 @@ func handleStats(w http.ResponseWriter, r *http.Request) error {
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
var users, entries, toplevel, votesLeft, votesRight int64
var users, entries, toplevel, votesYes, votesNo int64
if err := db.QueryRow(
`SELECT (SELECT COUNT(*) FROM account),
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
(SELECT COUNT(*) FROM vote WHERE mode = 'left'),
(SELECT COUNT(*) FROM vote WHERE mode = 'right')`).
Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil {
(SELECT COUNT(*) FROM vote WHERE mode = 'yes'),
(SELECT COUNT(*) FROM vote WHERE mode = 'no')`).
Scan(&users, &entries, &toplevel, &votesYes, &votesNo); err != nil {
return Internal(err)
}
@@ -313,7 +313,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
"entries": entries,
"toplevel": toplevel,
"replies": entries - toplevel,
"votes": map[string]any{"left": votesLeft, "right": votesRight},
"votes": map[string]any{"yes": votesYes, "no": votesNo},
"impressions_total": imprTotal,
"visitors_total": visitorsTotal,
"daily": daily,