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);