From deec39e82ac99aa46c63d2f22d3ac92b2c80d42b Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 27 Aug 2026 10:38:53 +0200 Subject: [PATCH] feat(diagram): Personen-Leiste mit Personen-Linse und Belastungsanzeige (D87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CHANGELOG.md | 2 + docs/DECISIONS.md | 79 +++++++++++++++++ docs/SPEC.md | 35 ++++++++ docs/examples/werkbaum.werkbaum | 18 ++++ frontend/index.html | 1 + frontend/src/app.js | 148 ++++++++++++++++++++++++++++++-- frontend/src/model.js | 51 +++++++++++ frontend/src/render.js | 7 +- frontend/src/style.css | 33 ++++++- frontend/tests/lens.test.js | 72 ++++++++++++++++ 10 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/lens.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0024d89..9278f74 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2770e80..e771d8b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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`. diff --git a/docs/SPEC.md b/docs/SPEC.md index 7f71a45..6804581 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 676eef4..7a6ec59 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 45add8d..ecb0e8e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -277,6 +277,7 @@
    +
    `; + }).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 d’alternatives 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 : n’afficher 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(); diff --git a/frontend/src/model.js b/frontend/src/model.js index 4f91007..466b5b8 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -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(); diff --git a/frontend/src/render.js b/frontend/src/render.js index 2004e22..3e293a8 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -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 - ? `` + ? `` : ''; /* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) — seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An diff --git a/frontend/src/style.css b/frontend/src/style.css index f3d25ed..86feba5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; diff --git a/frontend/tests/lens.test.js b/frontend/tests/lens.test.js new file mode 100644 index 0000000..739b504 --- /dev/null +++ b/frontend/tests/lens.test.js @@ -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([]); + }); +});