From 293d882cb4c50a5f1af02e5c9318be924892b4f2 Mon Sep 17 00:00:00 2001
From: Harlekin
Date: Mon, 21 Sep 2026 00:49:38 +0200
Subject: [PATCH] Antraege durchsuchbar, Fortschritt sichtbar, Spieler an einem
Stueck
Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die
erste Seite - danach steht der Umfang fest, und der Balken zeigt einen
ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer
Seite mit.
Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten
oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten
Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten
fuenfzig zu zeigen und den Rest zu verschweigen.
Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller
zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf,
waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt
den Spieler mit bereits eingetragenem Forum-Namen an.
Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei
Seiten zu wechseln:
- Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder
als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben
Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen.
- Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene
Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen.
Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt,
auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein
Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren.
Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide
Seiten sie brauchen.
---
README.md | 30 +-
server/src/routes/forum.routes.ts | 18 +
server/src/routes/imports.routes.ts | 24 +
server/src/services/forum.ts | 153 ++++--
server/src/services/imports.ts | 166 +++++++
server/src/services/players.ts | 39 +-
server/test/antraege.test.ts | 104 ++++
server/test/imports.test.ts | 142 ++++++
web/src/components/PendingRowsBox.tsx | 142 ++++++
web/src/components/ThreadModals.tsx | 328 +++++++++++++
web/src/components/ThreadsBox.tsx | 113 +++++
web/src/lib/types.ts | 29 ++
web/src/pages/Dashboard.tsx | 31 +-
web/src/pages/Forum.tsx | 652 +++++++++++---------------
web/src/pages/PlayerDetail.tsx | 24 +
web/src/styles.css | 91 ++++
16 files changed, 1655 insertions(+), 431 deletions(-)
create mode 100644 web/src/components/PendingRowsBox.tsx
create mode 100644 web/src/components/ThreadModals.tsx
create mode 100644 web/src/components/ThreadsBox.tsx
diff --git a/README.md b/README.md
index c794e9d..4dc656d 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,14 @@ Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der
landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der
Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu.
+Darüber steht *Ersteller ohne Spieler*: dieselben Anträge, aber nach Ersteller zusammengefasst.
+Wer dreiundzwanzig Anträge gestellt hat, fällt dort auf, während er zwischen fünfhundert
+Einzelanträgen untergeht. Ein Klick auf *Spieler anlegen* öffnet das Formular mit bereits
+eingetragenem Forum-Namen; danach wandern seine Anträge von selbst in die Arbeitsliste.
+
+Beide Listen lassen sich durchsuchen und wahlweise mit dem neuesten oder dem ältesten Antrag
+zuerst anzeigen; die untere blättert seitenweise.
+
Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder
ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste.
Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt.
@@ -241,6 +249,19 @@ Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge blei
Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein
Klick auf den CC-Namen direkt zum Forumsbeitrag.
+### Einen Spieler an einem Stück abarbeiten
+
+Auf der Spielerseite stehen seine Anträge unter *Forums-Anträge*, ältester zuerst, jeder als Link
+ins Forum. Offene lassen sich dort direkt entscheiden - dieselbe Entscheidung wie auf der
+Anträge-Seite, nur ohne den Spieler dort erst suchen zu müssen. Danach steht der Antrag in der
+Anträge-Liste nicht mehr offen.
+
+Genauso mit den Käufen: Liegen Pakete dieses Spielers noch in einer Import-Vorschau, erscheinen
+sie unter *Offene Pakete aus dem Abgleich* und können einzeln oder am Stück gebucht werden. Was
+hier gebucht ist, steht in keiner Vorschau mehr zur Buchung an - auch nicht, wenn dieselbe
+Bestellung in einem zweiten Import liegt. Wer ein Paket gar nicht buchen will, legt es weg; es
+verschwindet dann aus der Vorschau, ohne das Konto zu berühren.
+
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen.
@@ -261,9 +282,12 @@ voller Lauf braucht also rund eine halbe Minute. Bremst das Forum den Zugriff au
angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite
erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles.
-Deshalb läuft der Abgleich **im Hintergrund**. Der Knopf zeigt den Fortschritt, und der Lauf
-geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis
-dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat.
+Deshalb läuft der Abgleich **im Hintergrund**, mit einem Fortschrittsbalken und einem Protokoll,
+das Seite für Seite mitschreibt. Gelesen wird zuerst von jedem Board die erste Seite - erst
+danach steht der Umfang fest, und der Balken zeigt einen ehrlichen Anteil statt einer Schätzung.
+Der Lauf geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten,
+was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht
+hat.
## Problemfälle
diff --git a/server/src/routes/forum.routes.ts b/server/src/routes/forum.routes.ts
index 5093189..955aaff 100644
--- a/server/src/routes/forum.routes.ts
+++ b/server/src/routes/forum.routes.ts
@@ -10,6 +10,7 @@ import {
listBoardChangeNotices,
listThreads,
listUnassignedThreads,
+ listUntrackedAuthors,
startScan,
} from '../services/forum.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -31,6 +32,8 @@ export async function forumRoutes(app: FastifyInstance): Promise {
open: z.enum(['true', 'false']).default('true'),
playerId: z.coerce.number().int().positive().optional(),
board: z.nativeEnum(ForumBoard).optional(),
+ q: z.string().trim().max(120).optional(),
+ order: z.enum(['newest', 'oldest']).default('newest'),
}),
request.query,
);
@@ -38,8 +41,10 @@ export async function forumRoutes(app: FastifyInstance): Promise {
return {
items: await listThreads({
onlyOpen: query.open === 'true',
+ order: query.order,
...(query.playerId !== undefined ? { playerId: query.playerId } : {}),
...(query.board ? { board: query.board } : {}),
+ ...(query.q ? { query: query.q } : {}),
}),
};
});
@@ -49,16 +54,29 @@ export async function forumRoutes(app: FastifyInstance): Promise {
z.object({
q: z.string().trim().max(120).optional(),
take: z.coerce.number().int().min(1).max(200).default(50),
+ skip: z.coerce.number().int().min(0).default(0),
+ order: z.enum(['newest', 'oldest']).default('newest'),
}),
request.query,
);
return listUnassignedThreads({
take: query.take,
+ skip: query.skip,
+ order: query.order,
...(query.q ? { query: query.q } : {}),
});
});
+ /** Ersteller aus dem Forum, die hier unter keinem Namen geführt werden. */
+ app.get('/api/forum/authors/untracked', { preHandler: requireAuth }, async (request) => {
+ const query = parseWith(
+ z.object({ take: z.coerce.number().int().min(1).max(500).default(100) }),
+ request.query,
+ );
+ return listUntrackedAuthors(query.take);
+ });
+
app.get('/api/forum/notices', { preHandler: requireAuth }, async () => {
return { items: await listBoardChangeNotices() };
});
diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts
index c1dd32b..e56dc4a 100644
--- a/server/src/routes/imports.routes.ts
+++ b/server/src/routes/imports.routes.ts
@@ -10,6 +10,8 @@ import {
getImport,
listImports,
parseUpload,
+ bookRows,
+ dismissRows,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -63,6 +65,28 @@ export async function importRoutes(app: FastifyInstance): Promise {
return commitImport(id, contextOf(request));
});
+ /**
+ * Einzelne Zeilen sofort buchen, statt auf den ganzen Import zu warten.
+ * Gedacht für den Weg über die Spielerseite: einen Spieler an einem Stück
+ * abarbeiten. Gebuchtes verschwindet danach aus der Vorschau.
+ */
+ app.post('/api/imports/rows/book', { preHandler: requireEditor }, async (request) => {
+ const body = parseWith(
+ z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
+ request.body,
+ );
+ return bookRows(body.rowIds, contextOf(request));
+ });
+
+ /** Zeilen aus der Vorschau nehmen, ohne sie zu buchen. */
+ app.post('/api/imports/rows/dismiss', { preHandler: requireEditor }, async (request) => {
+ const body = parseWith(
+ z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
+ request.body,
+ );
+ return dismissRows(body.rowIds, contextOf(request));
+ });
+
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts
index 2c1e73f..ca7c6a4 100644
--- a/server/src/services/forum.ts
+++ b/server/src/services/forum.ts
@@ -14,6 +14,13 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
{ board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' },
];
+/// Die Board-Pfade in lesbarer Form, für das Protokoll des Laufs.
+const BOARD_NAMES: Record = {
+ 'board/66-custom-characters': 'Antragsübersicht',
+ 'board/355-aktive-ccs': 'Aktive CCs',
+ 'board/113-cc-archiv': 'Archiv',
+};
+
const MIN_REQUEST_GAP_MS = 1200;
/// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
const MAX_REQUEST_GAP_MS = 10000;
@@ -118,6 +125,10 @@ export interface ScanState {
/// Woran gerade gelesen wird, für die Anzeige während des Laufs.
currentBoard: string | null;
pagesRead: number;
+ /// Steht fest, sobald von jedem Board die erste Seite gelesen ist. Vorher 0.
+ totalPages: number;
+ /// Was der Lauf bisher getan hat, jüngster Eintrag zuletzt.
+ log: string[];
summary: ForumScanSummary | null;
error: string | null;
}
@@ -136,10 +147,20 @@ const scanState: ScanState = {
finishedAt: null,
currentBoard: null,
pagesRead: 0,
+ totalPages: 0,
+ log: [],
summary: null,
error: null,
};
+/// Damit ein langer Lauf den Speicher nicht vollschreibt.
+const MAX_LOG_LINES = 200;
+
+function protokoll(zeile: string): void {
+ scanState.log.push(zeile);
+ if (scanState.log.length > MAX_LOG_LINES) scanState.log.shift();
+}
+
export function getScanState(): ScanState {
return { ...scanState };
}
@@ -155,6 +176,8 @@ export function startScan(ctx: ActionContext): ScanState {
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
+ scanState.totalPages = 0;
+ scanState.log = [];
scanState.summary = null;
scanState.error = null;
@@ -206,40 +229,62 @@ export async function scanForum(ctx: ActionContext): Promise {
// dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
const gefunden = new Map();
+ // Erst von jedem Board die erste Seite. Danach steht der Umfang des Laufs
+ // fest, und die Anzeige kann einen ehrlichen Fortschritt zeigen statt zu
+ // raten, wie viel noch kommt.
+ const offen: { board: ForumBoard; path: string; seiten: number; gelesen: number }[] = [];
+
for (const { board, path } of BOARDS) {
- let gelesen = 0;
scanState.currentBoard = path;
+ protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`);
try {
const first = await fetchPage(path, 1);
- gelesen += 1;
summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages;
-
- const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board });
- for (let page = 2; page <= seiten; page += 1) {
- const html = await fetchPage(path, page);
- gelesen += 1;
+ const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
+ scanState.totalPages += seiten;
+ offen.push({ board, path, seiten, gelesen: 1 });
+ protokoll(`${BOARD_NAMES[path] ?? path}: Seite 1 von ${seiten} gelesen`);
+ } catch (error) {
+ const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
+ summary.failedBoards.push({ board: path, reason: grund, pagesRead: 0 });
+ protokoll(`${BOARD_NAMES[path] ?? path}: nicht erreichbar (${grund})`);
+ }
+ }
+
+ for (const eintrag of offen) {
+ const name = BOARD_NAMES[eintrag.path] ?? eintrag.path;
+ scanState.currentBoard = eintrag.path;
+
+ try {
+ for (let page = 2; page <= eintrag.seiten; page += 1) {
+ const html = await fetchPage(eintrag.path, page);
+ eintrag.gelesen += 1;
summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages;
- for (const thread of parseBoard(html)) gefunden.set(thread.threadId, { thread, board });
+ for (const thread of parseBoard(html)) {
+ gefunden.set(thread.threadId, { thread, board: eintrag.board });
+ }
+ protokoll(`${name}: Seite ${page} von ${eintrag.seiten} gelesen`);
}
summary.scannedBoards += 1;
+ protokoll(`${name}: fertig`);
} catch (error) {
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
// Seiten mit in den Papierkorb nehmen.
- summary.failedBoards.push({
- board: path,
- reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
- pagesRead: gelesen,
- });
+ const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
+ summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen });
+ protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`);
}
}
summary.rateLimited = rateLimitHits;
+ scanState.currentBoard = null;
+ protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`);
const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
for (const eintrag of gefunden.values()) {
@@ -353,6 +398,12 @@ export async function scanForum(ctx: ActionContext): Promise {
});
}
+ protokoll(
+ summary.newThreads > 0
+ ? `${summary.newThreads} neue Anträge erfasst`
+ : 'Keine neuen Anträge',
+ );
+
await recordAudit(prisma, ctx, {
action: 'forum.scan',
targetType: 'forum',
@@ -388,10 +439,33 @@ async function forumNameIndex(): Promise
@@ -163,15 +218,13 @@ export default function Forum() {
Forums-Anträge
Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches
- Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
- die hier mit passendem Forum-Namen erfasst sind.
+ Signal und lösen nie eine Buchung aus. Anträge von Erstellern, die hier unter keinem
+ Forum-Namen geführt werden, stehen weiter unten.
{darfEntscheiden && (
)}
@@ -179,14 +232,7 @@ export default function Forum() {
{error && setError(null)}>{error}}
{flash && setFlash(null)}>{flash}}
- {busy && (
-
- Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} -
- bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und
- zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
- Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
-
- )}
+ {busy && }
{notices.length > 0 && (
@@ -229,9 +275,28 @@ export default function Forum() {
Wartet auf Entscheidung ({threads.length})
+
+ setSucheOffen(event.target.value)}
+ />
+
+
+
{threads.length === 0 ? (
- Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'}
+ {sucheOffen
+ ? 'Kein Treffer.'
+ : `Keine offenen Anträge. ${darfEntscheiden ? 'Über „Forum abgleichen" nach neuen suchen.' : ''}`}
) : (
@@ -281,14 +346,77 @@ export default function Forum() {
)}
+
+
+
+
Ersteller ohne Spieler ({autoren.length})
+
+ Wer im Forum Anträge gestellt hat, hier aber unter keinem Forum-Namen geführt wird.
+ Ein Klick legt den Spieler an - der Forum-Name steht schon drin, und seine Anträge
+ wandern anschließend von selbst in die Arbeitsliste.
+
Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})
- Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben
- aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit
- passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
+ Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der
+ Arbeitsliste heraus, gehen aber nicht verloren.