Merge pull request 'Klick auf den PO oeffnet den Projekte-Dialog zuverlaessig (#27)' (#29) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/29
This commit is contained in:
fds2610 2026-08-02 22:15:57 +02:00
commit d525e51815
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);