Verwaltung: Stimmen als kompakte Kacheln, Supporter-Liste auf 4 gekuerzt

Beides auf Wunsch vom 27.08.2026 ein-/ausklappbar:

- Stimmen liegen jetzt in einem Raster (auto-fill, min. 290px) statt
  untereinander -- am Computer stehen 3-4 Kacheln nebeneinander, am Handy
  eine. Zugeklappt ist genau die erste Reihe sichtbar; wie viele Kacheln
  das sind, liest die Logik aus dem Raster selbst aus (getComputedStyle
  liefert bei auto-fill die gebauten Spuren), damit "erste Reihe" auf
  jeder Breite stimmt -- inklusive Neuberechnung beim Groessenwechsel.
- Supporter-Tabelle zeigt nur noch die 4 zuletzt Angemeldeten, Rest per
  Knopf. Knopftext nennt immer die Gesamtzahl, damit nichts versteckt
  wirkt; beim Zuklappen springt die Ansicht sauber zum Listenanfang.
- Neuer Test pruef-verwaltung-kacheln.mjs (18 Pruefungen, Computer +
  Handy, inkl. Groessenwechsel und Ueberlauf-Kontrolle).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-27 13:17:05 +02:00
co-authored by Claude Opus 5
parent 988a2fdc95
commit 0465a7a14a
2 changed files with 328 additions and 6 deletions
+194
View File
@@ -0,0 +1,194 @@
/* =====================================================================
Verwaltung: Stimmen-Kacheln + Supporter-Liste ein-/ausklappbar
(Nutzer-Wunsch 27.08.2026)
Geprüft wird mit nachgebildeten Daten, weil die Verwaltung hinter der
Zugangswand liegt: admin-auth.js wird durch eine Attrappe ersetzt, die
eine gültige Sitzung und feste Antworten liefert. Absichtlich lange
Namen/Texte — genau daran brechen Kachel-Layouts.
Gemessen wird:
1. Stimmen: im zugeklappten Zustand ist genau die erste Reihe
sichtbar (Anzahl = Spaltenzahl des Rasters), am Handy also 1.
2. Knopf klappt auf (alle sichtbar) und wieder zu (wieder eine Reihe).
3. Supporter: nur die 4 zuletzt Angemeldeten, Knopf zeigt alle, Knopf
klappt wieder zu.
4. Reihenfolge stimmt: die 4 sichtbaren sind die neuesten.
5. Nichts läuft am Handy seitlich über.
===================================================================== */
import { chromium } from "playwright";
import http from "http";
import fs from "fs";
import path from "path";
const TYP = {
".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp",
".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg",
".webmanifest": "application/manifest+json",
};
const srv = http.createServer((q, r) => {
const p = path.join(process.cwd(), decodeURIComponent(q.url.split("?")[0]));
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" });
return r.end(fs.readFileSync(p));
}
r.writeHead(404);
r.end("");
});
let ok = 0, fehl = 0;
const funde = [];
const pruefe = (name, gut, info) => {
if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); funde.push(name + " — " + info); }
};
const vorTagen = (n) => new Date(Date.now() - n * 864e5).toISOString();
const STIMMEN = [
"Team Dogi bedeutet für mich Zusammenhalt, echte Freundschaft und Menschen auf die man sich verlassen kann. In erster Linie bin ich sehr stolz und auch gleichzeitig dankbar das Dogi weiter gemacht hat.",
"Team Dogi bedeutet für uns Zusammenhalt, echte Freundschaft und Menschen, auf die man sich verlassen kann. Hinter all dem steht für uns vor allem ein ganz besonderer Mensch, Dogi.",
"Kurz und knapp: beste Community.",
"Ich bin seit dem ersten Tag dabei und habe hier Freunde fürs Leben gefunden — danke an alle, die das jeden Tag mittragen.",
"Ein Wahnsinns-Team mit einem Riesenherz. Jede Woche neue Aktionen, immer fair, immer ehrlich.",
"Danke für alles!",
"Hier fühlt man sich sofort willkommen, egal ob man 5 Minuten oder 5 Monate dabei ist.",
];
const TESTIMONIALS = STIMMEN.map((nachricht, i) => ({
id: "t" + i,
name: ["Sabrina-Charlotte Winterfeld-Baumgartner", "Klabine", "Ghost", "DjBeatim", "VanVan", "Miss", "Marina"][i],
tiktokUsername: ["sabrina_winterfeld_offiziell", "klabine", "_sven_85", "mairetim", "o.o.vanessa.o.o", "seelen_staub", "flauschirexi"][i],
nachricht,
status: i < 2 ? "freigegeben" : i === 2 ? "eingereicht" : "freigegeben",
avatar: null,
createdAt: vorTagen(i + 1),
}));
/* 9 Supporter, absteigend nach Anmeldedatum: s0 ist der neueste. */
const SUPPORTER = Array.from({ length: 9 }, (_, i) => ({
id: "s" + i,
display_name: ["Marina", "Miss", "Diene", "Ghost", "DjBeatim", "VanVan", "Dogi", "DogFather", "Immobilienverwaltung Sonnenhof"][i],
tiktok_username: ["flauschirexi", "seelen_staub", "_ditschi", "_sven_85", "mairetim", "o.o.vanessa.o.o", "dogfather0804", "dogfather0804", "sonnenhof_immobilienverwaltung"][i],
email: ["[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com"][i],
sub_status: "none",
total_paid_months: 0,
created_at: vorTagen(i),
progress: { cycleNumber: 1, currentCycleMonth: 0 },
}));
const ATTRAPPE = `
const SESSION = { name: "Dogi", isOwner: true, permissions: ["*"], token: "test" };
const TESTIMONIALS = ${JSON.stringify(TESTIMONIALS)};
const SUPPORTER = ${JSON.stringify(SUPPORTER)};
window.AdminAuth = {
getSession: () => SESSION,
setSession() {}, clearSession() {},
sessionHasPermission: () => true,
async apiCall(base, pfad) {
if (pfad === "/admin/testimonials/list") return { ok: true, testimonials: TESTIMONIALS };
if (pfad === "/admin/supporter/list") return { ok: true, supporters: SUPPORTER, supporter: SUPPORTER };
if (pfad === "/admin/overview") return { ok: true, premiums: [], payments: [] };
if (pfad === "/admin/events/list") return { ok: true, events: [null, null] };
if (pfad === "/admin/polls/list") return { ok: true, polls: [] };
if (pfad === "/admin/team/list") return { ok: true, members: [], team: [] };
if (pfad === "/admin/special-event") return { ok: true, event: null };
return { ok: true };
},
async doLogin() { return { ok: true }; },
async doLogout() {},
};
`;
const zustand = (seite) => seite.evaluate(() => {
const grid = document.getElementById("testimonials-liste");
const karten = Array.from(grid.querySelectorAll("[data-testimonial-id]"));
const sichtbar = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
const spur = getComputedStyle(grid).gridTemplateColumns;
return {
spalten: spur && spur !== "none" ? spur.split(" ").filter(Boolean).length : 1,
stimmenGesamt: karten.length,
stimmenSichtbar: karten.filter(sichtbar).length,
stimmenKnopf: (() => { const b = document.getElementById("stimmen-mehr-btn"); return { da: !b.hidden, text: b.textContent.trim() }; })(),
zeilen: Array.from(document.querySelectorAll("#supporter-tbody tr[data-id]")).map((tr) => tr.querySelector(".vw-name-cell")?.textContent || ""),
supporterKnopf: (() => { const b = document.getElementById("supporter-mehr-btn"); return { da: !b.hidden, text: b.textContent.trim() }; })(),
ueberlauf: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
async function laden(browser, viewport, dateiname) {
const seite = await browser.newPage({ viewport });
await seite.route("**/assets/js/admin-auth.js*", (route) =>
route.fulfill({ status: 200, contentType: "text/javascript", body: ATTRAPPE })
);
seite.on("pageerror", (e) => pruefe("Kein JS-Fehler (" + dateiname + ")", false, (e.stack || e.message).split("\n").slice(0, 4).join(" | ")));
await seite.goto("http://127.0.0.1:4422/verwaltung.html");
await seite.waitForSelector("[data-testimonial-id]", { timeout: 8000 });
await seite.waitForTimeout(400);
return seite;
}
(async () => {
await new Promise((r) => srv.listen(4422, "127.0.0.1", r));
const browser = await chromium.launch();
/* ---------- Computer ---------- */
console.log("\n== Computer (1440x900) ==");
const pc = await laden(browser, { width: 1440, height: 900 }, "computer");
let z = await zustand(pc);
console.log(" Raster-Spalten:", z.spalten);
pruefe("Stimmen stehen nebeneinander", z.spalten >= 2, z.spalten + " Kacheln pro Reihe");
pruefe("Nur die erste Reihe sichtbar", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + " von " + z.stimmenGesamt + " sichtbar");
pruefe("Aufklapp-Knopf da", z.stimmenKnopf.da, z.stimmenKnopf.text);
pruefe("Supporter: nur 4 Zeilen", z.zeilen.length === 4, z.zeilen.join(", "));
pruefe("Supporter: die 4 neuesten", z.zeilen.join("|") === "Marina|Miss|Diene|Ghost", z.zeilen.join("|"));
pruefe("Supporter-Knopf da", z.supporterKnopf.da, z.supporterKnopf.text);
await pc.screenshot({ path: "bild-kacheln-computer-zu.png", fullPage: true });
await pc.click("#stimmen-mehr-btn");
await pc.click("#supporter-mehr-btn");
await pc.waitForTimeout(300);
z = await zustand(pc);
pruefe("Aufgeklappt: alle Stimmen sichtbar", z.stimmenSichtbar === z.stimmenGesamt, z.stimmenSichtbar + "/" + z.stimmenGesamt);
pruefe("Aufgeklappt: alle Supporter sichtbar", z.zeilen.length === 9, z.zeilen.length + " Zeilen");
pruefe("Knopftext wechselt (Stimmen)", /Weniger/.test(z.stimmenKnopf.text), z.stimmenKnopf.text);
pruefe("Knopftext wechselt (Supporter)", /Weniger/.test(z.supporterKnopf.text), z.supporterKnopf.text);
await pc.screenshot({ path: "bild-kacheln-computer-offen.png", fullPage: true });
await pc.click("#stimmen-mehr-btn");
await pc.click("#supporter-mehr-btn");
await pc.waitForTimeout(400);
z = await zustand(pc);
pruefe("Wieder zugeklappt: eine Reihe Stimmen", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + "/" + z.stimmenGesamt);
pruefe("Wieder zugeklappt: 4 Supporter", z.zeilen.length === 4, z.zeilen.length + " Zeilen");
/* ---------- Handy ---------- */
console.log("\n== Handy (390x844) ==");
const handy = await laden(browser, { width: 390, height: 844 }, "handy");
let h = await zustand(handy);
pruefe("Handy: eine Kachel pro Reihe", h.spalten === 1, h.spalten + " Spalte(n)");
pruefe("Handy: nur die erste Kachel sichtbar", h.stimmenSichtbar === 1, h.stimmenSichtbar + " sichtbar");
pruefe("Handy: nur 4 Supporter", h.zeilen.length === 4, h.zeilen.length + " Zeilen");
pruefe("Handy: kein seitlicher Überlauf", h.ueberlauf <= 1, h.ueberlauf + "px");
await handy.screenshot({ path: "bild-kacheln-handy-zu.png", fullPage: true });
await handy.click("#stimmen-mehr-btn");
await handy.waitForTimeout(250);
h = await zustand(handy);
pruefe("Handy: aufgeklappt alle Stimmen", h.stimmenSichtbar === h.stimmenGesamt, h.stimmenSichtbar + "/" + h.stimmenGesamt);
/* ---------- Breitenwechsel im zugeklappten Zustand ---------- */
console.log("\n== Fenster verkleinern (zugeklappt) ==");
await pc.setViewportSize({ width: 760, height: 900 });
await pc.waitForTimeout(400);
z = await zustand(pc);
pruefe("Nach Größenänderung stimmt die erste Reihe", z.stimmenSichtbar === z.spalten, z.stimmenSichtbar + " sichtbar bei " + z.spalten + " Spalten");
await browser.close();
srv.close();
console.log("\n===== " + ok + " ok, " + fehl + " Fehler =====");
if (funde.length) { console.log("\nOffene Punkte:"); funde.forEach((f) => console.log(" - " + f)); }
process.exit(fehl ? 1 : 0);
})();