From 5c9c2c8c9b52d5755e4c60a0da3805d93b2d9018 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 10 Oct 2026 11:22:45 +0200 Subject: [PATCH] Fahne mit externen Tickets klickbar: Modal listet die Tickets (#117) Klick auf die rote Fahne am PO oeffnet ein Modal im Stil der Board-Spalten-Modals mit allen offenen Tickets externer User (Repo, Nummer mit Link, Titel, Typ, Beschreibung) plus der jeweiligen Spalte, spaltenuebergreifend nach created_at sortiert, neuestes zuerst. Die Fahne liegt dafuer ueber der Trefferflaeche des PO-Platzes und stoppt die Klick-Weitergabe, damit nicht zusaetzlich die Projektverwaltung aufgeht. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.md | 4 +- html/css/office.css | 19 ++++++ html/js/office-render.js | 37 ++++++++--- html/js/office-scene.js | 98 +++++++++++++++++++++-------- tests/frontend/office-scene.test.js | 32 ++++++++++ 6 files changed, 159 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29084a9..213cc29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## Unreleased +- Klick auf die rote Fahne am PO oeffnet ein Modal, das alle offenen Tickets externer User + wie bei den Kanban-Spalten listet (Repo, Nummer mit Link, Titel, Typ, Beschreibung), jeweils + mit ihrer Spalte, neuestes zuerst (#117). - `update.sh` erzeugt die Container `api` und `sync` bei jedem Update neu. Bisher lief der `sync`-Prozess nach einem Update mit dem alten Code weiter, schrieb deshalb keinen Ticket-Ersteller und die rote Fahne am PO (#113) erschien nicht (#115). diff --git a/README.md b/README.md index 8faa4f3..45a6203 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser. - Liegen offene Tickets (Issues oder PRs, alles außer „Done") vor, die nicht von `fds2610` oder `fds2610-bot` angelegt wurden, steht auf dem Tisch des PO eine kleine rote Fahne mit - deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`). + deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`). Ein Klick + auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten, zusätzlich mit + ihrer jeweiligen Spalte (Issue #117). - Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von diff --git a/html/css/office.css b/html/css/office.css index 3c0826a..99fc853 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -382,6 +382,16 @@ header.office-header h1 { font-weight: 700; } +/* Issue #117: die Fahne oeffnet die Liste der externen Tickets. */ +.external-ticket-flag-hit { + fill: transparent; + pointer-events: all; +} + +.external-ticket-flag:hover .external-ticket-flag-cloth { + fill: #b91c1c; +} + .person-head { fill: #f3d9b1; stroke: #d8b98a; @@ -795,6 +805,15 @@ header.office-header h1 { gap: 8px; } +/* Spalte eines Tickets im Modal der externen Tickets (Issue #117). */ +.office-modal-column { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #cbd5e1; + color: #475569; +} + .office-modal-badge { font-size: 0.75rem; padding: 2px 8px; diff --git a/html/js/office-render.js b/html/js/office-render.js index 5439949..7ba2ac9 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -19,6 +19,7 @@ import { aggregateTicketsByColumn, groupTicketsByColumn, countExternalOpenTickets, + buildExternalTicketModalContent, buildTicketModalContent, mapEventForDisplay, formatEventLineText, @@ -791,17 +792,15 @@ function renderDesk({ deskIndex, agent }) { * PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97). * @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27). * @param {number} externalTicketCount - offene Tickets externer User (Issue #113). + * @param {() => void} [onExternalTicketsClick] - oeffnet die Liste dieser Tickets (Issue #117). */ -function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) { +function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0, onExternalTicketsClick = () => {}) { 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", "clickable"); poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten"); poDeskGroup.appendChild(renderPoConfigHint()); - if (externalTicketCount > 0) { - poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount)); - } // Eine 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 @@ -815,6 +814,10 @@ function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) { class: "po-desk-hit", }) ); + // Issue #117: die Fahne liegt ueber der Trefferflaeche, sonst kaeme ihr eigener Klick nie an. + if (externalTicketCount > 0) { + poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount, onExternalTicketsClick)); + } poDeskGroup.addEventListener("click", onPoClick); g.appendChild(poDeskGroup); return g; @@ -841,9 +844,10 @@ function renderPoConfigHint() { // Issue #113: kleine rote Fahne auf dem PO-Tisch, sobald offene Tickets von externen Usern // (nicht fds2610/fds2610-bot) vorliegen - die Zahl steht im Fahnentuch. Sitzt zwischen // Bildschirm (x 20-80) und Blumen-Deko (ab x ~122), damit sie mit keinem von beiden kollidiert. -function renderExternalTicketFlag(count) { +// Issue #117: Klick auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten. +function renderExternalTicketFlag(count, onClick) { const poleX = 92; - const g = el("g", { class: "external-ticket-flag" }); + const g = el("g", { class: "external-ticket-flag clickable" }); const title = document.createElementNS(SVG_NS, "title"); title.textContent = `${count} offene${count === 1 ? "s" : ""} Ticket${count === 1 ? "" : "s"} externer User`; @@ -860,6 +864,15 @@ function renderExternalTicketFlag(count) { "text-anchor": "middle", }) ); + // Groessere unsichtbare Trefferflaeche (Stange + Tuch), analog zu .board-reload-hint-hit. + g.appendChild(el("rect", { x: poleX - 6, y: 0, width: 34, height: 50, class: "external-ticket-flag-hit" })); + + g.addEventListener("click", (event) => { + // Der ganze PO-Platz oeffnet die Projektverwaltung (Issue #27) - ohne das Stoppen laege + // sie sofort ueber der Ticketliste. + event.stopPropagation(); + onClick(); + }); return g; } @@ -1470,7 +1483,7 @@ function triggerDoorVisit(visit, onArrived) { function renderTicketModalBody(body, content) { if (content.items.length === 0) { - body.appendChild(emptyMessage("Keine Tickets in dieser Spalte.")); + body.appendChild(emptyMessage(content.emptyText || "Keine Tickets in dieser Spalte.")); return; } const list = document.createElement("ul"); @@ -1501,6 +1514,13 @@ function renderTicketModalBody(body, content) { const titleEl = document.createElement("strong"); titleEl.textContent = item.title; heading.appendChild(titleEl); + // Issue #117: im Modal der externen Tickets stammen die Eintraege aus mehreren Spalten. + if (item.column) { + const columnEl = document.createElement("span"); + columnEl.className = "office-modal-column"; + columnEl.textContent = item.column; + heading.appendChild(columnEl); + } if (item.type) { const badge = document.createElement("span"); badge.className = "office-modal-badge"; @@ -2460,7 +2480,8 @@ export function renderOfficeScene(container, data = {}) { deskLayout, poDeskAgent, openRepoConfigModal, - countExternalOpenTickets(tickets) + countExternalOpenTickets(tickets), + () => openModal(buildExternalTicketModalContent(tickets), renderTicketModalBody) ); const existingDesks = svg.querySelector(".desks"); if (existingDesks) { diff --git a/html/js/office-scene.js b/html/js/office-scene.js index c1792ae..88e78a0 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -217,13 +217,13 @@ export function groupTicketsByColumn(tickets = []) { export const INTERNAL_TICKET_AUTHORS = ["fds2610", "fds2610-bot"]; /** - * Zaehlt offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne - * author (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit. + * Offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne author + * (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit. * * @param {Array<{column_name: string, author?: string|null}>} tickets - * @returns {number} + * @returns {Array} */ -export function countExternalOpenTickets(tickets = []) { +export function filterExternalOpenTickets(tickets = []) { const internal = INTERNAL_TICKET_AUTHORS.map((login) => login.toLowerCase()); return tickets.filter( (ticket) => @@ -231,7 +231,17 @@ export function countExternalOpenTickets(tickets = []) { typeof ticket.author === "string" && ticket.author !== "" && !internal.includes(ticket.author.toLowerCase()) - ).length; + ); +} + +/** + * Zaehlt offene Tickets externer User (siehe filterExternalOpenTickets()). + * + * @param {Array<{column_name: string, author?: string|null}>} tickets + * @returns {number} + */ +export function countExternalOpenTickets(tickets = []) { + return filterExternalOpenTickets(tickets).length; } // Issue #88: Beschreibungen im Ticket-Modal werden auf diese Zeilenzahl gekuerzt. @@ -273,26 +283,64 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) { return { title: column ? column.label : columnKey, - items: newestFirst.map((ticket) => ({ - id: ticket.id, - title: ticket.title, - description: truncateDescriptionLines(ticket.description), - type: ticket.type || null, - color: ticket.color || null, - // Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link. - repo: - ticket.source === "codeberg" && ticket.external_ref - ? ticket.repo || `${codebergOwner}/${codebergRepo}` - : null, - number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null, - isPullRequest: isPullRequest(ticket), - link: - ticket.source === "codeberg" && ticket.external_ref - ? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${ - isPullRequest(ticket) ? "pulls" : "issues" - }/${ticket.external_ref}` - : null, - })), + items: newestFirst.map((ticket) => buildTicketModalItem(ticket, codebergOwner, codebergRepo)), + }; +} + +/** + * Issue #117: Inhaltsmodell fuer das Modal hinter der roten Fahne am PO - alle offenen + * Tickets externer User (filterExternalOpenTickets()), aufgebaut wie ein Board-Spalten-Modal. + * Weil die Tickets aus mehreren Spalten stammen, traegt jeder Eintrag zusaetzlich seine + * Spalte (column); sortiert wird spaltenuebergreifend nach created_at, neuestes zuerst + * (wie im Spalten-Modal, Issue #102). + * + * @param {Array} tickets - rohe Ticketliste (GET /api/v1/tickets) + * @param {{codebergOwner?: string, codebergRepo?: string}} [opts] + */ +export function buildExternalTicketModalContent(tickets = [], opts = {}) { + const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts; + const newestFirst = filterExternalOpenTickets(tickets) + .map((ticket, index) => ({ ticket, index })) + .sort( + (a, b) => + String(b.ticket.created_at || "").localeCompare(String(a.ticket.created_at || "")) || + b.index - a.index + ) + .map(({ ticket }) => ticket); + + return { + title: "Externe Tickets", + emptyText: "Keine offenen Tickets externer User.", + items: newestFirst.map((ticket) => { + const column = BOARD_COLUMNS.find((c) => c.key === ticket.column_name); + return { + ...buildTicketModalItem(ticket, codebergOwner, codebergRepo), + column: column ? column.label : ticket.column_name, + }; + }), + }; +} + +function buildTicketModalItem(ticket, codebergOwner, codebergRepo) { + return { + id: ticket.id, + title: ticket.title, + description: truncateDescriptionLines(ticket.description), + type: ticket.type || null, + color: ticket.color || null, + // Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link. + repo: + ticket.source === "codeberg" && ticket.external_ref + ? ticket.repo || `${codebergOwner}/${codebergRepo}` + : null, + number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null, + isPullRequest: isPullRequest(ticket), + link: + ticket.source === "codeberg" && ticket.external_ref + ? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${ + isPullRequest(ticket) ? "pulls" : "issues" + }/${ticket.external_ref}` + : null, }; } diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index cd7bed7..fee4d31 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -57,6 +57,8 @@ import { resolveReloadIntervalSeconds, nextCountdownSeconds, countExternalOpenTickets, + filterExternalOpenTickets, + buildExternalTicketModalContent, INTERNAL_TICKET_AUTHORS, } from "../../html/js/office-scene.js"; @@ -1006,3 +1008,33 @@ test("countExternalOpenTickets zaehlt nur offene Tickets externer User (Issue #1 assert.equal(countExternalOpenTickets(), 0); assert.deepEqual(INTERNAL_TICKET_AUTHORS, ["fds2610", "fds2610-bot"]); }); + +test("buildExternalTicketModalContent listet offene Tickets externer User wie ein Spalten-Modal (Issue #117)", () => { + const tickets = [ + { id: 1, column_name: "backlog", author: "jemand", title: "Alt", source: "codeberg", external_ref: "3", repo: "fds2610/monitor", created_at: "2026-10-01T10:00:00+02:00", type: "bug", color: "#ff0000" }, + { id: 2, column_name: "fertig", author: "extern-pr", title: "PR", source: "codeberg", external_ref: "9", repo: "fds2610/andere", is_pull_request: 1, created_at: "2026-10-05T10:00:00+02:00" }, + { id: 3, column_name: "in_arbeit", author: "fds2610", title: "Intern", created_at: "2026-10-06T10:00:00+02:00" }, + { id: 4, column_name: "done", author: "jemand", title: "Erledigt", created_at: "2026-10-07T10:00:00+02:00" }, + { id: 5, column_name: "qa", author: "jemand", title: "Neu", source: "api", created_at: "2026-10-08T10:00:00+02:00" }, + ]; + + assert.deepEqual(filterExternalOpenTickets(tickets).map((t) => t.id), [1, 2, 5]); + + const content = buildExternalTicketModalContent(tickets); + assert.equal(content.title, "Externe Tickets"); + assert.equal(typeof content.emptyText, "string"); + assert.deepEqual(content.items.map((item) => item.id), [5, 2, 1]); + assert.deepEqual(content.items.map((item) => item.column), ["QA", "Fertig", "Backlog"]); + + const [api, pr, issue] = content.items; + assert.equal(api.link, null); + assert.equal(api.number, null); + assert.equal(pr.isPullRequest, true); + assert.equal(pr.link, "https://codeberg.org/fds2610/andere/pulls/9"); + assert.equal(issue.link, "https://codeberg.org/fds2610/monitor/issues/3"); + assert.equal(issue.repo, "fds2610/monitor"); + assert.equal(issue.type, "bug"); + assert.equal(issue.color, "#ff0000"); + + assert.deepEqual(buildExternalTicketModalContent().items, []); +});