diff --git a/html/css/office.css b/html/css/office.css index 03c33d8..5214aeb 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -196,6 +196,13 @@ header.office-header h1 { stroke-width: 2; } +/* Zettel in der Farbe seines Projekts (Issue #27): die Fuellung kommt als Inline-Style aus + der Projektkonfiguration, die Kontur muss dann neutral sein - die gelbe Standardkontur + passt nur zum gelben Standardzettel. */ +.note-colored { + stroke: rgba(15, 23, 42, 0.35); +} + .note-stack-badge { fill: var(--color-note-stack-badge); stroke: #92400e; diff --git a/html/js/office-render.js b/html/js/office-render.js index 0507e91..eb2578d 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -150,18 +150,35 @@ function renderCoffeeCorner() { return g; } -function renderStackBadge(x, y, count) { +// Faerbt einen Zettel in der Farbe seines Quell-Projekts (Issue #26/#27: "welcher Farbe +// diese zugeordnet werden"). Ohne Farbe - Ticket ohne Projekt oder Demo-Modus - bleibt es +// bei der Standardfarbe aus dem Stylesheet. Die Farbe ist Nutzereingabe aus dem +// Projekte-Modal und landet deshalb ueber die style-Property im DOM, nie in einem +// zusammengesetzten Attribut-String. +function paintNote(rect, color) { + if (color) { + rect.style.fill = color; + // Die gelbe Standardkontur passt nur zum gelben Standardzettel - siehe .note-colored. + rect.classList.add("note-colored"); + } + return rect; +} + +function renderStackBadge(x, y, count, stackColors = []) { const g = el("g", { class: "note-stack" }); [6, 3, 0].forEach((offset, i) => { g.appendChild( - el("rect", { - x: x - offset, - y: y - offset, - width: NOTE_W, - height: NOTE_H, - rx: 6, - class: `note note-stack-layer-${i}`, - }) + paintNote( + el("rect", { + x: x - offset, + y: y - offset, + width: NOTE_W, + height: NOTE_H, + rx: 6, + class: `note note-stack-layer-${i}`, + }), + stackColors[i] + ) ); }); const cx = x + NOTE_W / 2; @@ -175,10 +192,11 @@ function renderStackBadge(x, y, count) { /** * @param {Record} ticketCounts + * @param {Record>} groupedTickets - liefert die Projektfarbe je Zettel. * @param {(columnKey: string) => void} onColumnClick */ -function renderBoard(ticketCounts, onColumnClick) { - const columns = buildBoardColumnLayout(ticketCounts); +function renderBoard(ticketCounts, groupedTickets, onColumnClick) { + const columns = buildBoardColumnLayout(ticketCounts, groupedTickets); const g = el("g", { class: "board" }); g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })); g.appendChild( @@ -227,13 +245,18 @@ function renderBoard(ticketCounts, onColumnClick) { for (let i = 0; i < column.visibleNotes; i++) { colGroup.appendChild( - el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" }) + paintNote( + el("rect", { x: noteX, y: noteY, width: NOTE_W, height: NOTE_H, rx: 6, class: "note" }), + column.noteColors[i] + ) ); noteY += NOTE_STEP_Y; } if (column.stackCount > 0) { - colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount)); + colGroup.appendChild( + renderStackBadge(noteX, noteY, column.stackCount, column.stackColors) + ); } g.appendChild(colGroup); @@ -1380,7 +1403,7 @@ export function renderOfficeScene(container, data = {}) { const groupedTickets = groupTicketsByColumn(tickets); - const boardGroup = renderBoard(ticketCounts, (columnKey) => { + const boardGroup = renderBoard(ticketCounts, groupedTickets, (columnKey) => { openModal( buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), renderTicketModalBody diff --git a/html/js/office-scene.js b/html/js/office-scene.js index ca75d02..cd7ab35 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -26,6 +26,10 @@ export const MAX_SYSTEM_TILES = 9; export const MAX_EVENT_LINES = 5; export const DEFAULT_DESK_COUNT = 8; +// Ein Stapel wird aus so vielen versetzten Zetteln gezeichnet (siehe renderStackBadge()) - +// entsprechend viele Projektfarben kann er zeigen. +export const NOTE_STACK_LAYERS = 3; + /** * Baut aus rohen Ticket-Zaehlern pro Spalte die Darstellung fuer das Board. * Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden @@ -33,31 +37,72 @@ export const DEFAULT_DESK_COUNT = 8; * Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie) * dargestellt, unabhaengig von der Anzahl. * + * Die Farben stammen aus dem Quell-Projekt des Tickets (ticket.color, gepflegt im + * Projekte-Modal am PO, Issue #27) und sind optional: ohne groupedTickets - etwa im + * Demo-Modus, der nur Zaehler kennt - bleiben die Zettel einfarbig wie zuvor. + * * @param {Record} ticketCounts - Anzahl Tickets je Spalten-Key. - * @returns {Array<{key: string, label: string, count: number, visibleNotes: number, stackCount: number}>} + * @param {Record>} groupedTickets - Tickets je Spalte in derselben + * Reihenfolge wie gezaehlt (siehe groupTicketsByColumn()). + * @returns {Array<{key: string, label: string, count: number, visibleNotes: number, + * stackCount: number, noteColors: Array, stackColors: Array}>} */ -export function buildBoardColumnLayout(ticketCounts = {}) { +export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) { return BOARD_COLUMNS.map(({ key, label }) => { const count = Math.max(0, ticketCounts[key] ?? 0); + const tickets = groupedTickets[key] ?? []; - if (key === "done") { - return { key, label, count, visibleNotes: 0, stackCount: count }; - } - - if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) { - return { key, label, count, visibleNotes: count, stackCount: 0 }; - } - - return { + const withColors = (visibleNotes, stackCount) => ({ key, label, count, - visibleNotes: MAX_VISIBLE_NOTES_PER_COLUMN, - stackCount: count - MAX_VISIBLE_NOTES_PER_COLUMN, - }; + visibleNotes, + stackCount, + noteColors: noteColors(tickets.slice(0, visibleNotes), visibleNotes), + stackColors: stackCount > 0 ? stackLayerColors(tickets.slice(visibleNotes)) : [], + }); + + if (key === "done") { + return withColors(0, count); + } + + if (count <= MAX_VISIBLE_NOTES_PER_COLUMN + 1) { + return withColors(count, 0); + } + + return withColors(MAX_VISIBLE_NOTES_PER_COLUMN, count - MAX_VISIBLE_NOTES_PER_COLUMN); }); } +// null steht fuer "keine Projektfarbe" (Ticket ohne Quell-Projekt, z.B. per API gemeldet) - +// solche Zettel behalten die Standardfarbe aus dem Stylesheet. +function noteColors(tickets, length) { + return Array.from({ length }, (_, i) => tickets[i]?.color ?? null); +} + +// Ein Stapel steht fuer viele Tickets, aber nur fuer NOTE_STACK_LAYERS Zettel: er zeigt +// deshalb die ersten verschiedenen Projektfarben darin, statt eine davon dreimal zu +// wiederholen. So ist auch an "Done" (immer ein Stapel) zu sehen, aus welchen Projekten die +// Tickets stammen. +// +// Reichen die gefundenen Farben nicht fuer alle Zettel, werden sie zyklisch wiederholt: ein +// ungefaerbter Rest-Zettel wuerde sonst wie ein weiteres Projekt aussehen, denn die +// Standardfarbe (--color-note) ist von einem gelben Projekt kaum zu unterscheiden. Nur wenn +// gar keine Farbe vorkommt, bleibt der Stapel komplett im Standard. +function stackLayerColors(tickets) { + const found = []; + for (const ticket of tickets) { + if (ticket.color && !found.includes(ticket.color)) { + found.push(ticket.color); + if (found.length === NOTE_STACK_LAYERS) break; + } + } + if (found.length === 0) { + return []; + } + return Array.from({ length: NOTE_STACK_LAYERS }, (_, i) => found[i % found.length]); +} + /** * Ordnet Agenten-Demo-Daten den Schreibtischen zu. Unbesetzte Schreibtische bleiben leer. * Ueberzaehlige Agenten (mehr als deskCount) werden ignoriert - Phase 2 zeigt nur den diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index ca77381..977e2fd 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -9,6 +9,7 @@ import { MAX_SYSTEM_TILES, MAX_EVENT_LINES, DEFAULT_DESK_COUNT, + NOTE_STACK_LAYERS, aggregateTicketsByColumn, groupTicketsByColumn, buildTicketModalContent, @@ -82,6 +83,80 @@ test("buildBoardColumnLayout behandelt fehlende/negative Zaehler als 0", () => { assert.equal(qa.visibleNotes, 0); }); +test("buildBoardColumnLayout faerbt jeden Zettel in der Farbe seines Projekts (Issue #27)", () => { + const tickets = [ + { column_name: "bereit", color: "#fbca04" }, + { column_name: "bereit", color: "#28a745" }, + { column_name: "bereit", color: "#fbca04" }, + ]; + const bereit = buildBoardColumnLayout( + aggregateTicketsByColumn(tickets), + groupTicketsByColumn(tickets) + ).find((c) => c.key === "bereit"); + + assert.deepEqual(bereit.noteColors, ["#fbca04", "#28a745", "#fbca04"]); +}); + +test("buildBoardColumnLayout laesst Zettel ohne Projektfarbe ungefaerbt", () => { + const tickets = [{ column_name: "qa" }, { column_name: "qa", color: "#0366d6" }]; + const qa = buildBoardColumnLayout( + aggregateTicketsByColumn(tickets), + groupTicketsByColumn(tickets) + ).find((c) => c.key === "qa"); + + assert.deepEqual(qa.noteColors, [null, "#0366d6"]); +}); + +test("buildBoardColumnLayout ohne Ticketliste (Demo-Modus) laesst alle Zettel ungefaerbt", () => { + const bereit = buildBoardColumnLayout({ bereit: 2 }).find((c) => c.key === "bereit"); + assert.deepEqual(bereit.noteColors, [null, null]); + assert.deepEqual(bereit.stackColors, []); +}); + +test("buildBoardColumnLayout zeigt im Stapel die verschiedenen Projektfarben der gestapelten Tickets", () => { + // 6 Tickets: die aeltesten 4 einzeln, die restlichen 2 im Stapel - dessen Farben stammen + // ausschliesslich aus diesen beiden. + const tickets = [ + ...Array.from({ length: 4 }, () => ({ column_name: "backlog", color: "#fbca04" })), + { column_name: "backlog", color: "#28a745" }, + { column_name: "backlog", color: "#0366d6" }, + ]; + const backlog = buildBoardColumnLayout( + aggregateTicketsByColumn(tickets), + groupTicketsByColumn(tickets) + ).find((c) => c.key === "backlog"); + + assert.equal(backlog.stackCount, 2); + // Dritter Zettel: zyklische Wiederholung statt Standardfarbe - siehe stackLayerColors(). + assert.deepEqual(backlog.stackColors, ["#28a745", "#0366d6", "#28a745"]); +}); + +test("buildBoardColumnLayout laesst einen Stapel ganz ohne Projektfarben im Standard", () => { + const tickets = Array.from({ length: 7 }, () => ({ column_name: "backlog" })); + const backlog = buildBoardColumnLayout( + aggregateTicketsByColumn(tickets), + groupTicketsByColumn(tickets) + ).find((c) => c.key === "backlog"); + + assert.equal(backlog.stackCount, 3); + assert.deepEqual(backlog.stackColors, []); +}); + +test("buildBoardColumnLayout zeigt im Stapel jede Projektfarbe nur einmal und hoechstens NOTE_STACK_LAYERS", () => { + const tickets = ["#fbca04", "#fbca04", "#28a745", "#0366d6", "#ec4899"].map((color) => ({ + column_name: "done", + color, + })); + const done = buildBoardColumnLayout( + aggregateTicketsByColumn(tickets), + groupTicketsByColumn(tickets) + ).find((c) => c.key === "done"); + + assert.equal(done.stackCount, 5); + assert.equal(done.stackColors.length, NOTE_STACK_LAYERS); + assert.deepEqual(done.stackColors, ["#fbca04", "#28a745", "#0366d6"]); +}); + test("buildDeskLayout erzeugt genau DEFAULT_DESK_COUNT Schreibtische", () => { const desks = buildDeskLayout([]); assert.equal(desks.length, DEFAULT_DESK_COUNT);