Portal: Aufgabenliste und Postfach fuer den Kunden

Schritt 4 von 4 -- damit sind beide Wuensche vom 22.08.2026 vollstaendig:
"wenn sie drauf druecken sollen sie auch sehen was ich schon gemacht habe
von dem was im plan war" und "die kunden sollen mir auch nachrichten
hinterlassen koennen".

AUFGABENLISTE IM PROJEKT

Bisher sah der Kunde nur die Phase ("Design . 3/7") -- eine Zahl ohne
Inhalt. Sie beantwortet die eigentliche Frage nicht: WAS ist denn fertig?

Die Liste steht GANZ OBEN, direkt nach dem Projektkopf, noch vor
Zahlungen und Dateien. Die sind wichtig, aber sie sind nicht der Grund
fuer den Klick.

Zwei Entscheidungen praegen die Ansicht:

1. Offene Kundenpunkte stehen in einem EIGENEN Kasten ganz oben ("Das
   brauche ich noch von dir"), nicht nur farblich markiert irgendwo
   mittendrin. Wer eine lange Liste sieht, liest sie als Bericht ueber
   fremde Arbeit und ueberliest seinen eigenen Teil -- genau daraus
   entstehen die meisten Verzoegerungen. Ist nichts offen, steht das
   auch da: "Von dir wird gerade nichts gebraucht." Eine gute Nachricht
   darf ausgesprochen werden.

2. Der Kunde hakt seine eigenen Punkte selbst ab. Nur diese haben einen
   Knopf; bei fremden Punkten ist das Zeichen reine Anzeige. Ein Knopf,
   der nichts tut, laesst die Seite kaputt wirken. Geprueft: 4 Knoepfe
   bei 2 offenen eigenen Punkten (sie erscheinen zweimal), 5 feste.

Grosse Zahl statt Prozent: "2 von 6 erledigt" ist greifbar, "33 %" ist
eine Rechnung, die niemand fuehlt. Der Balken traegt die vier
Prozessfarben -- dieselben wie auf der Ablaufseite und in der Verwaltung.

Der Ton ist bewusst gewaehlt. Der Kunde liest die Liste, wenn er unsicher
ist -- also im Zweifel schon angespannt. "Wir warten auf dich" waere
Druck, "Das brauche ich noch von dir" ist eine Bitte.

POSTFACH AUF DER UEBERSICHT

Bewusst auf der Uebersicht, nicht im Projekt: Wer kein Projekt hat -- oder
eine Frage, die zu keinem gehoert -- konnte vorher gar nicht schreiben und
musste zur E-Mail greifen. Damit war der Verlauf weg, sobald man ihn
brauchte.

Niedrigschwellig: kein Betreff, kein Pflichtfeld, der Projektbezug ist
freiwillig. Wer glaubt, eine Nachricht muesse eine "richtige" Anfrage
sein, schreibt gar nicht erst -- und genau die kurzen Fragen sollen hier
landen. Wird nachgeladen, damit die Uebersicht sofort dasteht.

Projekt- und Aufgabendaten werden gleichzeitig angefordert statt
nacheinander -- sonst waere die Wartezeit die Summe beider Anfragen. Die
Aufgabenliste darf dabei fehlschlagen, ohne die Seite mitzureissen: Wer
wegen einer leeren Liste seine Zahlungen nicht mehr saehe, waere
schlechter dran als vorher.

EIN SICHTBARER FEHLER GEFUNDEN

Auf dem Screenshot stand "Ideen & Aenderungswuensche". Ursache ist
eine Falle mit zwei Wegen: Texte ueber data-i18n landen als HTML in der
Seite, dort ist "&" richtig. Derselbe Text im JavaScript laeuft aber
durch die Absicherung und wird ein zweites Mal kodiert. Derselbe Baustein
ist also je nach Verwendungsort richtig oder falsch.

Das kann man nicht im Kopf behalten -- deshalb neu pruef-texte.mjs mit
ZWEI Pruefungen:

  Anzeige:   alle 11 Seiten x 5 Sprachen = 55 Kombinationen, sichtbarer
             Text darf keine Kodierungsreste enthalten.
  Quelltext: welcher Baustein mit "&" wird irgendwo abgesichert
             eingesetzt?

Beide sind noetig. Gegengeprueft: Die Anzeigepruefung allein haette den
Fehler NICHT gefunden, weil die Ideen-Karte erst nach einer Anmeldung
erscheint. Die Quelltextpruefung findet ihn ohne Anzeige.

Auch die Quelltextpruefung selbst wurde zweimal gegengeprueft: Zuerst
meldete sie zusaetzlich po_senden ("Senden", voellig harmlos) -- ihre
Blockerkennung lief bis zum naechsten Schluessel und schluckte dabei
einen Kommentar mit "&". Jetzt zaehlt sie geschweifte Klammern. Ein
Fehlalarm im Pruefwerkzeug ist fast so schaedlich wie ein uebersehener
Fehler: Man gewoehnt sich daran, ihn wegzusehen.

GEPRUEFT

32 Pruefungen im Portal (Computer und Handy), alle bestanden. Darunter:
114 Texte in allen fuenf Sprachen vollstaendig, Kundenpunkte stehen vor
der langen Liste, Aufgaben vor den Zahlungen, Abhaken meldet den
richtigen Punkt, alle Kaestchen mindestens 24 px, keine Fehler im
Protokoll. Dazu 55 Seiten-Sprach-Kombinationen ohne Kodierungsreste.

Ein Fehler im Test selbst gefunden und behoben: Die Adressabfrage prueft
jetzt den PFAD statt der ganzen Adresse. Der API-Server heisst
"postfach.dogfather-universe.com" -- ein includes("/postfach") traf den
HOSTNAMEN und damit jede Anfrage, auch die Uebersicht.

Versionsstempel und Cache-Name auf v5.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-23 10:46:09 +02:00
co-authored by Claude Opus 5
parent b7c333fca3
commit 469a6c195f
17 changed files with 1065 additions and 48 deletions
+224
View File
@@ -0,0 +1,224 @@
/* Prüft die Aufgabenliste und das Postfach im Kundenportal.
Wichtig sind hier drei Dinge, die man leicht falsch baut:
1. Der Kunde darf NUR seine eigenen Punkte anklicken. Ein Knopf, der
nichts tut, lässt die Seite kaputt wirken.
2. Offene Kundenpunkte müssen VOR der langen Liste stehen — sonst
liest man die Liste als Bericht und überliest seinen Teil.
3. Alle fünf Sprachen müssen vollständig sein. Ein fehlender
Schlüssel fällt sonst erst einem französischen Kunden auf.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";
const W = process.cwd();
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
const srv = createServer((q, r) => {
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
if (existsSync(p) && !p.endsWith("/")) {
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
return r.end(readFileSync(p));
}
r.writeHead(404).end("");
});
await new Promise((r) => srv.listen(4326, r));
const AUFGABEN = [
{ id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.",
kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
{ id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.",
kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
{ id: "a3", titel: "Entwurf der Startseite", beschreibung: null,
kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
{ id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null,
kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
{ id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null,
kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
{ id: "a6", titel: "Mehrsprachigkeit", beschreibung: null,
kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1,
hinweis: "Auf Wunsch als Zusatz." },
{ id: "a7", titel: "Seite live schalten", beschreibung: null,
kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
];
const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33,
jeKategorie: { start: { gesamt: 2, erledigt: 1 }, gestaltung: { gesamt: 2, erledigt: 1 },
umsetzung: { gesamt: 1, erledigt: 0 }, abschluss: { gesamt: 1, erledigt: 0 } } };
const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau",
status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf." };
const ergebnisse = [];
function pruefe(name, ok, zusatz) {
ergebnisse.push({ name, ok });
console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : ""));
}
const browser = await chromium.launch();
/* ---- 1. Vollständigkeit aller fünf Sprachen ---- */
{
const p = await browser.newPage();
await p.goto("http://127.0.0.1:4326/webdesign/portal.html");
await p.waitForTimeout(600);
const luecken = await p.evaluate(() => {
const w = window.I18N_WD || {};
const sprachen = ["de", "de-CH", "en", "fr", "pt"];
const fehlt = [];
for (const [k, v] of Object.entries(w)) {
for (const sp of sprachen) {
if (!v[sp] || !String(v[sp]).trim()) fehlt.push(k + "/" + sp);
}
}
return { fehlt, anzahl: Object.keys(w).length };
});
console.log("\n=== Sprachen ===");
pruefe("alle Texte in allen fünf Sprachen vorhanden", luecken.fehlt.length === 0,
luecken.fehlt.slice(0, 6).join(", "));
console.log(" (" + luecken.anzahl + " Texte geprüft)");
await p.close();
}
for (const [wo, breite, hoehe] of [["Computer", 1280, 950], ["Handy", 390, 844]]) {
const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
const fehlerImProtokoll = [];
seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
let abgehakt = null;
// Nur die API abfangen, nicht die Seite selbst.
await seite.route("**/webdesign/portal/**", (route) => {
/* Auf den PFAD prüfen, nicht auf die ganze Adresse.
Der API-Server heisst "postfach.dogfather-universe.com" -- ein
url.includes("/postfach") trifft also den HOSTNAMEN und damit
JEDE Anfrage. Beim ersten Lauf bekam deshalb auch die Übersicht
die Postfach-Antwort, und der Fehler sah aus wie ein Fehler im
Portal ("Cannot read properties of undefined"). Er lag im Test. */
const u = new URL(route.request().url()).pathname;
if (u.includes("/projekt/p1/aufgaben")) {
return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } });
}
if (u.includes("/aufgabe/") && u.includes("/erledigt")) {
abgehakt = u.split("/aufgabe/")[1].split("/")[0];
return route.fulfill({ json: { ok: true, status: "erledigt", fortschritt: FORTSCHRITT } });
}
if (u.includes("/projekt/p1")) {
return route.fulfill({ json: { ok: true, projekt: PROJEKT,
zahlungen: [], dateien: [], aenderungen: [], nachrichten: [] } });
}
if (u.includes("/postfach")) {
if (route.request().method() === "POST") return route.fulfill({ json: { ok: true, id: "x" } });
return route.fulfill({ json: { ok: true, projekte: [{ id: "p1", titel: "Website für Musterbau" }],
nachrichten: [
{ id: "m1", autor: "kunde", text: "Kurze Frage zur Domain.", projekt_titel: null,
erstellt_am: new Date(Date.now() - 7200000).toISOString() },
{ id: "m2", autor: "dogfather", text: "Die läuft schon auf dich, alles gut.",
projekt_titel: null, erstellt_am: new Date(Date.now() - 3600000).toISOString() },
] } });
}
if (u.includes("/uebersicht")) {
return route.fulfill({ json: { ok: true, kunde: { name: "Anna" },
projekte: [PROJEKT], offeneZahlungen: [], abos: [] } });
}
return route.fulfill({ json: { ok: true } });
});
await seite.addInitScript(() => {
sessionStorage.setItem("wd-portal-token", "t");
sessionStorage.setItem("wd-sitzung-aktiv", "1");
});
await seite.goto("http://127.0.0.1:4326/webdesign/portal.html");
await seite.waitForTimeout(1000);
console.log("\n=== " + wo + " ===");
// ---- Postfach auf der Übersicht ----
pruefe(wo + ": Postfach steht auf der Übersicht", await seite.locator("#po-pf-senden").count() === 1);
pruefe(wo + ": bisherige Nachrichten sind da", await seite.locator("#po-postfach .po-nachricht").count() === 2);
// Leere Nachricht muss abgelehnt werden, nicht stillschweigend gesendet
await seite.click("#po-pf-senden");
await seite.waitForTimeout(300);
pruefe(wo + ": leere Nachricht wird abgelehnt",
await seite.locator("#po-pf-feld.wd-feld--fehler").count() === 1);
await seite.screenshot({ path: `pruef-portal-postfach-${wo.toLowerCase()}.png`, fullPage: true });
// ---- Projekt öffnen ----
await seite.click("[data-projekt]");
await seite.waitForTimeout(900);
const m = await seite.evaluate(() => {
const gruppen = Array.from(document.querySelectorAll(".po-aufg-gruppe"));
const farben = gruppen.map((g) => getComputedStyle(g.querySelector(".po-aufg-kopf")).color);
const dubist = document.querySelector(".po-dubist");
const ersteGruppe = gruppen[0];
const knoepfe = document.querySelectorAll("button.po-aufg-haken");
const feste = document.querySelectorAll(".po-aufg-haken--fest");
const aufgKarte = document.querySelector(".po-aufg-stand");
const zahlKarte = Array.from(document.querySelectorAll(".wd-karte h3"))
.find((h) => h.textContent.includes("Zahlung"));
// Anordnung einer Zeile pruefen (Lehre aus der Verwaltung)
const z = document.querySelector(".po-aufg-gruppe .po-aufg");
const k = z?.querySelector(".po-aufg-haken")?.getBoundingClientRect();
const t = z?.querySelector(".po-aufg-titel")?.getBoundingClientRect();
return {
gruppen: gruppen.map((g) => g.dataset.k),
farbenVerschieden: new Set(farben).size,
dubistDa: !!dubist,
dubistVorListe: dubist && ersteGruppe
? dubist.getBoundingClientRect().top < ersteGruppe.getBoundingClientRect().top : false,
knoepfe: knoepfe.length,
feste: feste.length,
standDa: !!aufgKarte,
aufgabenVorZahlungen: aufgKarte && zahlKarte
? aufgKarte.getBoundingClientRect().top < zahlKarte.getBoundingClientRect().top : false,
kaestchenVorTitel: k && t ? k.right <= t.left + 1 : false,
balkenTeile: document.querySelectorAll(".po-fs-teil").length,
};
});
pruefe(wo + ": alle vier Abschnitte erscheinen",
m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(","));
pruefe(wo + ": jeder Abschnitt hat eine eigene Farbe", m.farbenVerschieden === 4, "n=" + m.farbenVerschieden);
pruefe(wo + ": Fortschrittsbalken hat vier Teile", m.balkenTeile === 4);
pruefe(wo + ": „Das brauche ich von dir“ ist da", m.dubistDa);
pruefe(wo + ": … und steht VOR der langen Liste", m.dubistVorListe);
pruefe(wo + ": Aufgabenliste steht vor den Zahlungen", m.aufgabenVorZahlungen);
pruefe(wo + ": grosse Zahl statt Prozent", m.standDa);
pruefe(wo + ": Kästchen steht links vom Titel", m.kaestchenVorTitel);
/* Der Kunde hat 2 offene eigene Punkte. Sie erscheinen zweimal (oben
und in der Gruppe) -> 4 Knöpfe. Alle übrigen Kästchen sind fest. */
pruefe(wo + ": nur eigene Punkte sind anklickbar", m.knoepfe === 4, "Knöpfe=" + m.knoepfe + ", fest=" + m.feste);
pruefe(wo + ": fremde Punkte haben KEINEN Knopf", m.feste === 5, "n=" + m.feste);
// Abhaken loest den richtigen Aufruf aus
await seite.locator("button.po-aufg-haken").first().click();
await seite.waitForTimeout(700);
pruefe(wo + ": Abhaken meldet den richtigen Punkt", abgehakt === "a2", "gemeldet: " + abgehakt);
if (breite < 900) {
const zuKlein = await seite.evaluate(() =>
Array.from(document.querySelectorAll("button.po-aufg-haken"))
.filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length);
pruefe(wo + ": Kästchen sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein);
}
pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
await seite.screenshot({ path: `pruef-portal-aufgaben-${wo.toLowerCase()}.png`, fullPage: true });
await seite.close();
}
await browser.close();
srv.close();
const fehler = ergebnisse.filter((e) => !e.ok).length;
console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`);
process.exitCode = fehler ? 1 : 0;