Klick auf den PO oeffnet den Projekte-Dialog zuverlaessig

Der Konfig-Dialog liess sich in der Praxis kaum oeffnen (Fehlerbericht in
Issue #27). Drei Ursachen, alle reproduziert:

- Ein Klick auf den PO selbst tat gar nichts: der Klick-Handler hing nur am
  Schreibtisch, die Figur liegt aber in der Avatar-Schicht darueber. Genau sie
  meint das Ticket ("bei Klick auf den PO"). Der Avatar ist jetzt klickbar,
  auch waehrend er am Board steht.
- Klicks auf den Platz trafen nur, wenn sie zufaellig auf einem gefuellten
  Moebelstueck landeten - eine <g> faengt in ihren Luecken keine Klicks. Der
  Platz hat jetzt eine eigene Trefferflaeche, wie die Board-Spalten.
- Dass hinter dem PO ueberhaupt etwas steckt, war nicht zu sehen. Ein kleines
  Zahnrad auf seinem Tisch (mit Tooltip) zeigt es an.

Ausserdem oeffnet der Dialog jetzt auch bei nicht erreichbarer API - er meldet
dann selbst, dass er die Projekte nicht laden konnte, statt tot zu wirken.

Und genau dieser Fall trat leicht ein: der eingebaute PHP-Server beantwortet
Anfragen nacheinander, eine offene SSE-Verbindung blockiert damit alles andere
(gemessen 16s fuer ein GET /api/v1/tickets, ab dem zweiten offenen Monitor
dauerhaft). Das Frontend faellt dann auf Demo-Daten zurueck. Mit
PHP_CLI_SERVER_WORKERS=4 sind es 0,006s; PRAGMA busy_timeout deckt die
Schreibzugriffe ab, die sich jetzt begegnen koennen.

ApplicationTest::testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey war
seit dem Seed in 003_repo_configs.sql rot (legte fds2610/monitor ein zweites
Mal an) - der Test nutzt jetzt ein nicht geseedetes Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-02 22:09:31 +02:00
parent 1ad4f25777
commit d5f7ac9f92
6 changed files with 128 additions and 13 deletions

File diff suppressed because one or more lines are too long

View file

@ -20,6 +20,11 @@ final class Database
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->exec('PRAGMA foreign_keys = ON');
// Seit der API-Server mit mehreren Workern laeuft (PHP_CLI_SERVER_WORKERS, siehe
// docker-compose.yml) koennen sich zwei Anfragen beim Schreiben begegnen. Ohne
// Wartezeit meldet SQLite dann sofort "database is locked"; fuenf Sekunden sind fuer
// die kurzen Schreibvorgaenge dieser Anwendung reichlich.
$this->pdo->exec('PRAGMA busy_timeout = 5000');
}
public static function forFile(string $path): self

View file

