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:
co-authored by
Claude Opus 5.5
parent
3d2447fe35
commit
b6cf8c7fa0
+9
-9
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user