feat(diagram): Personen-Leiste mit Personen-Linse und Belastungsanzeige (D87)

Je @Person eine Pille mit Anteil an der offenen Pfad-Arbeit (D71-Maß),
dazu ein Eintrag für Unzugewiesenes — die Anteile summieren auf 100 %.
Ein Tipp filtert das Diagramm auf die Knoten der Person: alles andere
faltet, ihre Pakete stehen als ▸ n, die Pillen färben petrol. Die Linse
ist rein Ansicht und Sitzungssache — in den Text wird nichts geschrieben,
in geteilten Dokumenten ginge der persönliche Filter sonst an alle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 10:38:53 +02:00
co-authored by Claude Fable 5
parent 1e1a00f794
commit deec39e82a
10 changed files with 439 additions and 7 deletions
+2
View File
@@ -19,6 +19,8 @@ reverse.
## 2026-08-27
- A people bar below the diagram shows each `@person` with their share of the open work on the cheapest path — plus an entry for what is assigned to nobody
- Tapping a person filters the diagram to their nodes: everything else folds, their packages stand collapsed as `▸ n`, their pills turn teal — a view-only lens that never writes into the text
- Folding regions in the text editor recorded as an idea in the plan — view-only and local, unrelated to the diagram's fold marks, deliberately not sized yet
- Fix: the root node's fold chip was white-on-pastel and barely visible — it now looks like every other node's, and the description mark and id line on a coloured root are readable too
- The caret on a node off the cheapest path now highlights only the ring — the fill stays pale instead of jumping back to full colour
+79
View File
@@ -7446,3 +7446,82 @@ lokales Dokument zeigt unverändert seine lokalen Stände. Der Namens-Dialog
beim Beitreten blockiert nichts (die Automation wies ihn ab — Dokument lädt,
nichts gemerkt, nächstes Mal wird wieder gefragt). 531 Frontend-Tests und
der Backend-`check` grün.
## D87 — Personen-Leiste mit Personen-Linse: eine Fläche für Filter und Belastung
Gewünscht: je `@`-Person die zuständigen Knoten hervorheben und alles andere
zuklappen — auch unter den explizit benannten Knoten —, dazu eine
Belastungsanzeige je Person. Zwei Bedienideen standen im Raum (Menü-Knopf im
Diagramm-Kopf oder die Namens-Pillen als Leiste unterm Diagramm); die
Entscheidungen fielen als Multiple-Choice-Runde:
**Die Leiste, nicht der Knopf** — und der Ausschlag ist das zweite Feature:
Ein Kopf-Knopf wäre das zwölfte Element in einer dreimal zurechtgemessenen
Zeile (D47/D56/D75) und zeigte die Belastung erst hinter einem Klick. Die
Leiste trägt beides in einer Fläche — Pille, Balken, Tipp = Linse — und
erscheint **nur bei Dokumenten mit Tags**: Für tag-lose Pläne (auch den
mitgelieferten) kostet sie nichts.
**Die Linse ist die erste bewusst rein sichtbare Faltung.** D38 schreibt
Falten in den Text — richtig für „so wird dieses Dokument eröffnet", falsch
für „zeig mir Annas Arbeit": In einem geteilten Dokument (D76) ginge der
persönliche Filter als Textänderung an alle Mitschreiber. Die Linse nutzt
deshalb nur die Darstellung (Sitzungszustand wie die News-Vorführung, D58);
Hand-Faltungen währenddessen bleiben in einer eigenen Überlagerung
(`lensOverrides`), der Falt-Durchschalter beendet die Linse (er arbeitet auf
dem Text-Zustand), und die Reihum-Prüfung des Durchschalters ruht solange —
sonst würfe der Linsen-Zustand die Position um, obwohl der Text unverändert
ist. **Export und Druck folgen ihr trotzdem** (Nutzer-Entscheidung): Die
D38-Regel „exportiert wird die sichtbare Struktur, eingeklappte Knoten tragen
▸ n" gilt wie überall — „Annas Sicht als Bild teilen" funktioniert damit von
selbst.
**Die Falt-Regel (`personFoldSet`, headless in model.js):** Zu klappt, wer
den Tag **selbst trägt** (das „▸ n" vertritt das Paket — mit der
D71-Vererbung ist alles darunter ohnehin dessen) und wer in seinem Teilbaum
**gar nichts** von der Person hat; offen bleiben genau die Vorfahren-Pfade.
Der interessante Fall ist getestet: Ein fremd getaggtes Paket, das tief innen
einen Knoten der gesuchten Person enthält, bleibt **offen** — die eigenen
Tags eines Knotens entscheiden nur über ihn, nicht über den Pfad hindurch.
Nicht faltbare Blatt-Geschwister auf offenen Pfaden bleiben sichtbar —
dieselbe Eigenschaft wie beim `path`-Preset (D75); welche Knoten gemeint
sind, sagt die **petrol gefärbte Pille** der Person (die Engpass-Warnfarbe
gewinnt: eine Warnung wiegt schwerer als eine Auswahl).
**„Ohne Zuständigen" ist ein eigener, klickbarer Eintrag** (gestrichelte
Pille — ein Loch, keine Person): Die Anteile summieren damit ehrlich auf
100 % (D71: Unzugewiesenes verwässert sie ohnehin), und seine Linse zeigt,
wofür noch niemand eingeteilt ist — jeder Knoten mit **eigenen** Tags klappt
zu, die Grenze ins zugewiesene Gebiet. Für Unzugewiesen taugt die
Personen-Regel nicht: „kein Tag" ist vorfahren-abgeschlossen (die Wurzel
wäre ihr eigener äußerster Vertreter), die Grenz-Formulierung ist die
tragfähige Umkehrung.
**Belastung: Anteile, keine absoluten Zahlen** (Nutzer-Entscheidung). Die
Größen sind ordinal (D46) — jede Summe ist eine Näherung, und eine Punktzahl
suggerierte Präzision, die die Schätzskala nicht hat. Gerechnet wird mit dem
vorhandenen D71-Maß (`assigneeLoads`: Marginalkosten der offenen Pfad-Arbeit,
Vererbung, geteilte Beiträge) — **eine** Kostenrechnung im System, Warnung
und Anzeige sagen dieselbe Zahl. Bei ausgeschaltetem Pfad-Umschalter wird der
Pfad eigens gerechnet (die D75-Linie: die Frage gilt dem Pfad, nicht seiner
Anzeige); die Bernstein-Färbung der Engpass-Person bleibt dagegen am
sichtbaren Pfad und färbt auch die Leisten-Pille.
**Eine Person zur Zeit** (Nutzer-Entscheidung): Tipp wechselt, zweiter Tipp
auf dieselbe schaltet aus — Mehrfachauswahl kann später dazukommen, umgekehrt
nicht. Die Linse erlischt von selbst, wenn es die Person (oder überhaupt
Tags) im Dokument nicht mehr gibt, und beim Dokumentwechsel
(`loadActiveIntoEditor`, die eine Stelle aus D80).
**Nebengewinn, notiert:** SPEC §11 reserviert `&tag` ausdrücklich bis zum
ersten Konsumenten — „Filter-/Hervorheben-Linse" ist dort wörtlich genannt.
Diese Linse baut genau die Mechanik; Schlagworte bekämen sie später
geschenkt.
**Nachgemessen** im Browser an einem Tag-Plan (anna/ben, elf Knoten): Leiste
ben 50 % · anna 21 % · ohne Zuständigen 29 % (Summe 100). Annas Linse:
`Backend` und `Frontend` als „▸ 2", `Infra` offen mit ihrem CI-Blatt, beide
anna-Pillen petrol, Text byte-identisch. Bens Linse hält annas Backend
**offen** (sein API liegt darin). Hand-Faltung während der Linse ändert den
Text nicht; Ausschalten stellt den textdefinierten Zustand wieder her;
Unzugewiesen-Linse klappt genau die getaggten Pakete zu. Tag-loses Dokument:
keine Leiste. 538 Tests, davon 7 neue in `tests/lens.test.js`.
+35
View File
@@ -760,6 +760,41 @@ Termine sagt die Warnung nichts (das tut in dieser Notation ohnehin nichts,
keine Pille und damit keine Färbung. Bei ausgeschaltetem Pfad entfällt
beides — ohne Pfad gibt es keine offene Front.
### Personen-Leiste und Personen-Linse
Trägt das Dokument `@`-Tags (§7), steht unter dem Diagramm eine **Leiste mit
einer Pille je Person** samt **Belastungs-Balken**: ihr Anteil an der offenen
Arbeit des günstigsten Pfads — dasselbe Maß wie der Zuständigen-Engpass
(oben), mit Vererbung und geteilten Beiträgen; die Bernstein-Färbung der
Engpass-Person erscheint auch hier. Angezeigt werden **Anteile, keine
absoluten Zahlen** — die Größen sind ordinal, jede Summe ist eine Näherung.
Der Rest **ohne Zuständigen** bekommt einen eigenen, gestrichelten Eintrag,
damit die Anteile auf 100 % summieren. Gerechnet wird auch bei
ausgeschaltetem Pfad-Umschalter (wie die `path`-Voreinstellung des
Falt-Durchschalters: die Frage gilt dem Pfad, nicht seiner Anzeige). Ohne
Tags gibt es die Leiste nicht.
Ein **Tipp auf eine Pille schaltet die Personen-Linse**: Sichtbar bleiben nur
die Vorfahren-Pfade zu den Knoten dieser Person; die getaggten Knoten selbst
stehen **eingeklappt** da — ihr „▸ n“ vertritt das Paket, und mit der
Vererbung ist alles darunter ohnehin ihres. Ihre Personen-Pillen an den
sichtbaren Knoten färben sich petrol (die Engpass-Warnfarbe gewinnt). Nicht
faltbare Geschwister auf offenen Pfaden bleiben stehen — dieselbe
Eigenschaft wie bei der Pfad-Voreinstellung des Falt-Durchschalters. Der
Eintrag „ohne Zuständigen“ zeigt als Linse die Struktur, deren Arbeit
niemandem gehört: Jeder Knoten mit eigenen Tags klappt zu. Ein zweiter Tipp
hebt die Linse auf, ein Tipp auf eine andere Person wechselt; es gilt
höchstens eine zugleich.
Die Linse ist **reine Ansicht und Sitzungssache**: Anders als das Falten (§9,
`>`/`<`) schreibt sie **nichts** in den Text — in einem geteilten Dokument
(`?live=`) ginge der persönliche Filter sonst als Textänderung an alle.
Hand-Faltungen während der Linse bleiben ebenso in der Sitzung;
Dokumentwechsel und Neuladen setzen zurück, der Falt-Durchschalter beendet
die Linse (er arbeitet auf dem Text-Faltzustand). **Grafikexport und Druck
folgen der sichtbaren Faltung** wie überall — „die Sicht einer Person als
Bild teilen“ funktioniert damit von selbst; die Leiste selbst erscheint in
beiden nicht.
### Diagramm aus einer URL laden (`?sourceUrl=`)
Der Editor kann den Notationstext aus einer externen Textdatei beziehen:
`…?sourceUrl=https://example.org/plan.txt` (relative Angaben werden gegen die
+18
View File
@@ -87,6 +87,9 @@
- [^] #ed.fold.write: Folding writes the mark back into the text (S) %% one undo step
- [^] #ed.fold.preset: The fold button cycles through four presets (S) %% M-and-smaller, off-path, all, none — see D44/D75
+ [?] #ed.textfold: Fold regions in the text editor, next to the line numbers %% view-only, unrelated to the > marks; not sized yet
- [x] #ed.lens: A people bar with a person lens (M)
- [x] #ed.lens.fold: One person's nodes, everything else folds (S) %% view-only, never written into the text
- [x] #ed.lens.load: A load bar per person, shares of the open path work (S) %% the D71 measure
- [^] #ed.snaps: Earlier states of a document, every ten minutes (S) %% only when something changed
- [^] #ed.snaps.manual: Save a state by hand, before a larger change (XS) %% ten minutes is the wrong beat for that moment
- [^] #ed.files: Open and save .werkbaum files (S)
@@ -619,6 +622,21 @@
lines, so this hinges on the parked editor-rebuild question (CodeMirror,
D76); deliberately not sized yet.
#ed.lens
A bar below the diagram, one pill per @person, shown only when the
document carries tags at all. Tapping a pill filters the diagram to that
person's work; an extra entry shows what is assigned to nobody.
#ed.lens.fold
The lens keeps only the ancestor paths to the person's nodes open; their
tagged packages stand collapsed. Deliberately view-only: written fold
marks would push a personal filter to everyone in a shared document.
#ed.lens.load
Each pill carries a bar with the person's share of the open work on the
cheapest path — the same measure as the bottleneck warning, shares
rather than absolute numbers because the sizes are ordinal.
#ed.snaps
A safety net against mishaps: the text is put aside every ten minutes, but
only when it differs from the last one kept, and the last twenty are offered
+1
View File
@@ -277,6 +277,7 @@
</div>
</div>
<div class="diagram"><ul class="tree" id="out"></ul></div>
<div class="peoplebar" id="peopleBar" data-i18n-aria="peopleBarLabel" aria-label="Zuständige" hidden></div>
<div class="warnings" id="warn" role="status" aria-live="polite"></div>
<div class="zoomctl" data-i18n-aria="zoomAria" aria-label="Zoom (Strg/Cmd + Mausrad)">
<button type="button" id="zoomToggle" class="zoomToggle" data-i18n-title="zoomTooltip" title="Zoom anzeigen" aria-label="Zoom anzeigen">
+143 -5
View File
@@ -1,6 +1,6 @@
import './style.css';
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js';
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js';
@@ -52,6 +52,7 @@ const INITIAL = `%% Project structure Sprint 14
const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
const peopleBar = document.getElementById('peopleBar');
/* Zeilennummern-Streifen (D33) — hier oben geholt, weil render() ihn füllt. */
const srcWrap = document.getElementById('srcWrap');
const lineNoBox = document.getElementById('lineNos');
@@ -130,6 +131,16 @@ let newsDay = null, newsKeySet = null;
Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg.
`foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */
let foldOverrides = new Map(), foldByLine = new Map();
/* Personen-Linse (SPEC §9, D87): `lens` ist null (aus) oder `{tag}` der
Personen-Tag bzw. `tag: null` für ohne Zuständigen". REIN Ansicht und
Sitzungssache: Sie überlagert den Faltzustand nur für die Darstellung,
in den Text wird nichts geschrieben in einem geteilten Dokument ginge
der persönliche Filter sonst als Textänderung an alle (D76/D38).
`lensOverrides` sind Hand-Faltungen WÄHREND der Linse (Label-Pfad-Schlüssel
wie `foldOverrides`); Ausschalten stellt den textdefinierten Zustand
wieder her. */
let lens = null, lensOverrides = new Map();
function clearLens(){ lens = null; lensOverrides.clear(); }
/* Zeile -> Zeile des sichtbaren Vertreters (D38-Nachtrag 4): Für Zeilen in
eingeklappten Teilbäumen zeigt sie auf den nächsten sichtbaren Vorfahren;
`nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */
@@ -163,11 +174,17 @@ function render(){
if(tabWarning) warnings = [tabWarning].concat(warnings); /* fremder Tab schreibt mit (D84) */
if(storeWarning) warnings = [storeWarning].concat(warnings); /* Datenverlust droht — zuoberst (D82) */
/* Personen-Linse (D87): erlischt, sobald es die Person (oder überhaupt
Tags) im Dokument nicht mehr gibt sonst zeigte sie auf nichts. */
const docTags = allTags(roots);
if(lens && (!docTags.length || (lens.tag !== null && !docTags.includes(lens.tag)))) clearLens();
if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
freshSet = new Set();
foldByLine = new Map();
lineTargetMap = new Map();
peopleBar.hidden = true;
} else {
/* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte
Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung
@@ -215,14 +232,20 @@ function render(){
er hier eigens für die Prüfung gerechnet (dieselbe Rechnung, die sonst
ohnehin je Tastendruck läuft). */
let presetSet = null, presetMatch = true;
if(foldCycleApplied >= 0){
/* Bei aktiver Linse (D87) ruht die Prüfung: Die Faltung ist dann
Linsen-Sache, der Text unverändert die Reihum-Position bleibt. */
if(foldCycleApplied >= 0 && !lens){
const mode = FOLD_CYCLE[foldCycleApplied];
const cs = (mode === 'path' && !cheapPathOn) ? computeCheapPlan(roots).set : cheapSet;
presetSet = presetFoldSet(roots, mode, cs);
}
/* Personen-Linse (D87): Ihr Faltzustand ERSETZT den textdefinierten für
die Darstellung überlagert nur von Hand-Faltungen während der Linse. */
const lensSet = lens ? personFoldSet(roots, lens.tag) : null;
keys.forEach((key, n) => {
const ov = foldOverrides.get(key);
const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
const ov = lensSet ? lensOverrides.get(key) : foldOverrides.get(key);
const base = lensSet ? lensSet.has(n) : initFold.has(n);
const collapsed = (ov !== undefined ? ov : base) && n.children.length > 0;
if(collapsed) collapsedSet.add(n);
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
if(presetSet && n.children.length > 0 && collapsed !== presetSet.has(n)) presetMatch = false;
@@ -234,9 +257,21 @@ function render(){
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds,
freshSet, collapsedSet,
effStatus: effectiveStatus(roots),
overloadTag: overload ? overload.tag : null});
overloadTag: overload ? overload.tag : null,
lensTag: lens ? lens.tag : null});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
(dasselbe Maß wie die D71-Warnung). Bei ausgeschaltetem Pfad-Umschalter
wird der Pfad eigens gerechnet die Leiste fragt nach dem Pfad, nicht
nach seiner Anzeige (die D75-Linie des 'path'-Presets). Die
Bernstein-Färbung des Engpasses bleibt dagegen am SICHTBAREN Pfad. */
if(docTags.length){
const loadSet = cheapPathOn ? cheapSet : computeCheapPlan(roots).set;
renderPeopleBar(roots, docTags, loadSet, overload);
} else {
peopleBar.hidden = true;
}
}
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
@@ -268,6 +303,49 @@ function render(){
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
}
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
Je Person eine Pille mit Belastungs-Balken; ein Tipp schaltet die Linse
(nur ihre Knoten, alles andere gefaltet), der zweite hebt sie auf, ein
Tipp auf eine andere wechselt. Angezeigt werden ANTEILE, keine absoluten
Zahlen die Größen sind ordinal, jede Summe ist eine Näherung (D46).
Der Rest ohne Zuständigen bekommt einen eigenen Eintrag, damit die Balken
ehrlich auf 100 % summieren; sein Klick zeigt, wofür noch niemand
eingeteilt ist. Sortiert nach Last, bei Gleichstand Dokumentreihenfolge. */
function renderPeopleBar(roots, docTags, loadSet, overload){
const {loads, total} = assigneeLoads(roots, loadSet);
const share = l => total > 0 ? Math.round(l / total * 100) : 0;
const entries = docTags.map(tg => ({tag: tg, pct: share(loads.get(tg) || 0)}))
.sort((a, b) => b.pct - a.pct || docTags.indexOf(a.tag) - docTags.indexOf(b.tag));
let assigned = 0;
loads.forEach(l => { assigned += l; });
const rest = total - assigned;
if(rest > 0) entries.push({tag: null, pct: share(rest)});
peopleBar.innerHTML = entries.map(e => {
const active = !!lens && lens.tag === e.tag;
const label = e.tag === null ? t('peopleUnassigned') : e.tag;
const tip = t('peopleShare', {share: e.pct}) + ' · ' +
t(active ? 'peopleLensOff' : 'peopleLensOn');
const warm = overload && e.tag !== null && overload.tag === e.tag;
return `<button type="button" class="pbperson" aria-pressed="${active}"` +
(e.tag === null ? ' data-nobody' : ` data-tag="${esc(e.tag)}"`) +
` title="${esc(label)}: ${esc(tip)}" aria-label="${esc(label)}: ${esc(tip)}">` +
`<span class="tag${warm ? ' overload' : ''}${e.tag === null ? ' nobody' : ''}" aria-hidden="true">${esc(label)}</span>` +
`<span class="pbbar" aria-hidden="true"><span class="pbfill" style="width:${e.pct}%"></span></span>` +
`<span class="pbpct" aria-hidden="true">${e.pct}%</span></button>`;
}).join('');
peopleBar.hidden = false;
}
peopleBar.addEventListener('click', e => {
const b = e.target && e.target.closest ? e.target.closest('.pbperson') : null;
if(!b) return;
const tag = ('nobody' in b.dataset) ? null : b.dataset.tag;
const same = !!lens && lens.tag === tag;
clearLens();
if(!same) lens = {tag};
render();
});
/* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt
aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des
Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des
@@ -1290,6 +1368,10 @@ function writeAllFoldMarks(roots, want){
bei ausgeschaltetem Pfad-Umschalter (die Voreinstellung fragt nach dem
Pfad, nicht nach seiner Anzeige). */
function applyFoldPreset(mode){
/* Der Durchschalter arbeitet auf dem TEXT-Faltzustand eine aktive
Personen-Linse (D87) endet damit; sonst schriebe er Marken, die man
unter der Linse gar nicht sieht. */
clearLens();
const roots = parse(src.value).roots;
if(!roots.length) return;
const cs = mode === 'path' ? computeCheapPlan(roots).set : null;
@@ -1310,6 +1392,16 @@ function toggleFold(el){
const line = +el.dataset.line;
const st = foldByLine.get(line);
if(!st || !st.canFold) return;
/* Bei aktiver Personen-Linse (D87) bleibt die Hand-Faltung in deren
Sitzungs-Überlagerung in den Text geschrieben würde sonst der
persönliche Filter, in geteilten Dokumenten für alle. */
if(lens){
lensOverrides.set(st.key, !st.collapsed);
render();
const again2 = out.querySelector('.node[data-line="' + line + '"]');
if(again2) again2.focus({preventScroll: true});
return;
}
foldOverrides.set(st.key, !st.collapsed);
/* Das Schreiben löst per `input`-Ereignis schon ein render() aus. */
if(writeFoldToText(line, !st.collapsed)) foldOverrides.clear();
@@ -2326,6 +2418,11 @@ const I18N = {
sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe",
cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).",
assigneeOverloadWarn:"@{tag} trägt {share} % der offenen Arbeit auf dem günstigsten Pfad ({stations} von {total} Stationen) — mögliche Engstelle.",
peopleBarLabel:"Zuständige",
peopleUnassigned:"ohne Zuständigen",
peopleShare:"{share} % der offenen Arbeit auf dem günstigsten Pfad",
peopleLensOn:"Klick: nur diese Knoten zeigen, alles andere zuklappen",
peopleLensOff:"Klick: Filter aufheben",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
@@ -2447,6 +2544,11 @@ const I18N = {
sizeConflictTooltip:"The sub-packages together exceed the given size",
cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).",
assigneeOverloadWarn:"@{tag} carries {share}% of the open work on the cheapest path ({stations} of {total} stations) — a possible bottleneck.",
peopleBarLabel:"Assignees",
peopleUnassigned:"unassigned",
peopleShare:"{share}% of the open work on the cheapest path",
peopleLensOn:"Click: show only these nodes, fold everything else",
peopleLensOff:"Click: remove the filter",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
@@ -2568,6 +2670,11 @@ const I18N = {
sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado",
cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).",
assigneeOverloadWarn:"@{tag} lleva el {share} % del trabajo pendiente en el camino más barato ({stations} de {total} estaciones) — posible cuello de botella.",
peopleBarLabel:"Responsables",
peopleUnassigned:"sin responsable",
peopleShare:"{share} % del trabajo pendiente en el camino más barato",
peopleLensOn:"Clic: mostrar solo estos nodos y plegar el resto",
peopleLensOff:"Clic: quitar el filtro",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
@@ -2689,6 +2796,11 @@ const I18N = {
sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée",
cheapApproxWarn:"Trop de groupes dalternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).",
assigneeOverloadWarn:"@{tag} porte {share} % du travail restant sur le chemin le moins cher ({stations} stations sur {total}) — goulot d’étranglement possible.",
peopleBarLabel:"Responsables",
peopleUnassigned:"sans responsable",
peopleShare:"{share} % du travail restant sur le chemin le moins cher",
peopleLensOn:"Clic : nafficher que ces nœuds, replier le reste",
peopleLensOff:"Clic : retirer le filtre",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
@@ -2810,6 +2922,11 @@ const I18N = {
sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar",
cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).",
assigneeOverloadWarn:"@{tag} niesie {share} % otwartej pracy na najtańszej ścieżce ({stations} z {total} stacji) — możliwe wąskie gardło.",
peopleBarLabel:"Odpowiedzialni",
peopleUnassigned:"bez odpowiedzialnego",
peopleShare:"{share} % otwartej pracy na najtańszej ścieżce",
peopleLensOn:"Klik: pokaż tylko te węzły, resztę zwiń",
peopleLensOff:"Klik: usuń filtr",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
@@ -2931,6 +3048,11 @@ const I18N = {
sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
assigneeOverloadWarn:"@{tag} несёт {share} % открытой работы на самом дешёвом пути ({stations} из {total} станций) — возможное узкое место.",
peopleBarLabel:"Ответственные",
peopleUnassigned:"без ответственного",
peopleShare:"{share} % открытой работы на самом дешёвом пути",
peopleLensOn:"Клик: показать только эти узлы, остальное свернуть",
peopleLensOff:"Клик: снять фильтр",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -3052,6 +3174,11 @@ const I18N = {
sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
assigneeOverloadWarn:"@{tag} सबसे सस्ते पथ पर खुले काम का {share}% उठाए हुए है ({total} में से {stations} स्टेशन) — संभावित अड़चन।",
peopleBarLabel:"ज़िम्मेदार",
peopleUnassigned:"बिना ज़िम्मेदार",
peopleShare:"सबसे सस्ते पथ पर खुले काम का {share}%",
peopleLensOn:"क्लिक: केवल ये नोड दिखाएँ, बाकी सब समेटें",
peopleLensOff:"क्लिक: फ़िल्टर हटाएँ",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -3173,6 +3300,11 @@ const I18N = {
sizeConflictTooltip:"子项合计超出所标注的尺寸",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
assigneeOverloadWarn:"@{tag} 承担最便宜路径上 {share}% 的未完成工作({total} 个站点中的 {stations} 个)——可能的瓶颈。",
peopleBarLabel:"负责人",
peopleUnassigned:"未分配",
peopleShare:"最便宜路径上未完成工作的 {share}%",
peopleLensOn:"点击:只显示这些节点,折叠其余部分",
peopleLensOff:"点击:取消筛选",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -3294,6 +3426,11 @@ const I18N = {
sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
assigneeOverloadWarn:"@{tag} が最安パスの未完了作業の {share}% を担っています(全 {total} 駅中 {stations} 駅)— ボトルネックの可能性。",
peopleBarLabel:"担当者",
peopleUnassigned:"担当者なし",
peopleShare:"最安パスの未完了作業の {share}%",
peopleLensOn:"クリック:このノードだけを表示し、他を折りたたむ",
peopleLensOff:"クリック:フィルターを解除",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
@@ -4237,6 +4374,7 @@ document.addEventListener('pointerdown', e => {
});
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
snapBase = src.value; closeSnapMenu();
+51
View File
@@ -528,6 +528,57 @@ export function presetFoldSet(roots, mode, cheapSet){
return set;
}
/* ---------- Personen-Linse (SPEC §9, D87) ----------
Vollständiger Faltzustand für „zeig mir die Arbeit von @tag": offen bleiben
nur die VORFAHREN der Knoten, die den Tag tragen; die getaggten Knoten
selbst klappen zu — ihr „▸ n" vertritt das Paket, und mit der Vererbung
(D71: wer das Paket hat, hat die Teilpakete) ist alles darunter ohnehin
ihres. Ein getaggter Knoten OHNE Kinder bleibt als Blatt stehen (es gibt
nichts zu falten); nicht faltbare Geschwister auf offenen Pfaden bleiben
sichtbar — dieselbe Eigenschaft wie beim 'path'-Preset (D75), die
Pillen-Hervorhebung sagt, welche gemeint sind.
`tag === null` ist die Unzugewiesen-Linse: Jeder Knoten mit EIGENEN Tags
klappt zu (die Grenze ins zugewiesene Gebiet) — sichtbar bleibt die
Struktur, deren Marge niemandem gehört. */
export function personFoldSet(roots, tag){
const set = new Set();
if(tag === null){
const walk = ns => {
for(const n of ns){
if(n.children.length && n.tags && n.tags.length) set.add(n);
walk(n.children);
}
};
walk(roots);
return set;
}
const hasTag = n => (n.tags || []).includes(tag) || n.children.some(hasTag);
const walk = ns => {
for(const n of ns){
if(n.children.length){
if((n.tags || []).includes(tag) || !hasTag(n)) set.add(n);
walk(n.children);
}
}
};
walk(roots);
return set;
}
/* Alle vergebenen Personen-Tags in Dokumentreihenfolge — die Einträge der
Personen-Leiste (D87). */
export function allTags(roots){
const tags = [];
const walk = ns => {
for(const n of ns){
for(const tg of n.tags || []) if(!tags.includes(tg)) tags.push(tg);
walk(n.children);
}
};
walk(roots);
return tags;
}
/* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */
export function statusByKey(roots){
const map = new Map();
+6 -1
View File
@@ -208,8 +208,13 @@ function nodeHtml(n, extra, opts, fold){
Nur direkte Tags haben eine Pille; geerbte Zuständigkeit (die in die
Rechnung eingeht) hat nichts, das sich färben ließe. */
const hot = tag => opts.overloadTag === tag && opts.cheapSet.has(n) && !isDone(n);
/* Personen-Linse (SPEC §9/D87): die Pille der gewählten Person hebt sich in
Petrol — sie sagt, WELCHE der sichtbaren Knoten gemeint sind (nicht
faltbare Geschwister bleiben ja stehen). Die Warnfarbe des Engpasses
gewinnt: eine Warnung wiegt schwerer als eine Auswahl. */
const pillCls = tag => hot(tag) ? ' overload' : (opts.lensTag === tag ? ' lens' : '');
const tagsHtml = n.tags && n.tags.length
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag${hot(tag) ? ' overload' : ''}">${esc(tag)}</span>`).join('')}</span>`
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag${pillCls(tag)}">${esc(tag)}</span>`).join('')}</span>`
: '';
/* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) —
seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An
+32 -1
View File
@@ -641,6 +641,33 @@
.panel.right{background:rgba(255,255,255,.6);position:relative}
.warnings{padding:0 16px 14px;font-size:.8rem;color:var(--warn)}
.warnings div{margin-top:4px}
/* Personen-Leiste (SPEC §9/D87): je Person eine Pille mit Belastungs-Balken,
Tipp schaltet die Personen-Linse. Erscheint nur bei Dokumenten mit
@-Tags; auf dem Telefon scrollt sie waagerecht statt umzubrechen (kein
Aufklapp-Inhalt darin, die D50-Falle greift also nicht). */
.peoplebar{
display:flex;gap:8px;align-items:center;flex-wrap:wrap;
padding:0 16px 10px;
}
.pbperson{
display:flex;gap:7px;align-items:center;
border:1px solid rgba(36,52,71,.18);border-radius:999px;
background:var(--card);cursor:pointer;
padding:3px 10px 3px 4px;font:inherit;
}
.pbperson:hover{border-color:var(--or)}
.pbperson[aria-pressed="true"]{border-color:var(--or);box-shadow:0 0 0 1.5px var(--or);background:rgba(15,118,110,.06)}
.pbperson .tag{box-shadow:none}
/* „ohne Zuständigen": gestrichelte Pille — es ist ein Loch, keine Person. */
.pbperson .tag.nobody{border-style:dashed;color:var(--muted);background:var(--card)}
.pbbar{
width:64px;height:6px;border-radius:999px;overflow:hidden;
background:rgba(36,52,71,.1);
}
.pbfill{display:block;height:100%;background:var(--or);border-radius:999px}
.pbpct{font-size:.68rem;color:var(--muted);min-width:3ch;text-align:right}
body.mobile .peoplebar{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;padding:0 10px 8px}
.panel.collapsed .peoplebar{display:none}
.empty{color:var(--muted);font-size:.85rem;padding:8px 4px}
/* Zoom-Steuerung: schwebt unten rechts über dem Diagramm. */
@@ -890,6 +917,10 @@
Hälfte der offenen Pfad-Arbeit trägt — Warnfarbe wie das
Größen-Konflikt-Badge (D62); der Export liest die gemessenen Farben. */
.tag.overload{background:var(--warn);color:#fff;border-color:var(--warn)}
/* Personen-Linse (D87): die Pille der gewählten Person in Petrol — welche
der sichtbaren Knoten ihr gehören. Die Engpass-Warnfarbe gewinnt (der
Renderer vergibt nur eine der beiden Klassen). */
.tag.lens{background:var(--or);color:#fff;border-color:var(--or)}
.ghost-node{
position:relative;margin-top:14px;
border:1.5px dashed var(--warn);
@@ -1551,7 +1582,7 @@
@media print{
html,body{height:auto!important;overflow:visible!important;background:#fff!important}
header,.panel.editor,.gutter,.panel.right .panel-head,
.warnings,.zoomctl,.site-footer,.nodetip,.aclist{display:none!important}
.warnings,.zoomctl,.site-footer,.nodetip,.aclist,.peoplebar{display:none!important}
.app,.app.side{display:block!important;max-width:none!important;width:auto!important;
height:auto!important;margin:0!important;gap:0!important}
.panel.right{grid-area:auto!important;background:#fff!important;border:0!important;
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { personFoldSet, allTags } from '../src/model.js';
/* Personen-Linse (SPEC §9, D87): personFoldSet liefert einen VOLLSTÄNDIGEN
Faltzustand offen bleiben nur die Vorfahren der getaggten Knoten, die
getaggten selbst klappen zu; tag === null ist die Unzugewiesen-Linse. */
const PLAN = `Projekt
- Backend @anna
- Auth (S)
- API (M) @ben
- Frontend @ben
- Views (S)
- Detail (XS)
- Infra
- CI (S) @anna
- Deploy (S)
- Doku (S)
`;
function byLabel(roots){
const map = new Map();
const walk = ns => { for(const n of ns){ map.set(n.label, n); walk(n.children); } };
walk(roots);
return map;
}
function labels(set){ return [...set].map(n => n.label).sort(); }
describe('personFoldSet — Personen-Linse (D87)', () => {
const roots = parse(PLAN).roots;
const N = byLabel(roots);
it('getaggte Knoten klappen zu, ihre Vorfahren bleiben offen', () => {
const set = personFoldSet(roots, 'anna');
expect(set.has(N.get('Backend'))).toBe(true); /* @anna, zu — ▸ vertritt das Paket */
expect(set.has(N.get('Projekt'))).toBe(false); /* Vorfahr von annas Knoten */
expect(set.has(N.get('Infra'))).toBe(false); /* Vorfahr von annas CI */
});
it('Teilbäume ohne den Tag klappen an ihrer Wurzel zu', () => {
const set = personFoldSet(roots, 'anna');
expect(set.has(N.get('Frontend'))).toBe(true); /* @ben — nichts von anna darin */
expect(labels(set)).toContain('Views'); /* auch tiefer: vollständiger Zustand */
});
it('ein getaggtes Blatt ist nicht in der Menge — es gibt nichts zu falten', () => {
const set = personFoldSet(roots, 'anna');
expect(set.has(N.get('CI'))).toBe(false);
});
it('Vorfahr mit fremdem Tag bleibt offen, wenn der gesuchte Tag darunter liegt', () => {
const set = personFoldSet(roots, 'ben');
expect(set.has(N.get('Backend'))).toBe(false); /* enthält bens API */
expect(set.has(N.get('Frontend'))).toBe(true); /* @ben selbst — zu */
expect(set.has(N.get('Infra'))).toBe(true); /* nichts von ben */
});
it('Unzugewiesen-Linse: jeder Knoten mit eigenen Tags klappt zu', () => {
const set = personFoldSet(roots, null);
expect(labels(set)).toEqual(['Backend', 'Frontend']); /* CI ist Blatt, Infra untagged */
});
});
describe('allTags — Einträge der Personen-Leiste (D87)', () => {
it('liefert die Tags in Dokumentreihenfolge, ohne Doppelte', () => {
expect(allTags(parse(PLAN).roots)).toEqual(['anna', 'ben']);
});
it('leer ohne Tags', () => {
expect(allTags(parse('- A\n - B').roots)).toEqual([]);
});
});