Tickets auf dem Board in der Farbe ihres Projekts zeichnen

Das Projekte-Modal am PO laesst seit #27 je Projekt eine Farbe pflegen, und
GET /api/v1/tickets liefert sie pro Ticket mit (ticket.color) - auf dem Board
kam sie aber nie an: renderBoard() bekam nur Zaehler je Spalte, jeder Zettel
war derselbe gelbe Standardzettel.

buildBoardColumnLayout() bekommt deshalb zusaetzlich die gruppierten Tickets
und liefert die Farbe je Zettel mit. Ohne Ticketliste (Demo-Modus, der nur
Zaehler kennt) bleibt alles wie bisher.

Ein Stapel steht fuer mehr Tickets als er Zettel hat und zeigt die ersten
verschiedenen Farben darin - so ist auch an "Done" (immer ein Stapel) zu
sehen, aus welchen Projekten die Tickets stammen. Reichen die Farben nicht
fuer alle drei Zettel, werden sie zyklisch wiederholt: ein ungefaerbter
Rest-Zettel saehe sonst wie ein weiteres Projekt aus, denn --color-note ist
von einem gelben Projekt kaum zu unterscheiden.

Gefaerbte Zettel bekommen ausserdem eine neutrale statt der gelben Kontur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-02 22:32:34 +02:00
parent d5f7ac9f92
commit ebf7dba537
4 changed files with 178 additions and 28 deletions

View file

@ -196,6 +196,13 @@ header.office-header h1 {
stroke-width: 2; 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 { .note-stack-badge {
fill: var(--color-note-stack-badge); fill: var(--color-note-stack-badge);
stroke: #92400e; stroke: #92400e;

View file

@ -150,10 +150,25 @@ function renderCoffeeCorner() {
return g; 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" }); const g = el("g", { class: "note-stack" });
[6, 3, 0].forEach((offset, i) => { [6, 3, 0].forEach((offset, i) => {
g.appendChild( g.appendChild(
paintNote(
el("rect", { el("rect", {
x: x - offset, x: x - offset,
y: y - offset, y: y - offset,
@ -161,7 +176,9 @@ function renderStackBadge(x, y, count) {
height: NOTE_H, height: NOTE_H,
rx: 6, rx: 6,
class: `note note-stack-layer-${i}`, class: `note note-stack-layer-${i}`,
}) }),
stackColors[i]
)
); );
}); });
const cx = x + NOTE_W / 2; const cx = x + NOTE_W / 2;
@ -175,10 +192,11 @@ function renderStackBadge(x, y, count) {
/** /**
* @param {Record<string, number>} ticketCounts * @param {Record<string, number>} ticketCounts
* @param {Record<string, Array<object>>} groupedTickets - liefert die Projektfarbe je Zettel.
* @param {(columnKey: string) => void} onColumnClick * @param {(columnKey: string) => void} onColumnClick
*/ */
function renderBoard(ticketCounts, onColumnClick) { function renderBoard(ticketCounts, groupedTickets, onColumnClick) {
const columns = buildBoardColumnLayout(ticketCounts); const columns = buildBoardColumnLayout(ticketCounts, groupedTickets);
const g = el("g", { class: "board" }); const g = el("g", { class: "board" });
g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" })); g.appendChild(el("rect", { ...BOARD_FRAME, rx: 10, class: "board-frame" }));
g.appendChild( g.appendChild(
@ -227,13 +245,18 @@ function renderBoard(ticketCounts, onColumnClick) {
for (let i = 0; i < column.visibleNotes; i++) { for (let i = 0; i < column.visibleNotes; i++) {
colGroup.appendChild( 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; noteY += NOTE_STEP_Y;
} }
if (column.stackCount > 0) { if (column.stackCount > 0) {
colGroup.appendChild(renderStackBadge(noteX, noteY, column.stackCount)); colGroup.appendChild(
renderStackBadge(noteX, noteY, column.stackCount, column.stackColors)
);
} }
g.appendChild(colGroup); g.appendChild(colGroup);
@ -1380,7 +1403,7 @@ export function renderOfficeScene(container, data = {}) {
const groupedTickets = groupTicketsByColumn(tickets); const groupedTickets = groupTicketsByColumn(tickets);
const boardGroup = renderBoard(ticketCounts, (columnKey) => { const boardGroup = renderBoard(ticketCounts, groupedTickets, (columnKey) => {
openModal( openModal(
buildTicketModalContent(columnKey, groupedTickets[columnKey] || []), buildTicketModalContent(columnKey, groupedTickets[columnKey] || []),
renderTicketModalBody renderTicketModalBody

View file

@ -26,6 +26,10 @@ export const MAX_SYSTEM_TILES = 9;
export const MAX_EVENT_LINES = 5; export const MAX_EVENT_LINES = 5;
export const DEFAULT_DESK_COUNT = 8; 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. * 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 * Regel (Detailantworten F3): mehr als 5 Tickets in einer Spalte -> die aeltesten 4 werden
@ -33,29 +37,70 @@ export const DEFAULT_DESK_COUNT = 8;
* Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie) * Regel (Detailantworten F2): die Spalte "done" wird immer als ein Stapel (Zeit-Historie)
* dargestellt, unabhaengig von der Anzahl. * 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<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key. * @param {Record<string, number>} ticketCounts - Anzahl Tickets je Spalten-Key.
* @returns {Array<{key: string, label: string, count: number, visibleNotes: number, stackCount: number}>} * @param {Record<string, Array<object>>} 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<string|null>, stackColors: Array<string>}>}
*/ */
export function buildBoardColumnLayout(ticketCounts = {}) { export function buildBoardColumnLayout(ticketCounts = {}, groupedTickets = {}) {
return BOARD_COLUMNS.map(({ key, label }) => { return BOARD_COLUMNS.map(({ key, label }) => {
const count = Math.max(0, ticketCounts[key] ?? 0); const count = Math.max(0, ticketCounts[key] ?? 0);
const tickets = groupedTickets[key] ?? [];
if (key === "done") { const withColors = (visibleNotes, stackCount) => ({
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 {
key, key,
label, label,
count, count,
visibleNotes: MAX_VISIBLE_NOTES_PER_COLUMN, visibleNotes,
stackCount: count - MAX_VISIBLE_NOTES_PER_COLUMN, 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]);
} }
/** /**

View file

@ -9,6 +9,7 @@ import {
MAX_SYSTEM_TILES, MAX_SYSTEM_TILES,
MAX_EVENT_LINES, MAX_EVENT_LINES,
DEFAULT_DESK_COUNT, DEFAULT_DESK_COUNT,
NOTE_STACK_LAYERS,
aggregateTicketsByColumn, aggregateTicketsByColumn,
groupTicketsByColumn, groupTicketsByColumn,
buildTicketModalContent, buildTicketModalContent,
@ -82,6 +83,80 @@ test("buildBoardColumnLayout behandelt fehlende/negative Zaehler als 0", () => {
assert.equal(qa.visibleNotes, 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", () => { test("buildDeskLayout erzeugt genau DEFAULT_DESK_COUNT Schreibtische", () => {
const desks = buildDeskLayout([]); const desks = buildDeskLayout([]);
assert.equal(desks.length, DEFAULT_DESK_COUNT); assert.equal(desks.length, DEFAULT_DESK_COUNT);