feat(taiga): Bulk-Abfrage und Abweichungs-Marke — das Diagramm zeigt, wo Ticket und Plan auseinanderlaufen (D91-Nachtrag 10, SPEC §9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 11:44:33 +02:00
co-authored by Claude Opus 5
parent e77a4d1ce4
commit a1e4a3bbab
17 changed files with 663 additions and 7 deletions
+97 -3
View File
@@ -1,7 +1,7 @@
import './style.css';
import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from './taiga.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from './taiga.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js';
@@ -322,7 +322,8 @@ function render(){
freshSet, collapsedSet,
effStatus: effectiveStatus(roots),
overloadTag: overload ? overload.tag : null,
lensTag: lens ? lens.tag : null});
lensTag: lens ? lens.tag : null,
tdiffRefs: ticketDiffBadgeMap(parsed.roots)});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
@@ -365,6 +366,12 @@ function render(){
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
/* Abweichungs-Marken (D91-Nachtrag 10): aus dem Cache kostenlos; die
Bulk-Abfrage selbst läuft je Slug nur einmal je Sitzung. Auf den
UNGEFILTERTEN roots, denn die Slug-Vererbung läuft über den ganzen
Baum; was nicht im DOM steht, überspringt das Markieren selbst. */
markTicketDiffs(parsed.roots);
scheduleTaigaBulk(parsed.roots);
}
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
@@ -892,7 +899,7 @@ function diagramToSvg(){
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
/* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen ein
SVG-<text> bricht nicht von selbst; je gemessene Zeile ein Element. */
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark,.tref-badge' + stripFold)){
const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy;
parts.push(`<text x="${cx.toFixed(1)}" y="${(cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(ln.text)}</text>`);
}
@@ -1742,6 +1749,10 @@ function storeTaigaSession(s){
if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s));
else localStorage.removeItem(LS_TAIGA);
}catch(_){}
/* Frisch angemeldet: die Abweichungs-Marken holen (D91-Nachtrag 10)
sonst warteten sie auf den naechsten Tastendruck. Deckt jeden
Login-Weg ab, weil alle hier durchlaufen. */
if(s) scheduleTaigaBulk(parse(src.value).roots);
}
/* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim
@@ -1767,6 +1778,9 @@ async function refreshTaiga(){
}
const info = await taigaProbeCache.get(basis);
if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; }
/* Die Antwort kommt NACH dem ersten Neubau die Bulk-Abfrage (D91-N10)
anstoßen, sonst wartete sie auf den nächsten Tastendruck. */
if(taigaOn) scheduleTaigaBulk(parse(src.value).roots);
}
function taigaFetch(path, options, token){
@@ -2020,6 +2034,7 @@ async function pushStatus(key, slug, ref, name, version){
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
}
repaintTicket(key);
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem geschriebenen Stand */
}
/* Die Spalten je Projekt und Typ einmal je Sitzung, wie der Ticket-Stand
@@ -2061,6 +2076,7 @@ async function loadTicket(key, slug, ref, interactive){
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
}
repaintTicket(key);
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem frischen Stand */
}
/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht
@@ -2072,6 +2088,84 @@ function repaintTicket(key){
if(tipNode) placeNodeTip(tipNode);
}
/* ---------- Abweichungs-Marken im Diagramm (D91-Nachtrag 10) ----------
Mit Anmeldung holt EINE Bulk-Anfrage je Projekt und Sitzung den Stand
aller referenzierten Tickets; eine Ref, deren Ticket-Status nicht zur
Statusbox des Knotens passt, färbt sich warnfarben (`tref-diff`) die
Einzelheiten samt der zwei Knöpfe stehen wie gehabt im Knoten-Fenster.
Der Fächer läuft im Proxy: Die Kosten skalieren mit den Refs im Plan,
nie mit der Projektgröße. Ohne Anmeldung wird NICHTS geholt (die
D91-Nachtrag-6-Linie), und ein Hintergrund-Fehler bleibt still kein
Dialog, keine Warnung; das Markieren aus dem Cache ist dagegen
kostenlos und läuft bei jedem Neubau mit. */
const taigaBulkDone = new Set(); /* Slugs, die diese Sitzung schon geholt sind */
function scheduleTaigaBulk(roots){
if(!taigaOn) return;
const session = taigaSession();
if(!session || !session.token) return;
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
if(taigaBulkDone.has(slug)) continue;
taigaBulkDone.add(slug);
const pfad = bulkPath(slug, eintraege.map(e => e.ref));
if(!pfad) continue;
taigaFetch(pfad, null, session.token).then(map => {
for(const [ref, data] of Object.entries(map || {})){
/* Ein schon einzeln geholtes Ticket bleibt stehen es ist nie
älter als der Bulk (der läuft einmal, ganz am Anfang). */
const key = slug + '/' + ref;
if(!taigaTickets.has(key)) taigaTickets.set(key, {kind: 'ok', data});
}
/* Badges an unsichtbaren Refs brauchen den Neubau (Renderer, oben);
ein offenes Knoten-Fenster würde der aber schließen dann nur die
Klassen-Marken, das Badge kommt mit dem nächsten Neubau. */
if(tipNode) markTicketDiffs(parse(src.value).roots);
else render();
}).catch(err => {
if(err && err.status === 401) storeTaigaSession(null);
});
}
}
/* Welche Knoten brauchen das Abweichungs-BADGE? Nur die, deren Ref nicht
im Label steht (sie ist die Knoten-ID, §1 erstes `#`-Token) sichtbare
Refs färben sich selbst (markTicketDiffs). Entschieden aus dem Cache zum
Zeitpunkt des Neubaus; kommt der Bulk später an, rendert sein Callback. */
function ticketDiffBadgeMap(roots){
const map = new Map();
if(!taigaOn) return map;
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
for(const e of eintraege){
const st = taigaTickets.get(slug + '/' + e.ref);
if(st && st.kind === 'ok' && ticketDiverges(e.node, st.data.status)
&& !refVisibleInLabel(e.node, e.ref)){
map.set(e.node.line, e.ref);
}
}
}
return map;
}
/* Die Marken aus dem Cache an die `tref`-Spannen legen reine Anzeige, je
Neubau neu (das DOM ist frisch). Eingeklappte Knoten stehen nicht im DOM
und bleiben unmarkiert; ihr Ticket-Stand liegt trotzdem im Cache. */
function markTicketDiffs(roots){
/* Erst räumen, dann setzen nach nach Taiga schreiben" stimmen Ticket
und Box wieder überein, ohne dass der Baum neu gebaut wurde. */
for(const alt of out.querySelectorAll('.tref-diff')) alt.classList.remove('tref-diff');
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
for(const e of eintraege){
const st = taigaTickets.get(slug + '/' + e.ref);
if(!st || st.kind !== 'ok' || !ticketDiverges(e.node, st.data.status)) continue;
const el = out.querySelector('.node[data-line="' + e.node.line + '"]');
if(!el) continue;
for(const span of el.querySelectorAll('.tref')){
if(span.textContent === '#' + e.ref) span.classList.add('tref-diff');
}
}
}
}
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
das Ticket im Taiga-Frontend (D91-Nachtrag 5) dieselbe Geste wie im
Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne
+10
View File
@@ -290,6 +290,16 @@ function nodeHtml(n, extra, opts, fold){
Notation. Nicht im Export: Der Text selbst kann dort nicht
erscheinen, eine Marke ohne Ziel wäre Rauschen. */
(n.desc ? '<span class="desc-mark" aria-hidden="true">”</span>' : '') +
/* Abweichungs-Badge (D91-Nachtrag 10): Wo die Ref die
KNOTEN-ID ist, steht sie nicht im Label — die Marke
braucht dann einen sichtbaren Träger. Welche Zeilen eines
brauchen, entscheidet app.js aus dem Ticket-Cache; hier
wird nur angehängt (Geometrie stimmt, weil es VOR dem
Messen geschieht — die D40-Bauform der ”-Marke). Nicht im
Export (Sitzungswissen): dort per excludeSel ausgenommen. */
(opts.tdiffRefs && opts.tdiffRefs.has(n.line)
? `<span class="tref tref-diff tref-badge" aria-hidden="true">#${esc(opts.tdiffRefs.get(n.line))}</span>`
: '') +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
+13
View File
@@ -779,6 +779,15 @@
bräche eine zu breit geratene Zeile sonst am Bindestrich mitten im Token
(`#US-`/`123`). Die Spanne setzt render.js (markTicketRefs). */
.node .tref{white-space:nowrap}
/* Abweichungs-Marke (D91-Nachtrag 10): Das Ticket hinter dieser Ref sagt
etwas anderes als die Statusbox — die Ref selbst wird warnfarben, kein
neues Element in den ohnehin dichten Knoten-Ecken. Nur Anzeige aus der
Sitzung: nicht im Grafikexport (der liest die Knotenfarbe, nicht die
Spanne) und nicht im Druck. */
.node .tref-diff{color:var(--warn);font-weight:600}
/* Das Badge für Refs, die (nur) die Knoten-ID sind — dort steht die Ref
sonst nirgends im Knoten. Klein und nachgestellt wie die ”-Marke. */
.node .tref-badge{margin-left:4px;font-size:.86em}
/* Der Knopf trägt das Zeichen selbst statt eines Icons — in der Größe der
SVGs daneben, damit die Kopfzeile ihre Höhe behält. */
.copybtn.idsbtn span{
@@ -1635,6 +1644,10 @@
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
/* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid}
/* Die Abweichungs-Marke hängt an der Sitzung (Anmeldung, Abrufzeit) —
gedruckt wird der Plan, nicht mein Abrufstand (D91-Nachtrag 10). */
.node .tref-diff{color:inherit!important;font-weight:inherit!important}
.node .tref-badge{display:none!important}
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1).
Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu — sonst stünde ein
Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */
+52
View File
@@ -113,6 +113,58 @@ export function statusListPath(ref, slug){
'?slug=' + encodeURIComponent(slug);
}
/* Alle Ticket-Refs eines Baums, gruppiert nach ihrem geerbten Projekt-Slug
(D91-Nachtrag 10): die Menge, die die Bulk-Abfrage holt die Refs im Plan
sind der Filter, nie die Projektgröße. Knoten ohne Slug fallen heraus
(ohne Projekt ist eine Ref nicht auflösbar dieselbe stille Regel wie
beim Öffnen, D91-Nachtrag 5). Map slug -> [{ref, node}]; dieselbe Ref an
mehreren Knoten steht mehrfach darin (markiert wird je Knoten),
dedupliziert wird erst im Anfrage-Pfad. */
export function collectTicketRefs(roots, slugMap){
const out = new Map();
(function w(ns){
for(const n of ns){
const ref = ticketRefOf(n);
const slug = slugMap.get(n);
if(ref && slug){
if(!out.has(slug)) out.set(slug, []);
out.get(slug).push({ref, node: n});
}
w(n.children || []);
}
})(roots);
return out;
}
/* Der Bulk-Pfad am Proxy (eine Anfrage je Projekt): Refs dedupliziert und
bei 200 gedeckelt der benannte Server-Deckel; mehr referenziert kein
Plan, und die ersten 200 markiert zu bekommen ist besser als wegen eines
400 gar keine. */
export function bulkPath(slug, refs){
const uniq = [...new Set(refs)].slice(0, 200);
if(!uniq.length || !slug) return null;
return '/tickets?slug=' + encodeURIComponent(slug) + '&refs=' + uniq.join(',');
}
/* Steht die Ref sichtbar im LABEL des Knotens? Nein heißt: Sie ist (nur)
seine Knoten-ID (§1, erstes `#`-Token) die Abweichungs-Marke braucht
dann ein Badge hinter dem Label als sichtbaren Träger (D91-Nachtrag 10).
Ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar. */
export function refVisibleInLabel(n, ref){
return new RegExp('(^|\\s)#' + ref + '(?=\\s|$)').test(n.label);
}
/* Weicht der Ticket-Status von der Statusbox des Knotens ab? (Die
Abweichungs-Marke im Diagramm, D91-Nachtrag 10.) Verglichen wird nur, was
abbildbar ist ein unbekannter Spaltenname sagt nichts über den Plan
(dieselbe Regel wie im Knoten-Fenster). Ein Knoten OHNE Statusbox weicht
von jedem abgebildeten Ticket-Status ab auch das zeigt das Fenster so. */
export function ticketDiverges(node, statusName){
const ticket = mapTaigaStatus(statusName);
if(!ticket) return false;
return (node.status ? node.status.code : null) !== ticket.code;
}
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im