feat: Zuständigen-Engpass — Warnung, wenn eine Person mehr als die Hälfte des Pfads trägt (D71)

Trägt ein @name mehr als die Hälfte der offenen Arbeit des günstigsten
Pfads (Marginalkosten-Maß, D69) und tragen mindestens zwei Personen Last
auf der offenen Front, meldet die zeilenlose Warnung assigneeOverload die
mögliche Engstelle; die Pillen der Person wechseln an offenen Pfad-Knoten
auf die Warnfarbe. Knoten ohne Tags erben für die Rechnung vom nächsten
getaggten Vorfahren, mehrere Tags teilen sich den Beitrag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-25 11:29:52 +02:00
co-authored by Claude Fable 5
parent d1da6a3529
commit 35b72fc178
10 changed files with 343 additions and 4 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-25 ## 2026-08-25
- A warning flags the bottleneck when one `@name` carries more than half of the open work on the cheapest path — their tag pills turn amber on the open nodes
- The estimated size of an unsized node now prices the remaining work — done sub-packages drop out, and a subtree with everything named done counts as XS - The estimated size of an unsized node now prices the remaining work — done sub-packages drop out, and a subtree with everything named done counts as XS
- The fold chip is solid white with an ink glyph now — the translucent box vanished on the pastel node colours - The fold chip is solid white with an ink glyph now — the translucent box vanished on the pastel node colours
- A size prices its whole subtree on the cheapest path — sub-packages no longer add on top, so a carefully decomposed `(S)` beats a coarse `(L)` - A size prices its whole subtree on the cheapest path — sub-packages no longer add on top, so a carefully decomposed `(S)` beats a coarse `(L)`
+69
View File
@@ -5219,3 +5219,72 @@ fallen; XS-Boden auf M zurück → genau die drei XS-Tests; „Gruppe fertig"
entfernt → genau die zwei disjunktiven. Der mitgelieferte Plan ist entfernt → genau die zwei disjunktiven. Der mitgelieferte Plan ist
unberührt (sein einziger größenloser Knoten ist ein Blatt): weiterhin unberührt (sein einziger größenloser Knoten ist ein Blatt): weiterhin
0 Warnungen, 137 Pfadknoten, 57 Stationen, exakt gerechnet. 0 Warnungen, 137 Pfadknoten, 57 Stationen, exakt gerechnet.
## D71 — Zuständigen-Engpass: warnen, wenn eine Person mehr als die Hälfte des Pfads trägt
Gewünscht: „eine Warnung, wenn ein und derselbe Verantwortliche (@name) zu oft
auf dem Lean-Path liegt — so eine Art Konflikt-Indikator." Kein neues
Zeichen — Tags (§7) und der günstigste Pfad existieren, es fehlte der
Konsument, der sie übereinanderlegt. Die Entscheidungen (Vorschlag bestätigt):
**Was der Indikator sagt:** Werkbaum ist bewusst kein Netzplan — die Warnung
behauptet nichts über Termine, sondern: Die **nächste Ausbaustufe ist nicht
parallelisierbar**, alles Weitere serialisiert sich durch eine Person. Genau
die Auskunft, die man beim Lean Pathfinding braucht.
**Maß: Marginalkosten, Stationen nur für den Text.** Zählen und Gewichten
standen zur Wahl; gewählt ist die Rollenteilung — Schwelle und Anteil rechnen
**gewichtet** (5×XS sind weniger Last als 2×XL), der Meldungstext nennt
zusätzlich die **Stationszahl**, weil Stationen sichtbare, mit dem Auge
nachprüfbare Objekte sind (die Punkte, die der D47-Knopf abgeht). Das
Gewichts-Maß gab es schon: die Marginalkosten des Belegungs-Vergleichs
(D69) — jeder nötige Knoten zählt mit dem, was seine Größe über die nötigen
Teilpakete hinaus behauptet; Erledigtes hat `ownCost` 0 und fällt von selbst
heraus. Die Personen-Summen ergeben zusammen exakt den Pfadpreis — kein
zweites Kostenmodell.
**Zuständigkeit erbt vom nächsten getaggten Vorfahren.** Die übliche
WBS-Lesart: Wer das Paket hat, hat die Teilpakete. Ohne Vererbung zählte in
sparsam getaggten Plänen fast nichts — im Test trüge die Person eines
zerlegten L-Pakets nur dessen Marge (1 statt 4). Mehrere Tags einer Zeile
**teilen** sich den Beitrag zu gleichen Teilen — voll doppelt gezählt bliese
gemeinsame Pakete künstlich zum Engpass auf. Beiträge ohne getaggten
Vorfahren gehen nur in die Gesamtsumme ein: Sie **verwässern** die Anteile
(konservativ — gemeldet wird nur, was sicher ist), warnen aber nicht selbst;
„viel ist niemandem zugewiesen" wäre eine eigene Aussage und, wenn überhaupt,
eine eigene Meldung.
**Schwelle: strikt mehr als die Hälfte, und mindestens zwei Personen mit Last
auf der offenen Front.** Der Solo-Plan ist die Falle — eine Person mit 100 %
ist dort keine Engstelle, sondern die Realität. Gezählt werden Personen **auf
der Front** (Last > 0), nicht im Dokument: Wer nur erledigte Knoten trägt,
macht aus einem faktischen Solo-Rest kein Zwei-Personen-Problem. Mehr als
eine Person über der Hälfte kann es nicht geben — die Meldung ist eindeutig.
Die Schwelle ist gesetzt, nicht hergeleitet (wie die 32 Zeichen in D64);
justiert wird nach Erfahrung.
**Anzeige: zeilenlose Warnung plus Warn-Pille.** Der Engpass hat keine
einzelne Zeile — `assigneeOverload` ist zeilenlos wie `cheapApprox` und nennt
Person, Anteil und Stationen. Zusätzlich wechseln die **Personen-Pillen** der
betroffenen Person an offenen Pfad-Knoten auf `--warn` — derselbe Griff wie
beim Größen-Konflikt-Badge (D62); der Grafikexport folgt von selbst, weil er
die gemessenen Pillen-Farben liest (D62). Geerbte Zuständigkeit hat keine
Pille und damit keine Färbung — benannt, kein Fehler. Kein neuer Umschalter,
kein Panel; bei ausgeschaltetem Pfad entfällt beides (ohne Pfad keine offene
Front).
**Headless nach Hausregel** (D54-Nachtrag 3): `assigneeLoads`/
`overloadedAssignee` in model.js, der Renderer bekommt nur `overloadTag`;
app.js verdrahtet Warnung und Option. Die mitgelieferten Dokumente bleiben
still (der Werkbaum-Plan hat keine Knoten-Tags; im Example liegt anna bei
3 von 13) — nachgemessen, nicht angenommen.
**Nachgemessen** im Browser an einem Wegwerf-Dokument: „@anna trägt 80 % der
offenen Arbeit auf dem günstigsten Pfad (1 von 2 Stationen) — mögliche
Engstelle.", genau eine Pille bernstein (`rgb(180,83,9)`) — am offenen
Pfad-Knoten, nicht am erledigten und nicht bei @ben; Pfad aus ⇒ Warnung und
Färbung weg, wieder an ⇒ beides zurück. 470 Tests, davon 13 neue in
`tests/overload.test.js`. Gegenproben per Mutation: Solo-Wächter entfernt →
genau der Solo-Test fällt; Schwelle aufgeweicht → genau die drei
Schwellen-Tests; Vererbung entfernt → genau der Vererbungs-Test; Teilung
entfernt → genau der Mehrfach-Tag-Test; Erledigt-Ausnahme der Pille
entfernt → genau der Pillen-Test.
+31
View File
@@ -396,6 +396,9 @@ Größe als **Bereich** gelesen (Untergrenzen verdoppeln sich):
- `@name` mit `name` aus Unicode-Buchstaben, Ziffern, `.`, `_`, `-`. - `@name` mit `name` aus Unicode-Buchstaben, Ziffern, `.`, `_`, `-`.
- Mehrere Tags pro Zeile möglich, Position im Text egal. - Mehrere Tags pro Zeile möglich, Position im Text egal.
- Anzeige: helle Pillen unten rechts an der Knoten-Ecke. - Anzeige: helle Pillen unten rechts an der Knoten-Ecke.
- Für den **Zuständigen-Engpass** (§9) erbt ein Knoten ohne Tags die Tags des
nächsten Vorfahren mit Tags — nur für diese Rechnung; an der Anzeige der
Pillen ändert das nichts.
## 8. Kommentare ## 8. Kommentare
@@ -727,6 +730,34 @@ vorn. Der Knopf ist **verborgen**, solange es keine Station gibt — bei
ausgeschaltetem Pfad ebenso wie bei einem durchweg erledigten Plan; sein ausgeschaltetem Pfad ebenso wie bei einem durchweg erledigten Plan; sein
Tooltip nennt die Zahl der offenen Stationen. Siehe D47. Tooltip nennt die Zahl der offenen Stationen. Siehe D47.
**Zuständigen-Engpass.** Trägt **eine** Person zu viel der offenen Arbeit des
günstigsten Pfads, meldet eine zeilenlose Warnung `assigneeOverload` die
mögliche Engstelle: Die nächste Ausbaustufe ist dann nicht parallelisierbar —
alles Weitere serialisiert sich durch diese Person. Über Reihenfolge oder
Termine sagt die Warnung nichts (das tut in dieser Notation ohnehin nichts,
§1). Siehe D71.
- **Last:** Jeder nötige Knoten zählt mit dem, was seine Größe über die
nötigen Teilpakete hinaus behauptet (dasselbe Maß wie der Belegungs-Vergleich
der Closure-Suche, oben) — Erledigtes also mit 0. Mehrere `@name`-Tags einer
Zeile teilen sich den Beitrag zu gleichen Teilen.
- **Zuständig** ist, wer auf der Zeile getaggt ist (§7); ein Knoten **ohne**
Tags erbt für diese Rechnung die Tags des nächsten Vorfahren **mit** Tags —
die übliche WBS-Lesart: Wer das Paket hat, hat die Teilpakete. Ohne
getaggten Vorfahren zählt der Beitrag als nicht zugewiesen — er verwässert
die Anteile, warnt aber nicht selbst.
- **Gemeldet** wird, wenn mindestens **zwei** Personen Last auf der offenen
Front tragen und eine davon **mehr als die Hälfte** der gesamten offenen
Pfad-Arbeit hält. Ein Solo-Plan warnt damit nie — eine Person, die alles
trägt, ist dort keine Engstelle, sondern die Realität. Die Schwelle ist
gesetzt, nicht hergeleitet (wie die ~32 Zeichen des Label-Umbruchs).
- **Anzeige:** Die Warnung nennt Person, Anteil und Stationen („@anna trägt
62 % der offenen Arbeit … 7 von 9 Stationen"); zusätzlich wechseln die
Personen-Pillen (§7) der betroffenen Person an **offenen Pfad-Knoten** auf
die Warnfarbe (`--warn`) — auch im Grafikexport. Geerbte Zuständigkeit hat
keine Pille und damit keine Färbung. Bei ausgeschaltetem Pfad entfällt
beides — ohne Pfad gibt es keine offene Front.
### Diagramm aus einer URL laden (`?sourceUrl=`) ### Diagramm aus einer URL laden (`?sourceUrl=`)
Der Editor kann den Notationstext aus einer externen Textdatei beziehen: Der Editor kann den Notationstext aus einer externen Textdatei beziehen:
`…?sourceUrl=https://example.org/plan.txt` (relative Angaben werden gegen die `…?sourceUrl=https://example.org/plan.txt` (relative Angaben werden gegen die
+7
View File
@@ -49,6 +49,7 @@
- [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61 - [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61
- [^] #ed.path.line: Metro-map line through the leaves (S) - [^] #ed.path.line: Metro-map line through the leaves (S)
- [^] #ed.path.step: Step from station to station, one button (S) %% see D47 - [^] #ed.path.step: Step from station to station, one button (S) %% see D47
- [x] #ed.path.load: Warn when one person is the bottleneck (S) %% see D71
- [^] #ed.closure: Count the whole dependency closure (M) - [^] #ed.closure: Count the whole dependency closure (M)
- [^] #ed.closure.once: Shared dependencies counted only once (S) - [^] #ed.closure.once: Shared dependencies counted only once (S)
- [^] #ed.closure.nonlocal: The cheapest alternative is no longer the local one (M) %% see D34, D42 - [^] #ed.closure.nonlocal: The cheapest alternative is no longer the local one (M) %% see D34, D42
@@ -423,6 +424,12 @@
next. Seeing the front was solved; walking it across a very wide tree was next. Seeing the front was solved; walking it across a very wide tree was
not. not.
#ed.path.load
Warns when one @name carries more than half of the open work on the
cheapest path while at least two people are on it: the next increment
would serialize through that person. Untagged nodes inherit the nearest
tagged ancestor, and the person's tag pills turn amber on open path nodes.
#ed.closure #ed.closure
The cheapest path pulls every dependency of a needed node into the needed The cheapest path pulls every dependency of a needed node into the needed
set, and shared targets count only once. That is why an alternative can win set, and shared targets count only once. That is why an alternative can win
+18 -3
View File
@@ -1,6 +1,6 @@
import './style.css'; import './style.css';
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM, lineTargets } from './model.js'; import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM, lineTargets } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
@@ -133,11 +133,16 @@ function render(){
/* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte
Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung
BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */ BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */
let cheapSet = new Set(); let cheapSet = new Set(), overload = null;
if(cheapPathOn){ if(cheapPathOn){
const plan = computeCheapPlan(roots); const plan = computeCheapPlan(roots);
cheapSet = plan.set; cheapSet = plan.set;
if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]); if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]);
/* Zuständigen-Engpass (SPEC §9/D71): Trägt eine Person mehr als die
Hälfte der offenen Pfad-Arbeit, wird das zeilenlos gemeldet und ihre
Pillen an offenen Pfad-Knoten warnfarben (overloadTag unten). */
overload = overloadedAssignee(roots, cheapSet);
if(overload) warnings = warnings.concat([{type: 'assigneeOverload', ...overload}]);
} }
out.classList.toggle('cheap-on', cheapPathOn); out.classList.toggle('cheap-on', cheapPathOn);
/* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit
@@ -185,7 +190,8 @@ function render(){
lineTargetMap = lineTargets(roots, collapsedSet, showDiscarded); lineTargetMap = lineTargets(roots, collapsedSet, showDiscarded);
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds, const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds,
freshSet, collapsedSet, freshSet, collapsedSet,
effStatus: effectiveStatus(roots)}); effStatus: effectiveStatus(roots),
overloadTag: overload ? overload.tag : null});
out.innerHTML = r.html; out.innerHTML = r.html;
warnings = warnings.concat(r.warnings); warnings = warnings.concat(r.warnings);
} }
@@ -2220,6 +2226,7 @@ const I18N = {
sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.", sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.",
sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe", 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).", 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.",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", 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", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
@@ -2324,6 +2331,7 @@ const I18N = {
sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.", sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.",
sizeConflictTooltip:"The sub-packages together exceed the given size", 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).", 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.",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", 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", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
@@ -2428,6 +2436,7 @@ const I18N = {
sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.", sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.",
sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado", 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).", 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.",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", 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", 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.", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
@@ -2532,6 +2541,7 @@ const I18N = {
sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.", sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.",
sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée", 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).", 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.",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", 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é", 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.", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
@@ -2636,6 +2646,7 @@ const I18N = {
sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.", sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.",
sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar", 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).", 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.",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", 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", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
@@ -2740,6 +2751,7 @@ const I18N = {
sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.", sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.",
sizeConflictTooltip:"Подзадачи вместе превышают указанный размер", sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).", cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
assigneeOverloadWarn:"@{tag} несёт {share} % открытой работы на самом дешёвом пути ({stations} из {total} станций) — возможное узкое место.",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -2844,6 +2856,7 @@ const I18N = {
sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।", sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।",
sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं", sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।", cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
assigneeOverloadWarn:"@{tag} सबसे सस्ते पथ पर खुले काम का {share}% उठाए हुए है ({total} में से {stations} स्टेशन) — संभावित अड़चन।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -2948,6 +2961,7 @@ const I18N = {
sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。", sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。",
sizeConflictTooltip:"子项合计超出所标注的尺寸", sizeConflictTooltip:"子项合计超出所标注的尺寸",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。", cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
assigneeOverloadWarn:"@{tag} 承担最便宜路径上 {share}% 的未完成工作({total} 个站点中的 {stations} 个)——可能的瓶颈。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -3052,6 +3066,7 @@ const I18N = {
sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。", sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。",
sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています", sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。", cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
assigneeOverloadWarn:"@{tag} が最安パスの未完了作業の {share}% を担っています(全 {total} 駅中 {stations} 駅)— ボトルネックの可能性。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
+53
View File
@@ -318,6 +318,59 @@ export function cheapCls(n, cheapSet, collapsed){
return hidesOpenCheap(n, cheapSet) ? 'cheap' : 'cheap cheap-leaf'; return hidesOpenCheap(n, cheapSet) ? 'cheap' : 'cheap cheap-leaf';
} }
/* ---------- Zuständigen-Engpass (SPEC §7/§9, D71) ----------
Wer trägt wie viel der OFFENEN Arbeit des günstigsten Pfads? Jeder nötige
Knoten zählt mit seiner Marge — dem, was seine Größe über die nötigen
Teilpakete hinaus behauptet (dasselbe Maß wie der Belegungs-Vergleich in
`computeCheapPlan`, D69); Erledigtes hat `ownCost` 0 und trägt damit von
selbst nichts. Zuständig ist, wer auf der Zeile getaggt ist; ein Knoten
OHNE Tags erbt für diese Rechnung die Tags des nächsten Vorfahren mit Tags
(WBS-Lesart: Wer das Paket hat, hat die Teilpakete). Mehrere Tags teilen
sich den Beitrag zu gleichen Teilen. Beiträge ohne getaggten Vorfahren
gehen nur in die Gesamtsumme ein — sie verwässern die Anteile, warnen aber
nicht selbst. Stationen (die tiefsten offenen Pfad-Knoten, D46) werden für
den Meldungstext mitgezählt; ein Knoten mit mehreren Zuständigen zählt als
Station bei jedem von ihnen. */
export function assigneeLoads(roots, cheapSet){
const loads = new Map(), stations = new Map();
let total = 0, totalStations = 0;
const walk = (ns, inherited) => {
for(const n of ns){
const owners = n.tags && n.tags.length ? n.tags : inherited;
if(cheapSet.has(n)){
let kids = 0;
for(const k of pathChildren(n)) if(cheapSet.has(k)) kids += ownCost(k);
const m = Math.max(0, ownCost(n) - kids);
total += m;
if(m > 0) for(const tag of owners)
loads.set(tag, (loads.get(tag) || 0) + m / owners.length);
if(!isDone(n) && !hidesOpenCheap(n, cheapSet)){
totalStations++;
for(const tag of owners) stations.set(tag, (stations.get(tag) || 0) + 1);
}
}
walk(n.children, owners);
}
};
walk(roots, []);
return {loads, total, stations, totalStations};
}
/* Die Engpass-Entscheidung (SPEC §9, D71): gemeldet nur, wenn mindestens ZWEI
Personen Last auf der offenen Front tragen — ein Solo-Plan warnt nie, eine
Person mit allem ist dort keine Engstelle, sondern die Realität — und eine
davon MEHR ALS DIE HÄLFTE der gesamten offenen Pfad-Arbeit hält (strikt;
mehr als eine Person über der Hälfte kann es nicht geben). Die Schwelle ist
gesetzt, nicht hergeleitet (D64-Linie). */
export function overloadedAssignee(roots, cheapSet){
const {loads, total, stations, totalStations} = assigneeLoads(roots, cheapSet);
if(loads.size < 2 || total <= 0) return null;
let tag = null, load = 0;
loads.forEach((l, name) => { if(l > load){ load = l; tag = name; } });
if(load * 2 <= total) return null;
return {tag, share: Math.round(load / total * 100),
stations: stations.get(tag) || 0, totalStations};
}
/* ---------- Effektiver Status (SPEC §4/§9, D39) ---------- /* ---------- Effektiver Status (SPEC §4/§9, D39) ----------
Fortschritts-Rang entlang der Ergebnis-Skala (D5). Außerhalb der Skala: Fortschritts-Rang entlang der Ergebnis-Skala (D5). Außerhalb der Skala:
neutrale und verworfene Knoten zählen als 0 („nichts Anrechenbares"), neutrale und verworfene Knoten zählen als 0 („nichts Anrechenbares"),
+9 -1
View File
@@ -13,6 +13,9 @@
collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38) collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38)
effStatus, // optional: Map Knoten -> effektiver Status-Key, nur effStatus, // optional: Map Knoten -> effektiver Status-Key, nur
// Diskrepanzen (effectiveStatus() in model.js, D39) // Diskrepanzen (effectiveStatus() in model.js, D39)
overloadTag, // optional: Person, die mehr als die Hälfte der offenen
// Pfad-Arbeit trägt (overloadedAssignee(), SPEC §9/D71) —
// ihre Pillen an offenen Pfad-Knoten werden warnfarben
} */ } */
import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone, assumedSize } from './model.js'; import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone, assumedSize } from './model.js';
@@ -190,8 +193,13 @@ function nodeHtml(n, extra, opts, fold){
als zweiten, dürftigeren Tooltip. Kein zweites Attribut mit demselben Text als zweiten, dürftigeren Tooltip. Kein zweites Attribut mit demselben Text
— es ist dasselbe, nur unter anderem Namen. */ — es ist dasselbe, nur unter anderem Namen. */
const title = ` data-tip="${attr(tip)}"`; const title = ` data-tip="${attr(tip)}"`;
/* Zuständigen-Engpass (SPEC §9/D71): Die Pille der überlasteten Person
wechselt an OFFENEN Pfad-Knoten auf die Warnfarbe — dort liegt die Last.
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);
const tagsHtml = n.tags && n.tags.length const tagsHtml = n.tags && n.tags.length
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>` ? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag${hot(tag) ? ' overload' : ''}">${esc(tag)}</span>`).join('')}</span>`
: ''; : '';
/* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) — /* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) —
seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An
+4
View File
@@ -885,6 +885,10 @@
border-radius:999px;padding:1px 6px;line-height:1.4; border-radius:999px;padding:1px 6px;line-height:1.4;
box-shadow:0 0 0 1.5px var(--card); box-shadow:0 0 0 1.5px var(--card);
} }
/* Zuständigen-Engpass (SPEC §9/D71): die Pille der Person, die mehr als die
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)}
.ghost-node{ .ghost-node{
position:relative;margin-top:14px; position:relative;margin-top:14px;
border:1.5px dashed var(--warn); border:1.5px dashed var(--warn);
+8
View File
@@ -29,6 +29,11 @@
angegebene Größe des Elternknotens, selbst angegebene Größe des Elternknotens, selbst
in der günstigsten Bereichs-Lesart in der günstigsten Bereichs-Lesart
(SPEC §5/D62); Zeile = Elternknoten (SPEC §5/D62); Zeile = Elternknoten
- assigneeOverload { tag, share, stations, totalStations }
— eine Person trägt mehr als die Hälfte der
offenen Arbeit des günstigsten Pfads
(SPEC §9/D71); zeilenlos — der Engpass
hat keine einzelne Zeile
- cheapApprox { } — günstigster Pfad nur gierig geschätzt: - cheapApprox { } — günstigster Pfad nur gierig geschätzt:
zu viele gekoppelte Gruppen für die zu viele gekoppelte Gruppen für die
exakte Suche (D42); zeilenlos exakte Suche (D42); zeilenlos
@@ -78,6 +83,9 @@ function build(w, t, esc){
return t('descStrayWarn', {line: w.line}); return t('descStrayWarn', {line: w.line});
case 'sizeConflict': case 'sizeConflict':
return t('sizeConflictWarn', {line: w.line, size: esc(w.size)}); return t('sizeConflictWarn', {line: w.line, size: esc(w.size)});
case 'assigneeOverload':
return t('assigneeOverloadWarn', {tag: esc(w.tag), share: w.share,
stations: w.stations, total: w.totalStations});
case 'cheapApprox': case 'cheapApprox':
return t('cheapApproxWarn'); return t('cheapApproxWarn');
case 'sourceLoad': case 'sourceLoad':
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { computeCheapSet, assigneeLoads, overloadedAssignee } from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
import { formatWarning } from '../src/warnings.js';
/* Zuständigen-Engpass (SPEC §7/§9, D71): Trägt eine Person mehr als die Hälfte
der offenen Arbeit des günstigsten Pfads (Marginalkosten-Maß, D69) und gibt
es mindestens zwei Personen mit Last auf der offenen Front, wird gewarnt.
Knoten ohne Tags erben für die Rechnung vom nächsten getaggten Vorfahren;
mehrere Tags teilen sich den Beitrag. Kostenskala: XS=1 S=2 M=3 L=4 XL=5. */
const roots = txt => parse(txt).roots;
const plan = txt => { const r = roots(txt); return { r, set: computeCheapSet(r) }; };
const t = (key, vars) => {
let s = key;
if(vars) for(const k in vars) s += ':' + vars[k];
return s;
};
describe('overloadedAssignee — die Engpass-Entscheidung', () => {
it('eine Person über der Hälfte, eine zweite auf der Front: Warnung', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (XS) @ben`);
/* anna 4, ben 1, Wurzel geschätzt L mit Marge 0 ⇒ total 5, anna 80 % */
expect(overloadedAssignee(r, set)).toEqual(
{tag: 'anna', share: 80, stations: 1, totalStations: 2});
});
it('ein Solo-Plan warnt nie — auch bei 100 %', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (S) @anna`);
expect(overloadedAssignee(r, set)).toBeNull();
});
it('genau die Hälfte reicht nicht — die Schwelle ist strikt', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (L) @ben`);
expect(overloadedAssignee(r, set)).toBeNull();
});
it('Knoten ohne Tags erben vom nächsten Vorfahren mit Tags', () => {
const { r, set } = plan(`[ ] Plan
- [ ] Paket (L) @anna
- [ ] Teil (M)
- [ ] B (S) @ben`);
/* Teil (3) gehört per Vererbung anna; dazu Paket-Marge 1 ⇒ anna 4 von 6.
Ohne Vererbung wäre anna nur die Marge 1 und niemand über der Hälfte. */
const o = overloadedAssignee(r, set);
expect(o && o.tag).toBe('anna');
expect(o.share).toBe(67);
});
it('mehrere Tags einer Zeile teilen sich den Beitrag', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna @ben
- [ ] C (S) @carla`);
/* A: je 2 für anna und ben; voll doppelt gezählt läge anna bei 4 von 6
und würde fälschlich gemeldet. */
expect(overloadedAssignee(r, set)).toBeNull();
});
it('Erledigtes trägt keine Last — die offene Front zählt', () => {
const { r, set } = plan(`[ ] Plan
- [x] Big (XL) @ben
- [ ] A (S) @anna
- [ ] B (XS) @ben`);
/* bens großer Brocken ist fertig (0); offen sind anna 2 und ben 1 */
const o = overloadedAssignee(r, set);
expect(o && o.tag).toBe('anna');
});
it('nicht zugewiesene Arbeit verwässert die Anteile, warnt aber nicht', () => {
const { r, set } = plan(`[ ] Plan (XXL)
- [ ] A (S) @anna
- [ ] B (XS) @ben`);
/* Wurzel-Marge 3 gehört niemandem ⇒ anna 2 von 6, unter der Hälfte */
expect(overloadedAssignee(r, set)).toBeNull();
});
it('leere Pfadmenge (Pfad aus): keine Aussage', () => {
const r = roots(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (XS) @ben`);
expect(overloadedAssignee(r, new Set())).toBeNull();
});
});
describe('assigneeLoads — das Maß dahinter', () => {
it('Marginalkosten je Person, Summe ist der Pfadpreis', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (XS) @ben`);
const { loads, total } = assigneeLoads(r, set);
expect(loads.get('anna')).toBe(4);
expect(loads.get('ben')).toBe(1);
expect(total).toBe(5);
});
it('Stationen zählen nur offene Blätter des Pfads', () => {
const { r, set } = plan(`[ ] Plan
- [x] Fertig (S) @anna
- [ ] Offen (S) @anna
- [ ] B (XS) @ben`);
const { stations, totalStations } = assigneeLoads(r, set);
expect(stations.get('anna')).toBe(1);
expect(totalStations).toBe(2);
});
});
describe('Darstellung', () => {
it('die Pille der überlasteten Person wird an offenen Pfad-Knoten markiert', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [x] Done (S) @anna
- [ ] B (XS) @ben`);
const { html } = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true,
cheapSet: set, overloadTag: 'anna'});
/* genau eine markierte Pille: am offenen A — nicht am erledigten Done,
nicht an bens Pille */
expect(html.match(/tag overload/g)).toHaveLength(1);
expect(html).toContain('<span class="tag overload">anna</span>');
expect(html).toContain('<span class="tag">ben</span>');
});
it('ohne overloadTag bleibt alles wie bisher', () => {
const { r, set } = plan(`[ ] Plan
- [ ] A (L) @anna
- [ ] B (XS) @ben`);
const { html } = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true,
cheapSet: set, overloadTag: null});
expect(html).not.toContain('overload');
});
it('die Warnung nennt Person, Anteil und Stationen', () => {
const msg = formatWarning(
{type: 'assigneeOverload', tag: 'anna', share: 80, stations: 1, totalStations: 2}, t);
expect(msg).toBe('assigneeOverloadWarn:anna:80:1:2');
});
});