@ -143,10 +143,13 @@ final class ApplicationTest extends TestCase
public function testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey(): void
{
// Bewusst ein Repo, das die Migration nicht schon geseedet hat (monitor/Steuerkram/
// nc-gantt, siehe 003_repo_configs.sql) - sonst scheitert schon das Anlegen an der
// Dublettenpruefung, und der eigentliche Ablauf wird gar nicht geprueft.
$createResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/repo-configs',
body: ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04'],
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#fbca04'],
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(201, $createResponse->status);
@ -155,7 +158,7 @@ final class ApplicationTest extends TestCase
$updateResponse = $this->app->handle(Request::create(
'PATCH',
'/api/v1/repo-configs/' . $id,
body: ['owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#000000'],
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#000000'],
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(200, $updateResponse->status);

View file

@ -14,6 +14,12 @@ services:
ports:
- "8091:8080"
environment:
# Der eingebaute PHP-Server arbeitet Anfragen sonst streng nacheinander ab: eine einzige
# offene SSE-Verbindung (GET /api/v1/stream, bis zu 60s) blockiert damit jede weitere
# Anfrage - gemessen 16s fuer ein GET /api/v1/tickets. Ab dem zweiten offenen Monitor
# laeuft die API praktisch dauerhaft dicht, das Frontend faellt auf Demo-Daten zurueck
# und der PO reagiert dann auf nichts mehr (Fehlerbericht zu Issue #27).
PHP_CLI_SERVER_WORKERS: 4
MONITOR_DB_PATH: /app/data/monitor.sqlite
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
MONITOR_STATUS_FILE: /status.txt

View file

@ -344,6 +344,41 @@ header.office-header h1 {
pointer-events: all;
}
/* Trefferflaeche des PO-Platzes (Issue #27): macht den ganzen Platz klickbar, nicht nur die
gefuellten Moebelstuecke - ein Klick daneben landete sonst auf dem Boden. */
.po-desk-hit {
fill: transparent;
pointer-events: all;
}
/* Zahnrad am PO-Platz (Issue #27): zeigt ueberhaupt erst, dass sich hier die
Projektkonfiguration oeffnen laesst. Beim Hover ueber den Platz tritt es hervor. */
.po-config-hint-bg {
fill: #ffffff;
stroke: #94a3b8;
stroke-width: 1.5;
opacity: 0.85;
}
.po-config-hint-tooth,
.po-config-hint-hub {
stroke: #475569;
stroke-width: 2;
stroke-linecap: round;
fill: none;
}
.po-desk:hover .po-config-hint-bg {
fill: #e0f2fe;
stroke: #0284c7;
opacity: 1;
}
.po-desk:hover .po-config-hint-tooth,
.po-desk:hover .po-config-hint-hub {
stroke: #0284c7;
}
.just-updated .board-frame,
.just-updated .monitor-frame {
animation: office-flash 0.9s ease-out;

View file

@ -533,24 +533,67 @@ function renderDesk({ deskIndex, agent }) {
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
* @param {(() => void)|null} onPoClick - oeffnet das Projekte-Modal (Issue #27); nur gesetzt
* bei erreichbarer API, da das Modal echte Daten braucht und im Demo-Fallback ohne
* funktionierende API nichts zu konfigurieren waere.
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
*/
function renderDesks(desks, poAgent, onPoClick = null) {
function renderDesks(desks, poAgent, onPoClick) {
const g = el("g", { class: "desks" });
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
poDeskGroup.classList.add("po-desk");
if (onPoClick) {
poDeskGroup.classList.add("clickable");
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
poDeskGroup.addEventListener("click", onPoClick);
}
poDeskGroup.classList.add("po-desk", "clickable");
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
poDeskGroup.appendChild(renderPoConfigHint());
// Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte,
// Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete
// (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog
// zu .board-column-hit bei den Board-Spalten.
poDeskGroup.appendChild(
el("rect", {
x: -10,
y: 0,
width: PO_DESK.w + 20,
height: 40 + DESK.h + 90,
class: "po-desk-hit",
})
);
poDeskGroup.addEventListener("click", onPoClick);
g.appendChild(poDeskGroup);
return g;
}
// Dass hinter dem PO die Projektkonfiguration steckt, war bisher nirgends zu sehen - der
// Fehlerbericht zu Issue #27 ("damit ich den Konfig Dialog irgendwie oeffnen kann") war
// deshalb auch eine Frage der Auffindbarkeit. Das Zahnrad sitzt auf der Tischplatte des
// PO-Platzes; klickbar ist weiterhin der ganze Platz (und der PO selbst). <title> ist der
// SVG-eigene Tooltip.
function renderPoConfigHint() {
const cx = PO_DESK.w - 34;
const cy = 40 + DESK.h / 2;
const g = el("g", { class: "po-config-hint", transform: `translate(${cx}, ${cy})` });
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren";
g.appendChild(title);
g.appendChild(el("circle", { cx: 0, cy: 0, r: 15, class: "po-config-hint-bg" }));
// Zahnrad aus acht Zaehnen plus Nabe - in dieser Groesse von einem exakten Zahnradpfad
// nicht zu unterscheiden und ohne fremde Icon-Bibliothek zu haben.
for (let i = 0; i < 8; i++) {
const angle = (i * Math.PI) / 4;
g.appendChild(
el("line", {
x1: Math.cos(angle) * 5.5,
y1: Math.sin(angle) * 5.5,
x2: Math.cos(angle) * 9.5,
y2: Math.sin(angle) * 9.5,
class: "po-config-hint-tooth",
})
);
}
g.appendChild(el("circle", { cx: 0, cy: 0, r: 5, class: "po-config-hint-hub" }));
return g;
}
// ---------------------------------------------------------------------------------------
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
// ---------------------------------------------------------------------------------------
@ -634,6 +677,9 @@ function updateAgentsLayer(layer, placements) {
let node = agentAvatarElements.get(id);
if (!node) {
node = createAgentAvatarElement();
if (id === PO_AGENT_ID) {
makePoAvatarClickable(node);
}
agentAvatarElements.set(id, node);
layer.appendChild(node);
}
@ -641,6 +687,11 @@ function updateAgentsLayer(layer, placements) {
const feet = feetForLocation(agent.location, homeFeet);
const showName = agent.location !== AGENT_LOCATIONS.DESK;
updateAgentAvatarElement(node, agent, feet, showName);
if (id === PO_AGENT_ID) {
// updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile
// verloere der PO die Klickbarkeit beim ersten Zustandswechsel.
node.classList.add("clickable");
}
});
for (const [id, node] of agentAvatarElements) {
@ -652,6 +703,17 @@ function updateAgentsLayer(layer, placements) {
}
}
// Das Ticket sagt woertlich "bei Klick auf den PO" - gemeint ist die Figur, nicht ihr
// Schreibtisch. Der Avatar liegt in einer eigenen Schicht ueber der Szene und hatte bisher
// keinen Klick-Handler; ein Klick auf den PO selbst lief damit ins Leere. Beides ist jetzt
// klickbar, auch wenn der PO gerade am Board steht statt an seinem Platz.
function makePoAvatarClickable(node) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "Projekte konfigurieren";
node.appendChild(title);
node.addEventListener("click", openRepoConfigModal);
}
function clearIdleCosmeticTimer(agentId) {
const handle = idleCosmeticTimers.get(agentId);
if (handle) {
@ -1347,7 +1409,10 @@ export function renderOfficeScene(container, data = {}) {
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
const desksGroup = renderDesks(deskLayout, poDeskAgent, isLive ? openRepoConfigModal : null);
// Auch ohne erreichbare API klickbar: das Modal sagt dann selbst, dass es die Projekte
// nicht laden konnte. Ein toter PO waere in genau der Lage, in der man nachsehen will,
// die schlechtere Antwort (Fehlerbericht zu Issue #27).
const desksGroup = renderDesks(deskLayout, poDeskAgent, openRepoConfigModal);
const existingDesks = svg.querySelector(".desks");
if (existingDesks) {
existingDesks.replaceWith(desksGroup);