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:
commit
d525e51815
6 changed files with 128 additions and 13 deletions
1
api/.phpunit.result.cache
Normal file
1
api/.phpunit.result.cache
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -20,6 +20,11 @@ final class Database
|
||||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
$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
|
public static function forFile(string $path): self
|
||||||
|
|
|
||||||
|
|
@ -143,10 +143,13 @@ final class ApplicationTest extends TestCase
|
||||||
|
|
||||||
public function testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey(): void
|
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(
|
$createResponse = $this->app->handle(Request::create(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/v1/repo-configs',
|
'/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']
|
headers: ['X-Admin-Key' => 'admin-secret']
|
||||||
));
|
));
|
||||||
self::assertSame(201, $createResponse->status);
|
self::assertSame(201, $createResponse->status);
|
||||||
|
|
@ -155,7 +158,7 @@ final class ApplicationTest extends TestCase
|
||||||
$updateResponse = $this->app->handle(Request::create(
|
$updateResponse = $this->app->handle(Request::create(
|
||||||
'PATCH',
|
'PATCH',
|
||||||
'/api/v1/repo-configs/' . $id,
|
'/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']
|
headers: ['X-Admin-Key' => 'admin-secret']
|
||||||
));
|
));
|
||||||
self::assertSame(200, $updateResponse->status);
|
self::assertSame(200, $updateResponse->status);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8091:8080"
|
- "8091:8080"
|
||||||
environment:
|
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_DB_PATH: /app/data/monitor.sqlite
|
||||||
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
|
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
|
||||||
MONITOR_STATUS_FILE: /status.txt
|
MONITOR_STATUS_FILE: /status.txt
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,41 @@ header.office-header h1 {
|
||||||
pointer-events: all;
|
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 .board-frame,
|
||||||
.just-updated .monitor-frame {
|
.just-updated .monitor-frame {
|
||||||
animation: office-flash 0.9s ease-out;
|
animation: office-flash 0.9s ease-out;
|
||||||
|
|
|
||||||
|
|
@ -533,24 +533,67 @@ function renderDesk({ deskIndex, agent }) {
|
||||||
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
|
* @param {Array<object>} desks - reguläre 8er-Raster-Plaetze (echte Agenten, F1).
|
||||||
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
* @param {object|null} poAgent - {name: "PO", role: "product_owner", pose} bei erreichbarer
|
||||||
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
|
* API, sonst null (dann zeigt der feste PO-Platz "frei", Issue #19).
|
||||||
* @param {(() => void)|null} onPoClick - oeffnet das Projekte-Modal (Issue #27); nur gesetzt
|
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
|
||||||
* bei erreichbarer API, da das Modal echte Daten braucht und im Demo-Fallback ohne
|
|
||||||
* funktionierende API nichts zu konfigurieren waere.
|
|
||||||
*/
|
*/
|
||||||
function renderDesks(desks, poAgent, onPoClick = null) {
|
function renderDesks(desks, poAgent, onPoClick) {
|
||||||
const g = el("g", { class: "desks" });
|
const g = el("g", { class: "desks" });
|
||||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||||
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||||||
poDeskGroup.classList.add("po-desk");
|
poDeskGroup.classList.add("po-desk", "clickable");
|
||||||
if (onPoClick) {
|
|
||||||
poDeskGroup.classList.add("clickable");
|
|
||||||
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
|
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);
|
poDeskGroup.addEventListener("click", onPoClick);
|
||||||
}
|
|
||||||
g.appendChild(poDeskGroup);
|
g.appendChild(poDeskGroup);
|
||||||
return g;
|
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)
|
// Phase 5 - Agenten-Avatare (bewegliche Schicht, siehe Architektur-Hinweis oben)
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
@ -634,6 +677,9 @@ function updateAgentsLayer(layer, placements) {
|
||||||
let node = agentAvatarElements.get(id);
|
let node = agentAvatarElements.get(id);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
node = createAgentAvatarElement();
|
node = createAgentAvatarElement();
|
||||||
|
if (id === PO_AGENT_ID) {
|
||||||
|
makePoAvatarClickable(node);
|
||||||
|
}
|
||||||
agentAvatarElements.set(id, node);
|
agentAvatarElements.set(id, node);
|
||||||
layer.appendChild(node);
|
layer.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
@ -641,6 +687,11 @@ function updateAgentsLayer(layer, placements) {
|
||||||
const feet = feetForLocation(agent.location, homeFeet);
|
const feet = feetForLocation(agent.location, homeFeet);
|
||||||
const showName = agent.location !== AGENT_LOCATIONS.DESK;
|
const showName = agent.location !== AGENT_LOCATIONS.DESK;
|
||||||
updateAgentAvatarElement(node, agent, feet, showName);
|
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) {
|
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) {
|
function clearIdleCosmeticTimer(agentId) {
|
||||||
const handle = idleCosmeticTimers.get(agentId);
|
const handle = idleCosmeticTimers.get(agentId);
|
||||||
if (handle) {
|
if (handle) {
|
||||||
|
|
@ -1347,7 +1409,10 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
|
|
||||||
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
const deskLayout = buildDeskLayout(normalizedDeskAgents(agents));
|
||||||
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
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");
|
const existingDesks = svg.querySelector(".desks");
|
||||||
if (existingDesks) {
|
if (existingDesks) {
|
||||||
existingDesks.replaceWith(desksGroup);
|
existingDesks.replaceWith(desksGroup);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue