Merge pull request 'Tickets auf dem Board in der Farbe ihres Projekts zeichnen' (#30) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/30
This commit is contained in:
fds2610 2026-08-02 22:40:44 +02:00
commit 80d121f703
4 changed files with 178 additions and 28 deletions

View file

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

View file

@ -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<string, number>} ticketCounts
* @param {Record<string, Array<object>>} 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

View file

@ -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<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 }) => {
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

View file

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