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:
co-authored by
Claude Opus 5
parent
e77a4d1ce4
commit
a1e4a3bbab
+97
-3
@@ -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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user