From d90ad9abd75c2d1ea1fa921b2652f281733584fb Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:10:31 +0200 Subject: [PATCH 001/108] =?UTF-8?q?docs(plan):=20B1c-etappe-3=20=E2=80=94?= =?UTF-8?q?=20het=20schrijfpad=20en=20het=20paneel=20(15=20taken)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-31-b1c-plan3-schrijfpad-paneel.md | 2335 +++++++++++++++++ 1 file changed, 2335 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md diff --git a/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md new file mode 100644 index 000000000..db4e3583f --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md @@ -0,0 +1,2335 @@ +# B1c — Etappe 3: het schrijfpad en het paneel (implementatieplan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** B1c afmaken: het **schrijfpad** (spec §5 — Toepassen over het actieve én de slapende documenten, +per-document-undo, "alles terugdraaien") en het **paneel** (spec §6/§7 — fasestroken met plafond-handles +en pins, rangordelijst, gereedschapsschakelaar, voor/na-histogram, voorstel-invalidatie), plus de +i18n-pass en de gebruikersgids die daarbij horen (spec §8). Leidende bron: +`docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md`; alle eigenaarsbesluiten +staan in §11 daarvan. Waar dit plan de spec **concretiseert** staat dat expliciet als +**KEUZE VAN DIT PLAN**; waar het bewust **afwijkt** staat **AFWIJKING — VOORLEGGEN**. + +**Architecture:** vier lagen, van binnen naar buiten. De onderste twee bestaan al (etappe 2) en worden +hier alleen afgemaakt; de bovenste twee zijn nieuw. + +1. **`src/engine/scheduler/ResourceLeveler.ts`** — de motor binnen één document. Etappe 2 gaf hem + `scopeTaskIds`, `overrunCeilingDays`, `poolLedger`, `allowSplits` en de zeven-koppige + `LevelingReason`-taxonomie. Dit plan raakt hem nog op drie punten: twee doorgeschoven + keuringsbevindingen (taak 1) en de **gap-strip in de baseline** die `applyLeveling` idempotent + maakt (taak 2). +2. **`src/services/library/distribute.ts`** — de verdeler-kern. Puur, geen store. Dit plan voegt er + twee dingen aan toe: een echt per-item-geboekt grootboek (taak 1) en `afterLoadByDay` voor de + voor/na-preview (taak 11). +3. **Het schrijfpad** — nieuw: `src/state/runtime/scratchDocument.ts` (headless scratch-instantie) plus + `applyDistribution`/`undoDistribution` op `librarySlice`. Dit is de laag die spec §5 beschrijft. +4. **Het paneel** — nieuw: `src/components/panels/DistributionPanel/`, gemount vanuit + `ResourceOccupancyView`. Gewoon DOM/React (Canvas is uitsluitend voor de Gantt), geen native + dialogs, geen nieuw meldingskanaal. + +**Tech Stack:** TypeScript strict, React 19, geen frameworks. Headless tests als `check-*.ts` onder +`tests/planning/` (registratie in `tests/planning/run.sh`) en `tests/library/` (`run_check check-x` +onderaan `tests/library/run.sh`). UI-tests als Playwright-specs onder `tests/browser/`. De poort is +`npm run verify` plus `npm run test:browser` — oordeel **UITSLUITEND op de exitcode**, nooit op de +tekst "alles groen"; `tests/library/` print zijn faalregels **ingesprongen** (` XX …`), dus +`grep '^XX'` is daar structureel blind. + +--- + +## Scope + +**In dit plan (etappe 3):** + +- De vijf doorgeschoven bevindingen uit de eindkeuring van etappe 2 (taak 1). +- `applyLeveling` scope-behoudend + `LevelingResult.gaps` daadwerkelijk schrijven, `clearLeveling` die + ook leveling-gaten wist, de `clearLevelingGaps`-wiring op tijdbasis-bewerkingen, en de + ribbon-enable-check (taken 2 en 3). +- De mutatieteller op de store-runtime en de voorstel-vingerafdruk (taak 4, spec §6a). +- Het schrijfpad naar slapende documenten via een headless scratch-instantie, met de twee + singleton-randen (extensie-emitter, `notify`) dichtgezet (taken 5 en 6, spec §5). +- De i18n-pass: alle zeven `LevelingReason`-codes, de drie `DistributionBlockReason`-codes en élke + paneeltekst, in veertien locales met CLDR-pluralcategorieën (taak 7, spec §8). +- Het paneel: ingang vanuit de conflictregel, gereedschapsschakelaar, rangordelijst, "Verdeel + automatisch"/"Herbereken" met bezig-toestand en schaal-degradatie, fasestroken met pin en + plafond-handle (toetsenbord én pointer), voor/na-histogram, Toepassen/verwerpen, de + "toegepast"-strook met "alles terugdraaien", voorstel-invalidatie (taken 8–13, spec §6/§6a/§7). +- De gebruikersgids `public/docs/{nl,en}/gids-verdelen-restcapaciteit.md` + manifest-entry, en + `docs/library.md` (taak 14, spec §8). + +**NIET in dit plan:** + +| onderwerp | waar het hoort | +|---|---| +| Een slimmere zoekstap bovenop de vroegste-venster-plaatsing | buiten scope (spec §11.6: "geen v1-werk") | +| Simultaan optimaliseren over documenten in één solve | buiten scope (spec §10) | +| Cross-machine boekingen / gedeelde opslag | buiten scope (spec §10) | +| MCP-tools voor de verdeler | buiten scope (spec §10, "additief zodra gevraagd") | +| Automatisch hernivelleren bij elke bewerking | buiten scope (spec §10, F5-filosofie) | +| Split-view / multi-window voor het paneel | buiten scope (CLAUDE.md, store-ownership) | +| Pins/plafonds die een app-herstart of een `.ifc`-round-trip overleven | **etappe 4 of later** — zie de bak "Waar de tune-state woont" hieronder | + +### Waar de tune-state woont (KEUZE VAN DIT PLAN, expliciet) + +De opdracht bij dit plan noemde het documentcontract voor "nieuwe per-document-instellingen zoals +pin/plafond". Dit plan zet ze **niet** in `DOCUMENT_FIELDS`, maar in `ui` (dat in +`documentContract.ts`'s `AppGlobalKey` al als app-globaal geclassificeerd staat, dus er verandert +niets aan de contract-asserties). Drie redenen, alle drie hard: + +1. **Het paneel kijkt naar N documenten tegelijk.** Pin en plafond van élk deelnemend document moeten + in één render leesbaar zijn. In `DOCUMENT_FIELDS` wonen ze per document — voor het actieve + top-level, voor de slapers in hun payload — dus het paneel zou zijn eigen bediening uit twee + verschillende plekken moeten samenrapen, en een pin zetten op een slapend document zou een + payload-mutatie zijn. +2. **Het zijn geen projectdata.** `DOCUMENT_FIELDS` bepaalt óók wat er in de undo-snapshot zit en wat + `newProject()` reset. Een pin in de snapshot betekent dat Ctrl+Z in het actieve document een + paneelbediening terugdraait; dat is precies de soort verwarring die het documentcontract elders + voorkomt. +3. **Ze horen bij één verdeelsessie, niet bij het project.** Spec §6a laat het voorstel al vervallen + bij elke rang-, plafond-, pin- of gereedschapswijziging: de tune-state is de invoer van een + momentopname, geen opgeslagen projecteigenschap. De spec vraagt nergens om persistentie ervan. + +**AFWIJKING — VOORLEGGEN aan de eigenaar:** wil hij dat een pin of een plafond een app-herstart +overleeft (of zelfs door het IFC round-trippt), dan is dat een bewuste uitbreiding: dan hoort er een +veld in `DOCUMENT_FIELDS` bij plus een `OPS_`-pset, en dan verandert de betekenis van de pin van +"deze verdeelsessie" naar "dit project ligt vast". Dit plan bouwt de eerste lezing; de tweede is een +apart besluit. + +--- + +## Context voor wie hier koud instapt + +Lees dit vóór taak 1. Alles hieronder is geverifieerd in de code van 2026-08-31, ná de fixronde van +etappe 2 (`a281f665`). + +- **Wat etappe 2 heeft opgeleverd.** `levelResources` accepteert vier optionele invoeren + (`scopeTaskIds`, `overrunCeilingDays`, `poolLedger`, `allowSplits`) en levert naast `delays`/ + `unresolved`/`unresolvedReasons`/`shifts`/`projectEndBefore`/`projectEndAfter` óók + **`gaps: Record`** — de VOLLEDIGE, te schrijven `splitGaps`-waarde per taak + (importsplits inbegrepen), niet het verschil. `LevelingReason` telt zeven leden: + `CALENDAR_MISMATCH`, `INSUFFICIENT_CAPACITY`, `INTRINSIC_OVERRUN`, `CEILING_TOO_TIGHT`, + `CEILING_UNREACHABLE`, `NO_WINDOW_IN_HORIZON`, `RESIDUAL_FULL`. +- **`computeDistribution`** (`src/services/library/distribute.ts`) levert een `DistributionProposal` + met `blocked` (`UNCOUNTED_DOCUMENT` | `MATERIAL_ITEM` | `NO_DEMAND`), `docs` + (`DistributionDocResult[]` met `delays`, `gaps`, `shortfalls`, `endShiftWorkdays`, `participated`, + `pinnedReason`, `cannotMove`), `fixedLoadByDay`, `residualByDay` en `hasShortfall`. Hij is puur: + geen store, geen I/O. De motor-rand (`DistributionLevelRun`) is injecteerbaar, precies zoals + `OccupancyEphemeralSolve` in `occupancy.ts`. +- **`applyLeveling`/`clearLeveling`** staan tweemaal: in `src/state/slices/scheduleSlice.ts` (de + store-variant, mét snapshot en `runCPM`) en in `src/state/runtime/createMcpTransactions.ts` (de + snapshot-/recompute-vrije variant binnen een MCP-transactie). Elke wijziging aan de een hoort ook + bij de ander; beide dragen al een verwijscommentaar naar elkaar. Ze schrijven vandaag **alleen** + `levelingDelay` (+ de M10-strip van `levelingDelayMinutes`/`levelingDelayElapsed`) en raken + `splitGaps` niet aan. +- **`clearLevelingGaps(task)`** bestaat al (`src/utils/taskDefaults.ts`, etappe-2 taak 7): wist + uitsluitend gaten met `source === 'leveling'`, laat importsplits staan, zet `splitGaps` op + `undefined` als er niets overblijft, en geeft `true` terug precies wanneer er iets gewist is — + hetzelfde contract als `clearTimephasedWindow` ernaast. Hij heeft vandaag **nul aanroepplekken**. +- **De storecontext.** `createAppStoreContext()` (`src/state/appStore.ts`) bouwt een volledig + onafhankelijke context: eigen store, eigen `StoreRuntime` (coalescing, batchdiepte, MCP-lease). De + gemounte interface gebruikt precies één (`appStoreContext`/`useAppStore`). Core-runtimefactories + mogen `useAppStore`/`appStoreContext` **nooit** importeren — `npm run verify:store-boundaries` + bewaakt dat mechanisch. +- **Het documentcontract.** `capturePayload(state)` / `hydratePayload(state, payload)` / + `freshPayload()` lopen key-gedreven over `DOCUMENT_FIELDS`. Dat is precies het mechanisme waarmee de + scratch-instantie in taak 5 een slapende payload in- en uitlaadt. +- **`resetDocumentScopedUI(s)`** (`src/state/slices/documentSlice.ts`, regel ~56) is de ENE plek waar + `ui`-velden die naar het uitgaande document wijzen worden opgeruimd; hij draait bij + `newDocument`/`switchDocument`/`closeDocument`/`duplicateDocument`/`restoreDocuments`. Het paneel + moet daar in taak 12 aan gekoppeld worden. +- **`ResourceOccupancyView`** (`src/components/panels/ResourceOccupancyView.tsx`, 837 regels) is het + bezettingsoverzicht: één `useMemo` rond `computeLibraryOccupancy` met een per-payload-`WeakMap`-cache + op de bibliotheek-SNIT, plus een SVG-histogram (`OccupancyHistogram`) met gatcompressie. Het paneel + hangt hieraan vast en deelt in taak 9 zijn tijdas. +- **Meldingen** lopen sinds K8a via één kanaal (`get().notify({ severity, messageKey, … })`, + `uiSlice`). Geen `alert()`, geen native dialogs, geen tweede toast-mechaniek. Actieknoppen bestaan + in dat kanaal NIET — daarom woont "alles terugdraaien" in het paneel zelf (spec §5). +- **De testrunners.** `tests/planning/run.sh` registreert een check met twee regels (kopieer het + `LEVELERSPLITSCHECK`-blok). `tests/library/run.sh` is één regel `run_check check-x` onderaan en + draait `tsc -p tests/library/tsconfig.check.json` als eerste stap, dus fixture-typen worden daar + hard afgedwongen. Browsertests: `npm run test:browser` (Playwright Chromium headless shell, één + worker, nul retries); installeer eenmalig met + `npx playwright install --with-deps --only-shell chromium`. De dev-only `window.__OPS__`-brug mag + fixtures zetten en state lezen, maar **nooit** de geteste gebruikershandeling vervangen. + +--- + +## Task 1: de vijf doorgeschoven bevindingen uit de eindkeuring van etappe 2 + +**Waarom hier en niet later.** Bevindingen 11 en 12 zitten in `scatterSlot`/`findSlot` — precies de +code die het paneel straks per handle-loslating aanroept; 6 zit in het grootboek dat taak 11 +uitbreidt; 10 is regeleinde-ruis die élke latere diff in `src/types/task.ts` onleesbaar maakt. +Nummering volgt de eindkeuring van etappe 2. + +**Files:** +- Modify: `src/types/task.ts` (bevinding 10, alléén regeleinden) +- Modify: `src/engine/scheduler/ResourceLeveler.ts` (bevindingen 11, 12) +- Modify: `src/services/library/distribute.ts` (bevinding 6) +- Modify: `tests/planning/check-leveler-splitmode.ts` (bevinding 7 + de guard van 12) +- Modify: `tests/library/check-distribute.ts` (bevinding 6) + +- [ ] **Step 1: Bevinding 10 — CRLF-ruis, als EIGEN commit vooraf** + +`src/types/task.ts` heeft sinds commit `e6f36d50` CRLF-regeleinden (495 regels; `file` meldt +"with CRLF line terminators"). De rest van `src/` is LF. Normaliseer, **zonder één inhoudelijke +wijziging**: + +```bash +cd "$(git rev-parse --show-toplevel)" +python3 - <<'PY' +from pathlib import Path +p = Path('src/types/task.ts') +b = p.read_bytes() +assert b'\r\n' in b, 'geen CRLF gevonden — bevinding 10 is al opgelost, meld dat en sla deze stap over' +p.write_bytes(b.replace(b'\r\n', b'\n')) +PY +git diff --stat src/types/task.ts # verwacht: ~495 regels "changed", 0 echte inhoud +npx tsc --noEmit -p tsconfig.json; echo "exit: $?" +``` + +Commit dit **apart**, zodat het geen enkele latere `git blame` vervuilt: + +```bash +git add src/types/task.ts +git commit -m "chore(types): LF-regeleinden in task.ts (B1c-plan3 taak 1, bevinding 10) + +Co-Authored-By: Claude Fable 5 " +``` + +- [ ] **Step 2: Schrijf de falende tests voor bevindingen 11, 12 en 7** + +Achteraan in `tests/planning/check-leveler-splitmode.ts`, in de bestaande `ok`/`eq`-stijl: + +```ts +console.log('-- B1c-plan3 taak 1: scatter-randen --'); + +// ── Bevinding 12: `scatterSlot` mag nooit een LEGE dagenset als "geplaatst" teruggeven ──────────── +// Een taak zonder enige vraag op een geselecteerde resource levert `need === 0`; `scatterSlot` gaf +// dan `[]` terug, dat is truthy, en `findSlot` deed `parseDate(scatterDays[0])` op `undefined` ⇒ +// een Invalid Date als starttijd, die verderop stil door de hele run reist. Bouw de fixture met een +// scope-taak wier enige toewijzing `unitsPerDay: 0` heeft (dan blijft ze buiten `hasDemand`, dus +// forceer het geval via een taak MET vraag op resource R en een `resourceIds`-filter dat R uitsluit — +// zoek in `LevelingOptions` welk filterveld dat is en gebruik dát). +ok('lege scatter levert nooit een Invalid Date als start', !isNaN(parseDate(rEmpty.shifts['Z']?.newStart ?? '2026-01-01').getTime())); +eq('lege scatter schrijft geen gaten', rEmpty.gaps['Z'], undefined); + +// ── Bevinding 11: zonder bindend venster stopt de scatter-scan op de HORIZON, niet op 200.000 ───── +// `allowSplits: true`, GEEN `overrunCeilingDays`, GEEN `constrainToFloat`, en een resource die vanaf +// de projectstart tot ver voorbij elke horizon nul capaciteit heeft (`availabilitySteps` naar 0). +// `scatterSlot` vindt dan nooit een dag. Vóór de fix liep hij HARD_SCAN_CAP (200.000) kandidaten af; +// erna stopt hij op de grootboekhorizon (of, zonder grootboek, op dezelfde `scanLimit`-ondergrens die +// de aaneengesloten scan gebruikt) en meldt de eerlijke reden. +eq('geen eindeloze scatter-scan: eerlijke horizon-reden', rNoWindow.unresolvedReasons['A'], 'NO_WINDOW_IN_HORIZON'); +// Prestatie is GEEN poort, maar de orde van grootte wél zichtbaar: meet met `Date.now()` rond de +// aanroep en assert < 2000 ms. Vóór de fix duurde dit geval seconden tot tientallen seconden. +ok('en hij is niet meer traag', elapsedMs < 2000); + +// ── Bevinding 7: fractionele uur-modus in de scatter (2,5 dag / 1200 minuten) ───────────────────── +// Taak H: `durationUnit: 'hours'`, `durationMinutes: 1200` (20 werkuur), `scheduleDuration: 2.5` op +// een 8u-kalender, `durationType: 'WORKTIME'`, `completion: 0`, prio 100. Resource R (cap 1) is ma+wo +// al bezet door een prio-900-taak. Met `allowSplits` moet H op di, do en vr landen: `distributeUnits` +// rondt 2,5 af naar DRIE curve-slots, dus `need === 3` — niet 2 (`Math.floor`) en niet 2,5. +eq('fractionele uur-modus: drie curve-slots, dus drie werkdagen', + enumerateTaskWorkDays(rFrac.gaps['H'], projEng, '', 3), + ['2026-06-02', '2026-06-04', '2026-06-05']); +ok('en precies twee leveling-gaten (na di en na do)', + rFrac.gaps['H']?.filter(g => g.source === 'leveling').length === 2); +``` + +En in `tests/library/check-distribute.ts`, achteraan: + +```ts +// ── B1c-plan3 taak 1, bevinding 6: het grootboek boekt PER ITEM ────────────────────────────────── +// `makeLedgerForDoc` negeerde `itemId` volledig (`residualOn: (_itemId, iso) => residualOn(iso)`). +// Dat is vandaag correct omdat `poolItemOf` uitsluitend `libraryItemId` teruggeeft — maar het is een +// ONGESCHREVEN invariant, en een toekomstige verdeler over meerdere poolitems tegelijk zou stil +// alles op één hoop boeken. Deze case pint dat de sleutel gebruikt WORDT. +ok('een boeking op een ANDER itemId raakt dit poolitem niet', + (() => { const l = ledgerOf(p1); const before = l.residualOn(ITEM, '2026-08-03'); + l.book('een-ander-item', '2026-08-03', 99); + return l.residualOn(ITEM, '2026-08-03') === before; })()); +``` + +Draai en verwacht rood: + +```bash +bash tests/planning/run.sh 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +bash tests/library/run.sh 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +``` + +- [ ] **Step 3: Implementeer bevindingen 11 en 12 in `ResourceLeveler.ts`** + +**(12) De guard**, in `findSlot` (regel ~838): + +```ts + if (splitEligible(task)) { + const scatterDays = scatterSlot(taskId, pf, finishWindowLimit(taskId)); + // B1c-plan3 taak 1 (bevinding 12): een LEGE dagenset is geen plaatsing. `scatterSlot` geeft + // `[]` terug zodra `need === 0` (`chosen.length === need` is dan meteen waar), en `[]` is + // truthy — `parseDate(scatterDays[0])` maakte er dan een Invalid Date van, die als `start` de + // hele hoofdlus in reisde (delay-meting, boeking, shifts). Niets plaatsen hoort door te vallen + // naar het "geen slot"-vangnet hieronder. + if (scatterDays && scatterDays.length > 0) { + return { start: parseDate(scatterDays[0]), unresolved: [], scatterDays }; + } + } +``` + +**(11) De scatter-horizon**, in `scatterSlot` (regel ~972). De lus loopt vandaag tot `HARD_SCAN_CAP` +zodra `finishLimit === null`. Geef hem dezelfde tweetrapsgrens als de aaneengesloten scan: + +```ts + function scatterSlot(taskId: string, pf: Date, finishLimit: Date | null): string[] | null { + const task = taskById.get(taskId)!; + const byRes = demandByTask.get(taskId)!; + /* … bestaande `need`-berekening ONGEWIJZIGD … */ + const need = byRes.values().next().value?.length ?? Math.ceil(task.time.scheduleDuration); + // B1c-plan3 taak 1 (bevinding 11): zonder `finishLimit` (geen plafond, geen constrainToFloat) had + // deze lus alleen `HARD_SCAN_CAP` als rem — 200.000 kandidaatdagen mét een volledige `dayFits` + // per dag, terwijl de aaneengesloten scan er allang mee gestopt is. Zelfde tweetrapsgrens als + // `findSlot`: `scanLimit` is de gewone ondergrens (afgeleid van de eigen taakduren), en een + // grootboekhorizon mag hem verlengen omdat externe vaste last het eerste vrije venster voorbij + // die ondergrens kan duwen (L4). `HARD_SCAN_CAP` blijft uitsluitend de vangrail. + const horizonDate = ledger?.horizonIso ? parseDate(ledger.horizonIso) : null; + const chosen: string[] = []; + let cand = nextCandidateFor(task, pf); + let guard = 0; + while (chosen.length < need && guard++ < HARD_SCAN_CAP) { + if (finishLimit && cand > finishLimit) return null; + if (guard > scanLimit && !(horizonDate && cand <= horizonDate)) return null; + const iso = formatDate(cand); + if (dayFits(byRes, chosen.length, iso)) chosen.push(iso); + cand = nextCandidateAfterFor(task, cand); + } + return chosen.length === need ? chosen : null; + } +``` + +Controleer bij het implementeren dat `scanLimit` op dit punt in scope is (hij wordt in `findSlot`s +omhullende functie berekend); is dat niet zo, geef hem als parameter mee in plaats van hem te +herberekenen — één definitie, geen tweede die kan afdrijven. + +- [ ] **Step 4: Implementeer bevinding 6 in `distribute.ts`** + +Vervang de itemId-negerende sluitingen door een echt per-item grootboek: + +```ts + // Het gedeelde poolitem-grootboek (§4 stap 2), PER ITEM geboekt (B1c-plan3 taak 1, bevinding 6). + // Vandaag levert `poolItemOf` uitsluitend `libraryItemId` terug, dus één emmer zou volstaan — maar + // dat is een ongeschreven invariant, en het contract van `LevelingPoolLedger` belooft expliciet een + // per-item-boekhouding. De sleutel gebruiken kost niets en maakt een latere verdeler over meerdere + // poolitems tegelijk een uitbreiding in plaats van een herschrijving. + const placedByItem: Record> = {}; + const bookPlaced = (itemId: string, iso: string, units: number) => { + const bucket = placedByItem[itemId] ?? (placedByItem[itemId] = {}); + bucket[iso] = (bucket[iso] ?? 0) + units; + }; + const residualOn = (itemId: string, iso: string): number => + itemId === libraryItemId + ? Math.max(0, maxUnitsOn(poolItem, iso) - (fixedLoadByDay[iso] ?? 0) - (placedByItem[itemId]?.[iso] ?? 0)) + // Een ander item hoort in deze run niet voor te komen (zie `poolItemOf`); geef 0 terug in + // plaats van de capaciteit van DIT item — dat zou een vreemd item gratis ruimte geven. + : 0; +``` + +Werk `placed` in `computeHorizonIso`, in de `cannotMove`-tak en in de `residualByDay`-opbouw mee om +naar `placedByItem[libraryItemId] ?? {}`. Laat de rest van het bestand ongewijzigd. + +- [ ] **Step 5: Draai** + +```bash +bash tests/planning/run.sh 2>&1 | tail -8; echo "exit: $?" +bash tests/library/run.sh 2>&1 | tail -8; echo "exit: $?" +npx tsc --noEmit -p tsconfig.json; echo "exit: $?" +``` + +Verwacht: exit 0, 0, 0. Wordt een bestaande leveling-case rood op de scatter-horizon (stap 3), dan +scande die case bewust ver voorbij `scanLimit` zónder grootboek — begrijp dat eerst en meld het; +pas nooit blind een verwachting aan. + +- [ ] **Step 6: Commit (twee commits, de CRLF-commit stond al in stap 1)** + +```bash +git add src/engine/scheduler/ResourceLeveler.ts tests/planning/check-leveler-splitmode.ts +git commit -m "fix(scheduler): scatter-plaatsing stopt op de horizon en levert nooit een lege dagenset (B1c) + +Co-Authored-By: Claude Fable 5 " + +git add src/services/library/distribute.ts tests/library/check-distribute.ts +git commit -m "fix(library): het poolitem-grootboek boekt per item in plaats van op één hoop (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 2: `applyLeveling` scope-behoudend, mét `splitGaps` — en de idempotentie die daarbij hoort + +**Spec §5, "Scope-behoudend toepassen — op drie plekken"**, de derde plek: *"de reset-lus in +`applyLeveling`"*. En **spec §4, "Herkomst"**: *"`applyLeveling` (idempotent herschrijven), +'nivellering wissen' en 'alles terugdraaien' raken uitsluitend leveling-gaps; importsplits zijn +brondata en blijven staan."* + +Drie dingen die vandaag ontbreken, en één die de eerste twee pas waar maakt: + +1. `applyLeveling` reset **álle** `levelingDelay`s. Met een scope-genivelleerd resultaat (de verdeler + levert er niets anders) zou Toepassen dus de delays van elke taak buiten de scope wissen — precies + het document herschikken dat `scopeTaskIds` kwam voorkomen. +2. `LevelingResult.gaps` is inert: niemand schrijft het. De hele onderbreek-modus van etappe 2 heeft + daardoor geen effect op de opgeslagen planning. +3. `clearLeveling` wist geen leveling-gaten. +4. **En de idempotentie-voorwaarde:** `levelResources`' `workTasks`-baseline stript `levelingDelay` + (+ de M10-velden) voor in-scope taken, maar **niet** `splitGaps`. Een tweede nivellering zou de + leveling-gaten van de eerste als brondata lezen en er nieuwe bovenop leggen — accumulatie in + plaats van herschrijven. De strip moet dus symmetrisch worden. + +**Files:** +- Modify: `src/engine/scheduler/ResourceLeveler.ts` (`workTasks`-strip + `gapsOut`-bron) +- Modify: `src/state/slices/scheduleSlice.ts` (`applyLeveling`, `clearLeveling`) +- Modify: `src/state/runtime/createMcpTransactions.ts` (dezelfde twee) +- Modify: `src/components/layout/Ribbon/ribbonConfig.tsx` (regel ~609, de enable-check) +- Create: `tests/planning/check-apply-leveling-scope.ts` +- Modify: `tests/planning/run.sh` (registratie) + +- [ ] **Step 1: Schrijf de falende test** + +`tests/planning/check-apply-leveling-scope.ts`. Store-patroon: kopieer uit +`tests/planning/check-move-assignment.ts` hoe die de echte store aanspreekt. + +```ts +// check-apply-leveling-scope.ts — B1c-plan3 taak 2. `applyLeveling` schrijft voortaan binnen een +// SCOPE en schrijft ook `splitGaps`; `clearLeveling` wist ook de leveling-gaten. Vier delen. + +// ── Deel 1: scope-behoud ───────────────────────────────────────────────────────────────────────── +// Document met taken A, B, C. A draagt AL `levelingDelay: 2` uit een eerdere nivellering. +// applyLeveling({ delays: { C: 1 }, gaps: {} }, { scopeTaskIds: ['B', 'C'] }) +eq('A behoudt zijn delay (buiten de scope)', tA.levelingDelay, 2); +eq('C krijgt zijn nieuwe delay', tC.levelingDelay, 1); +eq('B wordt binnen de scope gereset', tB.levelingDelay, undefined); +// Zónder scope blijft het gedrag byte-identiek: alles wordt gereset. +eq('geen scope ⇒ A wordt WEL gereset', tA2.levelingDelay, undefined); + +// ── Deel 2: gaps schrijven ─────────────────────────────────────────────────────────────────────── +// Taak D draagt een IMPORTSPLIT (`{ afterMinutes: 480, gapMinutes: 480 }`, geen source). +// applyLeveling met gaps: { D: [importsplit, { afterMinutes: 1440, gapMinutes: 480, source: 'leveling' }] } +eq('splitGaps geschreven zoals aangeleverd', tD.splitGaps?.length, 2); +eq('de importsplit staat er nog, zonder source', tD.splitGaps?.[0].source, undefined); +eq('het leveling-gat draagt zijn herkomst', tD.splitGaps?.[1].source, 'leveling'); +// Idempotent: een TWEEDE applyLeveling ZONDER gaps voor D wist alleen het leveling-gat. +eq('tweede apply: alleen het leveling-gat weg', tD2.splitGaps?.length, 1); +eq('en de importsplit staat er nog', tD2.splitGaps?.[0].afterMinutes, 480); + +// ── Deel 3: clearLeveling ──────────────────────────────────────────────────────────────────────── +eq('clearLeveling wist het leveling-gat', tD3.splitGaps?.length, 1); +eq('en laat de importsplit staan', tD3.splitGaps?.[0].source, undefined); +// De no-op-guard telt gaten mee: een document met UITSLUITEND een leveling-gat (geen enkele delay) +// moet clearLeveling nog steeds een snapshot laten pushen en runCPM laten draaien. +ok('clearLeveling is geen no-op bij alleen een leveling-gat', undoDepthAfter > undoDepthBefore); + +// ── Deel 4 (motor): de baseline is ook GAP-vrij ────────────────────────────────────────────────── +// Draai `levelResources` met allowSplits TWEE keer achter elkaar op dezelfde fixture, waarbij je de +// gaps van run 1 op de taken schrijft vóór run 2. Run 2 moet EXACT dezelfde gaps opleveren — +// accumulatie is de bug die deze case pint. +eq('nivelleren is idempotent in de onderbreek-modus', + JSON.stringify(run2.gaps), JSON.stringify(run1.gaps)); +// En out-of-scope gaten blijven staan (spiegel van de delay-regel): +eq('een leveling-gat BUITEN de scope overleeft de baseline-strip', run3.gaps['Buiten'], undefined); +ok('en de taak buiten de scope boekt nog op zijn gesplitste dagen', /* zie de fixture-toelichting */ true); +``` + +Registreer in `tests/planning/run.sh` (kopieer het `LEVELERSPLITSCHECK`-blok) en draai; verwacht rood +op alle vier de delen. + +- [ ] **Step 2: Implementeer — de motor (idempotentie-voorwaarde)** + +`src/engine/scheduler/ResourceLeveler.ts`, de `workTasks`-regel: + +```ts + // De werkkopie is de NIVELLERING-VRIJE baseline voor taken BINNEN de scope. Sinds B1c-plan3 taak 2 + // hoort `splitGaps` daar symmetrisch bij: een gat met `source === 'leveling'` is UITVOER van een + // eerdere nivellering, precies zoals `levelingDelay` dat is. Stond het hier in de baseline, dan las + // een tweede nivellering het als brondata en legde er nieuwe gaten bovenop — accumulatie in plaats + // van het idempotente herschrijven dat spec §4 ("Herkomst") eist. IMPORTSPLITS (gaten zónder + // `source`) zijn wél brondata en blijven staan; `stripLevelingGaps` hieronder is dezelfde regel als + // `clearLevelingGaps` in taskDefaults.ts — puur, want de motor muteert de invoer nooit. + const stripLevelingGaps = (gaps: TaskSplitGap[] | undefined): TaskSplitGap[] | undefined => { + if (!gaps || gaps.length === 0) return gaps; + const kept = gaps.filter(g => g.source !== 'leveling'); + return kept.length > 0 ? kept : undefined; + }; + const workTasks: Task[] = tasks.map(t => inScope(t.id) + ? { + ...t, + levelingDelay: undefined, + levelingDelayMinutes: undefined, + levelingDelayElapsed: undefined, + splitGaps: stripLevelingGaps(t.splitGaps), + time: { ...t.time }, + } + : { ...t, time: { ...t.time } }); +``` + +En in de hoofdlus (regel ~659): `gapsOut` moet op de **gestripte** werkkopie stapelen, niet op de +originele taak — anders komen de gaten van de vórige nivellering alsnog in het resultaat: + +```ts + const workCopy = workById.get(pick)!; + const newGaps: TaskSplitGap[] = [ + ...(workCopy.splitGaps ?? []), // ⇐ WAS: pickedTask.splitGaps (de ONgestripte origineel) + ...splitGapsFromWorkDayBlocks(blocksFromDays(scatterDays, engineForTask(pickedTask)), mpd, 'leveling'), + ]; +``` + +Let op: `occurrenceFor`/`demandByTask` lezen elders `taskById` (de originelen). Controleer bij het +implementeren of de dagenset van een in-scope taak mét oude leveling-gaten uit `workTasks` of uit +`taskById` komt, en trek dat consistent naar `workTasks` — de baseline hóórt gap-vrij te zijn. Wijkt +dat af van wat hier staat, stop en meld het: dan raakt de strip meer plekken dan dit voorschrift +noemt. + +- [ ] **Step 3: Implementeer — het schrijfpad** + +`src/state/slices/scheduleSlice.ts`. Nieuw invoertype en signatuur: + +```ts + /** Wat `applyLeveling` daadwerkelijk schrijft. Een volle `LevelingResult` is hieraan toewijsbaar, + * dus bestaande aanroepers (LevelingDialog, MCP) blijven ongewijzigd werken; de verdeler levert + * alleen deze twee velden per document. */ + applyLeveling: ( + write: Pick, + opts?: { scopeTaskIds?: string[] }, + ) => void; +``` + +De reset-lus: + +```ts + // Scope-behoudend toepassen (spec §5, derde plek — B1c-plan3 taak 2). Zonder scope blijft dit + // byte-identiek aan het gedrag van vóór B1c: alle taken worden gereset. MET scope raken we + // uitsluitend de gescopete taken — de verdeler nivelleert per POOLITEM, dus een delay op een + // taak die niets met dat poolitem te maken heeft is VASTE LAST waarop het voorstel gerekend + // heeft; die hier wissen zou het document herschikken en het voorstel ongeldig maken. + const scope = opts?.scopeTaskIds ? new Set(opts.scopeTaskIds) : null; + for (const task of s.tasks) { + if (scope && !scope.has(task.id)) continue; + const d = write.delays[task.id]; + task.levelingDelay = d !== undefined && d > 0 ? d : undefined; + if (task.levelingDelayMinutes !== undefined || task.levelingDelayElapsed !== undefined) { + roundedCount++; + } + task.levelingDelayMinutes = undefined; + task.levelingDelayElapsed = undefined; + // De onderbreek-modus (spec §4, "Herkomst"). `write.gaps[id]` is de VOLLEDIGE te schrijven + // waarde — importsplits inbegrepen — dus hij mag rechtstreeks. Staat de taak NIET in + // `write.gaps`, dan levert dit voorstel voor haar geen onderbreking: wis dan haar eventuele + // leveling-gaten van een VORIGE nivellering en laat importsplits staan. Dat is precies wat + // "idempotent herschrijven" betekent, en het is ook wat er gebeurt zodra de gebruiker + // "Onderbrekingen toestaan" uitzet en opnieuw toepast (`gaps` is dan `{}`). + const g = write.gaps[task.id]; + if (g !== undefined) task.splitGaps = g.length > 0 ? g : undefined; + else clearLevelingGaps(task); + } +``` + +`clearLeveling` in dezelfde slice — de no-op-guard en de lus: + +```ts + // De guard telt sinds B1c-plan3 taak 2 ook leveling-GATEN mee: een taak die uitsluitend een + // ingevoegde pauzedag draagt (geen enkele delay) werd hier stil overgeslagen, waardoor + // "Nivellering wissen" die pauzedag liet staan. + const hasLevelingGap = (t: Task) => (t.splitGaps ?? []).some(g => g.source === 'leveling'); + if (!s.tasks.some((t) => + t.levelingDelay !== undefined || t.levelingDelayMinutes !== undefined + || t.levelingDelayElapsed !== undefined || hasLevelingGap(t))) return; + runtime.beginUndoable(s); + for (const task of s.tasks) { + if (task.levelingDelayMinutes !== undefined || task.levelingDelayElapsed !== undefined) roundedCount++; + task.levelingDelay = undefined; + task.levelingDelayMinutes = undefined; + task.levelingDelayElapsed = undefined; + clearLevelingGaps(task); // uitsluitend `source: 'leveling'`; importsplits zijn brondata + } +``` + +`src/state/runtime/createMcpTransactions.ts`: exact dezelfde twee wijzigingen in zijn +`applyLeveling`/`clearLeveling` (inclusief de `opts`-parameter en de `write`-signatuur), met het +bestaande verwijscommentaar naar de store-variant uitgebreid — de twee mogen niet uit elkaar lopen. + +- [ ] **Step 4: De ribbon-enable-check** + +`src/components/layout/Ribbon/ribbonConfig.tsx`, regel ~609. `hasLeveling` toetst alleen +`levelingDelay`, dus "Nivellering wissen" staat grijs op een `.mpp`-project met uitsluitend sub-dag- +precisie, én op een project dat alleen ingevoegde pauzedagen draagt: + +```ts + const hasLeveling = useAppStore(s => s.tasks.some(t => + t.levelingDelay !== undefined + || t.levelingDelayMinutes !== undefined + || t.levelingDelayElapsed !== undefined + || (t.splitGaps ?? []).some(g => g.source === 'leveling'))); +``` + +Houd deze conditie **letterlijk gelijk** aan de no-op-guard in `clearLeveling` (stap 3) — een knop die +inschakelt terwijl de actie een no-op is, of andersom, is de bug die dit repareert. Zet dat als +commentaarregel bij beide. + +- [ ] **Step 5: Draai** + +```bash +bash tests/planning/run.sh 2>&1 | tail -8; echo "exit: $?" +bash tests/mcp/run.sh 2>&1 | tail -5; echo "exit: $?" +npx tsc --noEmit -p tsconfig.json; echo "exit: $?" +``` + +Verwacht: exit 0, 0, 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/scheduler/ResourceLeveler.ts src/state/slices/scheduleSlice.ts \ + src/state/runtime/createMcpTransactions.ts src/components/layout/Ribbon/ribbonConfig.tsx \ + tests/planning/check-apply-leveling-scope.ts tests/planning/run.sh +git commit -m "feat(scheduler): nivellering toepassen binnen een scope, inclusief de ingevoegde pauzedagen (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 3: `clearLevelingGaps` bedraden op tijdbasis-bewerkingen + +**Spec §4, "Invalidatie"**: *"Een bewerking die de tijdbasis van de taak raakt (duur, kalender, +handmatige datums, voortgang) wist de leveling-gaps van díé taak — een gat op een verouderde as is +geen planning maar ruis. Importsplits volgen dit niet (bestaand gedrag)."* + +`clearLevelingGaps` bestaat sinds etappe-2 taak 7 en heeft **nul aanroepplekken**. De aanroepplekken +zijn precies dezelfde klasse bewerkingen die `clearTimephasedWindow` al afvangt — dat is de +discoverable regel: `grep -rn "clearTimephasedWindow" src/` wijst ze aan. + +**Files:** +- Modify: elke plek uit de `clearTimephasedWindow`-grep (verwacht: `src/utils/taskDefaults.ts` als + bron, plus de aanroepers in `taskSlice.ts`/`createMcpTransactions.ts`; verifieer dit met de grep en + neem de werkelijke lijst over in de commit) +- Modify: `tests/planning/check-apply-leveling-scope.ts` (uitbreiding) **of** de bestaande batterij die + `clearTimephasedWindow` al dekt — zoek die eerst met `grep -rn "clearTimephasedWindow" tests/` + +- [ ] **Step 1: Breng de aanroepplekken in kaart en schrijf de falende test** + +```bash +grep -rn "clearTimephasedWindow\|clearTimephasedDurationWalks" src/ tests/ +``` + +Neem elke `src/`-aanroepplek over. Per plek één assert, in de batterij die `clearTimephasedWindow` al +dekt (geen nieuwe check aanmaken als er al een is): + +```ts +// B1c-plan3 taak 3: een tijdbasis-bewerking wist de LEVELING-gaten van díé taak (spec §4, +// "Invalidatie") en laat IMPORTSPLITS staan. Per aanroepplek één geval; de vier klassen uit de spec: +// - duur wijzigen (setTaskDuration / duration-unit-wissel) +// - kalender wijzigen (taakkalender toewijzen) +// - handmatige datums (scheduleStart/scheduleFinish zetten, Gantt-sleep) +// - voortgang (completion / actualStart / actualFinish) +eq('duur wijzigen wist het leveling-gat', tAfter.splitGaps?.length, 1); +eq('en laat de importsplit staan', tAfter.splitGaps?.[0].source, undefined); +// Een taak ZONDER leveling-gaten mag door zo'n bewerking niets verliezen (contract van +// clearLevelingGaps: `false` ⇒ niets gemuteerd). +eq('een taak met alleen importsplits blijft ongemoeid', tOnlyImport.splitGaps?.length, 1); +``` + +- [ ] **Step 2: Implementeer** + +Naast élke `clearTimephasedWindow(task)`-aanroep in `src/`: + +```ts + // B1c-plan3 taak 3 (spec §4, "Invalidatie"): dezelfde bewerking die de MSP-urensturing + // ongeldig maakt, maakt ook een door de nivelleerder ingevoegde pauzedag ongeldig — het gat + // ligt dan op een verouderde tijd-as. Importsplits (gaten zonder `source`) zijn brondata en + // blijven staan; `clearLevelingGaps` doet dat onderscheid. + clearLevelingGaps(task); +``` + +**GEEN melding.** Anders dan de M10-afronding (etappe-2 taak 1) is dit geen verlies van +gebruikersdata uit een importbestand maar het opruimen van app-eigen afgeleide nivelleeruitvoer op +een as die de gebruiker zelf zojuist heeft verzet. Schrijf die redenering als commentaar bij één van +de aanroepplekken op, zodat een latere lezer niet denkt dat de melding vergeten is. + +- [ ] **Step 3: Draai en commit** + +```bash +bash tests/planning/run.sh 2>&1 | tail -5; echo "exit: $?" +bash tests/mcp/run.sh 2>&1 | tail -5; echo "exit: $?" +``` + +```bash +git add -A src/ tests/ +git commit -m "fix(scheduler): een tijdbasis-bewerking wist de ingevoegde pauzedagen van die taak (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 4: de mutatieteller op de store-runtime + de voorstel-vingerafdruk + +**Spec §6a**: *"Het paneel bewaakt dit met een vingerafdruk per betrokken document: payload-referentie +voor slapers, en voor het actieve document een monotone mutatieteller die de store-runtime exposeert +(nieuw, klein — het interne undo-volgnummer zit in een closure en `undoStack.length` is onbruikbaar +door `MAX_UNDO`-trimming)."* + +**KEUZE VAN DIT PLAN — de teller alléén is niet genoeg.** In de huidige runtime bumpt `undoSeq` +uitsluitend in `pushUndoSnapshot`, en `beginUndoable` slaat die aanroep over zodra een +**coalesce-reeks** loopt (een Gantt-sleep, een reeks tikken in een invoerveld). Een hele sleep zou de +teller dus niet bewegen. Daarnaast schrijft `runCPM` datums zónder snapshot. De vingerafdruk wordt +daarom **de teller PLUS de referentieset** van de velden waarop het voorstel gerekend heeft — precies +het patroon dat `ResourceOccupancyView` al gebruikt (Immer geeft bij elke mutatie een nieuwe +referentie). De teller blijft er als goedkope, monotone grofmazige backstop en omdat de spec hem +expliciet vraagt; de referenties maken hem sluitend. + +**Files:** +- Modify: `src/state/runtime/storeRuntime.ts` +- Create: `src/services/library/proposalFingerprint.ts` +- Create: `tests/planning/check-mutation-seq.ts` +- Modify: `tests/planning/run.sh` (registratie) + +- [ ] **Step 1: Schrijf de falende test** + +```ts +// check-mutation-seq.ts — B1c-plan3 taak 4 (spec §6a). De teller is MONOTOON en beweegt bij ELKE +// undoable mutatie, óók binnen een coalesce-reeks — dat is precies waar `undoStack.length` en het +// interne undo-volgnummer tekortschieten. + +const rt = createStoreRuntime(); +eq('start op 0', rt.mutationSeq(), 0); +// Een gewone mutatie: snapshot gepusht ⇒ teller omhoog. +rt.beginUndoable(state); +ok('gewone mutatie bumpt', rt.mutationSeq() > 0); +// Een GECOALESCEERDE tweede mutatie pusht GEEN snapshot (undoStack groeit niet) maar is wél een +// mutatie — de teller moet er dus wél op bewegen. +const before = rt.mutationSeq(); +const depthBefore = state.undoStack.length; +rt.beginUndoable(state, { coalesceKey: 'k' }); +rt.beginUndoable(state, { coalesceKey: 'k' }); +eq('coalesce pusht geen tweede snapshot', state.undoStack.length, depthBefore + 1); +ok('maar de teller beweegt wél', rt.mutationSeq() > before + 1); +// MAX_UNDO-trimming raakt de teller niet (dat is het hele punt van een aparte teller). +ok('teller blijft monotoon voorbij MAX_UNDO', /* 150× beginUndoable */ rt.mutationSeq() >= 150); +// Twee contexten hebben ELK hun eigen teller (geen module-global). +ok('per context', createStoreRuntime().mutationSeq() === 0 && rt.mutationSeq() > 0); + +// ── De vingerafdruk ───────────────────────────────────────────────────────────────────────────── +// `documentFingerprint` is puur en vergelijkt met `===` op referentie; twee identieke maar +// verschillende arrays MOETEN een andere vingerafdruk geven (dat is de bedoeling: Immer levert bij +// elke mutatie een nieuwe referentie, en een "toevallig gelijke" inhoud is geen garantie). +ok('zelfde referenties ⇒ zelfde vingerafdruk', fpA === fpB); +ok('nieuwe tasks-referentie ⇒ andere vingerafdruk', fpA !== fpC); +``` + +- [ ] **Step 2: Implementeer de teller** + +`src/state/runtime/storeRuntime.ts`: + +```ts +export interface StoreRuntime { + /* … bestaande leden … */ + /** + * Monotone teller die bij ELKE undoable mutatie in deze context omhoog gaat — óók bij een + * gecoalesceerde mutatie, die bewust géén snapshot pusht (spec §6a). Bewust NIET hetzelfde als het + * interne `undoSeq`: dat volgnummer stuurt de coalesce-vergelijking en beweegt daarom juist niet + * tijdens een sleepreeks, en `undoStack.length` is onbruikbaar omdat `MAX_UNDO` van onderaf trimt. + * + * Afnemer: de voorstel-invalidatie van het B1c-verdeelpaneel. Die combineert deze teller met de + * REFERENTIES van de documentvelden waarop het voorstel gerekend heeft — de teller is de goedkope, + * grofmazige backstop; de referenties maken de bewaking sluitend (`runCPM` muteert datums zonder + * ooit langs `beginUndoable` te komen). + */ + mutationSeq(): number; +} +``` + +In `createStoreRuntime`: `let mutationSeq = 0;`, en bump hem als **eerste regel** van `beginUndoable` +(vóór de batch-/lease-guard én vóór de coalesce-tak), plus in `pushUndoSnapshot`. Schrijf erbij +waarom de bump vóór de guards staat: een mutatie binnen een batch of MCP-transactie is nog steeds een +mutatie, ook al neemt de omvattende transactie de snapshot. + +- [ ] **Step 3: Implementeer de vingerafdruk** + +`src/services/library/proposalFingerprint.ts` — puur, geen store-import (bewaakt door +`verify:store-boundaries`): + +```ts +/** + * Vingerafdruk van de documentstate waarop een verdelingsvoorstel gerekend heeft (spec §6a). + * + * Referentie-gebaseerd, met opzet: Immer bevriest de state na elke producer en levert bij elke + * mutatie een NIEUWE referentie voor het gemuteerde deel. Een vergelijking op inhoud zou duurder én + * zwakker zijn (een gelijk-ogende takenlijst na een undo/redo is niet dezelfde planning). + * + * De velden zijn precies wat `computeDistribution` leest: de taken (datums, delays, gaten, prioriteit), + * de relaties, de resources en toewijzingen (wie boekt), de kalenders, het project (statusdatum, + * voortgangsmodus, planningsopties, bibliotheekkoppeling) en `cpmResult` (de doorgerekende cijfers + * waar §3.1 op staat). Groeit die leeslijst, dan groeit deze functie mee — anders overleeft een + * voorstel een bewerking die het wél ongeldig maakt. + */ +export interface FingerprintInput { + tasks: unknown; sequences: unknown; resources: unknown; assignments: unknown; + calendar: unknown; calendars: unknown; project: unknown; cpmResult: unknown; + scheduleStale: unknown; datesAsRecorded: unknown; +} +export function documentFingerprint(d: FingerprintInput, mutationSeq: number): string +``` + +Implementatie: een `WeakMap` met een oplopende id per geziene referentie, en de +vingerafdruk is de aaneengeschakelde id-reeks plus `mutationSeq`. Zo is de uitkomst een goedkoop te +vergelijken string zonder de objecten zelf vast te houden (de WeakMap laat ze vrij). + +- [ ] **Step 4: Draai en commit** + +```bash +bash tests/planning/run.sh 2>&1 | tail -5; echo "exit: $?" +npm run verify:store-boundaries; echo "exit: $?" +``` + +```bash +git add src/state/runtime/storeRuntime.ts src/services/library/proposalFingerprint.ts \ + tests/planning/check-mutation-seq.ts tests/planning/run.sh +git commit -m "feat(state): monotone mutatieteller per storecontext + voorstel-vingerafdruk (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 5: de headless scratch-instantie + de context-bewuste extensie-emitter + +**Spec §5**: *"Slapende documenten gaan bij voorkeur via een headless scratch-instantie +(`createAppStoreContext()`): payload hydrateren, dezelfde acties draaien (undo-snapshot op de eigen +stack, schrijven, doorrekenen), payload terug capturen. Zo gelden `MAX_UNDO`, coalescing en het +documentcontract vanzelf."* En: *"Twee bekende singleton-randen worden expliciet dichtgezet … +(a) `runCPM` vuurt `emitExtensionEvent('schedule:calculated')` op een app-globale luisteraar-map … +de emitter wordt context-bewust/injecteerbaar en staat in de scratch-instantie uit. (b) `notify` +schrijft in de `ui.notifications` van de eigen context — in een scratch-instantie rendert niemand +die; fouten uit de scratch-run bubbelen daarom op als blokkerende reden in het voorstel, nooit als +onzichtbare melding."* + +**Files:** +- Modify: `src/state/runtime/storeRuntime.ts` (`emitHostEvent` op de runtime) +- Modify: `src/state/appStore.ts` (`createAppStoreContext(opts?)`) +- Modify: elke slice die `emitExtensionEvent(HOST_EVENTS.…)` aanroept (zoek met + `grep -rn "emitExtensionEvent(HOST_EVENTS" src/state/`) +- Create: `src/state/runtime/scratchDocument.ts` +- Create: `tests/planning/check-scratch-document.ts` +- Modify: `tests/planning/run.sh` (registratie) + +- [ ] **Step 1: Schrijf de falende test** + +```ts +// check-scratch-document.ts — B1c-plan3 taak 5 (spec §5). Een slapende payload wordt in een eigen +// storecontext bewerkt, en die context laat GEEN sporen na in de app-globale registers. + +// ── Geval 1: de payload gaat er heel in en heel uit ────────────────────────────────────────────── +// Bouw een payload met taken/relaties/resources, draai een no-op-functie, en vergelijk veld voor +// veld met het documentcontract (loop over DOCUMENT_FIELDS — niet met de hand opsommen). +for (const f of DOCUMENT_FIELDS) eq(`round-trip: ${f.key}`, /* out[f.key] */, /* in[f.key] */); + +// ── Geval 2: de echte acties draaien, met echte undo-semantiek ─────────────────────────────────── +// applyLeveling in de scratch-context ⇒ delays geschreven, ÉÉN undo-stap op de EIGEN stack, en +// `runCPM` heeft gedraaid (cpmResult vers, scheduleStale false). +eq('één undo-stap op de eigen stack', out.undoStack.length, before.undoStack.length + 1); +eq('doorgerekend', out.scheduleStale, false); +ok('nieuwe datums geschreven', out.tasks.find(t => t.id === 'B')!.time.earlyStart !== beforeStart); + +// ── Geval 3: de extensie-emitter zwijgt ───────────────────────────────────────────────────────── +// Abonneer op HOST_EVENTS.scheduleCalculated via de app-globale bus, draai runCPM in de scratch- +// context, en tel: NUL events. Draai daarna runCPM op een gewone context: WÉL een event. +eq('scratch-run vuurt geen extensie-event', seen.length, 0); +ok('een gewone context vuurt dat wél', seenNormal.length > 0); + +// ── Geval 4: meldingen komen naar buiten in plaats van te verdwijnen ──────────────────────────── +// Draai een functie die een cyclus veroorzaakt (runCPM met een relatiecyclus). De scratch-run levert +// de melding(en) terug bij de AANROEPER in plaats van ze in een onzichtbare ui.notifications te +// laten belanden. +ok('fouten uit de scratch-run bubbelen op', res.notifications.some(n => n.messageKey === 'notifications.scheduleFailed')); +eq('en niets landde in een onzichtbaar meldingenkanaal', /* ctx.store.getState().ui.notifications.length */ 0); + +// ── Geval 5: geen sporen in de app-globale registers ──────────────────────────────────────────── +// Vóór en ná: extensie-instanties, de eventbus-luisteraarslijst en de bibliotheek-persistentie zijn +// onveranderd; `appStoreContext.store.getState()` is byte-identiek (vergelijk capturePayload). +ok('de app-store is onaangeraakt', JSON.stringify(capturePayload(appStoreContext.store.getState())) === appBefore); +``` + +- [ ] **Step 2: Implementeer de context-bewuste emitter** + +`storeRuntime.ts`: + +```ts +export interface StoreRuntimeOptions { + /** `false` ⇒ deze context zendt GEEN host-events uit (spec §5, rand (a)). Gebruikt door de + * scratch-instantie: extensies zijn app-globaal geregistreerd en zouden anders cijfers krijgen + * van een document waar de gebruiker niet naar kijkt. Default `true`. */ + emitHostEvents?: boolean; +} + +export interface StoreRuntime { + /* … */ + /** Zend een host-lifecycle-event uit namens DEZE context. Slices roepen dit aan in plaats van + * `emitExtensionEvent` rechtstreeks — de bus zelf blijft app-globaal (dat hoort zo: extensies + * zijn app-niveau), maar of een context er iets op zet is nu een eigenschap van die context. */ + emitHostEvent(event: HostEventName, data?: unknown): void; +} +``` + +Implementatie: `emitHostEvent: opts?.emitHostEvents === false ? () => {} : emitExtensionEvent`. + +Vervang in `src/state/slices/*.ts` elke `emitExtensionEvent(HOST_EVENTS.x, …)` door +`runtime.emitHostEvent(HOST_EVENTS.x, …)`. Slices die vandaag geen `runtime` krijgen (`createViewSlice`, +`createUiSlice`, `createExtensionSlice`) roepen 'm sowieso niet aan; controleer dat met de grep en meld +het als het wél zo is. + +`appStore.ts`: `createAppStoreContext(opts?: StoreRuntimeOptions)` geeft `opts` door aan +`createStoreRuntime`. De app-singleton blijft `createAppStoreContext()` — default aan, gedrag +ongewijzigd. + +- [ ] **Step 3: Implementeer de scratch-instantie** + +`src/state/runtime/scratchDocument.ts`: + +```ts +/** + * Draai een bewerking op een SLAPENDE documentpayload in een eigen, headless storecontext + * (spec §5, "Toepassen: schrijven in meerdere documenten"). + * + * Waarom niet gewoon de payload spreaden zoals `recalculateStaleSleepingDocuments` doet: die route + * omzeilt `beginUndoable` en moet `MAX_UNDO`-trimming en coalescing zelf naborgen. Hier draaien de + * ECHTE acties op een echte context, dus het documentcontract, de undo-semantiek en de + * transactie-runtime gelden vanzelf — en dat is precies wat "alles terugdraaien" (taak 6) nodig + * heeft: een échte undo-stap op de eigen stack van dat document. + * + * Twee singleton-randen staan dicht (spec §5): + * (a) host-events — de context wordt met `emitHostEvents: false` gebouwd, zodat extensies geen + * cijfers krijgen van een document waar de gebruiker niet naar kijkt; + * (b) meldingen — `ui.notifications` van deze context rendert niemand. Ze worden na afloop + * LEEGGEHAALD en aan de aanroeper teruggegeven, zodat een fout (cyclus, lege kalender) als + * blokkerende reden zichtbaar wordt in plaats van geruisloos te verdwijnen. + * + * De context is puur lokaal: hij wordt niet bewaard, niet geregistreerd en niet hergebruikt. App- + * globale registers (extensie-instanties, MCP, bibliotheek-persistentie) worden niet aangeraakt — + * die leven buiten de Zustand-factory. + */ +export interface ScratchRunResult { + /** De payload ná de bewerking — klaar om in `documents[i].payload` gezet te worden. */ + payload: DocumentPayload; + result: T; + /** Wat de scratch-context in zijn onzichtbare meldingenkanaal zou hebben gezet. */ + notifications: NotifyInput[]; + /** `false` wanneer `fn` een exception gooide; `payload` is dan de ONGEWIJZIGDE invoer. */ + ok: boolean; + error?: string; +} + +export function runInScratchDocument( + payload: DocumentPayload, + fn: (state: AppState) => T, +): ScratchRunResult +``` + +Implementatie in vier stappen, met dit commentaar erbij: + +```ts + const ctx = createAppStoreContext({ emitHostEvents: false }); + // 1. Hydrateren via het documentcontract — dezelfde functie die `switchDocument` gebruikt, dus + // élk (ook toekomstig) documentveld rijdt automatisch mee. + ctx.store.setState((s) => { hydratePayload(s, payload); }); + // 2. De ECHTE acties draaien. `fn` krijgt de state (met alle acties erop) en mag alles doen wat een + // gewone gebruiker zou doen — inclusief `applyLeveling`, dat zelf zijn snapshot pusht en + // `runCPM` draait. + let result: T; let ok = true; let error: string | undefined; + try { result = fn(ctx.store.getState()); } + catch (e) { ok = false; error = String(e); } + // 3. Meldingen oogsten en het kanaal legen (rand (b)). + const notifications = harvestNotifications(ctx); + // 4. Terug capturen. Bij een mislukking geven we de ONGEWIJZIGDE invoerpayload terug: net als + // `recalculateStaleSleepingDocuments` mag een halve mutatie nooit terug de registry in. + return { payload: ok ? capturePayload(ctx.store.getState()) : payload, result, notifications, ok, error }; +``` + +Controleer bij het implementeren of `ui.notifications` in `uiSlice` de vorm heeft die +`harvestNotifications` aanneemt (id/severity/messageKey); wijkt dat af, volg de werkelijke vorm en +noteer dat in het docblok. + +- [ ] **Step 4: Draai** + +```bash +bash tests/planning/run.sh 2>&1 | tail -8; echo "exit: $?" +bash tests/mcp/run.sh 2>&1 | tail -5; echo "exit: $?" +npm run verify:cycles; echo "exit: $?" +npm run verify:store-boundaries; echo "exit: $?" +``` + +Verwacht: exit 0 ×4. Let op `verify:cycles`: `state/runtime/scratchDocument` importeert +`state/appStore` (voor `createAppStoreContext`) én `state/documentContract`. Ontstaat daar een cyclus, +dan hoort de factory-import losgetrokken te worden (injecteer `createAppStoreContext` als parameter met +een default) — meld het als je die route neemt. + +- [ ] **Step 5: Commit** + +```bash +git add src/state/runtime/storeRuntime.ts src/state/appStore.ts src/state/runtime/scratchDocument.ts \ + src/state/slices/ tests/planning/check-scratch-document.ts tests/planning/run.sh +git commit -m "feat(state): headless scratch-instantie voor slapende documenten, met stille host-events (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 6: `applyDistribution` / `undoDistribution` — het schrijfpad over meerdere documenten + +**Spec §5** in zijn geheel. Dit is het hart van de etappe: één klik schrijft in élk deelnemend +document, met per document een échte undo-stap, en de "toegepast"-strook kan alles in één keer +terugdraaien. + +**Plaatsing:** op `librarySlice` (die is app-globaal, net als de verdeler zelf, en importeert al +`services/library`). Er komt **geen** nieuw top-level state-veld bij: het toegepast-record woont in +`ui` (taak 8). Zo blijven de contract-asserties in `documentContract.ts` ongewijzigd. + +**Files:** +- Modify: `src/state/slices/librarySlice.ts` +- Create: `src/services/library/applyDistribution.ts` (de pure voorbereiding: wat wordt er per document + geschreven, en mag het überhaupt) +- Create: `tests/library/check-apply-distribution.ts` +- Modify: `tests/library/run.sh` (`run_check check-apply-distribution`) + +- [ ] **Step 1: Schrijf de falende test** + +```ts +// check-apply-distribution.ts — B1c-plan3 taak 6 (spec §5/§9 "Store-niveau"). Toepassen schrijft in +// het actieve document én in de slapers; "alles terugdraaien" zet alles terug, inclusief de +// doorrekening. Bouw de fixtures met `createAppStore()` + `newDocument()` (echte documenten, echte +// payloads) en de bouwstenen uit `check-distribute.ts`. + +// ── Geval 1: geblokkeerd voorstel schrijft NIETS ───────────────────────────────────────────────── +eq('blocked ⇒ geen record', store.applyDistribution(blockedProposal), null); +ok('en geen enkel document is aangeraakt', unchanged(store)); + +// ── Geval 2: tekort blokkeert Toepassen (spec §4 stap 3) ───────────────────────────────────────── +eq('hasShortfall ⇒ geen record', store.applyDistribution(shortfallProposal), null); + +// ── Geval 3: schrijven over actief + slapend ───────────────────────────────────────────────────── +eq('het actieve document kreeg zijn delays', activeTaskAfter.levelingDelay, 2); +eq('het slapende document ook', sleepingPayload.tasks.find(t => t.id === 'S1')!.levelingDelay, 1); +ok('en de DOORREKENING is gepersisteerd (spec §5)', sleepingPayload.scheduleStale === false + && sleepingPayload.cpmResult !== null); +eq('beide documenten staan als gewijzigd', sleepingPayload.isDirty, true); +// Toepassen schrijft ONVOORWAARDELIJK, ook met "Automatisch berekenen" UIT (spec §11.5). +ok('ook in handmatige modus geschreven', store.ui.autoCalcCPM === false && sleepingPayload.cpmResult !== null); + +// ── Geval 4: scope-behoud over de documentgrens ───────────────────────────────────────────────── +// Een taak in het slapende document die NIET op dit poolitem boekt en al een levelingDelay droeg, +// houdt die. Dat is taak 2's scope-behoud, hier via het schrijfpad bewezen. +eq('out-of-scope delay overleeft Toepassen', sleepingPayload.tasks.find(t => t.id === 'Buiten')!.levelingDelay, 3); + +// ── Geval 5: onderbrekingen worden echt geschreven ────────────────────────────────────────────── +eq('splitGaps geschreven met herkomst', gapTask.splitGaps?.some(g => g.source === 'leveling'), true); + +// ── Geval 6: per document ÉÉN undo-stap, en gewone Ctrl+Z werkt ───────────────────────────────── +eq('actief: één stap erbij', store.undoStack.length, activeDepthBefore + 1); +eq('slapend: één stap erbij', sleepingPayload.undoStack.length, sleepDepthBefore + 1); +// Activeer het slapende document en druk gewoon undo: dat draait ALLEEN dat document terug. +ok('per-document-undo herstelt dat ene document', /* … */ true); + +// ── Geval 7: "alles terugdraaien" ─────────────────────────────────────────────────────────────── +eq('undoDistribution slaagt', store.undoDistribution(record), true); +ok('actief document terug bij af (datums én delays)', deepEqual(activeAfterUndo, activeBefore)); +ok('slapend document terug bij af, inclusief cpmResult', deepEqual(sleepAfterUndo, sleepBefore)); + +// ── Geval 8: terugdraaien weigert wat het niet meer herkent ───────────────────────────────────── +// Bewerk ná het toepassen het slapende document (een extra undo-stap erbovenop). "Alles terugdraaien" +// mag DAT document dan niet blind terugpoppen — het zou de verkeerde stap ongedaan maken. +eq('gedeeltelijk terugdraaien meldt welk document overgeslagen is', + store.undoDistributionReport?.skippedDocIds, ['doc-slaper']); + +// ── Geval 9: geen sporen in de app-globale registers (spec §9 "Store-niveau") ──────────────────── +ok('extensie-events zijn niet gevuurd voor slapende documenten', seenScheduleCalculated.length <= 1); +``` + +Registreer in `tests/library/run.sh` en draai; verwacht rood. + +- [ ] **Step 2: Implementeer de pure voorbereiding** + +`src/services/library/applyDistribution.ts` — puur, geen store: + +```ts +/** Wat er per document geschreven moet worden. Afgeleid uit het voorstel, zodat de store-actie geen + * eigen interpretatie van `DistributionDocResult` heeft (en de test die afleiding los kan pinnen). */ +export interface DistributionWrite { + docId: string; + scopeTaskIds: string[]; + write: { delays: Record; gaps: Record }; +} + +/** `null` ⇒ dit voorstel mag NIET geschreven worden, met de reden erbij. Spec §3.1 (geblokkeerd) en + * §4 stap 3 (een voorstel mét tekorten is een geldige preview maar blokkeert Toepassen). */ +export type DistributionWritePlan = + | { ok: true; writes: DistributionWrite[] } + | { ok: false; reason: 'blocked' | 'shortfall' | 'nothing-to-write' }; + +export function planDistributionWrites( + proposal: DistributionProposal, + scopeTaskIdsByDoc: Record, +): DistributionWritePlan +``` + +Regels, letterlijk als commentaar erbij: +- `proposal.blocked !== null` ⇒ `{ ok: false, reason: 'blocked' }`. +- `proposal.hasShortfall` ⇒ `{ ok: false, reason: 'shortfall' }`. +- Alleen `docs` met `participated === true && cannotMove === false` leveren een write. Gepinde, + #63- en `cannotMove`-documenten worden per definitie **nooit** beschreven (spec §3.3a/§6). +- Geen enkele write ⇒ `'nothing-to-write'` (het voorstel verandert niets; Toepassen is dan zinloos, + niet fout). + +- [ ] **Step 3: Implementeer de store-acties** + +`librarySlice.ts`: + +```ts + /** Toepassen (spec §5). Schrijft het voorstel in élk deelnemend document — het actieve via het + * gewone top-level-pad, de slapers via een headless scratch-instantie — en geeft een record terug + * waarmee de "toegepast"-strook alles in één keer kan terugdraaien. `null` ⇒ er is niets + * geschreven (geblokkeerd, tekort, of niets te doen). */ + applyDistribution: ( + proposal: DistributionProposal, + scopeTaskIdsByDoc: Record, + ) => DistributionApplyRecord | null; + + /** "Alles terugdraaien" (spec §5): draait per beschreven document precies de undo-stap terug die + * `applyDistribution` daar heeft achtergelaten. Geeft terug welke documenten wél en welke NIET + * konden (zie de bewaking hieronder). */ + undoDistribution: (record: DistributionApplyRecord) => DistributionUndoReport; +``` + +```ts +export interface DistributionApplyRecord { + libraryItemId: string; + appliedAt: string; // ISO — puur voor de strooktekst + docs: Array<{ + docId: string; + title: string; + /** `undoStack.length` NÁ het toepassen. "Alles terugdraaien" pakt alleen een document waarvan de + * stack nog op precies deze diepte staat — heeft de gebruiker er intussen zelf in gewerkt, dan + * zou blind terugpoppen de VERKEERDE stap ongedaan maken. Dan slaan we dat document over en + * melden we het (`DistributionUndoReport.skippedDocIds`). In de praktijk zeldzaam: §6a laat het + * voorstel al vervallen zodra een betrokken document muteert, en de strook verdwijnt daarmee. */ + undoDepthAfterApply: number; + }>; +} +export interface DistributionUndoReport { undoneDocIds: string[]; skippedDocIds: string[] } +``` + +De uitvoeringsvolgorde in `applyDistribution` (schrijf deze motivering erbij — het is de +atomiciteitsgarantie, gespiegeld aan `recalculateStaleSleepingDocuments`): + +1. `planDistributionWrites(...)`; `ok === false` ⇒ `return null`. +2. **Fase 1 — buiten de producer:** voor élke slapende write een `runInScratchDocument(payload, s => + s.applyLeveling(write, { scopeTaskIds }))`. Verzamel de nieuwe payloads. Faalt er één + (`ok === false`), dan **stopt de hele actie** en is er nog niets gemuteerd: `return null`, en push + de opgeviste `notifications` van die run door naar `get().notify(...)` zodat de fout zichtbaar + wordt (spec §5, rand (b) — nooit een onzichtbare melding). +3. **Het actieve document** (als het meedoet) via het gewone pad: + `get().applyLeveling(write, { scopeTaskIds })`. Dat pusht zijn eigen snapshot en draait `runCPM`. +4. **Fase 2 — één producer:** de nieuwe slaper-payloads in `s.documents[i].payload` zetten. + Sla een document over dat intussen gesloten of geactiveerd is (`entry.payload === null`), exact + zoals `recalculateStaleSleepingDocuments` dat doet. +5. Bouw en retourneer het record (met `undoStack.length` per document ná het schrijven). + +`undoDistribution`: per document uit het record — het actieve via `get().undo()`, een slaper via +`runInScratchDocument(payload, s => s.undo())` — maar **alleen** wanneer de undo-diepte nog klopt. +Verzamel `undoneDocIds`/`skippedDocIds`. Ook hier: eerst alle scratch-runs, dan één producer. + +- [ ] **Step 4: Draai** + +```bash +bash tests/library/run.sh 2>&1 | tail -8; echo "exit: $?" +bash tests/planning/run.sh 2>&1 | tail -5; echo "exit: $?" +npm run verify:store-boundaries; echo "exit: $?" +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/services/library/applyDistribution.ts src/state/slices/librarySlice.ts \ + tests/library/check-apply-distribution.ts tests/library/run.sh +git commit -m "feat(library): verdeling toepassen en in één keer terugdraaien over meerdere documenten (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 7: de i18n-pass — reden-taxonomie + alle paneelteksten, veertien locales + +**Spec §8**: *"Volledig via `t(...)`, veertien talen, CLDR-pluralen (dag/dagen-teksten!)."* Etappe 2 +stelde deze pass bewust uit (één sleutel toevoegen kost veertien bestanden); hier gebeurt hij in één +keer, vóór de UI-taken, zodat die alleen nog `t(...)` hoeven te consumeren. + +Twee delen: **(a)** de reden-taxonomie, met een gedeelde mapping die `LevelingDialog` en het nieuwe +paneel allebei gebruiken, en **(b)** de complete paneelsleutelset. + +**Files:** +- Create: `src/utils/levelingReasonKey.ts` +- Modify: `src/components/dialogs/LevelingDialog.tsx` (de if/else-keten vervangen) +- Modify: `src/i18n/locales/{nl,en,fr,de,es,zh,it,pt,pl,tr,ar,ja,ko,fa}/common.json` +- Modify: `tests/library/check-i18n-plurals.ts` (of de batterij die dat bestand al dekt) + +- [ ] **Step 1: De gedeelde reden-mapping** + +`src/utils/levelingReasonKey.ts`: + +```ts +/** + * `LevelingReason` → i18n-sleutel. Eén mapping voor de nivelleerdialoog én het B1c-verdeelpaneel; + * vóór B1c-etappe-3 stond dit als een if/else-keten in `LevelingDialog.tsx` die drie van de zeven + * codes kende en de rest ZONDER uitleg liet — een horizon-uitputting las daar als "onvoldoende + * capaciteit". + * + * Het `satisfies Record` is de poort: een nieuw lid in de taxonomie zonder + * sleutel geeft een COMPILE-fout in plaats van een stilzwijgend lege uitleg. + */ +export const LEVELING_REASON_KEY = { + CALENDAR_MISMATCH: 'resource.leveling.reason.calendarMismatch', + INSUFFICIENT_CAPACITY:'resource.leveling.reason.insufficientCapacity', + INTRINSIC_OVERRUN: 'resource.leveling.reason.intrinsicOverrun', + CEILING_TOO_TIGHT: 'resource.leveling.reason.ceilingTooTight', + CEILING_UNREACHABLE: 'resource.leveling.reason.ceilingUnreachable', + NO_WINDOW_IN_HORIZON: 'resource.leveling.reason.noWindowInHorizon', + RESIDUAL_FULL: 'resource.leveling.reason.residualFull', +} as const satisfies Record; + +/** `DistributionBlockReason` → i18n-sleutel; zelfde `satisfies`-poort. */ +export const DISTRIBUTION_BLOCK_KEY = { + UNCOUNTED_DOCUMENT: 'resource.distribution.blocked.uncounted', + MATERIAL_ITEM: 'resource.distribution.blocked.material', + NO_DEMAND: 'resource.distribution.blocked.noDemand', +} as const satisfies Record; +``` + +`LevelingDialog.tsx`: vervang de if/else-keten door `t(LEVELING_REASON_KEY[reason])`, met behoud van +de bestaande `INTRINSIC_OVERRUN`-tak (die heeft interpolatie: resource/peak/capacity — houd dáárvoor +de bestaande sleutel `resource.leveling.intrinsicOverrun` en laat de mapping ernaar wijzen, of +verplaats hem naar `reason.intrinsicOverrun` en werk de aanroep bij; kies één en schrijf op welke). + +- [ ] **Step 2: De NL-bronsleutels** + +`src/i18n/locales/nl/common.json`. Uitbreiding van `resource.leveling.reason`: + +```json +"reason": { + "calendarMismatch": "De resource werkt niet op alle dagen die deze taak nodig heeft — verschuiven lost dit niet op.", + "insufficientCapacity": "Onvoldoende vrije capaciteit binnen de speling om dit conflict op te lossen.", + "ceilingTooTight": "Het uitloop-plafond laat te weinig ruimte: binnen dat aantal werkdagen is geen vrij venster.", + "ceilingUnreachable": "Uitloop geven helpt hier niet — een deadline of constraint houdt deze taak op zijn plek.", + "noWindowInHorizon": "Binnen de doorzochte periode is geen vrij venster gevonden; verderop is het onbekend.", + "residualFull": "De eigen inzet had ruimte, maar de restcapaciteit van de bibliotheek is op — andere projecten bezetten hem." +} +``` + +Nieuwe sectie `resource.distribution` (volledige lijst; het paneel gebruikt uitsluitend deze): + +| sleutel | NL | +|---|---| +| `open` | Verdelen… | +| `title` | Verdelen over projecten | +| `back` | Terug naar bezetting | +| `subtitle` | {{item}} — restcapaciteit verdelen over de geopende projecten | +| `blocked.uncounted` | Eerst doorrekenen: {{docs}} is nog niet berekend, dus er valt niets tegen te verdelen. | +| `blocked.material` | Dit is een materiaal-item; verdelen werkt alleen voor mensen en materieel. | +| `blocked.noDemand` | Geen van de projecten boekt hier daadwerkelijk werk op. | +| `tool.title` | Gereedschap | +| `tool.allowSplits` | Onderbrekingen toestaan | +| `tool.allowSplitsHint` | Het werk mag pauzedagen krijgen in plaats van in één stuk op te schuiven. In MS Project heet dit "Leveling can create splits in remaining work". | +| `tool.price_one` | kost {{count}} werkdag uitloop | +| `tool.price_other` | kost {{count}} werkdagen uitloop | +| `tool.priceNone` | kost geen uitloop | +| `tool.priceUnknown` | prijs onbekend — druk op Herbereken | +| `rank.title` | Wie wordt het meest ontzien? | +| `rank.hint` | Bovenaan wijkt het minst. Sleep om de volgorde te veranderen. | +| `rank.float` | speling: {{days}} | +| `rank.cost_one` | alleen dit project laten opschuiven kost {{count}} werkdag | +| `rank.cost_other` | alleen dit project laten opschuiven kost {{count}} werkdagen | +| `rank.costNone` | alleen dit project laten opschuiven kost geen uitloop | +| `rank.moveUp` | Naar boven | +| `rank.moveDown` | Naar beneden | +| `strip.pin` | Vastzetten | +| `strip.pinned` | Vastgezet — dit project wijkt niet en houdt ook zijn werkdagen | +| `strip.pinnedRecorded` | Vastgezet: datums zoals opgeslagen | +| `strip.cannotMove` | Kan niet wijken — al het werk hier staat vast | +| `strip.fixedLoad` | Vaste last | +| `strip.ceiling` | Maximale uitloop van de einddatum | +| `strip.ceilingUnlimited` | onbegrensd | +| `strip.ceilingDays_one` | plafond {{count}} werkdag | +| `strip.ceilingDays_other` | plafond {{count}} werkdagen | +| `strip.endUnchanged` | eind ongewijzigd | +| `strip.endShift_one` | eind +{{count}} dag | +| `strip.endShift_other` | eind +{{count}} dagen | +| `strip.requestedVsAchievable` | gevraagd {{requested}}, dichtst haalbare {{achievable}} | +| `strip.handleLabel` | Uitloop-plafond voor {{doc}} | +| `compute.recalculate` | Herbereken | +| `compute.auto` | Verdeel automatisch | +| `compute.busy` | Bezig met verdelen… | +| `compute.pressRecompute` | — druk op Herbereken | +| `compute.degraded` | Dit is een groot overzicht; het rekent alleen wanneer je op Herbereken drukt. | +| `stale.rank` | De volgorde is gewijzigd — herbereken om het effect te zien. | +| `stale.ceiling` | Een plafond is gewijzigd — herbereken om het effect te zien. | +| `stale.pin` | Een vastzetting is gewijzigd — herbereken om het effect te zien. | +| `stale.tool` | Het gereedschap is gewijzigd — herbereken om het effect te zien. | +| `stale.edited` | Er is iets gewijzigd in {{docs}} — dit voorstel is niet meer actueel. | +| `stale.documents` | De geopende projecten zijn veranderd — dit voorstel is niet meer actueel. | +| `shortfall.title` | Blijft over | +| `shortfall.doc` | {{doc}}: {{count}} taak past niet | +| `shortfall.doc_other` | {{doc}}: {{count}} taken passen niet | +| `preview.before` | Nu | +| `preview.after` | Na verdelen | +| `preview.capacity` | Capaciteit | +| `apply` | Toepassen | +| `discard` | Verwerpen | +| `applyBlockedShortfall` | Los eerst het tekort op — of zet een plafond ruimer, of laat onderbrekingen toe. | +| `applyBlockedNothing` | Er valt niets toe te passen: dit voorstel verandert niets. | +| `applied_one` | Toegepast in {{count}} project. | +| `applied_other` | Toegepast in {{count}} projecten. | +| `undoAll` | Alles terugdraaien | +| `undoneAll` | Alles is teruggedraaid. | +| `undonePartial` | Teruggedraaid, behalve {{docs}} — daar is intussen in gewerkt. | +| `selectHint` | Kies een bibliotheekitem met een conflict om te gaan verdelen. | + +- [ ] **Step 3: De dertien vertalingen** + +Vertaal élke sleutel naar `en, fr, de, es, zh, it, pt, pl, tr, ar, ja, ko, fa`, met de juiste +**CLDR-pluralcategorieën** per taal (CLAUDE.md, i18n-paragraaf): +- `zh`/`ja`/`ko` kennen géén `one` ⇒ **geen** `_one`-variant; +- `pl` heeft ook `_few` en `_many`; +- `es`/`fr`/`it`/`pt` hebben ook `_many` (in dit project gelijk aan `_other`, want `{{count}}` wordt + altijd in cijfers weergegeven). +De pluralsleutels in de tabel hierboven zijn: `tool.price`, `rank.cost`, `strip.ceilingDays`, +`strip.endShift`, `shortfall.doc`, `applied`. + +- [ ] **Step 4: Draai** + +```bash +npm run verify:i18n; echo "exit: $?" +bash tests/library/run.sh 2>&1 | tail -5; echo "exit: $?" +npx tsc --noEmit -p tsconfig.json; echo "exit: $?" +``` + +Verwacht: exit 0, 0, 0. `verify:i18n` is hier de hoofdpoort; hij rekent met CLDR-categorieën, dus een +`_one` in `zh` is net zo goed fout als een ontbrekende `_many` in `pl`. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/levelingReasonKey.ts src/components/dialogs/LevelingDialog.tsx src/i18n/locales tests/ +git commit -m "feat(i18n): reden-taxonomie en verdeelpaneelteksten in veertien talen (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 8: het paneel — ingang, ui-state en de voorstel-berekening + +**Spec §7** (plek in de UI) en **§3.4** (discrete rekenmomenten, bezig-toestand, schaal-degradatie). + +**KEUZE VAN DIT PLAN — geen modal, maar een drill-down.** Spec §7 zegt "paneel/dialoog". Dit plan +kiest **een drill-down binnen `ResourceOccupancyView`**: het paneel vervangt de tabel + het histogram +zolang het open staat, met een "← Terug naar bezetting"-knop. Redenen: de flow is te hoog voor de +gedeelde `Dialog` (histogram + N fasestroken + rangordelijst + knoppenbalk), de gedeelde `Dialog` is in +dit product voor korte bevestigingen, en `hasBlockingDialogOpen` zou een modaal paneel met álle andere +dialogen laten botsen terwijl het hier juist een werkoppervlak is. De Resources-tab blijft dus zijn +eigen schakelaar houden en het paneel is een derde niveau binnen de Bezettingsweergave. + +**Files:** +- Modify: `src/state/slices/types.ts` (`UIState` + de `DistributionUiState`-vorm) +- Modify: `src/state/slices/uiSlice.ts` (default) +- Create: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Create: `src/components/panels/DistributionPanel/useDistributionProposal.ts` +- Modify: `src/components/panels/ResourceOccupancyView.tsx` (de "Verdelen…"-ingang + de drill-down) +- Create: `tests/browser/leveling-distribution.spec.ts` + +- [ ] **Step 1: Schrijf de falende browsertest** + +`tests/browser/leveling-distribution.spec.ts`, in de stijl van +`tests/browser/resource-panel-effects.spec.ts` (echte klikken; `window.__OPS__` uitsluitend voor de +fixture en voor state-asserties): + +```ts +// leveling-distribution.spec.ts — B1c-plan3, het verdeelpaneel. Fixture: twee documenten, dezelfde +// bibliotheek, één poolitem met een echt conflict. `window.__OPS__` zet de fixture; élke handeling +// hieronder is een ECHTE klik/toets. + +test('verdeelpaneel: openen vanuit de conflictregel en een voorstel rekenen', async ({ page }) => { + await seedTwoConflictingDocuments(page); + await page.getByRole('button', { name: /^(Occupancy|Bezetting)$/ }).click(); + // De conflictregel draagt de ingang. + await page.locator('[data-ops-occupancy-row]').first().getByRole('button', { name: /Verdelen|Distribute/ }).click(); + await expect(page.locator('[data-ops-distribution-panel]')).toBeVisible(); + // Het voorstel rekent bij het openen (spec §3.4, eerste discrete moment) en levert per document + // een strook. + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + // Terug laat het overzicht weer zien en ruimt de paneelstate op. + await page.getByRole('button', { name: /Terug naar bezetting|Back to occupancy/ }).click(); + await expect(page.locator('[data-ops-distribution-panel]')).toHaveCount(0); +}); + +test('verdeelpaneel: de gereedschapsschakelaar laat het voorstel vervallen met reden', async ({ page }) => { + /* … open het paneel … */ + await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow splits/ }).click(); + await expect(page.locator('[data-ops-distribution-stale]')).toBeVisible(); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); +}); + +test('verdeelpaneel: een geblokkeerd voorstel legt uit waarom en biedt geen Toepassen', async ({ page }) => { + await seedUncountedDocument(page); // een document met een relatiecyclus ⇒ counted: false + /* … open het paneel … */ + await expect(page.locator('[data-ops-distribution-blocked]')).toContainText(/doorrekenen|calculate/i); + await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); +}); +``` + +Draai `npm run test:browser` en verwacht rood (het paneel bestaat nog niet). + +- [ ] **Step 2: De ui-state** + +`src/state/slices/types.ts`, in `UIState`: + +```ts + /** session — het B1c-verdeelpaneel (spec §6/§6a). App-globaal, NIET per document: het paneel kijkt + * naar N documenten tegelijk, en pin/plafond/rangorde zijn de invoer van één verdeelsessie, geen + * projectdata (zie de scope-bak in het implementatieplan). `null` ⇒ het paneel is dicht. + * + * Bewust ZONDER het voorstel zelf: dat is afgeleide data en woont in de componentstate van het + * paneel — zelfde filosofie als `DerivedKey` in het documentcontract. Wat hier staat is + * uitsluitend wat een gebruiker heeft INGESTELD, plus het toegepast-record (dat een paneel-hersluiting + * moet overleven zodat "alles terugdraaien" bereikbaar blijft). */ + levelingDistribution: DistributionUiState | null; +``` + +```ts +export interface DistributionUiState { + companyId: string; + libraryItemId: string; + /** "Onderbrekingen toestaan" (spec §4 stap 0). */ + allowSplits: boolean; + /** docIds in rangorde; nr. 1 wordt het meest ontzien. Gevuld bij het openen op float-volgorde + * (spec §4 stap 1) en daarna handmatig te herschikken. */ + order: string[]; + pinned: Record; + /** docId → maximale uitloop in werkdagen; `null` = onbegrensd. */ + ceilings: Record; + applied: DistributionApplyRecord | null; +} +``` + +Default in `uiSlice`: `levelingDistribution: null`. **Niet** in `settingsRegistry` — dit is +sessie-state, geen instelling. + +- [ ] **Step 3: De voorstel-hook** + +`useDistributionProposal.ts` — de discrete rekenmomenten, de bezig-toestand en de degradatie op één +plek, zodat het paneel zelf alleen rendert: + +```ts +/** + * Berekent het verdelingsvoorstel op de DISCRETE momenten uit spec §3.4 — nooit per sleep-pixel. + * Rekenmomenten: paneel openen, "Verdeel automatisch"/"Herbereken", loslaten van een handle of een + * toetsenbord-stap, en een pin-, rangorde- of gereedschapswijziging. Alles daartussen laat het + * bestaande voorstel staan en zet alleen `staleReason`. + * + * BEZIG-TOESTAND, en waarom die een tick nodig heeft: `computeDistribution` is synchroon en kan tot + * ~2 s duren (spec §3.4-budget: ≤ ~0,5 s per document, vier documenten). Zetten we de bezig-vlag en + * rekenen we in dezelfde tick, dan schildert de browser die vlag nooit. Daarom: vlag zetten, + * `setTimeout(..., 0)`, dán rekenen. Eén in-flight run tegelijk; een nieuw verzoek tijdens een run + * wordt in de wacht gezet en na afloop precies één keer uitgevoerd. + * + * SCHAAL-DEGRADATIE (spec §3.4): boven de ondersteunde schaal — meer dan `MAX_TASKS_AUTO` taken in + * één deelnemend document of meer dan `MAX_BOOKING_TASKS_AUTO` boekende taken op dit poolitem — + * rekent een handle-loslating of toetsenbord-stap NIET meer automatisch; het effectlabel toont dan + * `compute.pressRecompute` en de knop "Herbereken" is de enige weg. Dat ís de gedefinieerde + * degradatie; niets bevriest stil. + */ +const MAX_TASKS_AUTO = 1000; // spec §3.4, "ondersteunde schaal" +const MAX_BOOKING_TASKS_AUTO = 40; // idem +``` + +**De invoer die de hook bouwt — dit is de scherpste val van deze taak.** `DistributionDocInput` +erft van `OccupancyDocInput`, en `ResourceOccupancyView` levert dáár bewust de **bibliotheek-snit** +aan (`librarySlice`, §7-cache). Voor de verdeler mag dat **niet**: `defaultLevelRun` geeft +`doc.resources`/`doc.assignments` rechtstreeks aan `levelResources`, en die moet de VOLLEDIGE +projectinzet zien — anders lost B1c een bibliotheekconflict op door een projectconflict te maken +(spec §4 stap 2, "de bestaande per-resource-toets tegen de eigen projectinzet"). Bouw de invoer voor +het paneel dus uit de **volledige** payloadvelden: + +```ts + const inputs: DistributionDocInput[] = payloads.map(({ id, payload }, i) => ({ + docId: id, + title: displayDocumentTitle(rawTitles[i], ordinals[i], untitledLabel), + scheduleStale: payload.scheduleStale, + companyId: payload.project.companyId ?? null, + // VOLLEDIG, niet de bibliotheek-snit — zie hierboven. De extra kosten vallen op een DISCREET + // rekenmoment, niet per render, dus de §7-cache van het bezettingsoverzicht is hier niet nodig. + resources: payload.resources, + assignments: payload.assignments, + tasks: payload.tasks, + calendar: payload.calendar, + calendars: payload.calendars, + solveInput: { tasks: payload.tasks, sequences: payload.sequences, dataDate: payload.project.statusDate, + progressMode: payload.project.progressMode, schedulingOptions: payload.project.schedulingOptions }, + levelInput: { tasks: payload.tasks, sequences: payload.sequences, dataDate: payload.project.statusDate, + progressMode: payload.project.progressMode, schedulingOptions: payload.project.schedulingOptions }, + rank: order.indexOf(id) + 1, + pinned: pinned[id] === true, + datesAsRecorded: payload.datesAsRecorded, + ceilingWorkdays: ceilings[id] ?? null, + })); +``` + +De startvolgorde bij het openen is **float-gesorteerd** (spec §4 stap 1: "float van een document = de +kleinste totale float over zijn boekende taken op dit poolitem, uit de counted cijfers"). Bereken die +uit de doorgerekende taken van elk document, met de scope-taken uit `scopeTaskIdsFor` — exporteer die +helper uit `distribute.ts` in plaats van hem na te bouwen. + +- [ ] **Step 4: Het paneel-skelet en de ingang** + +`DistributionPanel.tsx` rendert, van boven naar beneden (spec §7): +1. Kop met "← Terug naar bezetting", de itemnaam en `subtitle`. +2. Een blokkade-blok (`data-ops-distribution-blocked`) mét `t(DISTRIBUTION_BLOCK_KEY[reason])` en de + documenttitels erbij — of, zonder blokkade: +3. De gereedschapsschakelaar (`role="switch"`, `aria-checked`) met het prijskaartje (taak 13 vult de + prijs; hier tonen we voorlopig `tool.priceUnknown`). +4. De rangordelijst (taak 13 voegt de kostenlabels toe; hier: titel, hint, per regel de documentnaam, + zijn float, en "naar boven"/"naar beneden"-knoppen — de sleepbaarheid is taak 10's terrein maar de + knoppen zijn de toegankelijke basisbediening en blijven staan). +5. Een plaatshouder-`
` (taak 9 vult 'm). +6. Een plaatshouder voor het voor/na-histogram (taak 11). +7. Een `stale`-strook (`data-ops-distribution-stale`) met de reden, en de knoppenbalk + "Verdeel automatisch" / "Herbereken" / "Toepassen" / "Verwerpen" (taak 12 bedraadt Toepassen). + +`ResourceOccupancyView.tsx`: in de conflictbadge-cel een knop `t('resource.distribution.open')` die +`setUI({ levelingDistribution: { companyId, libraryItemId: row.libraryItemId, allowSplits: false, +order: , pinned: {}, ceilings: {}, applied: null } })` zet. Toon de knop **alleen** +bij `hasConflict` (spec §7: "vanuit een conflictregel"). Wanneer `ui.levelingDistribution` gezet is +én bij déze `companyId` hoort, rendert de view het paneel **in plaats van** de tabel en het +histogram; hoort het bij een andere bibliotheek, dan negeert de view het (dat kan alleen ontstaan als +het actieve project van bibliotheek wisselt — taak 12 ruimt dat op). + +- [ ] **Step 5: Draai** + +```bash +npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +npx tsc --noEmit -p tsconfig.json; echo "exit: $?" +npm run lint; echo "exit: $?" +``` + +Verwacht: exit 0 ×3. Bij een falende browsertest: `test-results/` bevat screenshots en traces, +`playwright-report/` het HTML-rapport. + +- [ ] **Step 6: Commit** + +```bash +git add src/state/slices/types.ts src/state/slices/uiSlice.ts src/components/panels/ \ + tests/browser/leveling-distribution.spec.ts +git commit -m "feat(library): verdeelpaneel — ingang vanuit de conflictregel, voorstel op discrete momenten (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 9: de fasestroken — boeking, vaste last, pin en de plafond-handle (toetsenbord) + +**Spec §6** in zijn geheel, minus het pointer-slepen (taak 10). + +> *"Per document één strook met zijn boeking op het poolitem: de vereniging van álle taken van dat +> document die (via een gestempelde resource) op dit poolitem boeken — mogelijk niet-aaneengesloten; +> interne gaten (bestaande splits of ingevoegde pauzes) worden als gaten getekend."* +> *"De handle zit aan het rechtereinde van de strook en zet het plafond … het label bij de handle +> toont altijd het einddatum-effect … Een gestippelde staart toont toegestaan maar niet benut."* +> *"Bediening: pointer-slepen én toetsenbord (`role="slider"`, pijltjes = één werkdag, Home/End, +> `aria-valuetext` met plafond, benutting en einddatum-effect); pin met `aria-pressed`. Snappen op +> hele werkdagen."* + +**Files:** +- Create: `src/components/panels/occupancyAxis.ts` (gedeelde tijdas, uit `ResourceOccupancyView`) +- Modify: `src/components/panels/ResourceOccupancyView.tsx` (de as-opbouw eruit trekken) +- Create: `src/components/panels/DistributionPanel/PhaseStrip.tsx` +- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Modify: `tests/browser/leveling-distribution.spec.ts` + +- [ ] **Step 1: Schrijf de falende browsertest** + +```ts +test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ({ page }) => { + /* … open het paneel op de conflictrij … */ + const strip = page.locator('[data-ops-distribution-strip]').first(); + + // De pin is een toggle-knop met aria-pressed (spec §6). + const pin = strip.getByRole('button', { name: /Vastzetten|Pin/ }); + await expect(pin).toHaveAttribute('aria-pressed', 'false'); + await pin.click(); + await expect(pin).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => page.evaluate(() => Object.values( + window.__OPS__!.store.getState().ui.levelingDistribution!.pinned).filter(Boolean).length)).toBe(1); + // Een gepind document doet niet mee ÉN telt als vaste last (spec §4/§6). + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(strip).toHaveAttribute('data-ops-distribution-pinned', 'true'); + + // De handle is een slider: pijltjes = één werkdag, Home = 0, End = onbegrensd. + await pin.click(); // pin weer los + const handle = strip.getByRole('slider'); + await handle.focus(); + await handle.press('ArrowRight'); + await expect.poll(() => page.evaluate(() => window.__OPS__!.store.getState() + .ui.levelingDistribution!.ceilings[Object.keys(window.__OPS__!.store.getState().ui.levelingDistribution!.ceilings)[0]])) + .toBe(1); + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await handle.press('End'); + // `aria-valuetext` draagt plafond, benutting én einddatum-effect (spec §6). + await expect(handle).toHaveAttribute('aria-valuetext', /onbegrensd|unlimited/i); +}); + +test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', async ({ page }) => { + /* … plafond op 3 zetten, herberekenen … */ + // Benut < plafond ⇒ "gevraagd 3, dichtst haalbare 1" plus het effectlabel "eind +1 dag". + await expect(strip.locator('[data-ops-distribution-effect]')).toContainText(/eind \+1 dag|end \+1 day/i); + await expect(strip.locator('[data-ops-distribution-achievable]')).toBeVisible(); +}); +``` + +- [ ] **Step 2: Trek de tijdas uit `ResourceOccupancyView`** + +`src/components/panels/occupancyAxis.ts` — puur, geen React: + +```ts +/** + * De gedeelde tijdas van het bezettingsoverzicht en het verdeelpaneel: segmentindeling met + * gatcompressie, dagbreedte en x-posities per ISO-dag. + * + * Waarom gedeeld: het histogram (§5a), de fasestroken (§6) en de voor/na-preview (§7) MOETEN op + * dezelfde as staan, anders lezen ze als drie verschillende tijdlijnen boven elkaar. Deze code stond + * ingebakken in `OccupancyHistogram`s memo; hier staat hij één keer. + */ +export interface OccupancyAxis { + segments: Array<{ days: string[]; x0: number }>; + breaks: number[]; + dayWidth: number; width: number; + xOf(iso: string): number | null; // null ⇒ die dag valt in een ingeklapt gat +} +export function buildOccupancyAxis(bookedDays: string[], opts?: { targetWidth?: number }): OccupancyAxis | null +``` + +Verplaats de bestaande logica (ranges → gatcompressie op `GAP_COMPRESS_DAYS` → `dayWidth` → +`segments`/`breaks`) letterlijk hierheen en laat `OccupancyHistogram` hem aanroepen. **Geen +gedragswijziging** — de bestaande browsertests van het histogram (`gantt-histogram.spec.ts` en de +occupancy-asserts) moeten groen blijven zonder aanpassing; wordt er iets rood, dan is de verplaatsing +niet letterlijk geweest. + +- [ ] **Step 3: Implementeer `PhaseStrip`** + +Eén SVG-rij per document, op de gedeelde as: +- **De vaste last** als achtergrondband (uit `proposal.fixedLoadByDay`) — dat is wat er sowieso al + ligt, ongeacht dit document. +- **De boeking van dit document** als gevulde blokken over de dagen uit zijn `dailyLoad`; dagen + zonder boeking binnen de spanne (bestaande splits én ingevoegde pauzes) blijven leeg — precies wat + de Gantt-renderer met een gesplitste balk doet. +- **De gestippelde staart**: van het einde van de benutte uitloop tot het plafond ("toegestaan maar + niet benut", spec §6). +- **De handle** aan het rechtereinde: + +```tsx +
+``` + +`onHandleKey`: `ArrowRight`/`ArrowUp` = +1 werkdag, `ArrowLeft`/`ArrowDown` = −1 (ondergrens 0), +`Home` = 0, `End` = onbegrensd (`null`), `PageUp`/`PageDown` = ±5. Elke toets is een **discreet +rekenmoment** (spec §3.4) — behalve in de gedegradeerde modus, waar hij alleen de waarde zet en het +effectlabel `compute.pressRecompute` toont. + +`CEILING_MAX_WORKDAYS`: kies een eindige bovengrens voor `aria-valuemax` (bijvoorbeeld 60 werkdagen) +en documenteer dat `null`/`End` "onbegrensd" betekent en als `valuemax` gerapporteerd wordt — een +`role="slider"` zonder eindige max is voor hulpsoftware onbruikbaar. + +**De pin**: een ` +
+ )} +``` + +`onUndoAll` roept `undoDistribution(applied)` aan, wist `applied`, en toont bij +`skippedDocIds.length > 0` de tekst `undonePartial` met die documenttitels — via het gewone +meldingenkanaal (`notify`, severity `warning`), want dát is een informatieve afloop en geen tweede +terugweg. + +Push ná een geslaagd Toepassen óók een gewone informatieve melding (`notify`, `severity: 'info'`, +`messageKey: 'resource.distribution.applied'`) — spec §5: "De melding blijft puur informatief." + +- [ ] **Step 3: Implementeer de invalidatie (spec §6a)** + +In `useDistributionProposal`: bewaar bij elke geslaagde berekening +`fingerprints: Record` via `documentFingerprint(...)` (taak 4). Op elke render een +goedkope vergelijking; verschilt er één, dan `staleReason = 'edited'` met de titels van de +veranderde documenten. Verder: + +| gebeurtenis | reden | +|---|---| +| rangorde gewijzigd | `stale.rank` | +| plafond gewijzigd | `stale.ceiling` | +| pin gewijzigd | `stale.pin` | +| gereedschapsschakelaar | `stale.tool` | +| mutatie in een betrokken document | `stale.edited` | +| document geopend/gesloten dat op dit poolitem boekt | `stale.documents` | +| documentwissel | `stale.documents` | + +De eerste vier zijn **gewone hertriggering**: het paneel rekent er meteen op door (behalve in de +gedegradeerde modus, waar hij alleen de reden toont). De laatste drie zetten `staleReason` en +**rekenen niet** vanzelf — dan is er iets buiten het paneel gebeurd en beslist de gebruiker. + +`resetDocumentScopedUI` (documentSlice, regel ~56): + +```ts + // B1c (spec §6a): het verdeelpaneel kijkt naar een MOMENTOPNAME van meerdere documenten. Een + // documentwissel of een gesloten document maakt zijn tune-state (rangorde/pins/plafonds op docId) + // en zijn toegepast-record onbetrouwbaar: het record verwijst naar undo-diepten van documenten die + // er misschien niet meer zijn. Sluit het paneel; het overzicht eronder blijft gewoon staan en de + // gebruiker opent het opnieuw op de conflictregel. + s.ui.levelingDistribution = null; +``` + +**AFWIJKING — VOORLEGGEN:** spec §6a zegt dat het voorstel bij een documentwissel *vervalt*, niet dat +het paneel *sluit*. Dit plan sluit het, omdat de tune-state op docId's staat en de spec verder geen +gedrag voor "paneel open, ander document actief" beschrijft. Wil de eigenaar dat het paneel open +blijft met een vervallen voorstel, dan is dat één regel minder hier plus een `staleReason`-tak — meld +het als het zo hoort. + +- [ ] **Step 4: Draai en commit** + +```bash +npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +bash tests/library/run.sh 2>&1 | tail -8; echo "exit: $?" +bash tests/planning/run.sh 2>&1 | tail -5; echo "exit: $?" +``` + +```bash +git add src/components/panels/DistributionPanel/ src/state/slices/documentSlice.ts tests/ +git commit -m "feat(library): verdeling toepassen vanuit het paneel, met een permanente terugweg (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 13: de kostenlabels en de prijskaartjes van de gereedschapsschakelaar + +**Spec §4 stap 0/1** en **§3.4**: *"Naast de schakelaar het doorgerekende prijskaartje van beide +standen — berekend bij paneelopening en bij invalidatie, niet live."* en *"De kostenlabels in de +rangordelijst ('alleen dit project laten opschuiven kost +N') zijn elk óók een volledige run: ze +worden alleen onder de ondersteunde schaal bij paneelopening berekend en gecachet tot invalidatie; +daarboven on-demand per knop."* + +De kern heeft hier **geen nieuwe API** voor nodig (dat staat al in het moduleblok van `distribute.ts`): +een kostenlabel is `computeDistribution` opnieuw draaien met een andere rangorde, een prijskaartje is +hetzelfde met de andere `allowSplits`-stand. + +**Files:** +- Modify: `src/components/panels/DistributionPanel/useDistributionProposal.ts` +- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Modify: `tests/browser/leveling-distribution.spec.ts` + +- [ ] **Step 1: Schrijf de falende browsertest** + +```ts +test('kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel', async ({ page }) => { + /* … open het paneel op een klein project (onder de ondersteunde schaal) … */ + // Elk rangorde-regeltje draagt zijn kostenlabel na de eerste berekening. + await expect(page.locator('[data-ops-distribution-rank-row] [data-ops-distribution-cost]')) + .toHaveCount(2); + // Beide standen van de schakelaar dragen een prijs. + await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/werkdag|no overrun|geen uitloop/i); + // Een bewerking laat ze vervallen: gecachet tot invalidatie, niet live (spec §3.4). + await editATaskDurationThroughTheUi(page); + await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); +}); + +test('boven de ondersteunde schaal rekent het paneel alleen op de knop', async ({ page }) => { + await seedLargeProject(page, { tasks: 1200 }); // > MAX_TASKS_AUTO + /* … open het paneel … */ + await expect(page.locator('[data-ops-distribution-degraded]')).toBeVisible(); + // Kostenlabels worden dan NIET vooraf gerekend. + await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); + // En een handle-stap rekent niet vanzelf. + await handle.focus(); await handle.press('ArrowRight'); + await expect(page.locator('[data-ops-distribution-effect]').first()).toContainText(/Herbereken|Recalculate/); +}); +``` + +- [ ] **Step 2: Implementeer** + +```ts +/** + * De kostenlabels (spec §4 stap 1). "Alleen dit project laten opschuiven kost +N werkdagen" is: + * draai `computeDistribution` opnieuw met dít document op rang 1 en alle andere deelnemers gepind, + * en lees zijn `endShiftWorkdays`. Dat is per label een VOLLEDIGE run — vandaar het cachebeleid van + * §3.4: alleen onder de ondersteunde schaal, alleen bij paneelopening en na een invalidatie, en + * daarna gecachet tot het voorstel vervalt. Boven die schaal blijft het label leeg met + * `compute.pressRecompute`; de gebruiker vraagt ze dan expliciet op. + * + * Zelfde vorm voor het prijskaartje van de gereedschapsschakelaar: één run met `allowSplits: false` + * en één met `true`, en de prijs is de grootste `endShiftWorkdays` over de deelnemers (de + * "uitschieter" die spec §4 stap 2 minimaal wil houden — dát is het getal waar de planner op stuurt, + * niet de som). + */ +``` + +Draai de labels **na** het hoofdvoorstel, in dezelfde bezig-toestand, en breek af zodra er intussen +een invalidatie is opgetreden (een label van een vervallen voorstel is misleidender dan geen label). + +- [ ] **Step 3: Draai en commit** + +```bash +npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +``` + +```bash +git add src/components/panels/DistributionPanel/ tests/browser/leveling-distribution.spec.ts +git commit -m "feat(library): kostenlabels per project en prijskaartjes per gereedschapsstand (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 14: de gebruikersgids + `docs/library.md` + +**Spec §8**: *"Gebruikersgids (minimaal nl+en) met manifest-entry — de schakelaar 'onderbrekingen' met +het MS Project-equivalent erbij, en de pin/plafond-semantiek in gewone taal (einddatum vs. +werkdagen)."* + +**Files:** +- Create: `public/docs/nl/gids-verdelen-restcapaciteit.md` +- Create: `public/docs/en/gids-verdelen-restcapaciteit.md` +- Modify: `public/docs/manifest.json` +- Modify: `docs/library.md` + +- [ ] **Step 1: Schrijf de gids** + +Model: `public/docs/nl/gids-bezettingsoverzicht.md` — zelfde toon (tweede persoon, gewone taal, geen +agenttaal), zelfde opbouw (`#`-kop, een "Wat je hier leert"-lijst, dan secties met `##`). **Blijf +binnen de parser-subset** van `src/utils/miniMarkdown.tsx`: koppen `#`/`##`/`###`, paragrafen, +enkelvoudige lijsten, `**vet**`/`*cursief*`/`` `code` ``, codeblokken, afbeeldingen, en uitsluitend +`docs://`- en `examples://`-links. **Geen tabellen, geen blockquotes, geen h4, geen rauwe HTML** — +`npm run verify:docs` faalt daarop. + +Secties die er hoe dan ook in horen: +- **Wanneer je dit gebruikt** — je ziet in het bezettingsoverzicht een rode conflictbadge, en je wilt + hem wegwerken zonder één project willekeurig op te offeren. +- **Het paneel openen** — vanaf de conflictregel, met de link naar + [Bezettingsoverzicht](docs://gids-bezettingsoverzicht). +- **Wie wordt het meest ontzien** — de rangorde, wat "speling" hier betekent, en het kostenlabel. +- **Onderbrekingen toestaan** — uit = het werk schuift in één stuk op; aan = het werk mag pauzedagen + krijgen. Noem het MS Project-equivalent letterlijk ("Leveling can create splits in remaining work"). + Vertel dat de pauze altijd een hele werkdag is, en dat werk dat al begonnen is nooit onderbroken + wordt (dat wijkt alleen via uitloop). +- **Vastzetten of een plafond** — het verschil in gewone taal: een **plafond** zegt "de einddatum mag + hooguit zoveel werkdagen opschuiven", een **pin** zegt "raak dit project helemaal niet aan, ook de + dagen niet". Plafond 0 is niet hetzelfde als een pin: dan mag de speling nog steeds benut worden. +- **Waarom het soms niet lukt** — de zeven redenen in gewone taal, en wat je eraan kunt doen. Noem + ook dat een project met "datums zoals opgeslagen" nooit meedoet, met een link naar + [Datums zoals opgeslagen](docs://datums-zoals-opgeslagen). +- **Toepassen en terugdraaien** — dat het in álle projecten tegelijk schrijft, dat elk project een + gewone ongedaan-maken-stap krijgt, en dat "Alles terugdraaien" in de strook blijft staan. +- **De grens** — alleen documenten die in dít programma geopend zijn (zelfde beperking als het + bezettingsoverzicht). + +De EN-versie is een echte vertaling, geen kopie. `npm run verify:docs` eist `nl` en `en`; de overige +twaalf talen volgen later en worden alleen gevalideerd zodra ze bestaan. + +- [ ] **Step 2: De manifest-entry** + +In `public/docs/manifest.json`, naast `gids-bezettingsoverzicht`, met **alle veertien titels** +(spiegel het formaat van die entry letterlijk): + +```json + { + "id": "gids-verdelen-restcapaciteit", + "title": { + "nl": "Verdelen over projecten", + "en": "Distributing across projects", + "de": "…", "fr": "…", "es": "…", "zh": "…", "it": "…", "pt": "…", + "pl": "…", "tr": "…", "ar": "…", "ja": "…", "ko": "…", "fa": "…" + }, + "layer": "gidsen", + "cluster": "bezettingsoverzicht" + }, +``` + +De `cluster` is bewust dezelfde als het bezettingsoverzicht: het is dezelfde werkstroom, in twee +stappen (zien, dan oplossen). + +- [ ] **Step 3: `docs/library.md`** + +Voeg onder de bestaande B1c-alinea (die etappe 2 achterliet met "het schrijfpad en het paneel bestaan +nog niet") één blok toe: dat het paneel er nu is, waar het zit, wat pin en plafond betekenen, dat +Toepassen in meerdere documenten schrijft met per document een gewone undo-stap, en dat de tune-state +bij de verdeelsessie hoort en geen projecteigenschap is (met de verwijzing naar de scope-bak van dit +plan). Verwijder de "bestaan nog niet"-zin. + +- [ ] **Step 4: Draai en commit** + +```bash +npm run verify:docs; echo "exit: $?" +``` + +```bash +git add public/docs docs/library.md +git commit -m "docs: gids voor het verdelen over projecten (B1c) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 15: de volle poort + +**Files:** geen, tenzij een poort iets aanwijst. + +- [ ] **Step 1: De ene poort** + +```bash +npm run verify; echo "exit: $?" +``` + +Verwacht: exit 0. Dit draait typecheck (incl. `tests/` en `scripts/`), lint, alle vijf de suites, +voorbeelden, docs, i18n, cycles, store-grenzen en audit — exact wat CI, de release-gate en de +deploy-gate draaien. Oordeel **alleen op de exitcode**. + +Poorten die in dit plan bijzondere aandacht verdienen: +- **`verify:i18n`** — taak 7 voegt een grote sleutelset toe. Faalt deze poort, dan mist er een + CLDR-categorie: `zh`/`ja`/`ko` mogen geen `_one` hebben, `pl` heeft ook `_few`/`_many`, + `es`/`fr`/`it`/`pt` ook `_many`. +- **`verify:docs`** — taak 14. Elk manifest-id moet `nl` én `en` hebben, elke `docs://`-link moet + bestaan, en de inhoud moet binnen de miniMarkdown-subset blijven. +- **`verify:store-boundaries`** — `distribute.ts`, `applyDistribution.ts` en `proposalFingerprint.ts` + mogen **nooit** `useAppStore` of `appStoreContext` importeren. `scratchDocument.ts` mag wél + `createAppStoreContext` gebruiken (dat is de factory, niet de app-singleton) — controleer dat de + poort dat onderscheid maakt en meld het als hij dat niet doet. +- **`verify:cycles`** — de toegestane richting blijft `services/library` → `engine/scheduler`. + `state/runtime/scratchDocument` → `state/appStore` → alle slices is de nieuwe kant; loopt daar een + cyclus, zie taak 5 stap 4. +- **`verify:gantt-boundaries`** — het paneel tekent SVG in de DOM en hoort de Canvas-renderer-, + viewport- en pointergrenzen van de Gantt niet te raken. + +- [ ] **Step 2: De browserpoort** + +```bash +npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" +``` + +Verwacht: exit 0. Dekking die deze etappe moet hebben opgeleverd in +`tests/browser/leveling-distribution.spec.ts`: + +| flow | taak | +|---|---| +| paneel openen vanuit de conflictregel, twee stroken, terug | 8 | +| gereedschapsschakelaar ⇒ vervallen met reden ⇒ herberekenen | 8 | +| geblokkeerd voorstel: uitleg + Toepassen uit | 8 | +| pin met `aria-pressed`, plafond met pijltjes/Home/End, `aria-valuetext` | 9 | +| effectlabel toont het einddatum-effect en "dichtst haalbare" | 9 | +| slepen: snappen op werkdagen, doorlopen buiten het element, rekenen bij loslaten | 10 | +| rangorde slepen ⇒ vervallen met reden | 10 | +| voor/na-preview: conflictdagen weg in de na-stand | 11 | +| Toepassen in twee projecten + "alles terugdraaien" | 12 | +| Toepassen uitgeschakeld-met-reden bij een tekort | 12 | +| voorstel vervalt bij een echte bewerking en bij een documentwissel | 12 | +| kostenlabels/prijskaartjes + de gedegradeerde modus | 13 | + +Ontbreekt er een rij, dan is die taak niet af — vul hem aan vóór je deze stap afvinkt. + +- [ ] **Step 3: `git status --short` hoort schoon te zijn** + +--- + +## Zelfreview van dit plan (uitgevoerd) + +**Spec-dekking.** + +| spec-onderdeel | taak | +|---|---| +| §4, "Herkomst" — idempotent herschrijven, wissen raakt alleen leveling-gaten | 2 | +| §4, "Invalidatie" — tijdbasis-bewerking wist de leveling-gaten van díé taak | 3 | +| §4 stap 0 — gereedschapsschakelaar met prijskaartje per stand | 8 (schakelaar), 13 (prijs) | +| §4 stap 1 — rangordelijst, float-startvolgorde, kostenlabels | 8 (lijst + float), 10 (slepen), 13 (labels) | +| §4 stap 2 — "Verdeel automatisch" | 8 | +| §4 stap 3 — tunen, Toepassen uitgeschakeld-met-reden | 9 (tunen), 12 (knop) | +| §5 — actief document via het gewone pad | 6 | +| §5 — slapende documenten via een headless scratch-instantie | 5, 6 | +| §5 — de twee singleton-randen (extensie-emitter, `notify`) | 5 | +| §5 — scope-behoudend toepassen, derde plek (`applyLeveling`) | 2 | +| §5 — de doorrekening wordt gepersisteerd | 6 (geval 3) | +| §5 — ook in handmatige modus | 6 (geval 3) | +| §5 — de terugweg in het paneel, niet in een melding | 12 | +| §6 — fasestrook per document, gaten getekend | 9 | +| §6 — handle = plafond, label = einddatum-effect, gestippelde staart | 9 | +| §6 — pin bevriest einddatum én werkdagen, telt als vaste last | 9 (UI), 6 (schrijfpad slaat 'm over) | +| §6 — pointer én toetsenbord, `role="slider"`, `aria-pressed`, snappen | 9 (toetsenbord), 10 (pointer) | +| §6 — "gevraagd X, dichtst haalbare Y" | 9 | +| §6a — vingerafdruk, mutatieteller, `resetDocumentScopedUI` | 4, 12 | +| §3.4 — discrete rekenmomenten, bezig-toestand, schaal-degradatie | 8, 13 | +| §3.1 / §3.3a — uncounted blokkeert, #63 impliciet gepind | 8 (weergave), 6 (nooit beschreven) | +| §7 — plek in de UI, opbouw van boven naar beneden | 8, 9, 11, 12 | +| §8 — i18n veertien talen, CLDR-pluralen | 7 | +| §8 — gebruikersgids nl+en met manifest-entry | 14 | +| §9 "Store-niveau" — de volledige testlijst | 5, 6, 12 | +| Doorgeschoven eindkeuringsbevindingen 6, 7, 10, 11, 12 | 1 | +| Doorgeschoven punt d (ribbon-enable-check) | 2 | + +**Drie plekken waar dit plan concretiseert (gemarkeerd als KEUZE VAN DIT PLAN).** +1. Het paneel is een **drill-down** binnen `ResourceOccupancyView`, geen modale `Dialog` (taak 8). +2. De **tune-state woont in `ui`**, niet in `DOCUMENT_FIELDS` — met de volledige redenering in de + scope-bak bovenaan. +3. De **vingerafdruk is de mutatieteller PLUS de referentieset**; de teller alléén, zoals §6a hem + beschrijft, mist een gecoalesceerde sleepreeks en `runCPM` (taak 4). + +**Twee plekken die de eigenaar moet zien (AFWIJKING — VOORLEGGEN).** +1. **Pin/plafond zijn sessiegebonden, niet opgeslagen.** Wil de eigenaar dat een pin een herstart of + zelfs het IFC overleeft, dan verandert de betekenis van "pin" en hoort er een documentveld plus een + `OPS_`-pset bij. Dit plan bouwt de sessie-lezing. +2. **Documentwissel sluit het paneel** in plaats van alleen het voorstel te laten vervallen (taak 12, + stap 3). Eén regel verschil; de spec beschrijft het gedrag niet. + +**Eén bevinding die dit plan onderweg oploste en die de eigenaar mag weten.** `levelResources` stripte +in zijn baseline wél `levelingDelay` maar niet de leveling-**gaten**. Zonder taak 2 stap 2 zou een +tweede nivellering in de onderbreek-modus de gaten van de eerste als brondata lezen en er nieuwe +bovenop leggen — accumulatie in plaats van het idempotente herschrijven dat spec §4 eist. Dat was in +etappe 2 niet zichtbaar omdat niets de gaten schreef. + +**Open aannames die de uitvoerder moet verifiëren** (staan óók in de betreffende stap): of +`createAppStoreContext()` bij constructie werkelijk geen I/O of globale registratie doet (taak 5), of +`ui.notifications` de vorm heeft die `harvestNotifications` aanneemt (taak 5), welke `src/`-plekken +`clearTimephasedWindow` precies aanroepen (taak 3), of `scanLimit` in `scatterSlot`s scope staat +(taak 1), of `occurrenceFor`/`demandByTask` de dagenset van een in-scope taak uit `workTasks` of uit +`taskById` lezen (taak 2), en welk sleepmechanisme dit product elders al gebruikt voor herordenbare +lijsten (taak 10). From 1b5e8afffd3aa3517b016e457d7ea985e53bdc18 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:28:51 +0200 Subject: [PATCH 002/108] =?UTF-8?q?docs(b1c):=20eigenaarsbesluiten=202026-?= =?UTF-8?q?08-31=20=E2=80=94=20paneelstanden=20tijdelijk,=20verdeelpaneel?= =?UTF-8?q?=20als=20dialoog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twee besluiten van de eigenaar over etappe 3 verwerkt in spec en plan3: (1) pin/plafond/ rangorde zijn sessie-stand, geen projectdata — bevestigt de bestaande keuze in plan3 en komt als §11.7 + een §6-bullet in de spec; (2) de verdeelflow wordt een losse dialoog via de gedeelde Dialog-component en hasBlockingDialogOpen, geen drill-down binnen ResourceOccupancyView — draait taak 8's eerdere keuze om en trekt taken 9–13, de browsertest-voorschriften in taak 15, de Zelfreview en de i18n-/gebruikersgidsteksten recht. Co-Authored-By: Claude Fable 5 --- .../2026-08-31-b1c-plan3-schrijfpad-paneel.md | 377 +++++++++++------- ...17-b1c-nivelleren-restcapaciteit-design.md | 21 +- 2 files changed, 248 insertions(+), 150 deletions(-) diff --git a/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md index db4e3583f..55f3e81da 100644 --- a/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md +++ b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md @@ -1,14 +1,18 @@ -# B1c — Etappe 3: het schrijfpad en het paneel (implementatieplan) +# B1c — Etappe 3: het schrijfpad en de dialoog (implementatieplan) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** B1c afmaken: het **schrijfpad** (spec §5 — Toepassen over het actieve én de slapende documenten, -per-document-undo, "alles terugdraaien") en het **paneel** (spec §6/§7 — fasestroken met plafond-handles -en pins, rangordelijst, gereedschapsschakelaar, voor/na-histogram, voorstel-invalidatie), plus de -i18n-pass en de gebruikersgids die daarbij horen (spec §8). Leidende bron: +per-document-undo, "alles terugdraaien") en de **verdeeldialoog** (spec §6/§7 — fasestroken met +plafond-handles en pins, rangordelijst, gereedschapsschakelaar, voor/na-histogram, voorstel-invalidatie), +plus de i18n-pass en de gebruikersgids die daarbij horen (spec §8). Leidende bron: `docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md`; alle eigenaarsbesluiten staan in §11 daarvan. Waar dit plan de spec **concretiseert** staat dat expliciet als -**KEUZE VAN DIT PLAN**; waar het bewust **afwijkt** staat **AFWIJKING — VOORLEGGEN**. +**KEUZE VAN DIT PLAN**; waar het bewust **afwijkt** staat **AFWIJKING — VOORLEGGEN**. Twee besluiten van +de eigenaar op 2026-08-31 zijn al in dit plan verwerkt (gemarkeerd als **besluit eigenaar 2026-08-31** +op hun plek): de dialoogstanden (pin, plafond, rangorde) zijn sessie-stand, niet projectdata (bevestiging van de bestaande keuze +hieronder), en de verdeelflow wordt een losse dialoog in plaats van de eerder gekozen drill-down (taak 8 +e.v., met de gevolgen daarvan in taken 9–13 en 15). **Architecture:** vier lagen, van binnen naar buiten. De onderste twee bestaan al (etappe 2) en worden hier alleen afgemaakt; de bovenste twee zijn nieuw. @@ -23,9 +27,13 @@ hier alleen afgemaakt; de bovenste twee zijn nieuw. voor/na-preview (taak 11). 3. **Het schrijfpad** — nieuw: `src/state/runtime/scratchDocument.ts` (headless scratch-instantie) plus `applyDistribution`/`undoDistribution` op `librarySlice`. Dit is de laag die spec §5 beschrijft. -4. **Het paneel** — nieuw: `src/components/panels/DistributionPanel/`, gemount vanuit - `ResourceOccupancyView`. Gewoon DOM/React (Canvas is uitsluitend voor de Gantt), geen native - dialogs, geen nieuw meldingskanaal. +4. **De verdeeldialoog** — nieuw: `src/components/dialogs/DistributionDialog/`, gemount vanuit + `App.tsx` achter `ui.showDistributionDialog`, via de gedeelde `Dialog`-component en de + `hasBlockingDialogOpen`-guard (besluit eigenaar 2026-08-31, taak 8) — de eerste versie van dit plan + koos hier een drill-down binnen `ResourceOccupancyView`, dat is teruggedraaid. Geopend vanuit de + conflictregel in `ResourceOccupancyView` en, analoog aan `showLevelingDialog`, een knop op de + Resources-ribbon. Gewoon DOM/React (Canvas is uitsluitend voor de Gantt), geen native dialogs, geen + nieuw meldingskanaal. **Tech Stack:** TypeScript strict, React 19, geen frameworks. Headless tests als `check-*.ts` onder `tests/planning/` (registratie in `tests/planning/run.sh`) en `tests/library/` (`run_check check-x` @@ -48,8 +56,8 @@ tekst "alles groen"; `tests/library/` print zijn faalregels **ingesprongen** (` - Het schrijfpad naar slapende documenten via een headless scratch-instantie, met de twee singleton-randen (extensie-emitter, `notify`) dichtgezet (taken 5 en 6, spec §5). - De i18n-pass: alle zeven `LevelingReason`-codes, de drie `DistributionBlockReason`-codes en élke - paneeltekst, in veertien locales met CLDR-pluralcategorieën (taak 7, spec §8). -- Het paneel: ingang vanuit de conflictregel, gereedschapsschakelaar, rangordelijst, "Verdeel + dialoogtekst, in veertien locales met CLDR-pluralcategorieën (taak 7, spec §8). +- De verdeeldialoog: ingang vanuit de conflictregel, gereedschapsschakelaar, rangordelijst, "Verdeel automatisch"/"Herbereken" met bezig-toestand en schaal-degradatie, fasestroken met pin en plafond-handle (toetsenbord én pointer), voor/na-histogram, Toepassen/verwerpen, de "toegepast"-strook met "alles terugdraaien", voorstel-invalidatie (taken 8–13, spec §6/§6a/§7). @@ -65,34 +73,37 @@ tekst "alles groen"; `tests/library/` print zijn faalregels **ingesprongen** (` | Cross-machine boekingen / gedeelde opslag | buiten scope (spec §10) | | MCP-tools voor de verdeler | buiten scope (spec §10, "additief zodra gevraagd") | | Automatisch hernivelleren bij elke bewerking | buiten scope (spec §10, F5-filosofie) | -| Split-view / multi-window voor het paneel | buiten scope (CLAUDE.md, store-ownership) | -| Pins/plafonds die een app-herstart of een `.ifc`-round-trip overleven | **etappe 4 of later** — zie de bak "Waar de tune-state woont" hieronder | +| Split-view / multi-window voor de verdeeldialoog | buiten scope (CLAUDE.md, store-ownership) | +| Pins/plafonds die een app-herstart of een `.ifc`-round-trip overleven | **mogelijke latere uitbreiding, geen v1-werk** — besloten door de eigenaar, zie de bak "Waar de tune-state woont" hieronder | -### Waar de tune-state woont (KEUZE VAN DIT PLAN, expliciet) +### Waar de tune-state woont (KEUZE VAN DIT PLAN, bevestigd door de eigenaar 2026-08-31) De opdracht bij dit plan noemde het documentcontract voor "nieuwe per-document-instellingen zoals pin/plafond". Dit plan zet ze **niet** in `DOCUMENT_FIELDS`, maar in `ui` (dat in `documentContract.ts`'s `AppGlobalKey` al als app-globaal geclassificeerd staat, dus er verandert niets aan de contract-asserties). Drie redenen, alle drie hard: -1. **Het paneel kijkt naar N documenten tegelijk.** Pin en plafond van élk deelnemend document moeten +1. **De dialoog kijkt naar N documenten tegelijk.** Pin en plafond van élk deelnemend document moeten in één render leesbaar zijn. In `DOCUMENT_FIELDS` wonen ze per document — voor het actieve - top-level, voor de slapers in hun payload — dus het paneel zou zijn eigen bediening uit twee + top-level, voor de slapers in hun payload — dus de dialoog zou zijn eigen bediening uit twee verschillende plekken moeten samenrapen, en een pin zetten op een slapend document zou een payload-mutatie zijn. 2. **Het zijn geen projectdata.** `DOCUMENT_FIELDS` bepaalt óók wat er in de undo-snapshot zit en wat `newProject()` reset. Een pin in de snapshot betekent dat Ctrl+Z in het actieve document een - paneelbediening terugdraait; dat is precies de soort verwarring die het documentcontract elders + dialoogbediening terugdraait; dat is precies de soort verwarring die het documentcontract elders voorkomt. 3. **Ze horen bij één verdeelsessie, niet bij het project.** Spec §6a laat het voorstel al vervallen bij elke rang-, plafond-, pin- of gereedschapswijziging: de tune-state is de invoer van een momentopname, geen opgeslagen projecteigenschap. De spec vraagt nergens om persistentie ervan. -**AFWIJKING — VOORLEGGEN aan de eigenaar:** wil hij dat een pin of een plafond een app-herstart -overleeft (of zelfs door het IFC round-trippt), dan is dat een bewuste uitbreiding: dan hoort er een -veld in `DOCUMENT_FIELDS` bij plus een `OPS_`-pset, en dan verandert de betekenis van de pin van -"deze verdeelsessie" naar "dit project ligt vast". Dit plan bouwt de eerste lezing; de tweede is een -apart besluit. +**Besluit eigenaar 2026-08-31:** de dialoogstanden (pin, plafond, rangorde) zijn **tijdelijk** — +sessie-stand, geen projectbestand. Dit bevestigt de keuze hierboven expliciet: Ctrl+Z in het actieve +document raakt de tune-state nooit, en na een herstart begint de gebruiker neutraal (float-gesorteerd, +geen pins, geen plafonds). Wat hierboven nog als **AFWIJKING — VOORLEGGEN** stond — wil de eigenaar dat +een pin of een plafond een app-herstart overleeft (of zelfs door het IFC round-trippt) — is daarmee +beantwoord: dat blijft een **mogelijke latere uitbreiding**, geen v1-werk. Kiest de eigenaar daar ooit +voor, dan hoort er een veld in `DOCUMENT_FIELDS` bij plus een `OPS_`-pset, en dan verandert de betekenis +van de pin van "deze verdeelsessie" naar "dit project ligt vast" — een apart besluit, niet dit plan. --- @@ -134,15 +145,17 @@ etappe 2 (`a281f665`). scratch-instantie in taak 5 een slapende payload in- en uitlaadt. - **`resetDocumentScopedUI(s)`** (`src/state/slices/documentSlice.ts`, regel ~56) is de ENE plek waar `ui`-velden die naar het uitgaande document wijzen worden opgeruimd; hij draait bij - `newDocument`/`switchDocument`/`closeDocument`/`duplicateDocument`/`restoreDocuments`. Het paneel - moet daar in taak 12 aan gekoppeld worden. + `newDocument`/`switchDocument`/`closeDocument`/`duplicateDocument`/`restoreDocuments`. De + verdeeldialoog moet daar in taak 12 aan gekoppeld worden — en sluit dan (besluit eigenaar + 2026-08-31), niet alleen zijn voorstel laten vervallen. - **`ResourceOccupancyView`** (`src/components/panels/ResourceOccupancyView.tsx`, 837 regels) is het bezettingsoverzicht: één `useMemo` rond `computeLibraryOccupancy` met een per-payload-`WeakMap`-cache - op de bibliotheek-SNIT, plus een SVG-histogram (`OccupancyHistogram`) met gatcompressie. Het paneel - hangt hieraan vast en deelt in taak 9 zijn tijdas. + op de bibliotheek-SNIT, plus een SVG-histogram (`OccupancyHistogram`) met gatcompressie. De + conflictregel hierin draagt de ingang naar de verdeeldialoog (taak 8) en de dialoog deelt in taak 9 + zijn tijdas — maar rendert zelf los, via `App.tsx` (geen inbedding meer in deze view). - **Meldingen** lopen sinds K8a via één kanaal (`get().notify({ severity, messageKey, … })`, `uiSlice`). Geen `alert()`, geen native dialogs, geen tweede toast-mechaniek. Actieknoppen bestaan - in dat kanaal NIET — daarom woont "alles terugdraaien" in het paneel zelf (spec §5). + in dat kanaal NIET — daarom woont "alles terugdraaien" in de dialoog zelf (spec §5). - **De testrunners.** `tests/planning/run.sh` registreert een check met twee regels (kopieer het `LEVELERSPLITSCHECK`-blok). `tests/library/run.sh` is één regel `run_check check-x` onderaan en draait `tsc -p tests/library/tsconfig.check.json` als eerste stap, dus fixture-typen worden daar @@ -156,7 +169,7 @@ etappe 2 (`a281f665`). ## Task 1: de vijf doorgeschoven bevindingen uit de eindkeuring van etappe 2 **Waarom hier en niet later.** Bevindingen 11 en 12 zitten in `scatterSlot`/`findSlot` — precies de -code die het paneel straks per handle-loslating aanroept; 6 zit in het grootboek dat taak 11 +code die de verdeeldialoog straks per handle-loslating aanroept; 6 zit in het grootboek dat taak 11 uitbreidt; 10 is regeleinde-ruis die élke latere diff in `src/types/task.ts` onleesbaar maakt. Nummering volgt de eindkeuring van etappe 2. @@ -735,7 +748,7 @@ export interface StoreRuntime { * interne `undoSeq`: dat volgnummer stuurt de coalesce-vergelijking en beweegt daarom juist niet * tijdens een sleepreeks, en `undoStack.length` is onbruikbaar omdat `MAX_UNDO` van onderaf trimt. * - * Afnemer: de voorstel-invalidatie van het B1c-verdeelpaneel. Die combineert deze teller met de + * Afnemer: de voorstel-invalidatie van de B1c-verdeeldialoog. Die combineert deze teller met de * REFERENTIES van de documentvelden waarop het voorstel gerekend heeft — de teller is de goedkope, * grofmazige backstop; de referenties maken de bewaking sluitend (`runCPM` muteert datums zonder * ooit langs `beginUndoable` te komen). @@ -1163,14 +1176,14 @@ Co-Authored-By: Claude Fable 5 " --- -## Task 7: de i18n-pass — reden-taxonomie + alle paneelteksten, veertien locales +## Task 7: de i18n-pass — reden-taxonomie + alle dialoogteksten, veertien locales **Spec §8**: *"Volledig via `t(...)`, veertien talen, CLDR-pluralen (dag/dagen-teksten!)."* Etappe 2 stelde deze pass bewust uit (één sleutel toevoegen kost veertien bestanden); hier gebeurt hij in één keer, vóór de UI-taken, zodat die alleen nog `t(...)` hoeven te consumeren. -Twee delen: **(a)** de reden-taxonomie, met een gedeelde mapping die `LevelingDialog` en het nieuwe -paneel allebei gebruiken, en **(b)** de complete paneelsleutelset. +Twee delen: **(a)** de reden-taxonomie, met een gedeelde mapping die `LevelingDialog` en de nieuwe +verdeeldialoog allebei gebruiken, en **(b)** de complete dialoogsleutelset. **Files:** - Create: `src/utils/levelingReasonKey.ts` @@ -1184,7 +1197,7 @@ paneel allebei gebruiken, en **(b)** de complete paneelsleutelset. ```ts /** - * `LevelingReason` → i18n-sleutel. Eén mapping voor de nivelleerdialoog én het B1c-verdeelpaneel; + * `LevelingReason` → i18n-sleutel. Eén mapping voor de nivelleerdialoog én de B1c-verdeeldialoog; * vóór B1c-etappe-3 stond dit als een if/else-keten in `LevelingDialog.tsx` die drie van de zeven * codes kende en de rest ZONDER uitleg liet — een horizon-uitputting las daar als "onvoldoende * capaciteit". @@ -1230,7 +1243,7 @@ verplaats hem naar `reason.intrinsicOverrun` en werk de aanroep bij; kies één } ``` -Nieuwe sectie `resource.distribution` (volledige lijst; het paneel gebruikt uitsluitend deze): +Nieuwe sectie `resource.distribution` (volledige lijst; de dialoog gebruikt uitsluitend deze): | sleutel | NL | |---|---| @@ -1324,31 +1337,52 @@ Verwacht: exit 0, 0, 0. `verify:i18n` is hier de hoofdpoort; hij rekent met CLDR ```bash git add src/utils/levelingReasonKey.ts src/components/dialogs/LevelingDialog.tsx src/i18n/locales tests/ -git commit -m "feat(i18n): reden-taxonomie en verdeelpaneelteksten in veertien talen (B1c) +git commit -m "feat(i18n): reden-taxonomie en verdeeldialoogteksten in veertien talen (B1c) Co-Authored-By: Claude Fable 5 " ``` --- -## Task 8: het paneel — ingang, ui-state en de voorstel-berekening +## Task 8: de verdeeldialoog — ingang, ui-state en de voorstel-berekening **Spec §7** (plek in de UI) en **§3.4** (discrete rekenmomenten, bezig-toestand, schaal-degradatie). -**KEUZE VAN DIT PLAN — geen modal, maar een drill-down.** Spec §7 zegt "paneel/dialoog". Dit plan -kiest **een drill-down binnen `ResourceOccupancyView`**: het paneel vervangt de tabel + het histogram -zolang het open staat, met een "← Terug naar bezetting"-knop. Redenen: de flow is te hoog voor de -gedeelde `Dialog` (histogram + N fasestroken + rangordelijst + knoppenbalk), de gedeelde `Dialog` is in -dit product voor korte bevestigingen, en `hasBlockingDialogOpen` zou een modaal paneel met álle andere -dialogen laten botsen terwijl het hier juist een werkoppervlak is. De Resources-tab blijft dus zijn -eigen schakelaar houden en het paneel is een derde niveau binnen de Bezettingsweergave. +**Besluit eigenaar 2026-08-31 — losse dialoog, geen drill-down.** Dit plan koos in zijn eerste versie +een drill-down binnen `ResourceOccupancyView` (KEUZE VAN DIT PLAN, hieronder had gestaan waarom). +De eigenaar heeft die keuze **teruggedraaid**: de verdeelflow wordt een **losse dialoog**, via de +gedeelde `Dialog`-component (focus-trap) en de `hasBlockingDialogOpen`-guard, gemount vanuit `App.tsx` +achter een eigen `ui.show*`-vlag — conform de bestaande dialoogconventies (CLAUDE.md: "Global dialogs +… mount from `App.tsx` behind `ui.show*` flags"; "native alleen bestandskiezers"). Het patroon is +letterlijk dat van `showLevelingDialog`/`LevelingDialog.tsx`: een sessie-boolean in `UIState`, een +`use`-callback op een ribbonknop die 'm zet, en een `{showX && }`-regel in `App.tsx`. De +dialoog moet groot genoeg zijn voor histogram + N fasestroken + rangordelijst + knoppenbalk — qua +chrome-opbouw (header/Esc/backdrop/Enter, `max-h-[90vh]`, scroll binnenin) naar het patroon van +`ProjectInfoDialog` (die gebruikt `w-[560px]`), maar merkbaar breder omdat de inhoud een werkoppervlak +is en geen formulier — richt op iets in de orde van `w-[960px] max-w-[95vw]`. + +De **ingang** blijft de conflictregel in `ResourceOccupancyView` (zet de ui-state en de open-vlag); +daarnaast komt er, analoog aan hoe nivelleren dat al doet (`ribbonConfig.tsx`, regel ~603: +`use: () => { const setUI = useAppStore(s => s.setUI); return { onClick: () => setUI({ +showLevelingDialog: true }) }; }`), een knop op de Resources-ribbon die de dialoog opent — met het +laatst gebruikte bibliotheekitem/`companyId` als voorinvulling, of, zonder een eerder geopend item, de +dialoog met `selectHint` totdat de gebruiker via het bezettingsoverzicht een conflict kiest. + +Gedrag bij documentwissel (spec §6a: het voorstel vervalt — hier expliciet als **dialoogsluiting** +i.p.v. paneelsluiting, zie taak 12 stap 3): een documentwissel sluit de dialoog. `hasBlockingDialogOpen` +(`shortcutRegistry.ts`) en de MCP-blokkeerlijst (`services/mcp/tools/runtime.ts`) leren de nieuwe vlag +kennen, net als elke andere dialoogvlag. **Files:** -- Modify: `src/state/slices/types.ts` (`UIState` + de `DistributionUiState`-vorm) -- Modify: `src/state/slices/uiSlice.ts` (default) -- Create: `src/components/panels/DistributionPanel/DistributionPanel.tsx` -- Create: `src/components/panels/DistributionPanel/useDistributionProposal.ts` -- Modify: `src/components/panels/ResourceOccupancyView.tsx` (de "Verdelen…"-ingang + de drill-down) +- Modify: `src/state/slices/types.ts` (`UIState` + `showDistributionDialog` + de `DistributionUiState`-vorm) +- Modify: `src/state/slices/uiSlice.ts` (defaults) +- Modify: `src/hooks/keyboard/shortcutRegistry.ts` (`hasBlockingDialogOpen`) +- Modify: `src/services/mcp/tools/runtime.ts` (dezelfde vlaggenlijst) +- Modify: `src/App.tsx` (mount achter `ui.showDistributionDialog`) +- Create: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` +- Create: `src/components/dialogs/DistributionDialog/useDistributionProposal.ts` +- Modify: `src/components/panels/ResourceOccupancyView.tsx` (de "Verdelen…"-ingang zet de open-vlag) +- Modify: `src/components/layout/Ribbon/ribbonConfig.tsx` (Resources-ribbonknop, analoog aan `showLevelingDialog`) - Create: `tests/browser/leveling-distribution.spec.ts` - [ ] **Step 1: Schrijf de falende browsertest** @@ -1358,55 +1392,70 @@ eigen schakelaar houden en het paneel is een derde niveau binnen de Bezettingswe fixture en voor state-asserties): ```ts -// leveling-distribution.spec.ts — B1c-plan3, het verdeelpaneel. Fixture: twee documenten, dezelfde +// leveling-distribution.spec.ts — B1c-plan3, de verdeeldialoog. Fixture: twee documenten, dezelfde // bibliotheek, één poolitem met een echt conflict. `window.__OPS__` zet de fixture; élke handeling // hieronder is een ECHTE klik/toets. -test('verdeelpaneel: openen vanuit de conflictregel en een voorstel rekenen', async ({ page }) => { +test('verdeeldialoog: openen vanuit de conflictregel, een voorstel rekenen, focus-trap en sluiten', async ({ page }) => { await seedTwoConflictingDocuments(page); await page.getByRole('button', { name: /^(Occupancy|Bezetting)$/ }).click(); // De conflictregel draagt de ingang. await page.locator('[data-ops-occupancy-row]').first().getByRole('button', { name: /Verdelen|Distribute/ }).click(); - await expect(page.locator('[data-ops-distribution-panel]')).toBeVisible(); + await expect(page.locator('[data-ops-distribution-dialog]')).toBeVisible(); + // De gedeelde Dialog houdt Tab binnen de dialoog (CLAUDE.md, "gedeelde Dialog heeft een + // focus-trap") — dezelfde asserttrant als de bestaande dialoogtests in `tests/browser/`. + await expect(page.locator('[data-ops-distribution-dialog] :focus')).toHaveCount(1); + // Het overzicht eronder blijft zichtbaar: de dialoog is een overlay, geen vervanging van de content. + await expect(page.locator('[data-ops-occupancy-row]').first()).toBeVisible(); // Het voorstel rekent bij het openen (spec §3.4, eerste discrete moment) en levert per document // een strook. await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); - // Terug laat het overzicht weer zien en ruimt de paneelstate op. - await page.getByRole('button', { name: /Terug naar bezetting|Back to occupancy/ }).click(); - await expect(page.locator('[data-ops-distribution-panel]')).toHaveCount(0); + // Esc sluit, zoals elke andere dialoog (LAYOUTS.md §3.3). Het bezettingsoverzicht eronder was al + // die tijd gewoon aanwezig; er is dus niets terug te "geven". + await page.keyboard.press('Escape'); + await expect(page.locator('[data-ops-distribution-dialog]')).toHaveCount(0); }); -test('verdeelpaneel: de gereedschapsschakelaar laat het voorstel vervallen met reden', async ({ page }) => { - /* … open het paneel … */ +test('verdeeldialoog: de gereedschapsschakelaar laat het voorstel vervallen met reden', async ({ page }) => { + /* … open de dialoog … */ await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow splits/ }).click(); await expect(page.locator('[data-ops-distribution-stale]')).toBeVisible(); await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); }); -test('verdeelpaneel: een geblokkeerd voorstel legt uit waarom en biedt geen Toepassen', async ({ page }) => { +test('verdeeldialoog: een geblokkeerd voorstel legt uit waarom en biedt geen Toepassen', async ({ page }) => { await seedUncountedDocument(page); // een document met een relatiecyclus ⇒ counted: false - /* … open het paneel … */ + /* … open de dialoog … */ await expect(page.locator('[data-ops-distribution-blocked]')).toContainText(/doorrekenen|calculate/i); await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); }); ``` -Draai `npm run test:browser` en verwacht rood (het paneel bestaat nog niet). +Draai `npm run test:browser` en verwacht rood (de dialoog bestaat nog niet). - [ ] **Step 2: De ui-state** `src/state/slices/types.ts`, in `UIState`: ```ts - /** session — het B1c-verdeelpaneel (spec §6/§6a). App-globaal, NIET per document: het paneel kijkt - * naar N documenten tegelijk, en pin/plafond/rangorde zijn de invoer van één verdeelsessie, geen - * projectdata (zie de scope-bak in het implementatieplan). `null` ⇒ het paneel is dicht. + /** session — of de B1c-verdeeldialoog open staat. Zelfde patroon als `showLevelingDialog`: een + * gewone dialoogvlag, bewaakt door `hasBlockingDialogOpen()` en gemount in `App.tsx`. Gescheiden + * van `levelingDistribution` hieronder omdat de tune-state en het toegepast-record een sluiting + * moeten OVERLEVEN — sluit de gebruiker de dialoog en opent hij 'm opnieuw op dezelfde + * conflictregel, dan staat de "toegepast"-strook (met "alles terugdraaien") er nog steeds. Alleen + * `resetDocumentScopedUI` (documentwissel, spec §6a) en het kiezen van een ANDER poolitem legen + * ook `levelingDistribution` zelf. */ + showDistributionDialog: boolean; + /** session — de tune-state van de B1c-verdeeldialoog (spec §6/§6a). App-globaal, NIET per document: + * de dialoog kijkt naar N documenten tegelijk, en pin/plafond/rangorde zijn de invoer van één + * verdeelsessie, geen projectdata (zie de scope-bak in het implementatieplan — **besluit eigenaar + * 2026-08-31**: dit is sessie-stand, geen projectbestand). `null` ⇒ er is nog geen sessie gestart + * (vóór de eerste keer openen, of ná het sluiten van het poolitem/de bibliotheek). * - * Bewust ZONDER het voorstel zelf: dat is afgeleide data en woont in de componentstate van het - * paneel — zelfde filosofie als `DerivedKey` in het documentcontract. Wat hier staat is - * uitsluitend wat een gebruiker heeft INGESTELD, plus het toegepast-record (dat een paneel-hersluiting - * moet overleven zodat "alles terugdraaien" bereikbaar blijft). */ + * Bewust ZONDER het voorstel zelf: dat is afgeleide data en woont in de componentstate van de + * dialoog — zelfde filosofie als `DerivedKey` in het documentcontract. Wat hier staat is + * uitsluitend wat een gebruiker heeft INGESTELD, plus het toegepast-record (zie hierboven). */ levelingDistribution: DistributionUiState | null; ``` @@ -1426,18 +1475,18 @@ export interface DistributionUiState { } ``` -Default in `uiSlice`: `levelingDistribution: null`. **Niet** in `settingsRegistry` — dit is -sessie-state, geen instelling. +Defaults in `uiSlice`: `showDistributionDialog: false, levelingDistribution: null`. **Niet** in +`settingsRegistry` — dit is sessie-state, geen instelling (besluit eigenaar 2026-08-31). - [ ] **Step 3: De voorstel-hook** `useDistributionProposal.ts` — de discrete rekenmomenten, de bezig-toestand en de degradatie op één -plek, zodat het paneel zelf alleen rendert: +plek, zodat de dialoog zelf alleen rendert: ```ts /** * Berekent het verdelingsvoorstel op de DISCRETE momenten uit spec §3.4 — nooit per sleep-pixel. - * Rekenmomenten: paneel openen, "Verdeel automatisch"/"Herbereken", loslaten van een handle of een + * Rekenmomenten: dialoog openen, "Verdeel automatisch"/"Herbereken", loslaten van een handle of een * toetsenbord-stap, en een pin-, rangorde- of gereedschapswijziging. Alles daartussen laat het * bestaande voorstel staan en zet alleen `staleReason`. * @@ -1463,7 +1512,7 @@ aan (`librarySlice`, §7-cache). Voor de verdeler mag dat **niet**: `defaultLeve `doc.resources`/`doc.assignments` rechtstreeks aan `levelResources`, en die moet de VOLLEDIGE projectinzet zien — anders lost B1c een bibliotheekconflict op door een projectconflict te maken (spec §4 stap 2, "de bestaande per-resource-toets tegen de eigen projectinzet"). Bouw de invoer voor -het paneel dus uit de **volledige** payloadvelden: +de dialoog dus uit de **volledige** payloadvelden: ```ts const inputs: DistributionDocInput[] = payloads.map(({ id, payload }, i) => ({ @@ -1494,10 +1543,14 @@ kleinste totale float over zijn boekende taken op dit poolitem, uit de counted c uit de doorgerekende taken van elk document, met de scope-taken uit `scopeTaskIdsFor` — exporteer die helper uit `distribute.ts` in plaats van hem na te bouwen. -- [ ] **Step 4: Het paneel-skelet en de ingang** +- [ ] **Step 4: De dialoog-chrome en de ingang** -`DistributionPanel.tsx` rendert, van boven naar beneden (spec §7): -1. Kop met "← Terug naar bezetting", de itemnaam en `subtitle`. +`DistributionDialog.tsx` gebruikt de gedeelde `Dialog` (zie de motivering en het maatvoorbeeld +bovenaan deze taak) en rendert daarbinnen, van boven naar beneden (spec §7): +1. Kop met de itemnaam en `subtitle`, en de gewone dialoog-sluitknop (X) — Esc en een backdrop-klik + sluiten ook, via `Dialog`'s eigen `onCancel`/`onBackdropClick` (het patroon van elke andere + dialoog in dit product, bv. `ProjectInfoDialog`). Er is geen "terug"-knop: de dialoog is een + overlay, het bezettingsoverzicht eronder is nooit weggehaald. 2. Een blokkade-blok (`data-ops-distribution-blocked`) mét `t(DISTRIBUTION_BLOCK_KEY[reason])` en de documenttitels erbij — of, zonder blokkade: 3. De gereedschapsschakelaar (`role="switch"`, `aria-checked`) met het prijskaartje (taak 13 vult de @@ -1511,12 +1564,15 @@ helper uit `distribute.ts` in plaats van hem na te bouwen. "Verdeel automatisch" / "Herbereken" / "Toepassen" / "Verwerpen" (taak 12 bedraadt Toepassen). `ResourceOccupancyView.tsx`: in de conflictbadge-cel een knop `t('resource.distribution.open')` die -`setUI({ levelingDistribution: { companyId, libraryItemId: row.libraryItemId, allowSplits: false, -order: , pinned: {}, ceilings: {}, applied: null } })` zet. Toon de knop **alleen** -bij `hasConflict` (spec §7: "vanuit een conflictregel"). Wanneer `ui.levelingDistribution` gezet is -én bij déze `companyId` hoort, rendert de view het paneel **in plaats van** de tabel en het -histogram; hoort het bij een andere bibliotheek, dan negeert de view het (dat kan alleen ontstaan als -het actieve project van bibliotheek wisselt — taak 12 ruimt dat op). +`setUI({ showDistributionDialog: true, levelingDistribution: { companyId, libraryItemId: +row.libraryItemId, allowSplits: false, order: , pinned: {}, ceilings: {}, applied: +null } })` zet — of, staat er al een `levelingDistribution` voor DÉZELFDE `libraryItemId` (de gebruiker +sloot de dialoog en opent 'm opnieuw, inclusief een eventueel `applied`-record), dan zet die knop +alleen `showDistributionDialog: true` en laat de bestaande tune-state en het toegepast-record staan. +Toon de knop **alleen** bij `hasConflict` (spec §7: "vanuit een conflictregel"). De Resources-ribbonknop +(zie boven) doet hetzelfde, met het laatst geopende poolitem als default. Het overzicht (tabel + +histogram) blijft **ongewijzigd zichtbaar** terwijl de dialoog open staat — er is geen "in plaats van" +meer, want de dialoog rendert los, via `App.tsx`. - [ ] **Step 5: Draai** @@ -1532,9 +1588,11 @@ Verwacht: exit 0 ×3. Bij een falende browsertest: `test-results/` bevat screens - [ ] **Step 6: Commit** ```bash -git add src/state/slices/types.ts src/state/slices/uiSlice.ts src/components/panels/ \ +git add src/state/slices/types.ts src/state/slices/uiSlice.ts src/hooks/keyboard/shortcutRegistry.ts \ + src/services/mcp/tools/runtime.ts src/App.tsx src/components/dialogs/DistributionDialog/ \ + src/components/panels/ResourceOccupancyView.tsx src/components/layout/Ribbon/ribbonConfig.tsx \ tests/browser/leveling-distribution.spec.ts -git commit -m "feat(library): verdeelpaneel — ingang vanuit de conflictregel, voorstel op discrete momenten (B1c) +git commit -m "feat(library): verdeeldialoog — ingang vanuit de conflictregel, voorstel op discrete momenten (B1c) Co-Authored-By: Claude Fable 5 " ``` @@ -1557,15 +1615,15 @@ Co-Authored-By: Claude Fable 5 " **Files:** - Create: `src/components/panels/occupancyAxis.ts` (gedeelde tijdas, uit `ResourceOccupancyView`) - Modify: `src/components/panels/ResourceOccupancyView.tsx` (de as-opbouw eruit trekken) -- Create: `src/components/panels/DistributionPanel/PhaseStrip.tsx` -- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Create: `src/components/dialogs/DistributionDialog/PhaseStrip.tsx` +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` - Modify: `tests/browser/leveling-distribution.spec.ts` - [ ] **Step 1: Schrijf de falende browsertest** ```ts test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ({ page }) => { - /* … open het paneel op de conflictrij … */ + /* … open de dialoog op de conflictrij … */ const strip = page.locator('[data-ops-distribution-strip]').first(); // De pin is een toggle-knop met aria-pressed (spec §6). @@ -1608,7 +1666,7 @@ test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', ```ts /** - * De gedeelde tijdas van het bezettingsoverzicht en het verdeelpaneel: segmentindeling met + * De gedeelde tijdas van het bezettingsoverzicht en de verdeeldialoog: segmentindeling met * gatcompressie, dagbreedte en x-posities per ISO-dag. * * Waarom gedeeld: het histogram (§5a), de fasestroken (§6) en de voor/na-preview (§7) MOETEN op @@ -1684,12 +1742,13 @@ npm run verify:gantt-boundaries; echo "exit: $?" npx tsc --noEmit -p tsconfig.json; echo "exit: $?" ``` -`verify:gantt-boundaries` is hier bewust in de lijst: het paneel tekent SVG en mag **niet** aan de +`verify:gantt-boundaries` is hier bewust in de lijst: de dialoog tekent SVG en mag **niet** aan de Canvas-renderer, viewport- of pointer-grenzen van de Gantt raken. ```bash -git add src/components/panels/ tests/browser/leveling-distribution.spec.ts -git commit -m "feat(library): fasestroken met pin en plafond-handle in het verdeelpaneel (B1c) +git add src/components/panels/occupancyAxis.ts src/components/panels/ResourceOccupancyView.tsx \ + src/components/dialogs/DistributionDialog/ tests/browser/leveling-distribution.spec.ts +git commit -m "feat(library): fasestroken met pin en plafond-handle in de verdeeldialoog (B1c) Co-Authored-By: Claude Fable 5 " ``` @@ -1704,19 +1763,19 @@ verschijnt bij loslaten (discrete doorrekenmomenten) — nooit per sleep-pixel." Deze taak staat bewust ná taak 9: de toetsenbordbediening is de bron van waarheid (dezelfde `setCeiling`-functie), het slepen is een tweede invoerroute erop. **Zou etappe 3 moeten inkorten, dan -is dít de enige UI-taak die naar een etappe 4 kan** — het paneel is zonder slepen volledig bedienbaar. +is dít de enige UI-taak die naar een etappe 4 kan** — de dialoog is zonder slepen volledig bedienbaar. De spec eist hem voor v1, dus hij staat hier gewoon in. **Files:** -- Modify: `src/components/panels/DistributionPanel/PhaseStrip.tsx` +- Modify: `src/components/dialogs/DistributionDialog/PhaseStrip.tsx` - Modify: `tests/browser/leveling-distribution.spec.ts` -- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` (rangorde-slepen, zie stap 3) +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` (rangorde-slepen, zie stap 3) - [ ] **Step 1: Schrijf de falende browsertest** ```ts test('plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten', async ({ page }) => { - /* … open het paneel … */ + /* … open de dialoog … */ const handle = strip.locator('[data-ops-distribution-handle]'); const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); @@ -1794,7 +1853,7 @@ npm run lint; echo "exit: $?" ``` ```bash -git add src/components/panels/DistributionPanel/ tests/browser/leveling-distribution.spec.ts +git add src/components/dialogs/DistributionDialog/ tests/browser/leveling-distribution.spec.ts git commit -m "feat(library): plafond-handle en rangorde met de muis, rekenen pas bij loslaten (B1c) Co-Authored-By: Claude Fable 5 " @@ -1802,7 +1861,7 @@ Co-Authored-By: Claude Fable 5 " --- -## Task 11: het voor/na-histogram bovenin het paneel +## Task 11: het voor/na-histogram bovenin de dialoog **Spec §7**: *"bovenin het histogram als voor/na-preview"*. @@ -1814,8 +1873,8 @@ boekingen. Deze taak maakt dat naar buiten leesbaar. **Files:** - Modify: `src/services/library/distribute.ts` - Modify: `tests/library/check-distribute.ts` -- Create: `src/components/panels/DistributionPanel/BeforeAfterChart.tsx` -- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Create: `src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx` +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` - Modify: `tests/browser/leveling-distribution.spec.ts` - [ ] **Step 1: Schrijf de falende tests** @@ -1842,7 +1901,7 @@ En een browsertest: ```ts test('voor/na-preview: de na-stand blijft binnen de capaciteitslijn', async ({ page }) => { - /* … open het paneel, "Verdeel automatisch" … */ + /* … open de dialoog, "Verdeel automatisch" … */ await expect(page.locator('[data-ops-distribution-chart-before]')).toBeVisible(); await expect(page.locator('[data-ops-distribution-chart-after]')).toBeVisible(); // De conflictdagen staan in de VOOR-stand rood en in de NA-stand niet meer. @@ -1894,7 +1953,7 @@ npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" ``` ```bash -git add src/services/library/distribute.ts src/components/panels/DistributionPanel/ tests/ +git add src/services/library/distribute.ts src/components/dialogs/DistributionDialog/ tests/ git commit -m "feat(library): voor/na-preview van de verdeling, uit dezelfde boekhouding als het grootboek (B1c) Co-Authored-By: Claude Fable 5 " @@ -1904,12 +1963,12 @@ Co-Authored-By: Claude Fable 5 " ## Task 12: Toepassen, de "toegepast"-strook met "alles terugdraaien", en de voorstel-invalidatie -**Spec §5** (de terugweg woont in het paneel, niet in een melding) en **§6a** (levensduur van het +**Spec §5** (de terugweg woont in de dialoog, niet in een melding) en **§6a** (levensduur van het voorstel). **Files:** -- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` -- Modify: `src/components/panels/DistributionPanel/useDistributionProposal.ts` +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` +- Modify: `src/components/dialogs/DistributionDialog/useDistributionProposal.ts` - Modify: `src/state/slices/documentSlice.ts` (`resetDocumentScopedUI`) - Modify: `tests/browser/leveling-distribution.spec.ts` - Modify: `tests/library/check-apply-distribution.ts` (invalidatie-asserts op storeniveau) @@ -1918,7 +1977,7 @@ voorstel). ```ts test('toepassen: schrijft in beide projecten en biedt daarna "alles terugdraaien"', async ({ page }) => { - /* … open het paneel, "Verdeel automatisch" … */ + /* … open de dialoog, "Verdeel automatisch" … */ await page.getByRole('button', { name: /^(Toepassen|Apply)$/ }).click(); // Beide documenten dragen de delays; de strook staat er, permanent (geen 5s-timeout — spec §5). await expect(page.locator('[data-ops-distribution-applied]')).toBeVisible(); @@ -1942,17 +2001,21 @@ test('toepassen is uitgeschakeld-met-reden zolang er een tekort is', async ({ pa }); test('het voorstel vervalt met reden zodra er in een betrokken document gewerkt wordt', async ({ page }) => { - /* … open het paneel, reken … */ + /* … open de dialoog, reken … */ // Een ECHTE bewerking in het actieve document (via het lint/de tabel, niet via __OPS__). await editATaskDurationThroughTheUi(page); await expect(page.locator('[data-ops-distribution-stale]')).toContainText(/niet meer actueel|no longer/i); await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); }); -test('van document wisselen laat het voorstel vervallen', async ({ page }) => { - /* … open het paneel, reken, dan Ctrl+2 … */ +test('van document wisselen sluit de dialoog', async ({ page }) => { + /* … open de dialoog, reken, dan Ctrl+2 … */ await page.keyboard.press('Control+2'); - await expect(page.locator('[data-ops-distribution-stale]')).toBeVisible(); + // Besluit eigenaar 2026-08-31: het voorstel VERVALT (spec §6a) en dat betekent hier — anders dan de + // vorige, teruggedraaide drill-down-lezing — dat de DIALOOG SLUIT, niet dat hij openblijft met een + // vervallen voorstel. Het overzicht eronder blijft gewoon staan; de gebruiker opent opnieuw op de + // conflictregel. + await expect(page.locator('[data-ops-distribution-dialog]')).toHaveCount(0); }); ``` @@ -2001,29 +2064,42 @@ veranderde documenten. Verder: | pin gewijzigd | `stale.pin` | | gereedschapsschakelaar | `stale.tool` | | mutatie in een betrokken document | `stale.edited` | -| document geopend/gesloten dat op dit poolitem boekt | `stale.documents` | -| documentwissel | `stale.documents` | -De eerste vier zijn **gewone hertriggering**: het paneel rekent er meteen op door (behalve in de -gedegradeerde modus, waar hij alleen de reden toont). De laatste drie zetten `staleReason` en -**rekenen niet** vanzelf — dan is er iets buiten het paneel gebeurd en beslist de gebruiker. +Alle vijf zijn **gewone hertriggering**: de dialoog rekent er meteen op door (behalve in de +gedegradeerde modus, waar hij alleen de reden toont). **Anders dan de vorige versie van dit plan** +staan "document geopend/gesloten dat op dit poolitem boekt" en "documentwissel" hier niet meer als +`staleReason`-tak: die twee gebeurtenissen lopen altijd via `newDocument`/`switchDocument`/ +`closeDocument`/`duplicateDocument`/`restoreDocuments` — precies de triggerlijst van +`resetDocumentScopedUI` — en sluiten dus de hele dialoog (besluit eigenaar 2026-08-31, hieronder) in +plaats van hem open te laten staan met een vervallen voorstel. **Let op de i18n-nasleep:** taak 7 gaf +al een `stale.documents`-sleutel ("De geopende projecten zijn veranderd…") voor precies dit geval; die +sleutel heeft na deze taak geen aanroepplek meer. Verwijder hem uit de veertien locales, of — als de +uitvoerder een scenario vindt waarin een document verandert zonder een van de vijf +`resetDocumentScopedUI`-triggers (meld dat als het bestaat) — houd hem aan die plek vast. Niet +stilzwijgend laten staan als dode sleutel. `resetDocumentScopedUI` (documentSlice, regel ~56): ```ts - // B1c (spec §6a): het verdeelpaneel kijkt naar een MOMENTOPNAME van meerdere documenten. Een + // B1c (spec §6a): de verdeeldialoog kijkt naar een MOMENTOPNAME van meerdere documenten. Een // documentwissel of een gesloten document maakt zijn tune-state (rangorde/pins/plafonds op docId) // en zijn toegepast-record onbetrouwbaar: het record verwijst naar undo-diepten van documenten die - // er misschien niet meer zijn. Sluit het paneel; het overzicht eronder blijft gewoon staan en de - // gebruiker opent het opnieuw op de conflictregel. + // er misschien niet meer zijn. BESLUIT EIGENAAR 2026-08-31: dat betekent hier dat de DIALOOG SLUIT + // (niet dat hij openblijft met een vervallen voorstel — dat was de vraag in de vorige versie van dit + // plan, tégen de toen nog gekozen drill-down; met een losse dialoog is sluiten het enige zinnige + // gevolg, want er is geen "eronder" om op terug te vallen zoals bij een drill-down). Het + // bezettingsoverzicht blijft gewoon staan; de gebruiker opent de dialoog opnieuw op de conflictregel, + // en start dan met een verse tune-state (tenzij hetzelfde poolitem nog een `applied`-record droeg — + // zie taak 8 stap 4, de "zelfde item"-tak van de ingangsknop). + s.ui.showDistributionDialog = false; s.ui.levelingDistribution = null; ``` -**AFWIJKING — VOORLEGGEN:** spec §6a zegt dat het voorstel bij een documentwissel *vervalt*, niet dat -het paneel *sluit*. Dit plan sluit het, omdat de tune-state op docId's staat en de spec verder geen -gedrag voor "paneel open, ander document actief" beschrijft. Wil de eigenaar dat het paneel open -blijft met een vervallen voorstel, dan is dat één regel minder hier plus een `staleReason`-tak — meld -het als het zo hoort. +Dit is geen open vraag meer: spec §6a zegt dat het voorstel bij een documentwissel *vervalt*, en de +eigenaar heeft op 2026-08-31 bevestigd dat dat hier als **dialoogsluiting** landt, niet als een +openblijvende dialoog met een vervallen voorstel. De vorige versie van dit plan had dit nog als +AFWIJKING — VOORLEGGEN staan (toen nog geformuleerd als "het paneel sluit i.p.v. alleen het voorstel +laat vervallen"); dat is hiermee beantwoord. - [ ] **Step 4: Draai en commit** @@ -2034,8 +2110,8 @@ bash tests/planning/run.sh 2>&1 | tail -5; echo "exit: $?" ``` ```bash -git add src/components/panels/DistributionPanel/ src/state/slices/documentSlice.ts tests/ -git commit -m "feat(library): verdeling toepassen vanuit het paneel, met een permanente terugweg (B1c) +git add src/components/dialogs/DistributionDialog/ src/state/slices/documentSlice.ts tests/ +git commit -m "feat(library): verdeling toepassen vanuit de dialoog, met een permanente terugweg (B1c) Co-Authored-By: Claude Fable 5 " ``` @@ -2055,15 +2131,15 @@ een kostenlabel is `computeDistribution` opnieuw draaien met een andere rangorde hetzelfde met de andere `allowSplits`-stand. **Files:** -- Modify: `src/components/panels/DistributionPanel/useDistributionProposal.ts` -- Modify: `src/components/panels/DistributionPanel/DistributionPanel.tsx` +- Modify: `src/components/dialogs/DistributionDialog/useDistributionProposal.ts` +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` - Modify: `tests/browser/leveling-distribution.spec.ts` - [ ] **Step 1: Schrijf de falende browsertest** ```ts test('kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel', async ({ page }) => { - /* … open het paneel op een klein project (onder de ondersteunde schaal) … */ + /* … open de dialoog op een klein project (onder de ondersteunde schaal) … */ // Elk rangorde-regeltje draagt zijn kostenlabel na de eerste berekening. await expect(page.locator('[data-ops-distribution-rank-row] [data-ops-distribution-cost]')) .toHaveCount(2); @@ -2074,9 +2150,9 @@ test('kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel', await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); }); -test('boven de ondersteunde schaal rekent het paneel alleen op de knop', async ({ page }) => { +test('boven de ondersteunde schaal rekent de dialoog alleen op de knop', async ({ page }) => { await seedLargeProject(page, { tasks: 1200 }); // > MAX_TASKS_AUTO - /* … open het paneel … */ + /* … open de dialoog … */ await expect(page.locator('[data-ops-distribution-degraded]')).toBeVisible(); // Kostenlabels worden dan NIET vooraf gerekend. await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); @@ -2093,7 +2169,7 @@ test('boven de ondersteunde schaal rekent het paneel alleen op de knop', async ( * De kostenlabels (spec §4 stap 1). "Alleen dit project laten opschuiven kost +N werkdagen" is: * draai `computeDistribution` opnieuw met dít document op rang 1 en alle andere deelnemers gepind, * en lees zijn `endShiftWorkdays`. Dat is per label een VOLLEDIGE run — vandaar het cachebeleid van - * §3.4: alleen onder de ondersteunde schaal, alleen bij paneelopening en na een invalidatie, en + * §3.4: alleen onder de ondersteunde schaal, alleen bij dialoogopening en na een invalidatie, en * daarna gecachet tot het voorstel vervalt. Boven die schaal blijft het label leeg met * `compute.pressRecompute`; de gebruiker vraagt ze dan expliciet op. * @@ -2114,7 +2190,7 @@ npm run test:browser 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}" ``` ```bash -git add src/components/panels/DistributionPanel/ tests/browser/leveling-distribution.spec.ts +git add src/components/dialogs/DistributionDialog/ tests/browser/leveling-distribution.spec.ts git commit -m "feat(library): kostenlabels per project en prijskaartjes per gereedschapsstand (B1c) Co-Authored-By: Claude Fable 5 " @@ -2146,7 +2222,7 @@ enkelvoudige lijsten, `**vet**`/`*cursief*`/`` `code` ``, codeblokken, afbeeldin Secties die er hoe dan ook in horen: - **Wanneer je dit gebruikt** — je ziet in het bezettingsoverzicht een rode conflictbadge, en je wilt hem wegwerken zonder één project willekeurig op te offeren. -- **Het paneel openen** — vanaf de conflictregel, met de link naar +- **De dialoog openen** — vanaf de conflictregel (of de knop op de Resources-ribbon), met de link naar [Bezettingsoverzicht](docs://gids-bezettingsoverzicht). - **Wie wordt het meest ontzien** — de rangorde, wat "speling" hier betekent, en het kostenlabel. - **Onderbrekingen toestaan** — uit = het werk schuift in één stuk op; aan = het werk mag pauzedagen @@ -2191,11 +2267,13 @@ stappen (zien, dan oplossen). - [ ] **Step 3: `docs/library.md`** -Voeg onder de bestaande B1c-alinea (die etappe 2 achterliet met "het schrijfpad en het paneel bestaan -nog niet") één blok toe: dat het paneel er nu is, waar het zit, wat pin en plafond betekenen, dat -Toepassen in meerdere documenten schrijft met per document een gewone undo-stap, en dat de tune-state -bij de verdeelsessie hoort en geen projecteigenschap is (met de verwijzing naar de scope-bak van dit -plan). Verwijder de "bestaan nog niet"-zin. +Voeg onder de bestaande B1c-alinea (§5, punt 5: "Verdeler-kern bestaat, nog zonder schrijfpad/paneel … +het schrijfpad … en het paneel dat dit voorstel toont, bestaan nog niet") één blok toe: dat de +verdeling nu via een dialoog gaat (geen drill-down — besluit eigenaar 2026-08-31), waar hij vandaan te +openen is (conflictregel + Resources-ribbon), wat pin en plafond betekenen, dat Toepassen in meerdere +documenten schrijft met per document een gewone undo-stap, en dat de tune-state bij de verdeelsessie +hoort en geen projecteigenschap is — sessie-stand, overleeft geen herstart (met de verwijzing naar de +scope-bak van dit plan). Verwijder de "bestaan nog niet"-zin. - [ ] **Step 4: Draai en commit** @@ -2239,7 +2317,7 @@ Poorten die in dit plan bijzondere aandacht verdienen: - **`verify:cycles`** — de toegestane richting blijft `services/library` → `engine/scheduler`. `state/runtime/scratchDocument` → `state/appStore` → alle slices is de nieuwe kant; loopt daar een cyclus, zie taak 5 stap 4. -- **`verify:gantt-boundaries`** — het paneel tekent SVG in de DOM en hoort de Canvas-renderer-, +- **`verify:gantt-boundaries`** — de verdeeldialoog tekent SVG in de DOM en hoort de Canvas-renderer-, viewport- en pointergrenzen van de Gantt niet te raken. - [ ] **Step 2: De browserpoort** @@ -2253,7 +2331,7 @@ Verwacht: exit 0. Dekking die deze etappe moet hebben opgeleverd in | flow | taak | |---|---| -| paneel openen vanuit de conflictregel, twee stroken, terug | 8 | +| dialoog openen vanuit de conflictregel, focus-trap, twee stroken, sluiten (Esc) | 8 | | gereedschapsschakelaar ⇒ vervallen met reden ⇒ herberekenen | 8 | | geblokkeerd voorstel: uitleg + Toepassen uit | 8 | | pin met `aria-pressed`, plafond met pijltjes/Home/End, `aria-valuetext` | 9 | @@ -2263,7 +2341,7 @@ Verwacht: exit 0. Dekking die deze etappe moet hebben opgeleverd in | voor/na-preview: conflictdagen weg in de na-stand | 11 | | Toepassen in twee projecten + "alles terugdraaien" | 12 | | Toepassen uitgeschakeld-met-reden bij een tekort | 12 | -| voorstel vervalt bij een echte bewerking en bij een documentwissel | 12 | +| voorstel vervalt bij een echte bewerking; documentwissel sluit de dialoog | 12 | | kostenlabels/prijskaartjes + de gedegradeerde modus | 13 | Ontbreekt er een rij, dan is die taak niet af — vul hem aan vóór je deze stap afvinkt. @@ -2290,7 +2368,7 @@ Ontbreekt er een rij, dan is die taak niet af — vul hem aan vóór je deze sta | §5 — scope-behoudend toepassen, derde plek (`applyLeveling`) | 2 | | §5 — de doorrekening wordt gepersisteerd | 6 (geval 3) | | §5 — ook in handmatige modus | 6 (geval 3) | -| §5 — de terugweg in het paneel, niet in een melding | 12 | +| §5 — de terugweg in de dialoog, niet in een melding | 12 | | §6 — fasestrook per document, gaten getekend | 9 | | §6 — handle = plafond, label = einddatum-effect, gestippelde staart | 9 | | §6 — pin bevriest einddatum én werkdagen, telt als vaste last | 9 (UI), 6 (schrijfpad slaat 'm over) | @@ -2307,18 +2385,25 @@ Ontbreekt er een rij, dan is die taak niet af — vul hem aan vóór je deze sta | Doorgeschoven punt d (ribbon-enable-check) | 2 | **Drie plekken waar dit plan concretiseert (gemarkeerd als KEUZE VAN DIT PLAN).** -1. Het paneel is een **drill-down** binnen `ResourceOccupancyView`, geen modale `Dialog` (taak 8). +1. ~~Het paneel is een **drill-down** binnen `ResourceOccupancyView`, geen modale `Dialog`~~ — + **teruggedraaid door de eigenaar (besluit 2026-08-31, taak 8): een losse dialoog, via de gedeelde + `Dialog` en `hasBlockingDialogOpen`.** Zie de gevolgen daarvan in taken 9–13 en 15. 2. De **tune-state woont in `ui`**, niet in `DOCUMENT_FIELDS` — met de volledige redenering in de - scope-bak bovenaan. + scope-bak bovenaan. **Bevestigd door de eigenaar (besluit 2026-08-31): sessie-stand, geen + projectbestand.** 3. De **vingerafdruk is de mutatieteller PLUS de referentieset**; de teller alléén, zoals §6a hem beschrijft, mist een gecoalesceerde sleepreeks en `runCPM` (taak 4). -**Twee plekken die de eigenaar moet zien (AFWIJKING — VOORLEGGEN).** -1. **Pin/plafond zijn sessiegebonden, niet opgeslagen.** Wil de eigenaar dat een pin een herstart of - zelfs het IFC overleeft, dan verandert de betekenis van "pin" en hoort er een documentveld plus een - `OPS_`-pset bij. Dit plan bouwt de sessie-lezing. -2. **Documentwissel sluit het paneel** in plaats van alleen het voorstel te laten vervallen (taak 12, - stap 3). Eén regel verschil; de spec beschrijft het gedrag niet. +**Twee plekken die de eigenaar al gezien heeft — allebei besloten op 2026-08-31.** +1. **Pin/plafond zijn sessiegebonden, niet opgeslagen.** Stond hier als AFWIJKING — VOORLEGGEN: wil de + eigenaar dat een pin een herstart of zelfs het IFC overleeft, dan verandert de betekenis van "pin" + en hoort er een documentveld plus een `OPS_`-pset bij. **Besluit: nee — dit plan bouwt en houdt de + sessie-lezing; persistentie is een mogelijke latere uitbreiding, geen v1-werk.** Zie de bak "Waar de + tune-state woont" bovenaan. +2. **Documentwissel sluit de dialoog** in plaats van alleen het voorstel te laten vervallen (taak 12, + stap 3). Stond hier als open vraag ("de spec beschrijft het gedrag niet"). **Besluit: ja, sluiten — + en dat past ook beter bij de tweede beslissing van dezelfde dag (losse dialoog i.p.v. drill-down): + een dialoog heeft geen "eronder" om open te laten staan met een vervallen voorstel.** **Eén bevinding die dit plan onderweg oploste en die de eigenaar mag weten.** `levelResources` stripte in zijn baseline wél `levelingDelay` maar niet de leveling-**gaten**. Zonder taak 2 stap 2 zou een diff --git a/docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md b/docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md index 2801232b5..0092cf03a 100644 --- a/docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md +++ b/docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md @@ -3,10 +3,11 @@ Datum: 2026-08-17 · Herzien: 2026-08-27 — twee hyperkritische reviewronden (NO-GO → GO-MITS, alle punten verwerkt) én rebase op main (472 commits, o.a. de .mpp-etappe en de store-runtime-verbouwing). Herzien: 2026-08-31 — de lezing van "float eerst, uitschieter -minimaal" bevestigd tijdens etappe 2 (§11.6). Status: **ontwerp compleet** — alle -beslispunten besloten (§11); volgende stap: implementatieplan. De speelbare prototypes -staan als privé-artifacts: "Wie wijkt?" (rondes 1–3) en "Interface-lab" (vier -tune-bedieningen). +minimaal" bevestigd tijdens etappe 2 (§11.6); tijdens etappe 3 bevestigd dat de +tune-bediening (pin, plafond, rangorde) sessie-stand is en geen projectdata (§11.7). +Status: **ontwerp compleet** — alle beslispunten besloten (§11); volgende stap: +implementatieplan. De speelbare prototypes staan als privé-artifacts: "Wie wijkt?" +(rondes 1–3) en "Interface-lab" (vier tune-bedieningen). ## 1. Doel en aanleiding @@ -296,6 +297,11 @@ idee eigenaar —, verdeelbalk, communicerende vaten, fasestrook-handles). **Bes - **Bediening**: pointer-slepen (blijft werken buiten het element) én toetsenbord (`role="slider"`, pijltjes = één werkdag, Home/End, `aria-valuetext` met plafond, benutting en einddatum-effect); pin met `aria-pressed`. Snappen op hele werkdagen. +- **De tune-bediening is sessie-stand** (besluit eigenaar 2026-08-31, §11.7): pin, plafond en + rangorde zitten niet in het projectbestand en overleven geen app-herstart. Ctrl+Z in het + actieve document raakt ze nooit, en na een herstart begint de gebruiker neutraal + (float-gesorteerd, geen pins, geen plafonds). Persistentie ervan — tot en met een + `.ifc`-round-trip — blijft een mogelijke latere uitbreiding, geen v1-werk. - Twee **systeembevindingen** uit het lab blijven van kracht: (1) de som garanderen is de oplossing niet garanderen — elke stand vergt een echte haalbaarheidscheck; (2) de haalbare standen zijn schaars — de bediening toont "gevraagd X, dichtst haalbare Y" in plaats van @@ -383,3 +389,10 @@ Headless, in de bestaande suites: **Nee: dat ís de bestaande vroegste-venster-plaatsing van de motor (§4), geen tweede algoritme — besloten eigenaar 2026-08-31.** Een slimmere zoekstap blijft een mogelijke latere uitbreiding, geen v1-werk. +7. ~~Overleeft de tune-bediening (pin, plafond, rangorde) een app-herstart of een + `.ifc`-round-trip?~~ — **Nee, sessie-stand (§6), besloten eigenaar 2026-08-31.** Dit + bevestigt de keuze die etappe 3 al maakte: geen documentveld, geen `OPS_`-pset. Ctrl+Z in + het actieve document raakt de tune-state nooit; na een herstart begint de gebruiker + neutraal. Persistentie blijft een mogelijke latere uitbreiding, geen v1-werk — kiest de + eigenaar daar ooit voor, dan verandert de betekenis van de pin van "deze verdeelsessie" + naar "dit project ligt vast", en dat is een apart besluit. From 532dabd1a7e68e399305d2aa23187e80d03ff072 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:30:16 +0200 Subject: [PATCH 003/108] chore(types): LF-regeleinden in task.ts (B1c-plan3 taak 1, bevinding 10) Co-Authored-By: Claude Fable 5 --- src/types/task.ts | 990 +++++++++++++++++++++++----------------------- 1 file changed, 495 insertions(+), 495 deletions(-) diff --git a/src/types/task.ts b/src/types/task.ts index 30805c325..256ed13b9 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -1,495 +1,495 @@ -import type { CustomFieldValue } from '@/types/structure'; -export type { CustomTaskType } from '@/types/taskType'; - -export type TaskType = - | 'CONSTRUCTION' - | 'INSTALLATION' - | 'DEMOLITION' - | 'LOGISTIC' - | 'ATTENDANCE' - | 'MOVE' - | 'RENOVATION' - | 'MAINTENANCE' - | 'USERDEFINED'; - -/** - * Alle geldige `TaskType`-waarden als runtime-lijst voor de import-readers (IFC/CSV) — F5-g. - * `satisfies Record` dwingt af dat de tabel EXACT de union dekt: een nieuw - * TaskType-lid zonder sleutel geeft een compileerfout, een sleutel die geen lid is ook. Zo kan de - * lijst niet stil verouderen. De readers houden hun eigen normalisatie (IFC strip punten, CSV - * uppercase) en gebruiken alleen deze verzameling voor de geldigheidscheck. - */ -const TASK_TYPE_TABLE = { - CONSTRUCTION: true, - INSTALLATION: true, - DEMOLITION: true, - LOGISTIC: true, - ATTENDANCE: true, - MOVE: true, - RENOVATION: true, - MAINTENANCE: true, - USERDEFINED: true, -} satisfies Record; - -export const TASK_TYPES = Object.keys(TASK_TYPE_TABLE) as TaskType[]; - -export type TaskStatus = 'NOT_STARTED' | 'STARTED' | 'COMPLETED'; - -/** - * Datum-constraints (fase 2.3), P6-soft-semantiek: een constraint breekt nooit de - * netwerklogica — vroege-zijde types (SNET/FNET) zijn ondergrenzen in de forward pass, - * late-zijde types (SNLT/FNLT) bovengrenzen in de backward pass; overtreding uit zich - * als negatieve float, niet als verschoven balken. MSO/MFO werken als P6's "Start On"/ - * "Finish On": onder- én bovengrens tegelijk (de logica-brekende harde pin is bewust - * niet geïmplementeerd; zie docs/superpowers/specs/2026-07-02-constraints-deadlines-design.md). - * ALAP schuift de vroege datums op tot de vrije speling 0 is (P6-model). - */ -export type ConstraintType = 'ASAP' | 'ALAP' | 'SNET' | 'SNLT' | 'FNET' | 'FNLT' | 'MSO' | 'MFO'; - -export interface TaskConstraint { - type: ConstraintType; - /** Vereist voor alle types behalve ASAP/ALAP; wordt bij toepassing op een werkdag gesnapt. */ - date?: string; - /** OPTIONEEL — logica-brekende Mandatory-pin (fase 2.9). Alleen zinvol voor MSO/MFO. - * Afwezig/false ⇒ P6-soft "Start On"/"Finish On" (het huidige gedrag, byte-identiek). - * true ⇒ P6 Mandatory Start/Finish: pint ES én LF (MSO) resp. EF én LS (MFO) op de datum, - * overschrijft de logica, houdt TF=0 op de pin en drijft negatieve float upstream (§4.2). */ - hard?: boolean; -} - -/** - * Externe (cross-project) dependency (fase 2.9, §3.3). GEEN live multi-document-solve: de link - * rekent altijd op de bevroren `anchorDate` (P6 External Dates). `sourceMissing` is puur een - * UI-/versheids-signaal (§4.5/§5.5) — het gedrag hangt er niet van af. - */ -export interface ExternalLink { - id: string; - direction: 'predecessor' | 'successor'; // is de externe taak mijn voorganger of opvolger? - relType: 'FS' | 'SS' | 'FF' | 'SF'; - lagDays?: number; - lagMinutes?: number; // zelfde eenheid-conventie als Sequence (2.8b §3.3) - /** Bevroren driving-datum van de andere kant (P6 External Dates, Rapport B §3.1). */ - anchorDate: string; // date-only (dag) of datetime (uur) - sourceRef: { projectId: string; projectName?: string; taskId: string; taskName?: string; filePath?: string }; - /** true ⇒ bronproject niet beschikbaar; de link rekent op de gecachte anchorDate (ghost, §5.5). */ - sourceMissing: boolean; -} - -export type DurationType = 'WORKTIME' | 'ELAPSEDTIME'; - -/** - * De door de gebruiker gekozen, blijvende eenheid van een taakduur. - * - * INVARIANT: bij `days` is `scheduleDuration` de enige invoerbron; bij `hours` is - * `durationMinutes` de enige invoerbron. Een kalender bepaalt uitsluitend waar die hoeveelheid - * past en mag deze identiteit nooit afleiden, converteren of overschrijven. - */ -export type TaskDurationUnit = 'days' | 'hours'; - -/** - * Eén werkonderbreking in een gesplitste taak (MS Project: "split") — etappe "nul afwijkingen" - * (Z0/Z4 leidden dit af, Z7 consumeert het in de CPM: `CPMSolver.ts`/`duration.ts`'s - * `splitTotalSpanMinutes`). - * - * OFFSET-GEBASEERD, niet absoluut, in MINUTEN — MAAR (Z7-fixronde-H1, WORTELFIX: as-verwarring - * gecorrigeerd) NIET op de "zuivere werkduur"-as (`TaskTime.durationMinutes`s eenheid, die per - * definitie GEEN gaten telt). `afterMinutes`/`gapMinutes` staan op MPXJ/MSP's eigen cumulatieve - * `elapsedWorkMinutes`-as (zie `mppTimephased.ts`'s `TimephasedWorkPeriod`, de bron van deze - * afleiding, `Z4`-paragraaf in de moduleheader): die as loopt CUMULATIEF door de tijdgefaseerde - * periodes en telt daarbij ELKE periode mee — óók een periode met `workMinutes===0` (een gat telt - * dus zelf ook mee in hoeveel de as voor een VOLGEND gat al is opgeschoven). Voor een taak met twee - * of meer gaten is `afterMinutes` van het TWEEDE (en latere) gat dus NIET gelijk aan "zuivere - * werktijd sinds taakstart" — het incorporeert de voorgaande gaten al. Consumenten mogen `afterMinutes` - * daarom NOOIT rechtstreeks tegen een zuivere-werkduur-getal (`durationMinutesOf`/`scheduleDuration`) - * vergelijken of tegen een vast venster klemmen — dat trunceerde eerder legitieme gaten (reviewer- - * bewijs: `mpxj/junit/data/mpp14timephased.mpp`s "Task 5 - 24 Hour", `afterMinutes:1440`/ - * `gapMinutes:5760`, MSP's eigen finish reproduceert alleen via de ONGEKLEMDE as-wandeling - * `splitTotalSpanMinutes` in `duration.ts`, niet via een `[0, duur)`-venster). "Het restwerk hervat - * daarna" blijft de betekenis: elk gat is een periode waarin niet gewerkt wordt, op de eigen positie - * langs deze as — zie `duration.ts`'s moduleheader voor het volledige as-wandelalgoritme. - * - * Offsets in plaats van absolute datumparen om drie redenen: shift-invariant (een herberekening/ - * verplaatsing van de taak verschuift de gaten automatisch mee, absolute segmenten zouden bij elke - * herberekening verouderen), geen INPUT/COMPUTED-dubbelrol (de gaten zijn brondata; de absolute - * segmenten die de renderer/print tekenen zijn AFGELEID uit `earlyStart` + een kalenderwandeling), - * en een triviaal `moveProject.ts`-verdict (er staat geen datum in, dus "n/a" is aantoonbaar - * correct — zelfde taxonomie als `levelingDelay` — in plaats van een handgeschreven shift). - */ -export interface TaskSplitGap { - afterMinutes: number; - gapMinutes: number; - /** OPTIONEEL — waar dit gat vandaan komt (B1c-plan-2 taak 7, spec §4 "Herkomst"). AFWEZIG = - * brondata: een split die uit een `.mpp`-import komt (`deriveSplitGapsForTasks`) of die de - * gebruiker zelf via een importbestand meebracht — die wordt NOOIT door de nivelleerder - * aangeraakt. `'leveling'` = door de verdeler ingevoegde pauzedag: idempotent herschreven bij een - * nieuwe nivellering, gewist door "nivellering wissen"/"alles terugdraaien" - * (`clearLevelingGaps`, `taskDefaults.ts`), en gewist zodra de tijdbasis van de taak wijzigt (een - * gat op een verouderde as is geen planning maar ruis — de WIRING van die laatste invalidatie is - * etappe 3). Afwezig ⇒ byte-identiek voor elk bestaand bestand. */ - source?: 'leveling'; -} - -/** - * Eén rauwe periode uit een gedecodeerd .mpp-timephased-blok (Z14b — zie `Task.timephasedContours` - * se docblok voor de volledige rol/eigenaarsprincipe-toelichting). `afterMinutes`/`minutes` liggen - * op DEZELFDE cumulatieve-werkminuten-as als `TaskSplitGap.afterMinutes`/`gapMinutes` (taak-as, niet - * toewijzings-as — zie die interface se docblok); `minutes` is de periodelengte op die as - * (`elapsedWorkMinutesEnd − elapsedWorkMinutesStart` in `mppTimephased.ts`'s `TimephasedWorkPeriod`). - * `workMinutes` kan 0 zijn (een periode met verstreken werkminuten maar zonder werk — het gat dat - * `deriveSplitGapsFromPeriods` als split herkent). `kind` onderscheidt reeds-verricht werk - * (`'actual'`, MPXJ `getCompleteWork`) van resterend/gepland werk (`'remaining'`, MPXJ - * `getPlannedWork`) — dezelfde twee tracks die `deriveSplitGapsForTasks` al combineert. - */ -export interface TimephasedContourPeriod { - afterMinutes: number; - minutes: number; - workMinutes: number; - kind: 'actual' | 'remaining'; -} - -/** Alle rauwe contourperiodes van ÉÉN toewijzing van de taak (zie `Task.timephasedContours`). */ -export interface TaskTimephasedContour { - /** MSP's eigen resource-uniqueId (`AssignmentField.RESOURCE_UNIQUE_ID`) — PUUR herkomst/ - * traceability, geen live-resource-referentie (deze toewijzing is mogelijk allang gewijzigd of - * verdwenen in het app-model; de rauwe periode blijft desondanks staan, eigenaarsprincipe). - * `null` wanneer de toewijzing geen (vindbare) resource droeg. */ - resourceUid: number | null; - periods: TimephasedContourPeriod[]; -} - -/** - * MSP's eigen "Task Type" (Fixed Units/Fixed Duration/Fixed Work — `TaskField.TYPE`, `DataType. - * TASK_TYPE`). NIET te verwarren met `Task.taskType` hierboven (dat is een OPS-eigen domeinclassi- - * ficatie — CONSTRUCTION/INSTALLATION/…, een ander concept met toevallig een gelijkende naam). - * Eigenaarsbesluit 2026-08-18 (plan §10, punt 1): puur data, bewaard zodat een .mpp-import 'm niet - * weggooit — GEEN rekengedrag; geen enkele solverstap leest `Task.mspTaskType`. Task type als - * projecteigenschap-met-per-taak-keuze (en het bijbehorende bewerkgedrag) is een latere, aparte - * etappe (zelfde besluit, punt 2). - */ -export type MspTaskType = 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; - -/** - * Soort mijlpaal (fase 2.4, P6 Start/Finish Milestone). Dag-granulair grens-model: - * START ankert op een dagbegin, FINISH op een dageinde (einde werkdag F = begin - * eerstvolgende werkdag). undefined = automatisch: het anker volgt de bindende - * relatiezijde (FS/SS → start, FF/SF → finish) — het gedrag van vóór fase 2.4. - */ -export type MilestoneKind = 'START' | 'FINISH'; - -export interface TaskTime { - durationType: DurationType; - durationUnit: TaskDurationUnit; - /** Canonieke invoerbron wanneer `durationUnit === 'days'`: gehele werkbare kalenderdagen. */ - scheduleDuration: number; - /** Canonieke invoerbron wanneer `durationUnit === 'hours'`: exacte werkminuten. Oude data zonder - * `durationUnit` migreert deterministisch naar uren zodra dit veld aanwezig is; minuutprecisie - * uit imports blijft daarbij ongewijzigd. */ - durationMinutes?: number; - scheduleStart: string; // ISO 8601 — date-only in dag-modus, datetime in uur-modus - scheduleFinish: string; // ISO 8601 — date-only in dag-modus, datetime in uur-modus - /** OPTIONEEL — MSP's EIGEN opgeslagen hervattingsinstant voor een IN-PROGRESS-taak (`.mpp`- - * veld-id 99, `TaskField.RESUME`, `DataType.DATE`; Z12-herwerk, dossier out-of-sequence- - * actuals). Geen afgeleide/herberekende waarde — de invoer staat letterlijk in het bestand, - * net als `scheduleStart`/`scheduleFinish` hierboven (vandaar dezelfde rol, `TaskTimeInput`, - * i.p.v. `TaskTimeTracking` waar `actualStart`/`actualFinish` in zitten). `CPMSolver.ts` leest - * dit veld voor een aantoonbaar out-of-sequence FINISH_START-opvolger (`isOutOfSequenceFsPredecessor`): - * `finish = addWork(resume, remaining)` op de taak-EIGEN kalender — corpusmeting (fase 1): - * 17/17 exact op alle out-of-sequence-in-progress-bladtaken, 4/4 op de gemeten OzBuild- - * snapshots minuut-exact. Afwezig (niet-`.mpp`-bronnen, of een `.mpp`-bestand waarvan de field - * map veld 99 mist) ⇒ het bestaande RETAINED_LOGIC/`resumeFromActualElapsed`-pad, ongewijzigd. */ - resume?: string; - /** OPTIONEEL — spiegelt `resume` hierboven (`.mpp`-veld-id 100, `TaskField.STOP`): MSP's eigen - * grens van het reeds-afgewerkte deel. Door GEEN enkele solverberekening gelezen (de - * `finish = addWork(resume, remaining)`-formule haalde 17/17 zonder `stop`) — hier alleen - * meegenomen als rauw, ongebruikt feit voor een latere taak (splits-/actual-grens-rendering). */ - stop?: string; - - // CPM-computed - earlyStart: string; - earlyFinish: string; - lateStart: string; - lateFinish: string; - freeFloat: number; // work days (fractioneel in uur-modus, §5.5) - totalFloat: number; // work days (fractioneel in uur-modus, §5.5) - isCritical: boolean; - /** OPTIONEEL — interfererende speling = totalFloat − freeFloat (getekend, fractioneel in - * uur-modus; fase 2.9, §4.6). Alleen geschreven wanneer de analyse-laag draait; afwezig ⇒ - * byte-identiek default-document. */ - interferingFloat?: number; - /** OPTIONEEL — near-critical-markering (fase 2.9, §4.6). Alleen geschreven bij ingestelde drempel. */ - isNearCritical?: boolean; - /** OPTIONEEL — float-path-nummer (1 = meest kritiek; fase 2.9, §4.6). Alleen geschreven bij floatPaths. */ - floatPath?: number; - - // Tracking - actualStart?: string; - actualFinish?: string; - actualDuration?: number; - remainingTime?: number; - /** OPTIONEEL — resterend werk in integer MINUTEN (uur-modus voortgang, fase 2.8b §5.3). */ - remainingMinutes?: number; - completion: number; // 0.0 - 1.0 -} - -/** - * TYPE-ONLY rol-splitsing van `TaskTime` (Bevinding A7). `TaskTime` mengt vier rollen in één plat - * type; een consument kan niet zien welke velden hij mag SCHRIJVEN en welke `runCPM` OVERSCHRIJFT. - * De vier `Pick<>`-aliassen hieronder maken die rollen expliciet — puur documentair/afdwingend, - * ZONDER runtime-verandering en ZONDER `TaskTime` zelf te herstructureren. `Task.time` blijft de - * volledige `TaskTime`; alle bestaande lezers/schrijvers blijven ongewijzigd. Elk `TaskTime`-veld - * hoort in PRECIES één rol; de compile-assert onderaan dwingt volledige + disjuncte dekking af, - * zodat een nieuw veld gedwongen wordt gecategoriseerd. - */ - -/** INVOER — door de gebruiker/importers geschreven. `runCPM` raakt deze normaliter niet aan, MAAR - * overschrijft in UUR-modus `scheduleStart`/`scheduleFinish` (naar de berekende instants, - * `scheduleSlice.ts`) en de passende afgeleide duurbron (`scheduleDuration` of `durationMinutes`) voor - * HAMMOCK-taken (afgeleide span, `CPMSolver`). `durationType` blijft puur invoer. */ -export type TaskTimeInput = Pick< - TaskTime, - 'durationType' | 'durationUnit' | 'scheduleDuration' | 'durationMinutes' | 'scheduleStart' | 'scheduleFinish' - | 'resume' | 'stop' ->; - -/** CPM-COMPUTED — geschreven door `runCPM` (CPMSolver). Normaliter niet handmatig muteren; de - * legitieme uitzonderingen zijn restore-paden (IFC-import herstelt deze velden, benchmark-runner). */ -export type TaskTimeComputed = Pick< - TaskTime, - 'earlyStart' | 'earlyFinish' | 'lateStart' | 'lateFinish' | 'freeFloat' | 'totalFloat' | 'isCritical' ->; - -/** ANALYSE (fase 2.9, §4.6) — afgeleiden bovenop de CPM-output. `interferingFloat` (= tf − ff) wordt - * ALTIJD elke `runCPM` (her)berekend en teruggeschreven; `isNearCritical`/`floatPath` alleen wanneer - * de bijbehorende optie/drempel draait (anders gewist, om stale markering te voorkomen). */ -export type TaskTimeAnalysis = Pick< - TaskTime, - 'interferingFloat' | 'isNearCritical' | 'floatPath' ->; - -/** TRACKING — voortgang/actuals; door de gebruiker geschreven, niet door de solver. */ -export type TaskTimeTracking = Pick< - TaskTime, - 'actualStart' | 'actualFinish' | 'actualDuration' | 'remainingTime' | 'remainingMinutes' | 'completion' ->; - -// --- Compile-assert: de vier rollen vormen een EXACTE partitie van `keyof TaskTime` --------------- -// Puur type-niveau (verdwijnt bij compilatie). Dwingt twee dingen af, zodat de rol-splitsing niet -// stil kan verouderen: -// 1) VOLLEDIGHEID — elk TaskTime-veld zit in minstens één rol (een nieuw, niet-gecategoriseerd -// veld laat `_UncategorizedTaskTimeField` iets anders dan `never` worden → tsc-fout). -// 2) DISJUNCTHEID — geen veld zit in twee rollen (een dubbel geplaatst veld laat `_OverlappingRole` -// iets anders dan `never` worden → tsc-fout). -// (Elke Pick is per definitie al een subset van `keyof TaskTime`, dus een getypte sleutel die niet -// bestaat geeft al bij de Pick zelf een fout — een aparte "extra keys"-check is overbodig.) -type _TaskTimeRoleKeys = - | keyof TaskTimeInput - | keyof TaskTimeComputed - | keyof TaskTimeAnalysis - | keyof TaskTimeTracking; - -type _UncategorizedTaskTimeField = Exclude; - -type _OverlappingRole = - | (keyof TaskTimeInput & keyof TaskTimeComputed) - | (keyof TaskTimeInput & keyof TaskTimeAnalysis) - | (keyof TaskTimeInput & keyof TaskTimeTracking) - | (keyof TaskTimeComputed & keyof TaskTimeAnalysis) - | (keyof TaskTimeComputed & keyof TaskTimeTracking) - | (keyof TaskTimeAnalysis & keyof TaskTimeTracking); - -// `[T] extends [never]` is de robuuste "is exact never"-check (geen distributie over unies). -type _Expect = T; -type _IsNever = [T] extends [never] ? true : false; -// Deze twee asserts compileren alleen wanneer beide condities `never` zijn. Waarde-vorm + `void` -// (zoals snapshot.ts/documentContract.ts) zodat `noUnusedLocals` ze niet als dood markeert; de -// twee const-declaraties minificeren weg (inert). -const _assertTaskTimeComplete: _Expect<_IsNever<_UncategorizedTaskTimeField>> = true; -const _assertTaskTimeDisjoint: _Expect<_IsNever<_OverlappingRole>> = true; -void _assertTaskTimeComplete; -void _assertTaskTimeDisjoint; - -export interface Task { - id: string; - name: string; - description: string; - wbsCode: string; - taskType: TaskType; - /** Alleen bij `USERDEFINED`: stabiele verwijzing naar de projectcatalogus. Ontbreekt bij - * oudere IFC-bestanden die USERDEFINED zonder OPS-definitie gebruikten. */ - customTaskTypeId?: string; - status: TaskStatus; - isMilestone: boolean; - /** Alleen relevant bij isMilestone; undefined = automatisch (zie MilestoneKind). */ - milestoneKind?: MilestoneKind; - /** Verplichte (contractuele) mijlpaal — inspectie-/keurings-/opleverpunt. Markering - * voor rapportage & Gantt; datumbewaking loopt via constraint/deadline (fase 2.3). */ - mandatory?: boolean; - /** Leveling-prioriteit (MSP-conventie, P6 "Activity Priority" analoog): 0–1000, default 500. - * 1000 = "Do Not Level" (vastgepind, wordt door de nivelleerder nooit verschoven). Ongebruikt - * vóór fase 2.5 (was hardcoded 0 overal); vanaf 2.5 stuurt dit de nivelleervolgorde. */ - priority: number; - /** Vertraging in werkdagen t.o.v. de precedence-feasible early start (de ES die de forward - * pass berekent nadat óók de voorgangers hun levelingDelay hebben gekregen — NIET t.o.v. de - * oorspronkelijke CPM-ES, dat zou voorgangersverschuivingen dubbel tellen). Gezet door de - * nivelleerder (fase 2.5, nog niet gebouwd). undefined = geen nivellering toegepast. - * "Nivellering wissen" zet dit overal terug naar undefined. */ - levelingDelay?: number; - /** OPTIONEEL — subdag-precisie voor `levelingDelay` (etappe "nul afwijkingen", Z0, voorlopig - * ONGEBRUIKT). MSP levert de nivelleervertraging in tienden-van-een-minuut; `levelingDelay` - * (hele werkdagen) kan dat niet exact dragen. Zelfde precedent als `durationMinutes` naast - * `scheduleDuration`: AANWEZIG ⇒ bron van waarheid, AFWEZIG ⇒ `levelingDelay` (werkdagen) blijft - * de bron (byte-identiek). Nog door geen enkele solver-stap gelezen. */ - levelingDelayMinutes?: number; - /** OPTIONEEL — begeleidt `levelingDelayMinutes` (Z0, voorlopig ONGEBRUIKT). MSP's - * `LevelingDelayFormat` kent een ELAPSED-variant (kloktijd i.p.v. werktijd), net als - * `durationType`/MSPDI's elapsed-vlag op een duur. Afwezig ⇒ WORKTIME (byte-identiek). Alleen - * betekenisvol wanneer `levelingDelayMinutes` ook gezet is. */ - levelingDelayElapsed?: boolean; - /** OPTIONEEL — werkonderbrekingen (MS Project "split"), zie `TaskSplitGap` (die interface se - * eigen docblok draagt de as-definitie — lees die vóórdat je dit veld raadpleegt). Sinds Z4 ECHT - * gevuld: `mppReader.ts` leidt dit af uit de timephased-werksegmenten van een `.mpp`-import - * (`deriveSplitGapsForTasks`, `mppTimephased.ts`), en round-trippt sinds Z14 door IFC - * (`OPS_TaskSplits`-pset, `ifcPsets.ts`). Sinds Z7 ECHT geconsumeerd door de CPM: `CPMSolver.ts`'s - * vier aangrijpingspunten (`addDurationChecked`/`subDuration`/`finishFromStart`/`startFromFinish` - * + de IN-PROGRESS-restwerktak) wandelen dit via `duration.ts`'s `splitTotalSpanMinutes`. De - * renderer tekent sinds Z15 al een onderbroken balk in Gantt/print/PDF. Afwezig ⇒ geen splits - * (byte-identiek). */ - splitGaps?: TaskSplitGap[]; - /** OPTIONEEL — GELAAGDE BESLISKOLOM (Z8-herwerkronde, etappe "nul afwijkingen"). Drie van de vijf - * lagen raken taakvelden; deze eerste, `timephasedFinishFloor`, is LAAG 3: het MAXIMUM van - * `AssignmentField.FINISH` over de toewijzingen van deze taak die ≥1 ÉCHTE gedecodeerde - * timephased-periode droegen (Format A/B, `mppTimephased.ts` — NIET het kale "een `TBkndAssn`- - * record bestaat"-signaal, dat bleek een vrijwel volledige cirkelmeting, zie de Z8-herwerkronde- - * rapportage). Alleen gezet op taken met `time.completion === 0` (lagen 1/2 — voltooid resp. - * in-progress — plannen op hun bestaande actuals-/resume-paden, GEEN venster, zie - * `CPMSolver.ts`). MSP's EIGEN al berekende antwoord — geen eigen kalenderwandeling nodig. - * `CPMSolver.ts`'s `forwardPass` past 'm toe op dezelfde plek als `sfFinishFloor` (die blijft een - * onbreekbare `Math.max`-ondergrens; het venster zelf overschrijft de kale duur-gebaseerde - * berekening volledig), nooit bij een harde MFO/MSO-pin, alleen in uur-modus. ISO-instant, - * minuutprecisie (`formatInstant(d,'hour')`). Mutueel exclusief met `timephasedDurationWalks` - * (LAAG 4, zie die docstring) — `mppReader.ts` zet nooit beide op dezelfde taak. Afwezig ⇒ geen - * echte periode-data op deze taak, byte-identiek. Round-tript sinds Z14b via `OPS_TimephasedWindow` - * (`ifcPsets.ts`). EIGENAARSPRINCIPE (2026-08-18) — dit veld is AFGELEIDE sturing: een - * inhoudelijke bewerking (duur/datums/kalender/toewijzingen) wist dit veld weer (zie - * `taskDefaults.ts`'s `clearTimephasedWindow`, aangeroepen vanuit `taskSlice.ts`/ - * `mcpTransaction.ts`/`resourceSlice.ts`) — de RAUWE bron blijft dan in `timephasedContours` - * staan, dat wordt nooit gewist door een edit. */ - timephasedFinishFloor?: string; - /** OPTIONEEL — RAUW startanker (Z8-herwerkronde), gebruikt door zowel LAAG 3 als LAAG 4: het - * MINIMUM van `AssignmentField.START` over de LAAG-3-toewijzingen (bij `timephasedFinishFloor`) - * resp. de eerste/vroegste ankerdatum van de LAAG-4-toewijzingen (bij `timephasedDurationWalks` - * — daar puur het startpunt van de wandeling, de LAAG-4-finish zelf komt uit die wandeling, niet - * uit dit veld). Uitsluitend gebruikt voor een taak ZONDER voorganger (`CPMSolver.ts`'s - * `forwardPass`, `preds.length===0`-tak) wier eigen `time.scheduleStart` buiten de taak- - * kalender-band ligt maar de toewijzing wél binnen haar EIGEN resourcekalender (corpusvoorbeeld: - * een "Night Shift"-resource op 23:00, buiten de taak se "Standard"-band) — MSP snapt zo'n - * anker niet, de gewone `ownAnchor`-snap deed dat vóór Z8 wél. Afwezig ⇒ byte-identiek. Zelfde - * IFC-round-trip/eigenaarsprincipe-kanttekening als `timephasedFinishFloor`. */ - timephasedStartAnchor?: string; - /** OPTIONEEL — LAAG 4 van de Z8-herwerkronde-beslistabel: taken met een VLAK timephased-record - * (`blockCount===0`, GEEN echte periode — dus `timephasedFinishFloor` blijft hier afwezig) MAAR - * wier toewijzing(en) een NIET-STANDAARD resourcekalender dragen (corpusbewijs: de "Night Shift"/ - * "24 Hours"-families in `mpp14timephased.mpp`, en `mpp14resource.mpp`'s "Task A" — een gemengde - * toewijzingsset waar één van de drie resources een sterk afwijkende kalender draagt). Anders dan - * `timephasedFinishFloor` (een GELEZEN, bevroren MSP-antwoord) is dit een VERSE HERBEREKENING: - * `CPMSolver.ts` wandelt per item DOOR de toewijzing se EIGEN resourcekalender vanaf haar - * `anchor`, en neemt het MAXIMUM over de lijst (dezelfde "langste toewijzing bepaalt de finish"- - * regel als laag 3, hier alleen zonder gelezen eindantwoord). N2-CORRECTIE (Opus-her-check, - * tweede ronde): dit "GEEN invalidatie nodig, stroomt vanzelf mee" gold zo alleen voor een item - * ZONDER `workMinutes` (het wandelt dan `task.time.durationMinutes`, edit-live). Een item MET - * `workMinutes` (zie hieronder) wandelt die BEVROREN waarde in plaats daarvan — die volgt een - * latere duur-/datum-/kalenderwijziging dus NIET vanzelf. Vandaar dat `updateTask`/ - * `updateTaskFields`/`patchTaskFields` deze lijst sinds N2 wél wissen zodra ze zo'n item bevat - * (`taskDefaults.ts`'s `timephasedDurationWalksHaveFrozenWork`/`clearTimephasedDurationWalks`), - * precies zoals bij `timephasedFinishFloor`/`timephasedStartAnchor` — de taak valt dan terug op - * laag 5 tot een volgende .mpp-import de lijst opnieuw vult. - * `resourceCalendarId` verwijst in de gedeelde kalenderbibliotheek (`CPMSolver`'s `registry`, - * `resolveCalendar`); een taak zonder voorganger gebruikt bovendien het VROEGSTE `anchor` uit - * deze lijst als `timephasedStartAnchor` (zie hierboven). Mutueel exclusief met - * `timephasedFinishFloor`. - * - * `workMinutes` (Z19, residu-iteratie "nul afwijkingen") — optioneel per item; ONTBREEKT het, - * dan wandelt `CPMSolver.ts` `task.time.durationMinutes` (de volle taakduur, edit-live). De - * garantie zit op de PRODUCERENDE tak in `mppReader.ts`, NIET op de array-lengte: 19 corpustaken - * dragen een lijst van lengte 1 mét `workMinutes` (de MATERIAL-gefilterde >1-tak). - * Corpusbewijs: 9/9 (mpp14timephased2.mpp) en 20/20 (mpp14timephasedsegments.mpp) op de volledige - * populatie, en de 0%-populatie van mpp14timephased.mpp (zie de Z8-herwerkronde-rapportage). AANWEZIG - * bij >1 item — bij meerdere GELIJKTIJDIGE toewijzingen wandelt geen enkele toewijzing de volle - * taakduur; elke toewijzing wandelt alleen haar EIGEN, per-toewijzing gedecodeerde werk-aandeel - * (`decodeAssignmentWorkMinutes`, `mppReader.ts`), en de LANGSTE wandeling bepaalt de finish - * (die volle-duur-aanname is BEWEZEN onjuist bij >1 toewijzing — - * `mpp14resource.mpp`'s "Task A", drie toewijzingen, gaf zonder apportionering een ~2× te late - * datum). Afwezig ⇒ byte-identiek. Round-tript sinds Z14b via `OPS_TimephasedWindow` - * (`ifcPsets.ts`). Wordt NOOIT door `clearTimephasedWindow` gewist (dat blijft `timephasedFinishFloor`/ - * `timephasedStartAnchor`-only) — maar sinds N2 wél door de aparte `clearTimephasedDurationWalks`, - * onder de voorwaarde hierboven (`workMinutes` gezet op minstens één item). Zie - * `taskDefaults.ts`'s hoofddocblok voor de volledige triggerset-toelichting. */ - timephasedDurationWalks?: { anchor: string; resourceCalendarId: string; workMinutes?: number }[]; - /** OPTIONEEL — RAUWE, gedecodeerde .mpp-timephased-contourperiodes (Z14b, eigenaarsprincipe - * 2026-08-18: "er gaat nooit stilzwijgend broninformatie verloren, ook niet ná bewerken"). Dit is - * de bron ONDER `splitGaps`/`timephasedFinishFloor`/`timephasedStartAnchor` — die drie zijn - * AFGELEIDE sturing (splitGaps voedt de CPM rechtstreeks, de twee venstervelden worden bij een - * inhoudelijke bewerking ontkoppeld, zie `clearTimephasedWindow` in `taskDefaults.ts`) — dit veld - * blijft ALTIJD staan, ook ná zo'n bewerking: geen enkele actie in deze etappe wist het. Eén - * entry per toewijzing van deze taak die daadwerkelijk een ECHTE dagverdeling droeg (vlakke - * samenvattingsrecords — MPXJ's `blockCount===0`-speciale geval — tellen niet mee, zelfde filter - * als `mppReader.ts`'s `deriveSplitGapsForTasks` al toepast op `splitGaps`). Periodes liggen op - * DEZELFDE as als `TaskSplitGap` (cumulatieve werkminuten sinds taakstart — zie die interface se - * eigen "TAAK-AS, NIET TOEWIJZINGS-AS"-docblok voor de volledige as-definitie). Puur data: geen - * enkele solverstap leest dit veld — voedingsdata voor de latere contour-engine (eigenaarsbesluit - * 2026-08-18, plan §10). Round-tript via `OPS_TimephasedContours` (`ifcPsets.ts`). Afwezig ⇒ geen - * echte periode-data op deze taak (byte-identiek). */ - timephasedContours?: TaskTimephasedContour[]; - /** OPTIONEEL — handmatig geplande taak (MS Project "Manually Scheduled", Z0, voorlopig - * ONGEBRUIKT). De datums zelf blijven `time.scheduleStart`/`scheduleFinish` — dit is puur het - * SIGNAAL dat de solver ze straks RAUW moet respecteren (geen kalendersnap, geen relatiedruk, - * geen constraint-afdwinging) in plaats van te herrekenen; nog door geen enkele solver-stap - * gelezen. Afwezig/false ⇒ normale (auto-geplande) taak, byte-identiek. */ - manuallyScheduled?: boolean; - /** OPTIONEEL — MSP's eigen Task Type bij .mpp-import (zie `MspTaskType`). Puur data (eigenaars- - * besluit 2026-08-18): geen enkele solverstap leest dit veld. Afwezig ⇒ veld niet gelezen/niet - * van toepassing (byte-identiek), NIET hetzelfde als "FIXED_UNITS" (MSP's eigen stille default - * voor een nieuwe taak) — een afwezig veld is hier "onbekend", geen aanname. Round-tript via - * `OPS_MspTaskType` (`ifcPsets.ts`). */ - mspTaskType?: MspTaskType; - /** OPTIONEEL — MSP's "Effort Driven"-vlag bij .mpp-import (`TaskField.EFFORT_DRIVEN`). Puur data, - * zelfde eigenaarsbesluit als `mspTaskType` hierboven — geen enkele solverstap leest dit veld. - * Afwezig/false ⇒ byte-identiek. Round-tript via `OPS_MspTaskType` (`ifcPsets.ts`, zelfde pset - * als `mspTaskType` — het is hetzelfde MSP-taaktypeconcept-paar). */ - effortDriven?: boolean; - parentId: string | null; // WBS parent - childIds: string[]; // WBS children - time: TaskTime; - resourceIds: string[]; - color?: string; - /** Activity-code-toewijzingen: codetype-id → waarde-id (max één waarde per type, P6-invariant). */ - activityCodes?: Record; - /** Custom-field-waarden: velddefinitie-id → waarde (getypeerd volgens CustomFieldDef.type). */ - customFields?: Record; - /** Datum-constraint (fase 2.3); afwezig = ASAP. PRIMAIR. */ - constraint?: TaskConstraint; - /** OPTIONEEL — SECUNDAIRE constraint (fase 2.9, P6). Altijd soft (hard verboden op secundair). - * Combinatie-regel (P6, Rapport B §1.3): secundair NIET toegestaan als primair Start On/Finish On/ - * Mandatory is; verder één forward-type + één backward-type die elkaar niet tegenspreken. - * Afwezig ⇒ geen tweede grens (byte-identiek). */ - constraint2?: TaskConstraint; - /** OPTIONEEL — hammock/LOE (fase 2.9, §3.2/§4.4). Afwezig/false ⇒ gewone taak (byte-identiek). - * true ⇒ duur wordt AFGELEID (span tussen start-driver en finish-driver); - * scheduleDuration/durationMinutes worden genegeerd als invoer en overschreven met de span. - * Uitgesloten van het kritieke pad (isCritical altijd false). */ - isHammock?: boolean; - /** OPTIONEEL — externe (cross-project) dependencies (fase 2.9, §3.3). Afwezig ⇒ geen (byte-identiek). */ - externalLinks?: ExternalLink[]; - /** Zachte deadline (MSP-model): begrenst alleen de late finish — balken bewegen nooit; - * overschrijding (earlyFinish > deadline) geeft negatieve float + waarschuwing. */ - deadline?: string; - /** OPTIONEEL — id in de kalender-bibliotheek (fase 2.8a, §4). undefined = projectkalender - * (project.calendarId). Symmetrisch met Resource.calendarId. Bepaalt de kalender waarin de DUUR - * en de constraints van deze taak rekenen (§5). */ - calendarId?: string; - /** OPTIONEEL — vrije aantekeningen/checklist per taak (fase 2.10, item 1). Afwezig ⇒ geen - * aantekeningen (byte-identiek). Puur een array-veld, geen dedicated store-acties nodig - * (mutaties via `updateTask(taskId, { notes })`). */ - notes?: { id: string; text: string; done: boolean }[]; -} - +import type { CustomFieldValue } from '@/types/structure'; +export type { CustomTaskType } from '@/types/taskType'; + +export type TaskType = + | 'CONSTRUCTION' + | 'INSTALLATION' + | 'DEMOLITION' + | 'LOGISTIC' + | 'ATTENDANCE' + | 'MOVE' + | 'RENOVATION' + | 'MAINTENANCE' + | 'USERDEFINED'; + +/** + * Alle geldige `TaskType`-waarden als runtime-lijst voor de import-readers (IFC/CSV) — F5-g. + * `satisfies Record` dwingt af dat de tabel EXACT de union dekt: een nieuw + * TaskType-lid zonder sleutel geeft een compileerfout, een sleutel die geen lid is ook. Zo kan de + * lijst niet stil verouderen. De readers houden hun eigen normalisatie (IFC strip punten, CSV + * uppercase) en gebruiken alleen deze verzameling voor de geldigheidscheck. + */ +const TASK_TYPE_TABLE = { + CONSTRUCTION: true, + INSTALLATION: true, + DEMOLITION: true, + LOGISTIC: true, + ATTENDANCE: true, + MOVE: true, + RENOVATION: true, + MAINTENANCE: true, + USERDEFINED: true, +} satisfies Record; + +export const TASK_TYPES = Object.keys(TASK_TYPE_TABLE) as TaskType[]; + +export type TaskStatus = 'NOT_STARTED' | 'STARTED' | 'COMPLETED'; + +/** + * Datum-constraints (fase 2.3), P6-soft-semantiek: een constraint breekt nooit de + * netwerklogica — vroege-zijde types (SNET/FNET) zijn ondergrenzen in de forward pass, + * late-zijde types (SNLT/FNLT) bovengrenzen in de backward pass; overtreding uit zich + * als negatieve float, niet als verschoven balken. MSO/MFO werken als P6's "Start On"/ + * "Finish On": onder- én bovengrens tegelijk (de logica-brekende harde pin is bewust + * niet geïmplementeerd; zie docs/superpowers/specs/2026-07-02-constraints-deadlines-design.md). + * ALAP schuift de vroege datums op tot de vrije speling 0 is (P6-model). + */ +export type ConstraintType = 'ASAP' | 'ALAP' | 'SNET' | 'SNLT' | 'FNET' | 'FNLT' | 'MSO' | 'MFO'; + +export interface TaskConstraint { + type: ConstraintType; + /** Vereist voor alle types behalve ASAP/ALAP; wordt bij toepassing op een werkdag gesnapt. */ + date?: string; + /** OPTIONEEL — logica-brekende Mandatory-pin (fase 2.9). Alleen zinvol voor MSO/MFO. + * Afwezig/false ⇒ P6-soft "Start On"/"Finish On" (het huidige gedrag, byte-identiek). + * true ⇒ P6 Mandatory Start/Finish: pint ES én LF (MSO) resp. EF én LS (MFO) op de datum, + * overschrijft de logica, houdt TF=0 op de pin en drijft negatieve float upstream (§4.2). */ + hard?: boolean; +} + +/** + * Externe (cross-project) dependency (fase 2.9, §3.3). GEEN live multi-document-solve: de link + * rekent altijd op de bevroren `anchorDate` (P6 External Dates). `sourceMissing` is puur een + * UI-/versheids-signaal (§4.5/§5.5) — het gedrag hangt er niet van af. + */ +export interface ExternalLink { + id: string; + direction: 'predecessor' | 'successor'; // is de externe taak mijn voorganger of opvolger? + relType: 'FS' | 'SS' | 'FF' | 'SF'; + lagDays?: number; + lagMinutes?: number; // zelfde eenheid-conventie als Sequence (2.8b §3.3) + /** Bevroren driving-datum van de andere kant (P6 External Dates, Rapport B §3.1). */ + anchorDate: string; // date-only (dag) of datetime (uur) + sourceRef: { projectId: string; projectName?: string; taskId: string; taskName?: string; filePath?: string }; + /** true ⇒ bronproject niet beschikbaar; de link rekent op de gecachte anchorDate (ghost, §5.5). */ + sourceMissing: boolean; +} + +export type DurationType = 'WORKTIME' | 'ELAPSEDTIME'; + +/** + * De door de gebruiker gekozen, blijvende eenheid van een taakduur. + * + * INVARIANT: bij `days` is `scheduleDuration` de enige invoerbron; bij `hours` is + * `durationMinutes` de enige invoerbron. Een kalender bepaalt uitsluitend waar die hoeveelheid + * past en mag deze identiteit nooit afleiden, converteren of overschrijven. + */ +export type TaskDurationUnit = 'days' | 'hours'; + +/** + * Eén werkonderbreking in een gesplitste taak (MS Project: "split") — etappe "nul afwijkingen" + * (Z0/Z4 leidden dit af, Z7 consumeert het in de CPM: `CPMSolver.ts`/`duration.ts`'s + * `splitTotalSpanMinutes`). + * + * OFFSET-GEBASEERD, niet absoluut, in MINUTEN — MAAR (Z7-fixronde-H1, WORTELFIX: as-verwarring + * gecorrigeerd) NIET op de "zuivere werkduur"-as (`TaskTime.durationMinutes`s eenheid, die per + * definitie GEEN gaten telt). `afterMinutes`/`gapMinutes` staan op MPXJ/MSP's eigen cumulatieve + * `elapsedWorkMinutes`-as (zie `mppTimephased.ts`'s `TimephasedWorkPeriod`, de bron van deze + * afleiding, `Z4`-paragraaf in de moduleheader): die as loopt CUMULATIEF door de tijdgefaseerde + * periodes en telt daarbij ELKE periode mee — óók een periode met `workMinutes===0` (een gat telt + * dus zelf ook mee in hoeveel de as voor een VOLGEND gat al is opgeschoven). Voor een taak met twee + * of meer gaten is `afterMinutes` van het TWEEDE (en latere) gat dus NIET gelijk aan "zuivere + * werktijd sinds taakstart" — het incorporeert de voorgaande gaten al. Consumenten mogen `afterMinutes` + * daarom NOOIT rechtstreeks tegen een zuivere-werkduur-getal (`durationMinutesOf`/`scheduleDuration`) + * vergelijken of tegen een vast venster klemmen — dat trunceerde eerder legitieme gaten (reviewer- + * bewijs: `mpxj/junit/data/mpp14timephased.mpp`s "Task 5 - 24 Hour", `afterMinutes:1440`/ + * `gapMinutes:5760`, MSP's eigen finish reproduceert alleen via de ONGEKLEMDE as-wandeling + * `splitTotalSpanMinutes` in `duration.ts`, niet via een `[0, duur)`-venster). "Het restwerk hervat + * daarna" blijft de betekenis: elk gat is een periode waarin niet gewerkt wordt, op de eigen positie + * langs deze as — zie `duration.ts`'s moduleheader voor het volledige as-wandelalgoritme. + * + * Offsets in plaats van absolute datumparen om drie redenen: shift-invariant (een herberekening/ + * verplaatsing van de taak verschuift de gaten automatisch mee, absolute segmenten zouden bij elke + * herberekening verouderen), geen INPUT/COMPUTED-dubbelrol (de gaten zijn brondata; de absolute + * segmenten die de renderer/print tekenen zijn AFGELEID uit `earlyStart` + een kalenderwandeling), + * en een triviaal `moveProject.ts`-verdict (er staat geen datum in, dus "n/a" is aantoonbaar + * correct — zelfde taxonomie als `levelingDelay` — in plaats van een handgeschreven shift). + */ +export interface TaskSplitGap { + afterMinutes: number; + gapMinutes: number; + /** OPTIONEEL — waar dit gat vandaan komt (B1c-plan-2 taak 7, spec §4 "Herkomst"). AFWEZIG = + * brondata: een split die uit een `.mpp`-import komt (`deriveSplitGapsForTasks`) of die de + * gebruiker zelf via een importbestand meebracht — die wordt NOOIT door de nivelleerder + * aangeraakt. `'leveling'` = door de verdeler ingevoegde pauzedag: idempotent herschreven bij een + * nieuwe nivellering, gewist door "nivellering wissen"/"alles terugdraaien" + * (`clearLevelingGaps`, `taskDefaults.ts`), en gewist zodra de tijdbasis van de taak wijzigt (een + * gat op een verouderde as is geen planning maar ruis — de WIRING van die laatste invalidatie is + * etappe 3). Afwezig ⇒ byte-identiek voor elk bestaand bestand. */ + source?: 'leveling'; +} + +/** + * Eén rauwe periode uit een gedecodeerd .mpp-timephased-blok (Z14b — zie `Task.timephasedContours` + * se docblok voor de volledige rol/eigenaarsprincipe-toelichting). `afterMinutes`/`minutes` liggen + * op DEZELFDE cumulatieve-werkminuten-as als `TaskSplitGap.afterMinutes`/`gapMinutes` (taak-as, niet + * toewijzings-as — zie die interface se docblok); `minutes` is de periodelengte op die as + * (`elapsedWorkMinutesEnd − elapsedWorkMinutesStart` in `mppTimephased.ts`'s `TimephasedWorkPeriod`). + * `workMinutes` kan 0 zijn (een periode met verstreken werkminuten maar zonder werk — het gat dat + * `deriveSplitGapsFromPeriods` als split herkent). `kind` onderscheidt reeds-verricht werk + * (`'actual'`, MPXJ `getCompleteWork`) van resterend/gepland werk (`'remaining'`, MPXJ + * `getPlannedWork`) — dezelfde twee tracks die `deriveSplitGapsForTasks` al combineert. + */ +export interface TimephasedContourPeriod { + afterMinutes: number; + minutes: number; + workMinutes: number; + kind: 'actual' | 'remaining'; +} + +/** Alle rauwe contourperiodes van ÉÉN toewijzing van de taak (zie `Task.timephasedContours`). */ +export interface TaskTimephasedContour { + /** MSP's eigen resource-uniqueId (`AssignmentField.RESOURCE_UNIQUE_ID`) — PUUR herkomst/ + * traceability, geen live-resource-referentie (deze toewijzing is mogelijk allang gewijzigd of + * verdwenen in het app-model; de rauwe periode blijft desondanks staan, eigenaarsprincipe). + * `null` wanneer de toewijzing geen (vindbare) resource droeg. */ + resourceUid: number | null; + periods: TimephasedContourPeriod[]; +} + +/** + * MSP's eigen "Task Type" (Fixed Units/Fixed Duration/Fixed Work — `TaskField.TYPE`, `DataType. + * TASK_TYPE`). NIET te verwarren met `Task.taskType` hierboven (dat is een OPS-eigen domeinclassi- + * ficatie — CONSTRUCTION/INSTALLATION/…, een ander concept met toevallig een gelijkende naam). + * Eigenaarsbesluit 2026-08-18 (plan §10, punt 1): puur data, bewaard zodat een .mpp-import 'm niet + * weggooit — GEEN rekengedrag; geen enkele solverstap leest `Task.mspTaskType`. Task type als + * projecteigenschap-met-per-taak-keuze (en het bijbehorende bewerkgedrag) is een latere, aparte + * etappe (zelfde besluit, punt 2). + */ +export type MspTaskType = 'FIXED_UNITS' | 'FIXED_DURATION' | 'FIXED_WORK'; + +/** + * Soort mijlpaal (fase 2.4, P6 Start/Finish Milestone). Dag-granulair grens-model: + * START ankert op een dagbegin, FINISH op een dageinde (einde werkdag F = begin + * eerstvolgende werkdag). undefined = automatisch: het anker volgt de bindende + * relatiezijde (FS/SS → start, FF/SF → finish) — het gedrag van vóór fase 2.4. + */ +export type MilestoneKind = 'START' | 'FINISH'; + +export interface TaskTime { + durationType: DurationType; + durationUnit: TaskDurationUnit; + /** Canonieke invoerbron wanneer `durationUnit === 'days'`: gehele werkbare kalenderdagen. */ + scheduleDuration: number; + /** Canonieke invoerbron wanneer `durationUnit === 'hours'`: exacte werkminuten. Oude data zonder + * `durationUnit` migreert deterministisch naar uren zodra dit veld aanwezig is; minuutprecisie + * uit imports blijft daarbij ongewijzigd. */ + durationMinutes?: number; + scheduleStart: string; // ISO 8601 — date-only in dag-modus, datetime in uur-modus + scheduleFinish: string; // ISO 8601 — date-only in dag-modus, datetime in uur-modus + /** OPTIONEEL — MSP's EIGEN opgeslagen hervattingsinstant voor een IN-PROGRESS-taak (`.mpp`- + * veld-id 99, `TaskField.RESUME`, `DataType.DATE`; Z12-herwerk, dossier out-of-sequence- + * actuals). Geen afgeleide/herberekende waarde — de invoer staat letterlijk in het bestand, + * net als `scheduleStart`/`scheduleFinish` hierboven (vandaar dezelfde rol, `TaskTimeInput`, + * i.p.v. `TaskTimeTracking` waar `actualStart`/`actualFinish` in zitten). `CPMSolver.ts` leest + * dit veld voor een aantoonbaar out-of-sequence FINISH_START-opvolger (`isOutOfSequenceFsPredecessor`): + * `finish = addWork(resume, remaining)` op de taak-EIGEN kalender — corpusmeting (fase 1): + * 17/17 exact op alle out-of-sequence-in-progress-bladtaken, 4/4 op de gemeten OzBuild- + * snapshots minuut-exact. Afwezig (niet-`.mpp`-bronnen, of een `.mpp`-bestand waarvan de field + * map veld 99 mist) ⇒ het bestaande RETAINED_LOGIC/`resumeFromActualElapsed`-pad, ongewijzigd. */ + resume?: string; + /** OPTIONEEL — spiegelt `resume` hierboven (`.mpp`-veld-id 100, `TaskField.STOP`): MSP's eigen + * grens van het reeds-afgewerkte deel. Door GEEN enkele solverberekening gelezen (de + * `finish = addWork(resume, remaining)`-formule haalde 17/17 zonder `stop`) — hier alleen + * meegenomen als rauw, ongebruikt feit voor een latere taak (splits-/actual-grens-rendering). */ + stop?: string; + + // CPM-computed + earlyStart: string; + earlyFinish: string; + lateStart: string; + lateFinish: string; + freeFloat: number; // work days (fractioneel in uur-modus, §5.5) + totalFloat: number; // work days (fractioneel in uur-modus, §5.5) + isCritical: boolean; + /** OPTIONEEL — interfererende speling = totalFloat − freeFloat (getekend, fractioneel in + * uur-modus; fase 2.9, §4.6). Alleen geschreven wanneer de analyse-laag draait; afwezig ⇒ + * byte-identiek default-document. */ + interferingFloat?: number; + /** OPTIONEEL — near-critical-markering (fase 2.9, §4.6). Alleen geschreven bij ingestelde drempel. */ + isNearCritical?: boolean; + /** OPTIONEEL — float-path-nummer (1 = meest kritiek; fase 2.9, §4.6). Alleen geschreven bij floatPaths. */ + floatPath?: number; + + // Tracking + actualStart?: string; + actualFinish?: string; + actualDuration?: number; + remainingTime?: number; + /** OPTIONEEL — resterend werk in integer MINUTEN (uur-modus voortgang, fase 2.8b §5.3). */ + remainingMinutes?: number; + completion: number; // 0.0 - 1.0 +} + +/** + * TYPE-ONLY rol-splitsing van `TaskTime` (Bevinding A7). `TaskTime` mengt vier rollen in één plat + * type; een consument kan niet zien welke velden hij mag SCHRIJVEN en welke `runCPM` OVERSCHRIJFT. + * De vier `Pick<>`-aliassen hieronder maken die rollen expliciet — puur documentair/afdwingend, + * ZONDER runtime-verandering en ZONDER `TaskTime` zelf te herstructureren. `Task.time` blijft de + * volledige `TaskTime`; alle bestaande lezers/schrijvers blijven ongewijzigd. Elk `TaskTime`-veld + * hoort in PRECIES één rol; de compile-assert onderaan dwingt volledige + disjuncte dekking af, + * zodat een nieuw veld gedwongen wordt gecategoriseerd. + */ + +/** INVOER — door de gebruiker/importers geschreven. `runCPM` raakt deze normaliter niet aan, MAAR + * overschrijft in UUR-modus `scheduleStart`/`scheduleFinish` (naar de berekende instants, + * `scheduleSlice.ts`) en de passende afgeleide duurbron (`scheduleDuration` of `durationMinutes`) voor + * HAMMOCK-taken (afgeleide span, `CPMSolver`). `durationType` blijft puur invoer. */ +export type TaskTimeInput = Pick< + TaskTime, + 'durationType' | 'durationUnit' | 'scheduleDuration' | 'durationMinutes' | 'scheduleStart' | 'scheduleFinish' + | 'resume' | 'stop' +>; + +/** CPM-COMPUTED — geschreven door `runCPM` (CPMSolver). Normaliter niet handmatig muteren; de + * legitieme uitzonderingen zijn restore-paden (IFC-import herstelt deze velden, benchmark-runner). */ +export type TaskTimeComputed = Pick< + TaskTime, + 'earlyStart' | 'earlyFinish' | 'lateStart' | 'lateFinish' | 'freeFloat' | 'totalFloat' | 'isCritical' +>; + +/** ANALYSE (fase 2.9, §4.6) — afgeleiden bovenop de CPM-output. `interferingFloat` (= tf − ff) wordt + * ALTIJD elke `runCPM` (her)berekend en teruggeschreven; `isNearCritical`/`floatPath` alleen wanneer + * de bijbehorende optie/drempel draait (anders gewist, om stale markering te voorkomen). */ +export type TaskTimeAnalysis = Pick< + TaskTime, + 'interferingFloat' | 'isNearCritical' | 'floatPath' +>; + +/** TRACKING — voortgang/actuals; door de gebruiker geschreven, niet door de solver. */ +export type TaskTimeTracking = Pick< + TaskTime, + 'actualStart' | 'actualFinish' | 'actualDuration' | 'remainingTime' | 'remainingMinutes' | 'completion' +>; + +// --- Compile-assert: de vier rollen vormen een EXACTE partitie van `keyof TaskTime` --------------- +// Puur type-niveau (verdwijnt bij compilatie). Dwingt twee dingen af, zodat de rol-splitsing niet +// stil kan verouderen: +// 1) VOLLEDIGHEID — elk TaskTime-veld zit in minstens één rol (een nieuw, niet-gecategoriseerd +// veld laat `_UncategorizedTaskTimeField` iets anders dan `never` worden → tsc-fout). +// 2) DISJUNCTHEID — geen veld zit in twee rollen (een dubbel geplaatst veld laat `_OverlappingRole` +// iets anders dan `never` worden → tsc-fout). +// (Elke Pick is per definitie al een subset van `keyof TaskTime`, dus een getypte sleutel die niet +// bestaat geeft al bij de Pick zelf een fout — een aparte "extra keys"-check is overbodig.) +type _TaskTimeRoleKeys = + | keyof TaskTimeInput + | keyof TaskTimeComputed + | keyof TaskTimeAnalysis + | keyof TaskTimeTracking; + +type _UncategorizedTaskTimeField = Exclude; + +type _OverlappingRole = + | (keyof TaskTimeInput & keyof TaskTimeComputed) + | (keyof TaskTimeInput & keyof TaskTimeAnalysis) + | (keyof TaskTimeInput & keyof TaskTimeTracking) + | (keyof TaskTimeComputed & keyof TaskTimeAnalysis) + | (keyof TaskTimeComputed & keyof TaskTimeTracking) + | (keyof TaskTimeAnalysis & keyof TaskTimeTracking); + +// `[T] extends [never]` is de robuuste "is exact never"-check (geen distributie over unies). +type _Expect = T; +type _IsNever = [T] extends [never] ? true : false; +// Deze twee asserts compileren alleen wanneer beide condities `never` zijn. Waarde-vorm + `void` +// (zoals snapshot.ts/documentContract.ts) zodat `noUnusedLocals` ze niet als dood markeert; de +// twee const-declaraties minificeren weg (inert). +const _assertTaskTimeComplete: _Expect<_IsNever<_UncategorizedTaskTimeField>> = true; +const _assertTaskTimeDisjoint: _Expect<_IsNever<_OverlappingRole>> = true; +void _assertTaskTimeComplete; +void _assertTaskTimeDisjoint; + +export interface Task { + id: string; + name: string; + description: string; + wbsCode: string; + taskType: TaskType; + /** Alleen bij `USERDEFINED`: stabiele verwijzing naar de projectcatalogus. Ontbreekt bij + * oudere IFC-bestanden die USERDEFINED zonder OPS-definitie gebruikten. */ + customTaskTypeId?: string; + status: TaskStatus; + isMilestone: boolean; + /** Alleen relevant bij isMilestone; undefined = automatisch (zie MilestoneKind). */ + milestoneKind?: MilestoneKind; + /** Verplichte (contractuele) mijlpaal — inspectie-/keurings-/opleverpunt. Markering + * voor rapportage & Gantt; datumbewaking loopt via constraint/deadline (fase 2.3). */ + mandatory?: boolean; + /** Leveling-prioriteit (MSP-conventie, P6 "Activity Priority" analoog): 0–1000, default 500. + * 1000 = "Do Not Level" (vastgepind, wordt door de nivelleerder nooit verschoven). Ongebruikt + * vóór fase 2.5 (was hardcoded 0 overal); vanaf 2.5 stuurt dit de nivelleervolgorde. */ + priority: number; + /** Vertraging in werkdagen t.o.v. de precedence-feasible early start (de ES die de forward + * pass berekent nadat óók de voorgangers hun levelingDelay hebben gekregen — NIET t.o.v. de + * oorspronkelijke CPM-ES, dat zou voorgangersverschuivingen dubbel tellen). Gezet door de + * nivelleerder (fase 2.5, nog niet gebouwd). undefined = geen nivellering toegepast. + * "Nivellering wissen" zet dit overal terug naar undefined. */ + levelingDelay?: number; + /** OPTIONEEL — subdag-precisie voor `levelingDelay` (etappe "nul afwijkingen", Z0, voorlopig + * ONGEBRUIKT). MSP levert de nivelleervertraging in tienden-van-een-minuut; `levelingDelay` + * (hele werkdagen) kan dat niet exact dragen. Zelfde precedent als `durationMinutes` naast + * `scheduleDuration`: AANWEZIG ⇒ bron van waarheid, AFWEZIG ⇒ `levelingDelay` (werkdagen) blijft + * de bron (byte-identiek). Nog door geen enkele solver-stap gelezen. */ + levelingDelayMinutes?: number; + /** OPTIONEEL — begeleidt `levelingDelayMinutes` (Z0, voorlopig ONGEBRUIKT). MSP's + * `LevelingDelayFormat` kent een ELAPSED-variant (kloktijd i.p.v. werktijd), net als + * `durationType`/MSPDI's elapsed-vlag op een duur. Afwezig ⇒ WORKTIME (byte-identiek). Alleen + * betekenisvol wanneer `levelingDelayMinutes` ook gezet is. */ + levelingDelayElapsed?: boolean; + /** OPTIONEEL — werkonderbrekingen (MS Project "split"), zie `TaskSplitGap` (die interface se + * eigen docblok draagt de as-definitie — lees die vóórdat je dit veld raadpleegt). Sinds Z4 ECHT + * gevuld: `mppReader.ts` leidt dit af uit de timephased-werksegmenten van een `.mpp`-import + * (`deriveSplitGapsForTasks`, `mppTimephased.ts`), en round-trippt sinds Z14 door IFC + * (`OPS_TaskSplits`-pset, `ifcPsets.ts`). Sinds Z7 ECHT geconsumeerd door de CPM: `CPMSolver.ts`'s + * vier aangrijpingspunten (`addDurationChecked`/`subDuration`/`finishFromStart`/`startFromFinish` + * + de IN-PROGRESS-restwerktak) wandelen dit via `duration.ts`'s `splitTotalSpanMinutes`. De + * renderer tekent sinds Z15 al een onderbroken balk in Gantt/print/PDF. Afwezig ⇒ geen splits + * (byte-identiek). */ + splitGaps?: TaskSplitGap[]; + /** OPTIONEEL — GELAAGDE BESLISKOLOM (Z8-herwerkronde, etappe "nul afwijkingen"). Drie van de vijf + * lagen raken taakvelden; deze eerste, `timephasedFinishFloor`, is LAAG 3: het MAXIMUM van + * `AssignmentField.FINISH` over de toewijzingen van deze taak die ≥1 ÉCHTE gedecodeerde + * timephased-periode droegen (Format A/B, `mppTimephased.ts` — NIET het kale "een `TBkndAssn`- + * record bestaat"-signaal, dat bleek een vrijwel volledige cirkelmeting, zie de Z8-herwerkronde- + * rapportage). Alleen gezet op taken met `time.completion === 0` (lagen 1/2 — voltooid resp. + * in-progress — plannen op hun bestaande actuals-/resume-paden, GEEN venster, zie + * `CPMSolver.ts`). MSP's EIGEN al berekende antwoord — geen eigen kalenderwandeling nodig. + * `CPMSolver.ts`'s `forwardPass` past 'm toe op dezelfde plek als `sfFinishFloor` (die blijft een + * onbreekbare `Math.max`-ondergrens; het venster zelf overschrijft de kale duur-gebaseerde + * berekening volledig), nooit bij een harde MFO/MSO-pin, alleen in uur-modus. ISO-instant, + * minuutprecisie (`formatInstant(d,'hour')`). Mutueel exclusief met `timephasedDurationWalks` + * (LAAG 4, zie die docstring) — `mppReader.ts` zet nooit beide op dezelfde taak. Afwezig ⇒ geen + * echte periode-data op deze taak, byte-identiek. Round-tript sinds Z14b via `OPS_TimephasedWindow` + * (`ifcPsets.ts`). EIGENAARSPRINCIPE (2026-08-18) — dit veld is AFGELEIDE sturing: een + * inhoudelijke bewerking (duur/datums/kalender/toewijzingen) wist dit veld weer (zie + * `taskDefaults.ts`'s `clearTimephasedWindow`, aangeroepen vanuit `taskSlice.ts`/ + * `mcpTransaction.ts`/`resourceSlice.ts`) — de RAUWE bron blijft dan in `timephasedContours` + * staan, dat wordt nooit gewist door een edit. */ + timephasedFinishFloor?: string; + /** OPTIONEEL — RAUW startanker (Z8-herwerkronde), gebruikt door zowel LAAG 3 als LAAG 4: het + * MINIMUM van `AssignmentField.START` over de LAAG-3-toewijzingen (bij `timephasedFinishFloor`) + * resp. de eerste/vroegste ankerdatum van de LAAG-4-toewijzingen (bij `timephasedDurationWalks` + * — daar puur het startpunt van de wandeling, de LAAG-4-finish zelf komt uit die wandeling, niet + * uit dit veld). Uitsluitend gebruikt voor een taak ZONDER voorganger (`CPMSolver.ts`'s + * `forwardPass`, `preds.length===0`-tak) wier eigen `time.scheduleStart` buiten de taak- + * kalender-band ligt maar de toewijzing wél binnen haar EIGEN resourcekalender (corpusvoorbeeld: + * een "Night Shift"-resource op 23:00, buiten de taak se "Standard"-band) — MSP snapt zo'n + * anker niet, de gewone `ownAnchor`-snap deed dat vóór Z8 wél. Afwezig ⇒ byte-identiek. Zelfde + * IFC-round-trip/eigenaarsprincipe-kanttekening als `timephasedFinishFloor`. */ + timephasedStartAnchor?: string; + /** OPTIONEEL — LAAG 4 van de Z8-herwerkronde-beslistabel: taken met een VLAK timephased-record + * (`blockCount===0`, GEEN echte periode — dus `timephasedFinishFloor` blijft hier afwezig) MAAR + * wier toewijzing(en) een NIET-STANDAARD resourcekalender dragen (corpusbewijs: de "Night Shift"/ + * "24 Hours"-families in `mpp14timephased.mpp`, en `mpp14resource.mpp`'s "Task A" — een gemengde + * toewijzingsset waar één van de drie resources een sterk afwijkende kalender draagt). Anders dan + * `timephasedFinishFloor` (een GELEZEN, bevroren MSP-antwoord) is dit een VERSE HERBEREKENING: + * `CPMSolver.ts` wandelt per item DOOR de toewijzing se EIGEN resourcekalender vanaf haar + * `anchor`, en neemt het MAXIMUM over de lijst (dezelfde "langste toewijzing bepaalt de finish"- + * regel als laag 3, hier alleen zonder gelezen eindantwoord). N2-CORRECTIE (Opus-her-check, + * tweede ronde): dit "GEEN invalidatie nodig, stroomt vanzelf mee" gold zo alleen voor een item + * ZONDER `workMinutes` (het wandelt dan `task.time.durationMinutes`, edit-live). Een item MET + * `workMinutes` (zie hieronder) wandelt die BEVROREN waarde in plaats daarvan — die volgt een + * latere duur-/datum-/kalenderwijziging dus NIET vanzelf. Vandaar dat `updateTask`/ + * `updateTaskFields`/`patchTaskFields` deze lijst sinds N2 wél wissen zodra ze zo'n item bevat + * (`taskDefaults.ts`'s `timephasedDurationWalksHaveFrozenWork`/`clearTimephasedDurationWalks`), + * precies zoals bij `timephasedFinishFloor`/`timephasedStartAnchor` — de taak valt dan terug op + * laag 5 tot een volgende .mpp-import de lijst opnieuw vult. + * `resourceCalendarId` verwijst in de gedeelde kalenderbibliotheek (`CPMSolver`'s `registry`, + * `resolveCalendar`); een taak zonder voorganger gebruikt bovendien het VROEGSTE `anchor` uit + * deze lijst als `timephasedStartAnchor` (zie hierboven). Mutueel exclusief met + * `timephasedFinishFloor`. + * + * `workMinutes` (Z19, residu-iteratie "nul afwijkingen") — optioneel per item; ONTBREEKT het, + * dan wandelt `CPMSolver.ts` `task.time.durationMinutes` (de volle taakduur, edit-live). De + * garantie zit op de PRODUCERENDE tak in `mppReader.ts`, NIET op de array-lengte: 19 corpustaken + * dragen een lijst van lengte 1 mét `workMinutes` (de MATERIAL-gefilterde >1-tak). + * Corpusbewijs: 9/9 (mpp14timephased2.mpp) en 20/20 (mpp14timephasedsegments.mpp) op de volledige + * populatie, en de 0%-populatie van mpp14timephased.mpp (zie de Z8-herwerkronde-rapportage). AANWEZIG + * bij >1 item — bij meerdere GELIJKTIJDIGE toewijzingen wandelt geen enkele toewijzing de volle + * taakduur; elke toewijzing wandelt alleen haar EIGEN, per-toewijzing gedecodeerde werk-aandeel + * (`decodeAssignmentWorkMinutes`, `mppReader.ts`), en de LANGSTE wandeling bepaalt de finish + * (die volle-duur-aanname is BEWEZEN onjuist bij >1 toewijzing — + * `mpp14resource.mpp`'s "Task A", drie toewijzingen, gaf zonder apportionering een ~2× te late + * datum). Afwezig ⇒ byte-identiek. Round-tript sinds Z14b via `OPS_TimephasedWindow` + * (`ifcPsets.ts`). Wordt NOOIT door `clearTimephasedWindow` gewist (dat blijft `timephasedFinishFloor`/ + * `timephasedStartAnchor`-only) — maar sinds N2 wél door de aparte `clearTimephasedDurationWalks`, + * onder de voorwaarde hierboven (`workMinutes` gezet op minstens één item). Zie + * `taskDefaults.ts`'s hoofddocblok voor de volledige triggerset-toelichting. */ + timephasedDurationWalks?: { anchor: string; resourceCalendarId: string; workMinutes?: number }[]; + /** OPTIONEEL — RAUWE, gedecodeerde .mpp-timephased-contourperiodes (Z14b, eigenaarsprincipe + * 2026-08-18: "er gaat nooit stilzwijgend broninformatie verloren, ook niet ná bewerken"). Dit is + * de bron ONDER `splitGaps`/`timephasedFinishFloor`/`timephasedStartAnchor` — die drie zijn + * AFGELEIDE sturing (splitGaps voedt de CPM rechtstreeks, de twee venstervelden worden bij een + * inhoudelijke bewerking ontkoppeld, zie `clearTimephasedWindow` in `taskDefaults.ts`) — dit veld + * blijft ALTIJD staan, ook ná zo'n bewerking: geen enkele actie in deze etappe wist het. Eén + * entry per toewijzing van deze taak die daadwerkelijk een ECHTE dagverdeling droeg (vlakke + * samenvattingsrecords — MPXJ's `blockCount===0`-speciale geval — tellen niet mee, zelfde filter + * als `mppReader.ts`'s `deriveSplitGapsForTasks` al toepast op `splitGaps`). Periodes liggen op + * DEZELFDE as als `TaskSplitGap` (cumulatieve werkminuten sinds taakstart — zie die interface se + * eigen "TAAK-AS, NIET TOEWIJZINGS-AS"-docblok voor de volledige as-definitie). Puur data: geen + * enkele solverstap leest dit veld — voedingsdata voor de latere contour-engine (eigenaarsbesluit + * 2026-08-18, plan §10). Round-tript via `OPS_TimephasedContours` (`ifcPsets.ts`). Afwezig ⇒ geen + * echte periode-data op deze taak (byte-identiek). */ + timephasedContours?: TaskTimephasedContour[]; + /** OPTIONEEL — handmatig geplande taak (MS Project "Manually Scheduled", Z0, voorlopig + * ONGEBRUIKT). De datums zelf blijven `time.scheduleStart`/`scheduleFinish` — dit is puur het + * SIGNAAL dat de solver ze straks RAUW moet respecteren (geen kalendersnap, geen relatiedruk, + * geen constraint-afdwinging) in plaats van te herrekenen; nog door geen enkele solver-stap + * gelezen. Afwezig/false ⇒ normale (auto-geplande) taak, byte-identiek. */ + manuallyScheduled?: boolean; + /** OPTIONEEL — MSP's eigen Task Type bij .mpp-import (zie `MspTaskType`). Puur data (eigenaars- + * besluit 2026-08-18): geen enkele solverstap leest dit veld. Afwezig ⇒ veld niet gelezen/niet + * van toepassing (byte-identiek), NIET hetzelfde als "FIXED_UNITS" (MSP's eigen stille default + * voor een nieuwe taak) — een afwezig veld is hier "onbekend", geen aanname. Round-tript via + * `OPS_MspTaskType` (`ifcPsets.ts`). */ + mspTaskType?: MspTaskType; + /** OPTIONEEL — MSP's "Effort Driven"-vlag bij .mpp-import (`TaskField.EFFORT_DRIVEN`). Puur data, + * zelfde eigenaarsbesluit als `mspTaskType` hierboven — geen enkele solverstap leest dit veld. + * Afwezig/false ⇒ byte-identiek. Round-tript via `OPS_MspTaskType` (`ifcPsets.ts`, zelfde pset + * als `mspTaskType` — het is hetzelfde MSP-taaktypeconcept-paar). */ + effortDriven?: boolean; + parentId: string | null; // WBS parent + childIds: string[]; // WBS children + time: TaskTime; + resourceIds: string[]; + color?: string; + /** Activity-code-toewijzingen: codetype-id → waarde-id (max één waarde per type, P6-invariant). */ + activityCodes?: Record; + /** Custom-field-waarden: velddefinitie-id → waarde (getypeerd volgens CustomFieldDef.type). */ + customFields?: Record; + /** Datum-constraint (fase 2.3); afwezig = ASAP. PRIMAIR. */ + constraint?: TaskConstraint; + /** OPTIONEEL — SECUNDAIRE constraint (fase 2.9, P6). Altijd soft (hard verboden op secundair). + * Combinatie-regel (P6, Rapport B §1.3): secundair NIET toegestaan als primair Start On/Finish On/ + * Mandatory is; verder één forward-type + één backward-type die elkaar niet tegenspreken. + * Afwezig ⇒ geen tweede grens (byte-identiek). */ + constraint2?: TaskConstraint; + /** OPTIONEEL — hammock/LOE (fase 2.9, §3.2/§4.4). Afwezig/false ⇒ gewone taak (byte-identiek). + * true ⇒ duur wordt AFGELEID (span tussen start-driver en finish-driver); + * scheduleDuration/durationMinutes worden genegeerd als invoer en overschreven met de span. + * Uitgesloten van het kritieke pad (isCritical altijd false). */ + isHammock?: boolean; + /** OPTIONEEL — externe (cross-project) dependencies (fase 2.9, §3.3). Afwezig ⇒ geen (byte-identiek). */ + externalLinks?: ExternalLink[]; + /** Zachte deadline (MSP-model): begrenst alleen de late finish — balken bewegen nooit; + * overschrijding (earlyFinish > deadline) geeft negatieve float + waarschuwing. */ + deadline?: string; + /** OPTIONEEL — id in de kalender-bibliotheek (fase 2.8a, §4). undefined = projectkalender + * (project.calendarId). Symmetrisch met Resource.calendarId. Bepaalt de kalender waarin de DUUR + * en de constraints van deze taak rekenen (§5). */ + calendarId?: string; + /** OPTIONEEL — vrije aantekeningen/checklist per taak (fase 2.10, item 1). Afwezig ⇒ geen + * aantekeningen (byte-identiek). Puur een array-veld, geen dedicated store-acties nodig + * (mutaties via `updateTask(taskId, { notes })`). */ + notes?: { id: string; text: string; done: boolean }[]; +} + From c39c3b888ba12eadfcd2c7605ad9b35c92cc5089 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:00:52 +0200 Subject: [PATCH 004/108] fix(scheduler): scatter-plaatsing stopt op de horizon en levert nooit een lege dagenset (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bevinding 12: `scatterSlot` gaf `[]` terug zodra `need === 0` (fractionele duur strikt tussen 0 en 1, waarbij `distributeUnits` een lege vraagarray levert). `[]` is truthy, dus `findSlot` deed `parseDate(scatterDays[0])` op `undefined` — een Invalid Date die als starttijd de hele hoofdlus in reisde. De guard valt nu door naar het "geen slot"-vangnet. Onderzocht en in de testcase gedocumenteerd: met de huidige fits()/occurrenceFor()-implementatie is deze tak in de praktijk niet bereikbaar (een lege vraagarray maakt de aaneengesloten scan altijd triviaal geslaagd), dus de guard is een vangnet voor een toekomstige wijziging aan die symmetrie, geen live-reproduceerbare bug meer. Bevinding 11: zonder bindend venster (geen plafond, geen constrainToFloat) had de scatter-scan alleen `HARD_SCAN_CAP` (200.000) als rem. `scatterSlot` krijgt nu dezelfde tweetrapsgrens als de aaneengesloten scan (`scanLimit`, eventueel verlengd door een grootboekhorizon) — gemeten: 20 onafhankelijke onplaatsbare taken gingen van ~2,9s naar ~26ms. Co-Authored-By: Claude Fable 5 --- src/engine/scheduler/ResourceLeveler.ts | 17 +++- tests/planning/check-leveler-splitmode.ts | 110 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/engine/scheduler/ResourceLeveler.ts b/src/engine/scheduler/ResourceLeveler.ts index 4a1a7f2f0..3ac1ff6c6 100644 --- a/src/engine/scheduler/ResourceLeveler.ts +++ b/src/engine/scheduler/ResourceLeveler.ts @@ -835,7 +835,14 @@ export function levelResources( // van het plafond, niet de start-`limit` hierboven) — zie het docblok daar. if (splitEligible(task)) { const scatterDays = scatterSlot(taskId, pf, finishWindowLimit(taskId)); - if (scatterDays) return { start: parseDate(scatterDays[0]), unresolved: [], scatterDays }; + // B1c-plan3 taak 1 (bevinding 12): een LEGE dagenset is geen plaatsing. `scatterSlot` geeft + // `[]` terug zodra `need === 0` (`chosen.length === need` is dan meteen waar), en `[]` is + // truthy — `parseDate(scatterDays[0])` maakte er dan een Invalid Date van, die als `start` de + // hele hoofdlus in reisde (delay-meting, boeking, shifts). Niets plaatsen hoort door te vallen + // naar het "geen slot"-vangnet hieronder. + if (scatterDays && scatterDays.length > 0) { + return { start: parseDate(scatterDays[0]), unresolved: [], scatterDays }; + } } // Geen slot: blijf op de gesnapte PF, verzamel de conflictdagen (waar de vraag de restcapaciteit @@ -984,11 +991,19 @@ export function levelResources( // een geselecteerde resource (`byRes` leeg) — `splitEligible` wordt normaliter pas bereikt nadat // `hasDemand` al vraag bevestigde, dus dit pad is defensief. const need = byRes.values().next().value?.length ?? Math.ceil(task.time.scheduleDuration); + // B1c-plan3 taak 1 (bevinding 11): zonder `finishLimit` (geen plafond, geen constrainToFloat) had + // deze lus alleen `HARD_SCAN_CAP` als rem — 200.000 kandidaatdagen mét een volledige `dayFits` + // per dag, terwijl de aaneengesloten scan er allang mee gestopt is. Zelfde tweetrapsgrens als + // `findSlot`: `scanLimit` is de gewone ondergrens (afgeleid van de eigen taakduren), en een + // grootboekhorizon mag hem verlengen omdat externe vaste last het eerste vrije venster voorbij + // die ondergrens kan duwen (L4). `HARD_SCAN_CAP` blijft uitsluitend de vangrail. + const horizonDate = ledger?.horizonIso ? parseDate(ledger.horizonIso) : null; const chosen: string[] = []; let cand = nextCandidateFor(task, pf); let guard = 0; while (chosen.length < need && guard++ < HARD_SCAN_CAP) { if (finishLimit && cand > finishLimit) return null; + if (guard > scanLimit && !(horizonDate && cand <= horizonDate)) return null; const iso = formatDate(cand); if (dayFits(byRes, chosen.length, iso)) chosen.push(iso); cand = nextCandidateAfterFor(task, cand); diff --git a/tests/planning/check-leveler-splitmode.ts b/tests/planning/check-leveler-splitmode.ts index 361b04031..1d789e2db 100644 --- a/tests/planning/check-leveler-splitmode.ts +++ b/tests/planning/check-leveler-splitmode.ts @@ -15,6 +15,7 @@ import { levelResources, type LevelingOptions } from '@/engine/scheduler/ResourceLeveler'; import { enumerateTaskWorkDays } from '@/engine/scheduler/splitWalk'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { parseDate } from '@/utils/dateUtils'; import type { CPMResult } from '@/engine/scheduler/CPMSolver'; import type { Task } from '@/types/task'; import type { Resource, ResourceAssignment } from '@/types/resource'; @@ -249,6 +250,115 @@ console.log('-- leveler-splitmode: importsplit blijft, leveling-gat komt erbij ( r6.gaps['f6']?.filter(g => g.source === 'leveling').length === 1); } +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 1: scatter-randen (bevindingen 12, 11, 7 uit de eindkeuring van etappe 2). +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- B1c-plan3 taak 1: scatter-randen --'); + +// ── Bevinding 12: `scatterSlot` mag nooit een LEGE dagenset als "geplaatst" teruggeven ──────────── +// Een taak met een FRACTIONELE duur strikt tussen 0 en 1 (bv. 0,5) levert bij `distributeUnits` een +// LEGE vraagarray per resource (`durationDays <= 1 && durationDays !== 1` ⇒ `[]`), dus `need` (de +// lengte van die array) wordt 0 — `scatterSlot` gaf dan `[]` terug, dat is truthy, en `findSlot` deed +// `parseDate(scatterDays[0])` op `undefined` ⇒ een Invalid Date als starttijd. +// +// ONDERZOCHT (afwijking van het letterlijke voorschrift, zie ook het commitbericht): met de huidige +// `fits()`/`occurrenceFor()`-implementatie is een lege vraagarray voor ELKE resource van de taak +// tegelijk (de arraylengte hangt uitsluitend af van de taak-eigen duur, niet per resource) — dus +// `fits()` slaagt DAARDOOR triviaal (de binnenlus over een lege array loopt nul keer), en +// `occurrenceFor` levert voor zo'n fractionele duur altijd minstens één dag op (`consumeWorkDays` +// rondt af naar boven). De aaneengesloten scan in `findSlot` plaatst zo'n taak dus altijd al op de +// EERSTE kandidaat, ongeacht plafond/blokkers, en bereikt `scatterSlot` in de praktijk nooit — deze +// case kan daarom niet "rood" draaien vóór de fix (geverifieerd met een side-by-side probe tegen +// deze exacte fixture). De guard blijft niettemin correct en nuttig als vangnet: hij bewaakt precies +// de invariant die het eigen commentaar in `ResourceLeveler.ts` beschrijft, en deze case pint die +// invariant voor het geval een toekomstige wijziging aan `distributeUnits`/`occurrenceFor` de +// symmetrie tussen vraaglengte en dagenlengte doorbreekt. +{ + const blk = blocker('x7', 'r-split7'); + const taskZ = task('z7', '2026-06-01', '2026-06-01', 0.5, { + priority: 100, deadline: '2026-06-01', + }); + const resource7 = res('r-split7', 1); + const assignments7 = [blk.a, assign('z7-r', 'z7', 'r-split7', 1)]; + const rEmpty = levelResources( + [blk.t, taskZ], [], [resource7], assignments7, PROJECT_CAL, [], stubCpmResult('2026-06-04'), + { constrainToFloat: false, overrunCeilingDays: CEILING, allowSplits: true }, + ); + ok('lege scatter levert nooit een Invalid Date als start', + !isNaN(parseDate(rEmpty.shifts['z7']?.newStart ?? '2026-06-01').getTime())); + eq('lege scatter schrijft geen gaten', rEmpty.gaps['z7'], undefined); +} + +// ── Bevinding 11: zonder bindend venster stopt de scatter-scan op de HORIZON, niet op 200.000 ───── +// `allowSplits: true`, GEEN `overrunCeilingDays`, GEEN `constrainToFloat`, en N onafhankelijke +// taken die elk hun eigen resource hebben met `availabilitySteps` naar 0 vanaf de projectstart — +// `scatterSlot` vindt dan nooit een dag. Vóór de fix liep elke taak afzonderlijk HARD_SCAN_CAP +// (200.000) kandidaten af (gemeten: ~2,9s voor 20 taken); erna stopt elke scan op de +// grootboekhorizon (hier: dezelfde `scanLimit`-ondergrens als de aaneengesloten scan, want er is +// geen `poolLedger`) en meldt de eerlijke reden. +{ + const N = 20; + const tasksN: Task[] = []; + const resourcesN: Resource[] = []; + const assignsN: ResourceAssignment[] = []; + for (let i = 0; i < N; i++) { + tasksN.push(task(`nw${i}`, '2026-06-01', '2026-06-02', 2, { priority: 100 })); + resourcesN.push(res(`r-nw${i}`, 1, { availabilitySteps: [{ from: '2026-06-01', maxUnits: 0 }] })); + assignsN.push(assign(`nw${i}-a`, `nw${i}`, `r-nw${i}`, 1)); + } + const t0 = Date.now(); + const rNoWindow = levelResources( + tasksN, [], resourcesN, assignsN, PROJECT_CAL, [], stubCpmResult('2026-06-02'), + { constrainToFloat: false, allowSplits: true }, + ); + const elapsedMs = Date.now() - t0; + eq('geen eindeloze scatter-scan: eerlijke horizon-reden', rNoWindow.unresolvedReasons['nw0'], 'NO_WINDOW_IN_HORIZON'); + // Prestatie is GEEN poort, maar de orde van grootte wél zichtbaar. Vóór de fix duurde dit geval + // (20 onafhankelijke onplaatsbare taken, elk een eigen HARD_SCAN_CAP-scan) ruim 2,9 seconden. + ok('en hij is niet meer traag', elapsedMs < 2000); +} + +// ── Bevinding 7: fractionele uur-modus in de scatter (2,5 dag / 1200 minuten) ───────────────────── +// Taak H: `durationUnit: 'hours'`, `durationMinutes: 1200` (20 werkuur), `scheduleDuration: 2.5` op +// een 8u-kalender, `durationType: 'WORKTIME'`, `completion: 0`, prio 100. Resource R (cap 1) is +// ma+wo bezet door een prio-900-taak (blokker met een importsplit die alleen dinsdag overslaat). +// Met `allowSplits` landt H op di, do en vr: `distributeUnits` rondt 2,5 af naar DRIE curve-slots, +// dus `need === 3` — niet 2 (`Math.floor`) en niet 2,5. +// +// AFWIJKING van het letterlijke voorschrift: het plan verwachtte "precies twee leveling-gaten (na +// di en na do)"; met deze blokker (die alleen woensdag bezet) sluiten do en vr op elkaar aan (geen +// kalenderdag ertussen), dus er ontstaat maar één leveling-gat (na di, wo overslaand). De kern van +// de bevinding — drie curve-slots i.p.v. twee of 2,5 — blijft volledig gedekt door de exacte +// dagenset-assertie hieronder. +{ + const blockerTask = task('blk7h', '2026-06-01', '2026-06-03', 2, { + priority: 900, + splitGaps: [{ afterMinutes: 480, gapMinutes: 480 }], // bezet ma+wo, di vrij + }); + const blockerAssign = assign('blk7h-r', 'blk7h', 'r-split7h', 1); + const taskH = task('h7', '2026-06-01', '2026-06-02', 2.5, { + priority: 100, deadline: '2026-06-05', + time: { + durationType: 'WORKTIME', durationUnit: 'hours', scheduleDuration: 2.5, durationMinutes: 1200, + scheduleStart: '2026-06-01', scheduleFinish: '2026-06-02', + earlyStart: '2026-06-01', earlyFinish: '2026-06-02', + lateStart: '2026-06-01', lateFinish: '2026-06-02', + freeFloat: 0, totalFloat: 0, isCritical: false, completion: 0, + }, + }); + const hAssign = assign('h7-r', 'h7', 'r-split7h', 1); + const resource7h = res('r-split7h', 1); + const rFrac = levelResources( + [blockerTask, taskH], [], [resource7h], [blockerAssign, hAssign], PROJECT_CAL, [], stubCpmResult('2026-06-03'), + { constrainToFloat: false, overrunCeilingDays: 2, allowSplits: true }, + ); + eq('fractionele uur-modus: drie curve-slots, dus drie werkdagen', + enumerateTaskWorkDays(rFrac.gaps['h7'], projEng, rFrac.shifts['h7']?.newStart ?? '2026-06-01', 3), + ['2026-06-02', '2026-06-04', '2026-06-05']); + ok('en precies één leveling-gat (na di, wo overslaand — zie de afwijking hierboven)', + rFrac.gaps['h7']?.filter(g => g.source === 'leveling').length === 1); +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (diffs.length === 0) { console.log(`OK leveler-splitmode: alle checks groen (${checks})`); From 67ae7d9612b38cebfc82c786ed660ce2d7eb3ecc Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:00 +0200 Subject: [PATCH 005/108] =?UTF-8?q?fix(library):=20het=20poolitem-grootboe?= =?UTF-8?q?k=20boekt=20per=20item=20in=20plaats=20van=20op=20=C3=A9=C3=A9n?= =?UTF-8?q?=20hoop=20(B1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makeLedgerForDoc` negeerde `itemId` volledig (`residualOn`/`book` gingen rechtstreeks naar één gedeelde `placed`-emmer). Dat was tot dusver correct omdat `poolItemOf` uitsluitend `libraryItemId` teruggeeft, maar het is een ongeschreven invariant terwijl `LevelingPoolLedger`s contract expliciet een per-item-boekhouding belooft. `placed` is nu `placedByItem`, geboekt en gelezen op de sleutel; een boeking op een ander itemId raakt het poolitem van deze run niet meer. Co-Authored-By: Claude Fable 5 --- src/services/library/distribute.ts | 37 ++++++++++++++++++--------- tests/library/check-distribute.ts | 41 +++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/services/library/distribute.ts b/src/services/library/distribute.ts index e957eded8..7eebc7acd 100644 --- a/src/services/library/distribute.ts +++ b/src/services/library/distribute.ts @@ -338,12 +338,24 @@ export function computeDistribution( } } - // Het gedeelde poolitem-grootboek (§4 stap 2): `placed` accumuleert over ALLE deelnemende - // documenten, in rangorde — dat IS de sequentiële kern van het protocol. - const placed: Record = {}; - const bookPlaced = (iso: string, units: number) => { placed[iso] = (placed[iso] ?? 0) + units; }; - const residualOn = (iso: string): number => - Math.max(0, maxUnitsOn(poolItem, iso) - (fixedLoadByDay[iso] ?? 0) - (placed[iso] ?? 0)); + // Het gedeelde poolitem-grootboek (§4 stap 2), PER ITEM geboekt (B1c-plan3 taak 1, bevinding 6). + // Vandaag levert `poolItemOf` uitsluitend `libraryItemId` terug, dus één emmer zou volstaan — maar + // dat is een ongeschreven invariant, en het contract van `LevelingPoolLedger` belooft expliciet een + // per-item-boekhouding. De sleutel gebruiken kost niets en maakt een latere verdeler over meerdere + // poolitems tegelijk een uitbreiding in plaats van een herschrijving. `placed[libraryItemId]` + // accumuleert over ALLE deelnemende documenten, in rangorde — dat IS de sequentiële kern van het + // protocol. + const placedByItem: Record> = {}; + const bookPlaced = (itemId: string, iso: string, units: number) => { + const bucket = placedByItem[itemId] ?? (placedByItem[itemId] = {}); + bucket[iso] = (bucket[iso] ?? 0) + units; + }; + const residualOn = (itemId: string, iso: string): number => + itemId === libraryItemId + ? Math.max(0, maxUnitsOn(poolItem, iso) - (fixedLoadByDay[iso] ?? 0) - (placedByItem[itemId]?.[iso] ?? 0)) + // Een ander item hoort in deze run niet voor te komen (zie `poolItemOf`); geef 0 terug in + // plaats van de capaciteit van DIT item — dat zou een vreemd item gratis ruimte geven. + : 0; // Bevinding 5 (fixronde B1c-plan-2-etappe-2): `poolItemOf` wordt door `findSlot` per KANDIDAATDAG // aangeroepen (elke conflictcheck, elke boeking); een `Array.find` over `doc.resources` daarbinnen @@ -359,8 +371,8 @@ export function computeDistribution( if (r.libraryOrigin.companyId !== companyId || r.libraryOrigin.libraryItemId !== libraryItemId) return null; return libraryItemId; }, - residualOn: (_itemId: string, iso: string): number => residualOn(iso), - book: (_itemId: string, iso: string, units: number): void => bookPlaced(iso, units), + residualOn: (itemId: string, iso: string): number => residualOn(itemId, iso), + book: (itemId: string, iso: string, units: number): void => bookPlaced(itemId, iso, units), horizonIso, }; }; @@ -421,7 +433,7 @@ export function computeDistribution( // bestaande boeking rechtstreeks in het grootboek (het document bezet de pool wél — het is // gewoon niet verplaatsbaar), geen motor-run. const booking = bookingByDocId.get(doc.docId)!; - for (const [iso, units] of Object.entries(booking.dailyLoad)) bookPlaced(iso, units); + for (const [iso, units] of Object.entries(booking.dailyLoad)) bookPlaced(libraryItemId, iso, units); const end = currentProjectEndFor(doc); results.push({ docId: doc.docId, title: doc.title, participated: true, cannotMove: true, @@ -430,7 +442,7 @@ export function computeDistribution( continue; } - const horizonIso = computeHorizonIso(fixedLoadByDay, placed, participants); + const horizonIso = computeHorizonIso(fixedLoadByDay, placedByItem[libraryItemId] ?? {}, participants); const levelOptions: LevelingOptions = { constrainToFloat: false, scopeTaskIds, @@ -461,8 +473,9 @@ export function computeDistribution( } const residualByDay: Record = {}; - for (const iso of new Set([...Object.keys(fixedLoadByDay), ...Object.keys(placed)])) { - residualByDay[iso] = residualOn(iso); + const placedThisItem = placedByItem[libraryItemId] ?? {}; + for (const iso of new Set([...Object.keys(fixedLoadByDay), ...Object.keys(placedThisItem)])) { + residualByDay[iso] = residualOn(libraryItemId, iso); } return { diff --git a/tests/library/check-distribute.ts b/tests/library/check-distribute.ts index 11fdb6e58..3d8859378 100644 --- a/tests/library/check-distribute.ts +++ b/tests/library/check-distribute.ts @@ -5,7 +5,8 @@ // stijl van deze suite. // // Draait via run.sh. Exit 0 = alles groen. -import { computeDistribution, type DistributionDocInput } from '@/services/library/distribute'; +import { computeDistribution, type DistributionDocInput, type DistributionLevelRun } from '@/services/library/distribute'; +import type { LevelingPoolLedger } from '@/engine/scheduler/ResourceLeveler'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readIFC } from '@/services/ifc/ifcReader'; import { createDefaultProject } from '@/state/slices/projectSlice'; @@ -596,6 +597,44 @@ console.log('-- distribute: NO_DEMAND is het algemenere vangnet naast MATERIAL_I assert(p17.docs.length === 0, 'case 17: en levert geen leeg "opgelost"-voorstel'); } +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 1, bevinding 6: het grootboek boekt PER ITEM. `makeLedgerForDoc` negeerde `itemId` +// volledig (`residualOn: (_itemId, iso) => residualOn(iso)`). Dat was tot dusver correct omdat +// `poolItemOf` uitsluitend `libraryItemId` teruggeeft — maar het is een ONGESCHREVEN invariant, en +// een toekomstige verdeler over meerdere poolitems tegelijk zou stil alles op één hoop boeken. Deze +// case pint dat de sleutel gebruikt WORDT: de `runLeveling`-hook (dezelfde injecteerbare motor-rand +// als de rest van dit bestand) vangt de door `computeDistribution` gebouwde `poolLedger` op, zodat +// de test rechtstreeks tegen `book`/`residualOn` kan toetsen — `makeLedgerForDoc` zelf is niet +// geëxporteerd. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: het poolitem-grootboek boekt PER ITEM (bevinding 6) --'); +{ + const p = pool([poolRes('lib-1', 'Kraan', 2)]); + const d1 = distDoc('d1', { + rank: 1, + resources: [stamped('d1-r1', 'lib-1')], + tasks: [task('a1', '2026-08-03', '2026-08-03', 1)], + assignments: [assign('d1-a1', 'a1', 'd1-r1', 1)], + }); + let capturedLedger: LevelingPoolLedger | undefined; + const stubRun: DistributionLevelRun = (_doc, options) => { + capturedLedger = options.poolLedger; + return { + delays: {}, unresolved: {}, unresolvedReasons: {}, shifts: {}, + projectEndBefore: '2026-08-03', projectEndAfter: '2026-08-03', gaps: {}, + }; + }; + computeDistribution('c1', p, 'lib-1', [d1], OPTS_OFF, stubRun); + const l = capturedLedger!; + assert(l !== undefined, 'bevinding 6: de motor-run kreeg een poolLedger mee'); + const before = l.residualOn('lib-1', '2026-08-03'); + l.book('een-ander-item', '2026-08-03', 99); + assert( + l.residualOn('lib-1', '2026-08-03') === before, + 'bevinding 6: een boeking op een ANDER itemId raakt dit poolitem niet', + ); +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (fails === 0) { console.log(`OK distribute: alle checks groen (${checks})`); From a0be1c469f88a10d04fdacc500766a0df943d45b Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:22:45 +0200 Subject: [PATCH 006/108] feat(scheduler): nivellering toepassen binnen een scope, inclusief de ingevoegde pauzedagen (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyLeveling` accepteert nu `Pick` plus een optionele `opts.scopeTaskIds`: met scope raakt de reset-lus alleen de gescopete taken (een delay op een taak buiten de scope is vaste last waarop het voorstel gerekend heeft), zonder scope blijft het gedrag byte-identiek. `applyLeveling` schrijft nu ook `splitGaps` — de volledige waarde uit de verdeler, idempotent herschreven via `clearLevelingGaps` wanneer een taak niet in `write.gaps` voorkomt. `clearLeveling` wist nu ook leveling-gaten en de no-op-guard telt ze mee; de ribbon-enable-check voor "Nivellering wissen" is letterlijk gelijk gehouden aan die guard. Dezelfde twee wijzigingen zijn doorgevoerd in de MCP-transactievariant (`createMcpTransactions.ts`). De motor-idempotentie die dit pas waar maakt: `levelResources`' `workTasks`- baseline stript nu ook `splitGaps` (symmetrisch met `levelingDelay`) voor in-scope taken, en `bookDemandAt`/`findSlot`/`scatterSlot` lezen hun occurrence-taak voortaan uit de gestripte `workById` in plaats van het ongestripte `taskById` — zonder die tweede stap bleef een leveling-gat uit een vorige nivellering via de kandidaat-scan doorsijpelen (getest en bevestigd met een falende idempotentiecase vóór deze uitbreiding). Co-Authored-By: Claude Fable 5 --- src/components/layout/Ribbon/ribbonConfig.tsx | 12 +- src/engine/scheduler/ResourceLeveler.ts | 55 ++++- src/state/runtime/createMcpTransactions.ts | 30 ++- src/state/slices/scheduleSlice.ts | 47 ++++- tests/mcp/cases-draft.ts | 9 +- tests/planning/check-apply-leveling-scope.ts | 190 ++++++++++++++++++ tests/planning/check-leveling-delay-units.ts | 9 +- tests/planning/run.sh | 6 + 8 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 tests/planning/check-apply-leveling-scope.ts diff --git a/src/components/layout/Ribbon/ribbonConfig.tsx b/src/components/layout/Ribbon/ribbonConfig.tsx index 36ced487d..06cb60346 100644 --- a/src/components/layout/Ribbon/ribbonConfig.tsx +++ b/src/components/layout/Ribbon/ribbonConfig.tsx @@ -606,7 +606,17 @@ const resourcesTab: RibbonTabConfig = [ kind: 'button', id: 'clearLeveling', icon: , labelKey: 'menu:ribbon.clearLeveling', use: () => { const clearLeveling = useAppStore(s => s.clearLeveling); - const hasLeveling = useAppStore(s => s.tasks.some(t => t.levelingDelay !== undefined)); + // B1c-plan3 taak 2: houd deze conditie LETTERLIJK gelijk aan de no-op-guard in + // `clearLeveling` (`scheduleSlice.ts`) — een knop die inschakelt terwijl de actie een + // no-op is, of andersom, is de bug die dit repareert. Vóór deze uitbreiding stond "Nivellering + // wissen" grijs op een `.mpp`-project met uitsluitend sub-dag-precisie (`levelingDelayMinutes`/ + // `levelingDelayElapsed`) én op een project dat alleen ingevoegde pauzedagen draagt + // (`splitGaps` met `source: 'leveling'`, geen enkele `levelingDelay`). + const hasLeveling = useAppStore(s => s.tasks.some(t => + t.levelingDelay !== undefined + || t.levelingDelayMinutes !== undefined + || t.levelingDelayElapsed !== undefined + || (t.splitGaps ?? []).some(g => g.source === 'leveling'))); return { onClick: () => clearLeveling(), disabled: !hasLeveling }; }, }, diff --git a/src/engine/scheduler/ResourceLeveler.ts b/src/engine/scheduler/ResourceLeveler.ts index 3ac1ff6c6..92af4eaac 100644 --- a/src/engine/scheduler/ResourceLeveler.ts +++ b/src/engine/scheduler/ResourceLeveler.ts @@ -303,8 +303,27 @@ export function levelResources( // is nu vaste last, geen te herberekenen sorteersleutel. Dat is precies de spec-plicht die // `computePF` moet doorstaan: een opvolger van een out-of-scope taak moet haar VERSCHOVEN // (behouden-delay) positie volgen, niet haar ongenivelleerde positie. + // B1c-plan3 taak 2 (idempotentie-voorwaarde): `splitGaps` hoort symmetrisch bij deze strip — een + // gat met `source === 'leveling'` is UITVOER van een eerdere nivellering, precies zoals + // `levelingDelay` dat is. Stond het hier in de baseline, dan las een tweede nivellering het als + // brondata en legde er nieuwe gaten bovenop — accumulatie in plaats van het idempotente + // herschrijven dat spec §4 ("Herkomst") eist. IMPORTSPLITS (gaten zónder `source`) zijn wél + // brondata en blijven staan; `stripLevelingGaps` is dezelfde regel als `clearLevelingGaps` in + // `taskDefaults.ts`, maar puur — de motor muteert de invoer nooit. + const stripLevelingGaps = (gaps: TaskSplitGap[] | undefined): TaskSplitGap[] | undefined => { + if (!gaps || gaps.length === 0) return gaps; + const kept = gaps.filter(g => g.source !== 'leveling'); + return kept.length > 0 ? kept : undefined; + }; const workTasks: Task[] = tasks.map(t => inScope(t.id) - ? { ...t, levelingDelay: undefined, levelingDelayMinutes: undefined, levelingDelayElapsed: undefined, time: { ...t.time } } + ? { + ...t, + levelingDelay: undefined, + levelingDelayMinutes: undefined, + levelingDelayElapsed: undefined, + splitGaps: stripLevelingGaps(t.splitGaps), + time: { ...t.time }, + } : { ...t, time: { ...t.time } }); const workById = new Map(workTasks.map(t => [t.id, t])); @@ -517,11 +536,16 @@ export function levelResources( // blijft); alleen de POOL-boeking is voorwaardelijk — spec §4 stap 3, "niet-plaatsbaar = tekort, // geen cascade". // B1c-plan-2 taak 9: `occOverride` — de onderbreek-modus zet de nieuwe leveling-gaten pas op de - // WERKKOPIE (`workById`), niet op het originele `taskById`-object dat `occurrenceFor` hierboven - // leest; zonder deze override zou de boeking dus de OUDE (ongescatterde) dagenset gebruiken. De - // scatter-aanroepplek geeft de al-gekozen dagen rechtstreeks door en slaat `occurrenceFor` zo over. + // WERKKOPIE (`workById`) NA het `findSlot`-resultaat; zonder deze override zou de boeking dus de + // OUDE (ongescatterde) dagenset gebruiken. De scatter-aanroepplek geeft de al-gekozen dagen + // rechtstreeks door en slaat `occurrenceFor` zo over. + // B1c-plan3 taak 2: `task` komt sinds nu uit `workById`, niet `taskById` — voor een IN-SCOPE taak + // is dat de GESTRIPTE baseline (zie `stripLevelingGaps` hierboven); voor een taak BUITEN de scope + // is `workById` een byte-identieke spread van `taskById` (dezelfde `splitGaps`-array-referentie), + // dus dit verandert niets aan de vastelast-boeking. Zonder deze wissel zou een taak die een + // leveling-gat uit een VORIGE nivellering droeg dat gat hier alsnog als brondata lezen. const bookDemandAt = (taskId: string, startDate: Date, toPoolLedger: boolean, occOverride?: string[]): string[] => { - const task = taskById.get(taskId)!; + const task = workById.get(taskId)!; const occ = occOverride ?? occurrenceFor(task, startDate); const byRes = demandByTask.get(taskId)!; for (const [resId, arr] of byRes) { @@ -656,8 +680,12 @@ export function levelResources( if (slot.scatterDays) { scatterDays = slot.scatterDays; const mpd = engineForTask(pickedTask).hoursPerDay * 60; + // B1c-plan3 taak 2: stapel op de GESTRIPTE werkkopie (`workById`), niet op de originele + // taak (`pickedTask`, uit `taskById`) — anders komen de leveling-gaten van de VORIGE + // nivellering alsnog in dit resultaat terecht (accumulatie in plaats van herschrijven). + const workCopy = workById.get(pick)!; const newGaps: TaskSplitGap[] = [ - ...(pickedTask.splitGaps ?? []), + ...(workCopy.splitGaps ?? []), ...splitGapsFromWorkDayBlocks(blocksFromDays(scatterDays, engineForTask(pickedTask)), mpd, 'leveling'), ]; workById.get(pick)!.splitGaps = newGaps; // ⇒ de proef-solve (A1) ziet de opgerekte spanne @@ -765,7 +793,14 @@ export function levelResources( pf: Date, limit: Date | null, ): { start: Date; unresolved: string[]; reason?: LevelingReason; scatterDays?: string[] } { - const task = taskById.get(taskId)!; + // B1c-plan3 taak 2: `task` komt uit `workById`, niet `taskById` — `findSlot` wordt uitsluitend + // aangeroepen voor movable/vastgepinde (dus per definitie IN-SCOPE) taken, dus dit is precies de + // GESTRIPTE baseline. Zonder deze wissel zou `occurrenceFor` hieronder (via `computeOccurrence`) + // een leveling-gat uit een VORIGE nivellering als brondata lezen — de kandidaat-scan zou dan een + // gesplitste dagenset toetsen in plaats van de schone aaneengesloten baseline waar deze functie + // vanuit gaat, en de idempotentie-eis van spec §4 ("Herkomst") zou stuklopen. Zie `bookDemandAt` + // hierboven voor dezelfde wissel en dezelfde motivering. + const task = workById.get(taskId)!; const byRes = demandByTask.get(taskId)!; // B1c-plan-2 taak 6: reset de reden-sturing bij elke aanroep — zie de declaratie hierboven bij @@ -977,7 +1012,11 @@ export function levelResources( * LAATSTE dag (niet de start): met onderbrekingen groeit de FINISH van de taak, en dát is wat het * plafond moet binden (zie `finishWindowLimit`). */ function scatterSlot(taskId: string, pf: Date, finishLimit: Date | null): string[] | null { - const task = taskById.get(taskId)!; + // B1c-plan3 taak 2: `task` komt uit `workById`, zelfde wissel als `findSlot`/`bookDemandAt` + // hierboven, voor consistentie — `scatterSlot` leest hier vandaag geen `splitGaps`, maar de + // gedeelde bron voorkomt dat een latere uitbreiding stilzwijgend weer op de ongestripte + // `taskById` gaat leunen. + const task = workById.get(taskId)!; const byRes = demandByTask.get(taskId)!; // LET OP — uur-modus (eigenaarsbesluit 2026-08-31, v1-grens verbreed): `task.time.scheduleDuration` // is voor een uur-modus-taak GEEN geheel getal in het algemeen — `distributeUnits` diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index f2095436c..5e372def7 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -6,7 +6,7 @@ import { generateId } from '@/utils/id'; import { formatDate } from '@/utils/dateUtils'; import { createDefaultTaskTime, mergeTaskTime, clearTimephasedWindow, timeUpdateTouchesTimephasedWindow, - clearTimephasedDurationWalks, timephasedDurationWalksHaveFrozenWork, + clearTimephasedDurationWalks, timephasedDurationWalksHaveFrozenWork, clearLevelingGaps, } from '@/utils/taskDefaults'; import { deriveWbsCodes, applyWbsNumbering } from '@/utils/wbs'; import { syncProjectCalendar } from '../syncProjectCalendar'; @@ -711,9 +711,9 @@ function createMcpDraft( }, /** - * Snapshot/recompute-vrije variant van de store-`applyLeveling`: schrijft alle `levelingDelay`- - * waarden (idempotent — reset eerst álles, dan de nieuwe delays). GEEN eigen `runCPM`: de transactie - * herrekent aan het eind en verwerkt de delays dan precies één keer. + * Snapshot/recompute-vrije variant van de store-`applyLeveling`: schrijft `levelingDelay`s + + * `splitGaps` (idempotent — reset eerst álles binnen de scope, dan de nieuwe waarden). GEEN eigen + * `runCPM`: de transactie herrekent aan het eind en verwerkt de delays dan precies één keer. * * B1c-plan-2 taak 1 (M10, eigenaarsbesluit 2026-08-31) — zelfde fix + melding als de store- * `applyLeveling` (`scheduleSlice.ts`): strip ook `levelingDelayMinutes`/`levelingDelayElapsed` @@ -721,18 +721,29 @@ function createMcpDraft( * eenmalig per document als dat écht iets wiste. De notify-aanroep staat BEWUST BUITEN * `store.setState`: `notify` roept zelf `set` aan, dus genest zou een tweede, nog lopende * Immer-produce triggeren (zelfde precedent als de eind-notify in `run()` hieronder). + * + * B1c-plan3 taak 2 — zelfde twee uitbreidingen als de store-variant: `write` is nu + * `Pick` (een volle `LevelingResult` blijft toewijsbaar) met een + * optionele `opts.scopeTaskIds` die het resetten tot de gescopete taken beperkt, en `write.gaps` + * wordt geschreven/idempotent teruggedraaid via `clearLevelingGaps` — deze twee mogen NOOIT uit + * elkaar lopen met `scheduleSlice.ts`'s `applyLeveling`. */ - applyLeveling(result: LevelingResult): void { + applyLeveling(write: Pick, opts?: { scopeTaskIds?: string[] }): void { let roundedCount = 0; store.setState((s) => { + const scope = opts?.scopeTaskIds ? new Set(opts.scopeTaskIds) : null; for (const task of s.tasks) { - const d = result.delays[task.id]; + if (scope && !scope.has(task.id)) continue; + const d = write.delays[task.id]; task.levelingDelay = d !== undefined && d > 0 ? d : undefined; if (task.levelingDelayMinutes !== undefined || task.levelingDelayElapsed !== undefined) { roundedCount++; } task.levelingDelayMinutes = undefined; task.levelingDelayElapsed = undefined; + const g = write.gaps[task.id]; + if (g !== undefined) task.splitGaps = g.length > 0 ? g : undefined; + else clearLevelingGaps(task); } s.isDirty = true; }); @@ -743,8 +754,10 @@ function createMcpDraft( }, /** Snapshot/recompute-vrije variant van de store-`clearLeveling`: zet alle `levelingDelay` terug op - * undefined. GEEN eigen `runCPM` (de transactie herrekent). M10: zelfde sub-dag-strip + melding - * als `applyLeveling` hierboven — zie dat docblok voor de "notify buiten setState"-motivering. */ + * undefined en wist de leveling-gaten. GEEN eigen `runCPM` (de transactie herrekent). M10: zelfde + * sub-dag-strip + melding als `applyLeveling` hierboven — zie dat docblok voor de "notify buiten + * setState"-motivering. B1c-plan3 taak 2: zelfde `clearLevelingGaps`-uitbreiding als de + * store-variant. */ clearLeveling(): void { let roundedCount = 0; store.setState((s) => { @@ -755,6 +768,7 @@ function createMcpDraft( task.levelingDelay = undefined; task.levelingDelayMinutes = undefined; task.levelingDelayElapsed = undefined; + clearLevelingGaps(task); } s.isDirty = true; }); diff --git a/src/state/slices/scheduleSlice.ts b/src/state/slices/scheduleSlice.ts index bc2136c57..b782fc23a 100644 --- a/src/state/slices/scheduleSlice.ts +++ b/src/state/slices/scheduleSlice.ts @@ -11,6 +11,8 @@ import { import { finishMutation, markScheduleStale } from '../transaction'; import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; import { notifyLevelingDelayRounded } from '../timephasedLossNotice'; +import { clearLevelingGaps } from '@/utils/taskDefaults'; +import type { Task } from '@/types/task'; import type { AppSliceFactory } from './types'; export interface ScheduleSlice { @@ -46,9 +48,18 @@ export interface ScheduleSlice { * CPM-run en geeft het resultaat terug ZONDER de store te muteren (UI toont eerst een diff, * commit gaat via `applyLeveling`). Vereist een geldige `cpmResult`. */ levelResources: (options: LevelingOptions) => LevelingResult; - /** Commit een nivelleerresultaat: één undo-snapshot, schrijf alle `levelingDelay`-waarden - * (idempotent — reset eerst álles, dan de nieuwe delays) en her-draai CPM (§5.6). */ - applyLeveling: (result: LevelingResult) => void; + /** Commit een nivelleerresultaat: één undo-snapshot, schrijf `levelingDelay`s + `splitGaps` + * (idempotent — reset eerst álles binnen de scope, dan de nieuwe waarden) en her-draai CPM (§5.6). + * `write` is precies wat de verdeler levert (`Pick`) — een + * volle `LevelingResult` is hieraan toewijsbaar, dus bestaande aanroepers (LevelingDialog, MCP) + * blijven ongewijzigd werken. Met `opts.scopeTaskIds` (B1c-plan3 taak 2, spec §5 "scope-behoudend + * toepassen — op drie plekken") raken alleen de gescopete taken hun delay/gaten kwijt; taken + * buiten de scope zijn vaste last waarop het voorstel gerekend heeft en blijven ongemoeid. Afwezig + * ⇒ byte-identiek aan het gedrag van vóór B1c-plan3: alle taken worden gereset. */ + applyLeveling: ( + write: Pick, + opts?: { scopeTaskIds?: string[] }, + ) => void; /** "Nivellering wissen": één undo-snapshot, zet alle `levelingDelay` terug op undefined, * her-draai CPM. */ clearLeveling: () => void; @@ -252,18 +263,22 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => ); }, - applyLeveling: (result) => { + applyLeveling: (write, opts) => { // B1c-plan-2 taak 1 (M10, eigenaarsbesluit 2026-08-31): telt HOEVEEL taken hier hun sub-dag- // precisie verliezen, voor de eenmalige-per-document melding hieronder (buiten de producer, // zie `notifyTimephasedLoss`-precedent — `notify` roept zelf `set` aan, dus niet genest). let roundedCount = 0; set((s) => { runtime.beginUndoable(s); - // Idempotent: eerst álle levelingDelays wissen, dan de nieuwe zetten — zo levert een - // her-nivellering (of een leveling na een eerdere) exact het resultaat van `result`, - // niet een optelsom. + // Scope-behoudend toepassen (spec §5, derde plek — B1c-plan3 taak 2). Zonder scope blijft dit + // byte-identiek aan het gedrag van vóór B1c-plan3: alle taken worden gereset. MET scope raken + // we uitsluitend de gescopete taken — de verdeler nivelleert per POOLITEM, dus een delay op een + // taak die niets met dat poolitem te maken heeft is VASTE LAST waarop het voorstel gerekend + // heeft; die hier wissen zou het document herschikken en het voorstel ongeldig maken. + const scope = opts?.scopeTaskIds ? new Set(opts.scopeTaskIds) : null; for (const task of s.tasks) { - const d = result.delays[task.id]; + if (scope && !scope.has(task.id)) continue; + const d = write.delays[task.id]; task.levelingDelay = d !== undefined && d > 0 ? d : undefined; // M10: `CPMSolver.shiftByLevelingDelay` leest `levelingDelayMinutes` VÓÓR `levelingDelay`. // Een achtergebleven sub-dag-waarde (uit een `.mpp`-import) zou de zojuist berekende delay @@ -275,6 +290,15 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => } task.levelingDelayMinutes = undefined; task.levelingDelayElapsed = undefined; + // De onderbreek-modus (spec §4, "Herkomst"). `write.gaps[id]` is de VOLLEDIGE te schrijven + // waarde — importsplits inbegrepen — dus hij mag rechtstreeks. Staat de taak NIET in + // `write.gaps`, dan levert dit voorstel voor haar geen onderbreking: wis dan haar eventuele + // leveling-gaten van een VORIGE nivellering en laat importsplits staan. Dat is precies wat + // "idempotent herschrijven" betekent, en het is ook wat er gebeurt zodra de gebruiker + // "Onderbrekingen toestaan" uitzet en opnieuw toepast (`gaps` is dan `{}`). + const g = write.gaps[task.id]; + if (g !== undefined) task.splitGaps = g.length > 0 ? g : undefined; + else clearLevelingGaps(task); } // Wél de stale-vlag (issue #63): dit is een datum-rakende mutatie, en `stale` is het signaal // waarop `finishMutation` de modus "datums zoals opgeslagen" verlaat — in dezelfde producer @@ -297,9 +321,13 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => // Fixronde B1c-plan-2-etappe-2 (bevinding 6): `levelingDelayElapsed` ontbrak hier terwijl de // teller vlak eronder 'm wél meetelt — een taak met UITSLUITEND `levelingDelayElapsed` werd zo // stil overgeslagen (geen snapshot, geen melding), ook al zou de lus 'm wél gewist hebben. + // B1c-plan3 taak 2: de guard telt sinds nu ook leveling-GATEN mee — dezelfde conditie als de + // ribbon-enable-check in `ribbonConfig.tsx` (letterlijk gelijk houden: een knop die inschakelt + // terwijl de actie een no-op is, of andersom, is precies de bug die dit repareert). + const hasLevelingGap = (t: Task) => (t.splitGaps ?? []).some(g => g.source === 'leveling'); if (!s.tasks.some((t) => t.levelingDelay !== undefined || t.levelingDelayMinutes !== undefined - || t.levelingDelayElapsed !== undefined)) return; // niets te wissen, geen snapshot + || t.levelingDelayElapsed !== undefined || hasLevelingGap(t))) return; // niets te wissen, geen snapshot runtime.beginUndoable(s); for (const task of s.tasks) { if (task.levelingDelayMinutes !== undefined || task.levelingDelayElapsed !== undefined) { @@ -308,6 +336,7 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => task.levelingDelay = undefined; task.levelingDelayMinutes = undefined; task.levelingDelayElapsed = undefined; + clearLevelingGaps(task); // uitsluitend `source: 'leveling'`; importsplits zijn brondata } finishMutation(s, { stale: true }); // zie applyLeveling; de aansluitende runCPM wist de vlag. changed = true; diff --git a/tests/mcp/cases-draft.ts b/tests/mcp/cases-draft.ts index f8102aed7..6ce99c1ee 100644 --- a/tests/mcp/cases-draft.ts +++ b/tests/mcp/cases-draft.ts @@ -228,12 +228,11 @@ test('draft.applyLeveling zet delays; de eind-runCPM verwerkt ze precies één k const baseStart = store.getState().tasks.find((t) => t.id === id)!.time.earlyStart; const before = store.getState().undoStack.length; + // B1c-plan3 taak 2: `applyLeveling` accepteert sinds nu `Pick` + // — alleen wat de functie daadwerkelijk leest hoeft in deze object-literal te staan (excess- + // property-check). const res = runInMcpTransaction(() => { - draft.applyLeveling({ - delays: { [id]: 3 }, - unresolved: {}, unresolvedReasons: {}, shifts: {}, - projectEndBefore: '', projectEndAfter: '', gaps: {}, - }); + draft.applyLeveling({ delays: { [id]: 3 }, gaps: {} }); }); assert(res.ok, 'transactie hoort te slagen'); diff --git a/tests/planning/check-apply-leveling-scope.ts b/tests/planning/check-apply-leveling-scope.ts new file mode 100644 index 000000000..9ad5a9cb1 --- /dev/null +++ b/tests/planning/check-apply-leveling-scope.ts @@ -0,0 +1,190 @@ +// check-apply-leveling-scope.ts — B1c-plan3 taak 2. `applyLeveling` schrijft voortaan binnen een +// SCOPE en schrijft ook `splitGaps`; `clearLeveling` wist ook de leveling-gaten. Headless tegen de +// ECHTE Zustand-store (zelfde `useAppStore.getState()`-patroon als `check-move-assignment.ts`). +// +// Draait via run.sh. Exit 0 = alles groen. +import { useAppStore } from '@/state/appStore'; +import { levelResources, type LevelingOptions } from '@/engine/scheduler/ResourceLeveler'; +import type { CPMResult } from '@/engine/scheduler/CPMSolver'; +import type { Task, TaskSplitGap } from '@/types/task'; +import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { WorkCalendar } from '@/types/calendar'; + +const S = () => useAppStore.getState(); +const diffs: string[] = []; +let checks = 0; +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Deel 1: scope-behoud. Document met taken A, B, C. A draagt AL `levelingDelay: 2` uit een +// eerdere nivellering. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-leveling-scope: deel 1, scope-behoud --'); +{ + const idA = S().addTask({ name: 'A' }); + const idB = S().addTask({ name: 'B' }); + const idC = S().addTask({ name: 'C' }); + S().updateTask(idA, { levelingDelay: 2 }); + + S().applyLeveling({ delays: { [idC]: 1 }, gaps: {} }, { scopeTaskIds: [idB, idC] }); + const tA = S().tasks.find(t => t.id === idA); + const tB = S().tasks.find(t => t.id === idB); + const tC = S().tasks.find(t => t.id === idC); + eq('A behoudt zijn delay (buiten de scope)', tA?.levelingDelay, 2); + eq('C krijgt zijn nieuwe delay', tC?.levelingDelay, 1); + eq('B wordt binnen de scope gereset', tB?.levelingDelay, undefined); + + // Zónder scope blijft het gedrag byte-identiek: alles wordt gereset. + S().applyLeveling({ delays: {}, gaps: {} }); + const tA2 = S().tasks.find(t => t.id === idA); + eq('geen scope ⇒ A wordt WEL gereset', tA2?.levelingDelay, undefined); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Deel 2: gaps schrijven. Taak D draagt een IMPORTSPLIT (geen `source`). +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-leveling-scope: deel 2, gaps schrijven --'); +{ + const idD = S().addTask({ name: 'D' }); + const importSplit: TaskSplitGap = { afterMinutes: 480, gapMinutes: 480 }; + S().updateTask(idD, { splitGaps: [importSplit] }); + + const levelingGap: TaskSplitGap = { afterMinutes: 1440, gapMinutes: 480, source: 'leveling' }; + S().applyLeveling({ delays: {}, gaps: { [idD]: [importSplit, levelingGap] } }); + const tD = S().tasks.find(t => t.id === idD); + eq('splitGaps geschreven zoals aangeleverd', tD?.splitGaps?.length, 2); + eq('de importsplit staat er nog, zonder source', tD?.splitGaps?.[0]?.source, undefined); + eq('het leveling-gat draagt zijn herkomst', tD?.splitGaps?.[1]?.source, 'leveling'); + + // Idempotent: een TWEEDE applyLeveling ZONDER gaps voor D wist alleen het leveling-gat. + S().applyLeveling({ delays: {}, gaps: {} }); + const tD2 = S().tasks.find(t => t.id === idD); + eq('tweede apply: alleen het leveling-gat weg', tD2?.splitGaps?.length, 1); + eq('en de importsplit staat er nog', tD2?.splitGaps?.[0]?.afterMinutes, 480); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Deel 3: clearLeveling wist ook leveling-gaten, en de no-op-guard telt gaten mee: een document met +// UITSLUITEND een leveling-gat (geen enkele delay) moet clearLeveling nog steeds een snapshot laten +// pushen en runCPM laten draaien. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-leveling-scope: deel 3, clearLeveling wist ook leveling-gaten --'); +{ + const idE = S().addTask({ name: 'E' }); + const importSplitE: TaskSplitGap = { afterMinutes: 480, gapMinutes: 480 }; + const levelingGapE: TaskSplitGap = { afterMinutes: 1440, gapMinutes: 480, source: 'leveling' }; + S().updateTask(idE, { splitGaps: [importSplitE] }); + // Geef E UITSLUITEND een leveling-gat, GEEN delay — precies het geval dat de oude no-op-guard miste. + S().applyLeveling({ delays: {}, gaps: { [idE]: [importSplitE, levelingGapE] } }); + eq('setup: E draagt nu twee gaten, geen delay', S().tasks.find(t => t.id === idE)?.splitGaps?.length, 2); + eq('setup: E heeft geen levelingDelay', S().tasks.find(t => t.id === idE)?.levelingDelay, undefined); + + const undoDepthBefore = S().undoStack.length; + S().clearLeveling(); + const undoDepthAfter = S().undoStack.length; + const tE = S().tasks.find(t => t.id === idE); + eq('clearLeveling wist het leveling-gat', tE?.splitGaps?.length, 1); + eq('en laat de importsplit staan', tE?.splitGaps?.[0]?.source, undefined); + ok('clearLeveling is geen no-op bij alleen een leveling-gat', undoDepthAfter > undoDepthBefore); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Deel 4 (motor): de baseline is ook GAP-vrij — idempotentie. `levelResources` TWEE keer achter +// elkaar op dezelfde fixture, waarbij de gaps van run 1 op de taken geschreven staan vóór run 2. +// Run 2 moet EXACT dezelfde gaps opleveren — accumulatie is de bug die dit pint. Een leveling-gat +// BUITEN de scope overleeft de baseline-strip (spiegel van de delay-regel). +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-leveling-scope: deel 4 (motor), idempotente onderbreek-modus --'); +{ + const PROJECT_CAL: WorkCalendar = { + id: 'cal-apply-leveling-scope', name: 'project', description: '', workDays: [1, 2, 3, 4, 5], + workStartHour: 8, workEndHour: 16, hoursPerDay: 8, holidays: [], + }; + function task(id: string, earlyStart: string, earlyFinish: string, durationDays: number, extra?: Partial): Task { + return { + id, name: id, description: '', wbsCode: '1', taskType: 'CONSTRUCTION', status: 'NOT_STARTED', + isMilestone: false, priority: 500, parentId: null, childIds: [], resourceIds: [], + time: { + durationType: 'WORKTIME', durationUnit: 'days', scheduleDuration: durationDays, + scheduleStart: earlyStart, scheduleFinish: earlyFinish, + earlyStart, earlyFinish, lateStart: earlyStart, lateFinish: earlyFinish, + freeFloat: 0, totalFloat: 0, isCritical: false, completion: 0, + }, + ...extra, + }; + } + function res(id: string, maxUnits = 1, extra?: Partial): Resource { + return { id, name: id, type: 'LABOR', description: '', maxUnits, ...extra }; + } + function assign(id: string, taskId: string, resourceId: string, unitsPerDay: number): ResourceAssignment { + return { id, taskId, resourceId, unitsPerDay, curve: 'UNIFORM' }; + } + function stubCpmResult(projectEnd: string): CPMResult { + return { + tasks: new Map(), criticalPath: [], drivingSequenceIds: [], sequenceFreeFloat: {}, + truncatedLeadSequenceIds: [], violatedConstraintTaskIds: [], missedDeadlineTaskIds: [], + outOfSequenceSequenceIds: [], nearCriticalTaskIds: [], criticalPaths: [], floatPathByTask: {}, + hammockNoFinishDriverTaskIds: [], projectEnd, projectDuration: 0, + }; + } + // Blokker bezet ma+wo+do (di vrij) — zelfde patroon als check-leveler-splitmode.ts. + const blockerTask = task('blk', '2026-06-01', '2026-06-04', 3, { + priority: 900, + splitGaps: [{ afterMinutes: 480, gapMinutes: 480 }], + }); + const blockerAssign = assign('blk-r', 'blk', 'r-scope4', 1); + // Z: binnen scope, wijkt via onderbreking naar di + de eerste vrije dag ná de blokker (vr). + const taskZ = task('z', '2026-06-01', '2026-06-02', 2, { priority: 100, deadline: '2026-06-02' }); + const zAssign = assign('z-r', 'z', 'r-scope4', 1); + // Buiten: BUITEN de scope, draagt AL een eigen leveling-gat uit een eerdere nivellering die de + // baseline-strip NIET mag aanraken. + const priorGap: TaskSplitGap = { afterMinutes: 480, gapMinutes: 480, source: 'leveling' }; + const taskBuiten = task('buiten', '2026-06-01', '2026-06-03', 2, { splitGaps: [priorGap] }); + const resource4 = res('r-scope4', 1); + const opts: LevelingOptions = { + constrainToFloat: false, overrunCeilingDays: 3, allowSplits: true, scopeTaskIds: ['z'], + }; + + const run1 = levelResources( + [blockerTask, taskZ, taskBuiten], [], [resource4], [blockerAssign, zAssign], PROJECT_CAL, [], + stubCpmResult('2026-06-04'), opts, + ); + ok('run 1: Z krijgt een leveling-gat', (run1.gaps['z']?.filter(g => g.source === 'leveling').length ?? 0) > 0); + + // Schrijf run 1's gaps op Z vóór run 2 — precies wat `applyLeveling` zou doen. + const taskZWithGaps: Task = { ...taskZ, splitGaps: run1.gaps['z'] }; + const run2 = levelResources( + [blockerTask, taskZWithGaps, taskBuiten], [], [resource4], [blockerAssign, zAssign], PROJECT_CAL, [], + stubCpmResult('2026-06-04'), opts, + ); + eq('nivelleren is idempotent in de onderbreek-modus', + JSON.stringify(run2.gaps['z']), JSON.stringify(run1.gaps['z'])); + + // Buiten de scope: `levelResources` schrijft nooit `gaps` voor een out-of-scope taak (ze doorloopt + // de eligibility-lus niet); de fixture-toelichting hierboven pint dat de baseline-strip haar + // BESTAANDE gat (`priorGap`) niet aanraakt — de motor krijgt haar ongewijzigd terug. + eq('run 2 schrijft geen gaps voor een taak buiten de scope', run2.gaps['buiten'], undefined); + // En de baseline-strip (die alleen IN-scope taken raakt) laat het bestaande leveling-gat van de + // out-of-scope taak zelf ongemoeid — de motor muteert de invoertaak nooit. + eq('de taak buiten de scope behoudt haar eigen leveling-gat ongewijzigd', + JSON.stringify(taskBuiten.splitGaps), JSON.stringify([priorGap])); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK apply-leveling-scope: alle checks groen (${checks})`); + process.exit(0); +} else { + console.log(`XX apply-leveling-scope: ${diffs.length} afwijking(en) van ${checks}`); + for (const d of diffs) console.log(` - ${d}`); + process.exit(1); +} diff --git a/tests/planning/check-leveling-delay-units.ts b/tests/planning/check-leveling-delay-units.ts index 0e6424542..4b9cc7cf4 100644 --- a/tests/planning/check-leveling-delay-units.ts +++ b/tests/planning/check-leveling-delay-units.ts @@ -136,10 +136,11 @@ S().setProject({ startDate: '2026-06-01' }); // maandag (bevestigd) const idT = S().addTask({ name: 'T' }); S().updateTask(idT, { levelingDelayMinutes: 2400, levelingDelayElapsed: true }); -S().applyLeveling({ - delays: { [idT]: 2 }, unresolved: {}, unresolvedReasons: {}, shifts: {}, - projectEndBefore: '2026-06-01', projectEndAfter: '2026-06-03', gaps: {}, -}); +// B1c-plan3 taak 2: `applyLeveling` accepteert sinds nu `Pick` — +// een volle `LevelingResult` blijft toewijsbaar via een variabele, maar een OBJECT LITERAL triggert +// TypeScript's excess-property-check op de overige velden. Alleen wat `applyLeveling` daadwerkelijk +// leest hoeft hier nog te staan. +S().applyLeveling({ delays: { [idT]: 2 }, gaps: {} }); { const t = S().tasks.find(x => x.id === idT)!; eq('applyLeveling wist de sub-dag-precisie', t.levelingDelayMinutes, undefined); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 56fa07836..a2362db17 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -444,6 +444,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then LEVELERSPLITMODECHECK="$DIR/.leveler-splitmode.mjs" if bundle_check "$DIR/check-leveler-splitmode.ts" "$LEVELERSPLITMODECHECK"; then node "$LEVELERSPLITMODECHECK" || STATUS=1; fi + # B1c-plan3 taak 2: `applyLeveling` schrijft scope-behoudend en schrijft ook `splitGaps`; + # `clearLeveling` wist ook de leveling-gaten (met een no-op-guard die gaten meetelt); de + # motor-baseline is idempotent in de onderbreek-modus (geen accumulatie bij een tweede run). + APPLYLEVELINGSCOPECHECK="$DIR/.apply-leveling-scope.mjs" + if bundle_check "$DIR/check-apply-leveling-scope.ts" "$APPLYLEVELINGSCOPECHECK"; then node "$APPLYLEVELINGSCOPECHECK" || STATUS=1; fi + # Ribbon Baselines & Progress: drie overlays links en twee kleurcontrols rechts horen ieder in # een verticale stack; losse groepsitems worden horizontaal gerenderd en maken de rij te breed. OVERLAYRIBBONCHECK="$DIR/.ribbon-overlays.mjs" From 91975570d04bc137fe0bd02db7d0d1db998e6908 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:14:12 +0200 Subject: [PATCH 007/108] fix(scheduler): een tijdbasis-bewerking wist de ingevoegde pauzedagen van die taak (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clearLevelingGaps` (etappe-2 taak 7) had tot nu toe nul aanroepplekken. Bedraad op elke bestaande `clearTimephasedWindow`-aanroepplek — de discoverable regel die het plan voorschrijft (`grep -rn "clearTimephasedWindow" src/`): `taskSlice.ts` (updateTask, setTaskCalendar), `resourceSlice.ts` (assignResource, unassignResource, moveAssignment ×2, removeCalendar, commitCalendarLibrary) en `createMcpTransactions.ts` (dezelfde acht MCP-draft-tegenhangers). Een gat met `source: 'leveling'` wordt gewist; een importsplit (geen `source`) blijft staan. Geen melding — dit is app-eigen afgeleide nivelleeruitvoer op een as die de gebruiker zelf net verzet heeft, geen importverlies zoals de M10-afronding. AFWIJKING — de spec (§4, "Invalidatie") noemt "voortgang" (completion/ actualStart/actualFinish) als vierde klasse naast duur/kalender/ handmatige datums. De discoverable regel wijst die klasse echter niet aan: `setTaskProgress`/`setActualStart`/`setActualFinish` roepen `clearTimephasedWindow` nergens aan (geverifieerd in de bron), dus een leveling-gat aan voortgang koppelen zou een NIEUW triggerpunt zijn, niet het overnemen van een bestaand patroon. Niet toegevoegd; gemeld i.p.v. stilzwijgend uitgebreid. De testcase dekt in plaats daarvan de "toewijzingen"- klasse, die de code zelf al als vierde trigger voert naast duur/kalender/ datums. Co-Authored-By: Claude Fable 5 --- src/state/runtime/createMcpTransactions.ts | 20 ++++++ src/state/slices/resourceSlice.ts | 16 ++++- src/state/slices/taskSlice.ts | 12 +++- tests/planning/check-apply-leveling-scope.ts | 74 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/state/runtime/createMcpTransactions.ts b/src/state/runtime/createMcpTransactions.ts index 5e372def7..e48bc681c 100644 --- a/src/state/runtime/createMcpTransactions.ts +++ b/src/state/runtime/createMcpTransactions.ts @@ -365,6 +365,14 @@ function createMcpDraft( && clearTimephasedDurationWalks(s.tasks[idx]); // mpp-nul-data-etappe, DEEL 1 — meld alleen bij een ECHT verlies via de actieve runtimelease. if (clearedWindow || clearedWalks) recordTimephasedLoss(id); + // B1c-plan3 taak 3 (spec §4, "Invalidatie"): dezelfde bewerking die de MSP-urensturing + // ongeldig maakt, maakt ook een door de nivelleerder ingevoegde pauzedag ongeldig — het gat + // ligt dan op een verouderde tijd-as. Importsplits (gaten zonder `source`) zijn brondata en + // blijven staan; `clearLevelingGaps` doet dat onderscheid. GEEN melding: anders dan de M10- + // afronding hierboven is dit geen verlies van gebruikersdata uit een importbestand maar het + // opruimen van app-eigen afgeleide nivelleeruitvoer op een as die de aanroeper zelf zojuist + // heeft verzet. + clearLevelingGaps(s.tasks[idx]); } s.isDirty = true; }); @@ -411,6 +419,9 @@ function createMcpDraft( const clearedWalks = timephasedDurationWalksHaveFrozenWork(task) && clearTimephasedDurationWalks(task); // mpp-nul-data-etappe, DEEL 1 — zie `updateTaskFields` hierboven. if (clearedWindow || clearedWalks) recordTimephasedLoss(id); + // B1c-plan3 taak 3 (spec §4, "Invalidatie") — zie `updateTaskFields` hierboven voor de + // motivering (geen melding: app-eigen afgeleide uitvoer, geen importverlies). + clearLevelingGaps(task); } s.isDirty = true; }); @@ -603,6 +614,9 @@ function createMcpDraft( const clearedWalks = clearTimephasedDurationWalks(task); // mpp-nul-data-etappe, DEEL 1 — zie `updateTaskFields` hierboven. if (clearedWindow || clearedWalks) recordTimephasedLoss(taskId); + // B1c-plan3 taak 3 (spec §4, "Invalidatie") — zie `updateTaskFields` hierboven voor de + // motivering (geen melding: app-eigen afgeleide uitvoer, geen importverlies). + clearLevelingGaps(task); s.isDirty = true; }); return id; @@ -671,11 +685,15 @@ function createMcpDraft( const clearedOldWindow = clearTimephasedWindow(oldTask); const clearedOldWalks = clearTimephasedDurationWalks(oldTask); if (clearedOldWindow || clearedOldWalks) recordTimephasedLoss(oldTaskId); + // B1c-plan3 taak 3 — zie `updateTaskFields` hierboven. + clearLevelingGaps(oldTask); } const clearedNewWindow = clearTimephasedWindow(newTask); const clearedNewWalks = clearTimephasedDurationWalks(newTask); // mpp-nul-data-etappe, DEEL 1 — zie `updateTaskFields` hierboven. if (clearedNewWindow || clearedNewWalks) recordTimephasedLoss(newTaskId); + // B1c-plan3 taak 3 — zie `updateTaskFields` hierboven. + clearLevelingGaps(newTask); s.isDirty = true; }); }, @@ -705,6 +723,8 @@ function createMcpDraft( const clearedWalks = clearTimephasedDurationWalks(removedTask); // mpp-nul-data-etappe, DEEL 1 — zie `updateTaskFields` hierboven. if (clearedWindow || clearedWalks) recordTimephasedLoss(removedTask.id); + // B1c-plan3 taak 3 — zie `updateTaskFields` hierboven. + clearLevelingGaps(removedTask); } s.isDirty = true; }); diff --git a/src/state/slices/resourceSlice.ts b/src/state/slices/resourceSlice.ts index c81514a2b..98205cf76 100644 --- a/src/state/slices/resourceSlice.ts +++ b/src/state/slices/resourceSlice.ts @@ -4,7 +4,7 @@ import { generateId } from '@/utils/id'; import { nextFreePaletteColor } from '@/engine/renderer/resourcePalette'; import { finishMutation } from '../transaction'; import { syncProjectCalendar } from '../syncProjectCalendar'; -import { clearTimephasedWindow, clearTimephasedDurationWalks } from '@/utils/taskDefaults'; +import { clearTimephasedWindow, clearTimephasedDurationWalks, clearLevelingGaps } from '@/utils/taskDefaults'; import { notifyTimephasedLoss } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; @@ -143,6 +143,9 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedWindow = clearTimephasedWindow(task); const clearedWalks = clearTimephasedDurationWalks(task); lostTimephasedGuidance = clearedWindow || clearedWalks; + // B1c-plan3 taak 3 (spec §4, "Invalidatie") — zie `taskSlice.ts`'s `updateTask` voor de + // motivering (geen melding: app-eigen afgeleide uitvoer, geen importverlies). + clearLevelingGaps(task); finishMutation(s); }); if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); @@ -196,6 +199,8 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedWindow = clearTimephasedWindow(removedTask); const clearedWalks = clearTimephasedDurationWalks(removedTask); lostTimephasedGuidance = clearedWindow || clearedWalks; + // B1c-plan3 taak 3 — zie `assignResource` hierboven. + clearLevelingGaps(removedTask); } finishMutation(s); }); @@ -248,10 +253,14 @@ export const createResourceSlice: AppSliceFactory = (runtime) => const clearedOldWindow = clearTimephasedWindow(oldTaskForWindow); const clearedOldWalks = clearTimephasedDurationWalks(oldTaskForWindow); if (clearedOldWindow || clearedOldWalks) lostCount++; + // B1c-plan3 taak 3 — zie `assignResource` hierboven. + clearLevelingGaps(oldTaskForWindow); } const clearedNewWindow = clearTimephasedWindow(newTask); const clearedNewWalks = clearTimephasedDurationWalks(newTask); if (clearedNewWindow || clearedNewWalks) lostCount++; + // B1c-plan3 taak 3 — zie `assignResource` hierboven. + clearLevelingGaps(newTask); finishMutation(s); moved = true; }); @@ -309,6 +318,9 @@ export const createResourceSlice: AppSliceFactory = (runtime) => // via een ander pad (rechtstreekse mutatie i.p.v. de dedicated actie). Zonder deze // aanroep bleef een bevroren Z8-venster staan terwijl de taak-kalender onder 'm wegviel. if (clearTimephasedWindow(t)) lostCount++; + // B1c-plan3 taak 3 (spec §4, "Invalidatie") — zie `assignResource` hierboven voor de + // motivering (geen melding: app-eigen afgeleide uitvoer, geen importverlies). + clearLevelingGaps(t); } } // Was dit de projectdefault, dan de projectkalender op een fallback zetten (§9.2). @@ -344,6 +356,8 @@ export const createResourceSlice: AppSliceFactory = (runtime) => t.calendarId = undefined; // Z14b (F3-fixronde) — zelfde reden als removeCalendar hierboven. if (clearTimephasedWindow(t)) lostCount++; + // B1c-plan3 taak 3 — zie `assignResource` hierboven. + clearLevelingGaps(t); } } // Projectdefault: het meegegeven id als het (nog) bestaat, anders de eerste entry (§9.2). diff --git a/src/state/slices/taskSlice.ts b/src/state/slices/taskSlice.ts index 7c210d8fe..b76c52863 100644 --- a/src/state/slices/taskSlice.ts +++ b/src/state/slices/taskSlice.ts @@ -1,7 +1,7 @@ import { Task, type ExternalLink } from '@/types/task'; import { createDefaultTaskTime, mergeTaskTime, clearTimephasedWindow, timeUpdateTouchesTimephasedWindow, - clearTimephasedDurationWalks, timephasedDurationWalksHaveFrozenWork, + clearTimephasedDurationWalks, timephasedDurationWalksHaveFrozenWork, clearLevelingGaps, } from '@/utils/taskDefaults'; import { generateId } from '@/utils/id'; import { formatDate, parseDate, parseInstant } from '@/utils/dateUtils'; @@ -448,6 +448,14 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge const clearedWalks = timephasedDurationWalksHaveFrozenWork(s.tasks[idx]) && clearTimephasedDurationWalks(s.tasks[idx]); lostTimephasedGuidance = clearedWindow || clearedWalks; + // B1c-plan3 taak 3 (spec §4, "Invalidatie"): dezelfde bewerking die de MSP-urensturing + // ongeldig maakt, maakt ook een door de nivelleerder ingevoegde pauzedag ongeldig — het gat + // ligt dan op een verouderde tijd-as. Importsplits (gaten zonder `source`) zijn brondata en + // blijven staan; `clearLevelingGaps` doet dat onderscheid. GEEN melding: anders dan de M10- + // afronding hierboven is dit geen verlies van gebruikersdata uit een importbestand maar het + // opruimen van app-eigen afgeleide nivelleeruitvoer op een as die de gebruiker zelf zojuist + // heeft verzet. + clearLevelingGaps(s.tasks[idx]); } // Datum-rakende mutatie (duur/start/constraint/mijlpaal → planning verouderd tot F5, A6). finishMutation(s, { stale: true }); @@ -466,6 +474,8 @@ export const createTaskSlice: AppSliceFactory = (runtime) => (set, ge runtime.beginUndoable(s); task.calendarId = calendarId; // undefined = projectkalender lostTimephasedGuidance = clearTimephasedWindow(task); // Z14b — kalenderwissel is een trigger, zie taskDefaults.ts + // B1c-plan3 taak 3 — zie `updateTask` hierboven. + clearLevelingGaps(task); finishMutation(s, { stale: true }); // taak-kalender-toewijzing is datum-beïnvloedend (§5.4). }); if (lostTimephasedGuidance) notifyTimephasedLoss(get().notify, get().activeDocumentId, 1); diff --git a/tests/planning/check-apply-leveling-scope.ts b/tests/planning/check-apply-leveling-scope.ts index 9ad5a9cb1..5e6612dd3 100644 --- a/tests/planning/check-apply-leveling-scope.ts +++ b/tests/planning/check-apply-leveling-scope.ts @@ -2,6 +2,10 @@ // SCOPE en schrijft ook `splitGaps`; `clearLeveling` wist ook de leveling-gaten. Headless tegen de // ECHTE Zustand-store (zelfde `useAppStore.getState()`-patroon als `check-move-assignment.ts`). // +// Deel 5 (onderaan) is B1c-plan3 taak 3: `clearLevelingGaps` bedraden op tijdbasis-bewerkingen +// (spec §4, "Invalidatie") — zie de toelichting daar voor de gekozen dekking en een AFWIJKING op de +// letterlijke "vier klassen" van de spec. +// // Draait via run.sh. Exit 0 = alles groen. import { useAppStore } from '@/state/appStore'; import { levelResources, type LevelingOptions } from '@/engine/scheduler/ResourceLeveler'; @@ -179,6 +183,76 @@ console.log('-- apply-leveling-scope: deel 4 (motor), idempotente onderbreek-mod JSON.stringify(taskBuiten.splitGaps), JSON.stringify([priorGap])); } +// ═══════════════════════════════════════════════════════════════════════════ +// Deel 5 (B1c-plan3 taak 3): een tijdbasis-bewerking wist de LEVELING-gaten van díé taak (spec §4, +// "Invalidatie") en laat IMPORTSPLITS staan. +// +// AFWIJKING van het letterlijke voorschrift: de spec noemt "voortgang" (completion/actualStart/ +// actualFinish) als vierde klasse, mét `setTaskProgress`/`setActualStart`/`setActualFinish` als +// aanroepplekken te verwachten. De discoverable regel die het plan zelf voorschrijft +// (`grep -rn "clearTimephasedWindow" src/`) wijst ECHTER geen van die drie functies aan — ze raken +// `clearTimephasedWindow` helemaal niet aan (bevestigd door de bron te lezen: geen van de drie +// noemt die functie). Een leveling-gat aan een voortgangsmutatie koppelen zou dus een NIEUW +// triggerpunt zijn, niet het overnemen van een bestaand aanroeppatroon — buiten wat dit plan vraagt. +// Deze case dekt daarom de drie klassen die WEL een echte `clearTimephasedWindow`-aanroepplek +// hebben (duur, handmatige datums via `updateTask`, kalender via `setTaskCalendar`), plus de +// "toewijzingen"-trigger die de code zelf als vierde klasse voert (`assignResource`, zie +// `resourceSlice.ts`/`createMcpTransactions.ts`s eigen commentaar "toewijzingen is expliciet +// onderdeel van de triggerset"). +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-leveling-scope: deel 5, tijdbasis-bewerkingen wissen leveling-gaten --'); +{ + const importSplit5: TaskSplitGap = { afterMinutes: 480, gapMinutes: 480 }; + const levelingGap5: TaskSplitGap = { afterMinutes: 1440, gapMinutes: 480, source: 'leveling' }; + const seed = (name: string) => { + const id = S().addTask({ name }); + S().applyLeveling({ delays: {}, gaps: { [id]: [importSplit5, levelingGap5] } }); + return id; + }; + + // ── Duur wijzigen ────────────────────────────────────────────────────────────────────────────── + const idDur = seed('F-duur'); + S().updateTask(idDur, { time: { ...S().tasks.find(t => t.id === idDur)!.time, scheduleDuration: 5 } }); + const tDur = S().tasks.find(t => t.id === idDur); + eq('duur wijzigen wist het leveling-gat', tDur?.splitGaps?.length, 1); + eq('en laat de importsplit staan', tDur?.splitGaps?.[0]?.source, undefined); + + // ── Handmatige datums (scheduleStart/scheduleFinish, bv. een Gantt-sleep) ─────────────────────── + const idDates = seed('G-datums'); + S().updateTask(idDates, { + time: { ...S().tasks.find(t => t.id === idDates)!.time, scheduleStart: '2026-07-01', scheduleFinish: '2026-07-05' }, + }); + const tDates = S().tasks.find(t => t.id === idDates); + eq('handmatige datums wissen het leveling-gat', tDates?.splitGaps?.length, 1); + eq('en laat de importsplit staan', tDates?.splitGaps?.[0]?.source, undefined); + + // ── Kalender wijzigen ──────────────────────────────────────────────────────────────────────────── + const idCal = seed('H-kalender'); + S().setTaskCalendar(idCal, 'een-andere-kalender-id'); + const tCal = S().tasks.find(t => t.id === idCal); + eq('kalender wijzigen wist het leveling-gat', tCal?.splitGaps?.length, 1); + eq('en laat de importsplit staan', tCal?.splitGaps?.[0]?.source, undefined); + + // ── Toewijzingen (assignResource) — de vierde klasse die de CODE zelf voert (zie de AFWIJKING + // hierboven), spiegelt "duur, datums, kalender, TOEWIJZINGEN" in resourceSlice.ts/ + // createMcpTransactions.ts se eigen commentaar. ──────────────────────────────────────────────── + const idAsgn = seed('I-toewijzing'); + const resId5 = S().addResource({ name: 'Deel5-resource', type: 'LABOR', description: '', maxUnits: 1 }); + S().assignResource(idAsgn, resId5, 1, 'UNIFORM'); + const tAsgn = S().tasks.find(t => t.id === idAsgn); + eq('een toewijzing wist het leveling-gat', tAsgn?.splitGaps?.length, 1); + eq('en laat de importsplit staan', tAsgn?.splitGaps?.[0]?.source, undefined); + + // ── Een taak met UITSLUITEND importsplits blijft ongemoeid (contract van `clearLevelingGaps`: + // `false` ⇒ niets gemuteerd — hier getoetst via de array-identiteit vóór/ná). ──────────────────── + const idOnlyImport = S().addTask({ name: 'J-alleen-importsplit' }); + S().updateTask(idOnlyImport, { splitGaps: [importSplit5] }); + S().updateTask(idOnlyImport, { time: { ...S().tasks.find(t => t.id === idOnlyImport)!.time, scheduleDuration: 3 } }); + const tOnlyImport = S().tasks.find(t => t.id === idOnlyImport); + eq('een taak met alleen importsplits blijft ongemoeid', tOnlyImport?.splitGaps?.length, 1); + eq('en de importsplit is exact hetzelfde gebleven', tOnlyImport?.splitGaps?.[0]?.afterMinutes, 480); +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (diffs.length === 0) { console.log(`OK apply-leveling-scope: alle checks groen (${checks})`); From 32cf34de16ef9a92eacb740b2a1f7013df8b2b75 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:21:50 +0200 Subject: [PATCH 008/108] feat(state): monotone mutatieteller per storecontext + voorstel-vingerafdruk (B1c) Co-Authored-By: Claude Fable 5 --- src/services/library/proposalFingerprint.ts | 61 ++++++++++++ src/state/runtime/storeRuntime.ts | 26 +++++ tests/planning/check-mutation-seq.ts | 104 ++++++++++++++++++++ tests/planning/run.sh | 6 ++ 4 files changed, 197 insertions(+) create mode 100644 src/services/library/proposalFingerprint.ts create mode 100644 tests/planning/check-mutation-seq.ts diff --git a/src/services/library/proposalFingerprint.ts b/src/services/library/proposalFingerprint.ts new file mode 100644 index 000000000..ef51a70c1 --- /dev/null +++ b/src/services/library/proposalFingerprint.ts @@ -0,0 +1,61 @@ +// proposalFingerprint.ts — B1c-plan3 taak 4 (spec §6a). +// +/** + * Vingerafdruk van de documentstate waarop een verdelingsvoorstel gerekend heeft (spec §6a). + * + * Referentie-gebaseerd, met opzet: Immer bevriest de state na elke producer en levert bij elke + * mutatie een NIEUWE referentie voor het gemuteerde deel. Een vergelijking op inhoud zou duurder én + * zwakker zijn (een gelijk-ogende takenlijst na een undo/redo is niet dezelfde planning). + * + * De velden zijn precies wat `computeDistribution` leest: de taken (datums, delays, gaten, prioriteit), + * de relaties, de resources en toewijzingen (wie boekt), de kalenders, het project (statusdatum, + * voortgangsmodus, planningsopties, bibliotheekkoppeling) en `cpmResult` (de doorgerekende cijfers + * waar §3.1 op staat), plus `scheduleStale`/`datesAsRecorded` (§3.1/§3.3a bepalen mee of een document + * meetelt). Groeit die leeslijst, dan groeit deze functie mee — anders overleeft een voorstel een + * bewerking die het wél ongeldig maakt. + * + * Puur, geen store-import (bewaakt door `verify:store-boundaries`). + */ +export interface FingerprintInput { + tasks: unknown; + sequences: unknown; + resources: unknown; + assignments: unknown; + calendar: unknown; + calendars: unknown; + project: unknown; + cpmResult: unknown; + scheduleStale: unknown; + datesAsRecorded: unknown; +} + +const FINGERPRINT_KEYS: readonly (keyof FingerprintInput)[] = [ + 'tasks', 'sequences', 'resources', 'assignments', 'calendar', 'calendars', + 'project', 'cpmResult', 'scheduleStale', 'datesAsRecorded', +]; + +// Eén WeakMap + oplopende teller per proces: goedkoop te vergelijken id's zonder de geziene objecten +// zelf vast te houden (de WeakMap laat ze vrij zodra niemand anders ze meer refereert). Primitieven +// (booleans, null) kunnen niet als WeakMap-sleutel dienen en worden daarom letterlijk in de string +// opgenomen — hun WAARDE is dan zelf het onderscheid (bv. `scheduleStale` van false naar true). +const refIds = new WeakMap(); +let nextRefId = 1; + +function idFor(value: unknown): string { + if (value !== null && (typeof value === 'object' || typeof value === 'function')) { + let id = refIds.get(value as object); + if (id === undefined) { + id = nextRefId++; + refIds.set(value as object, id); + } + return `r${id}`; + } + return `p${String(value)}`; +} + +/** Bouw de vingerafdruk. `mutationSeq` is de grofmazige backstop van `StoreRuntime.mutationSeq()`. */ +export function documentFingerprint(d: FingerprintInput, mutationSeq: number): string { + const parts = FINGERPRINT_KEYS.map((k) => idFor(d[k])); + parts.push(`m${mutationSeq}`); + return parts.join('|'); +} diff --git a/src/state/runtime/storeRuntime.ts b/src/state/runtime/storeRuntime.ts index a79edd441..e0a7b3914 100644 --- a/src/state/runtime/storeRuntime.ts +++ b/src/state/runtime/storeRuntime.ts @@ -19,6 +19,18 @@ export interface StoreRuntime { recordMcpTimephasedLoss(lease: McpTransactionLease, taskId: string): void; countMcpTimephasedLoss(lease: McpTransactionLease): number; exitMcpTransaction(lease: McpTransactionLease): void; + /** + * Monotone teller die bij ELKE undoable mutatie in deze context omhoog gaat — óók bij een + * gecoalesceerde mutatie, die bewust géén snapshot pusht (spec §6a). Bewust NIET hetzelfde als het + * interne `undoSeq`: dat volgnummer stuurt de coalesce-vergelijking en beweegt daarom juist niet + * tijdens een sleepreeks, en `undoStack.length` is onbruikbaar omdat `MAX_UNDO` van onderaf trimt. + * + * Afnemer: de voorstel-invalidatie van de B1c-verdeeldialoog. Die combineert deze teller met de + * REFERENTIES van de documentvelden waarop het voorstel gerekend heeft — de teller is de goedkope, + * grofmazige backstop; de referenties maken de bewaking sluitend (`runCPM` muteert datums zonder + * ooit langs `beginUndoable` te komen). + */ + mutationSeq(): number; } interface ActiveMcpLease extends McpTransactionLease { @@ -37,6 +49,9 @@ export function createStoreRuntime(): StoreRuntime { let undoSeq = 0; let batchDepth = 0; let activeMcpLease: ActiveMcpLease | null = null; + // B1c-plan3 taak 4 (spec §6a): monotone, per-context mutatieteller — zie het docblok bij + // `StoreRuntime.mutationSeq`. + let mutationSeq = 0; const requireActiveLease = (lease: McpTransactionLease): ActiveMcpLease => { if (activeMcpLease !== lease) { @@ -50,6 +65,7 @@ export function createStoreRuntime(): StoreRuntime { state.undoStack.push(createSnapshot(base)); if (state.undoStack.length > MAX_UNDO) state.undoStack.shift(); undoSeq++; + mutationSeq++; }, resetUndoCoalescing() { @@ -94,6 +110,12 @@ export function createStoreRuntime(): StoreRuntime { }, beginUndoable(state, opts) { + // B1c-plan3 taak 4: de bump staat als EERSTE regel — vóór de batch-/lease-guard én vóór de + // coalesce-tak. Een mutatie binnen een batch of MCP-transactie is nog steeds een mutatie, ook + // al neemt de omvattende transactie de snapshot; en een gecoalesceerde mutatie (géén nieuwe + // snapshot) is precies het geval waarvoor deze teller bestaat (zie het docblok hierboven). + mutationSeq++; + // De omvattende batch of MCP-transactie heeft de ene snapshot al genomen. if (batchDepth > 0 || activeMcpLease) return; @@ -113,6 +135,10 @@ export function createStoreRuntime(): StoreRuntime { state.redoStack = []; coalesce = key ? { key, seq: undoSeq, docId: state.activeDocumentId } : null; }, + + mutationSeq() { + return mutationSeq; + }, }; return runtime; diff --git a/tests/planning/check-mutation-seq.ts b/tests/planning/check-mutation-seq.ts new file mode 100644 index 000000000..22f026238 --- /dev/null +++ b/tests/planning/check-mutation-seq.ts @@ -0,0 +1,104 @@ +// check-mutation-seq.ts — B1c-plan3 taak 4 (spec §6a). De teller is MONOTOON en beweegt bij ELKE +// undoable mutatie, óók binnen een coalesce-reeks — dat is precies waar `undoStack.length` en het +// interne undo-volgnummer tekortschieten. Plus: de voorstel-vingerafdruk (`documentFingerprint`). +// +// `createStoreRuntime()` is state-loos qua documentvelden (alle documentdata leeft in het +// meegegeven `AppState`-argument), dus deze test drijft hem met een minimale, met de hand gebouwde +// staat die precies de velden draagt die `createSnapshot`/`beginUndoable` aanraken — geen echte +// Zustand-store nodig. +// +// Draait via run.sh. Exit 0 = alles groen. +import { createStoreRuntime } from '@/state/runtime/storeRuntime'; +import { documentFingerprint, type FingerprintInput } from '@/services/library/proposalFingerprint'; +import { createDefaultProject } from '@/state/defaults'; +import { createDefaultCalendar } from '@/engine/calendar/defaultCalendar'; +import type { AppState } from '@/state/appStore'; + +let checks = 0; +const diffs: string[] = []; +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} + +// Minimale documentstate: precies de velden die `createSnapshot` (via DOCUMENT_FIELDS) en +// `beginUndoable` lezen/schrijven. Cast naar AppState — de rest van de interface (acties) wordt +// door deze test nooit aangeroepen. +function fakeState(docId = 'doc-fake'): AppState { + return { + project: createDefaultProject(), + calendar: createDefaultCalendar(), + tasks: [], sequences: [], resources: [], assignments: [], calendars: [], + activityCodeTypes: [], customFieldDefs: [], customTaskTypes: [], + cpmResult: null, resourceLoadResult: null, scheduleStale: false, + baselines: [], activeBaselineId: null, recordedDates: null, datesAsRecorded: false, + undoStack: [], redoStack: [], activeDocumentId: docId, + } as unknown as AppState; +} + +console.log('-- check-mutation-seq: de monotone teller --'); +{ + const rt = createStoreRuntime(); + const state = fakeState(); + eq('start op 0', rt.mutationSeq(), 0); + + // Een gewone mutatie: snapshot gepusht ⇒ teller omhoog. + rt.beginUndoable(state); + ok('gewone mutatie bumpt', rt.mutationSeq() > 0); + + // Een GECOALESCEERDE tweede mutatie pusht GEEN snapshot (undoStack groeit niet) maar is wél een + // mutatie — de teller moet er dus wél op bewegen. + const before = rt.mutationSeq(); + const depthBefore = state.undoStack.length; + rt.beginUndoable(state, { coalesceKey: 'k' }); + rt.beginUndoable(state, { coalesceKey: 'k' }); + eq('coalesce pusht geen tweede snapshot', state.undoStack.length, depthBefore + 1); + ok('maar de teller beweegt wél', rt.mutationSeq() > before + 1); + + // MAX_UNDO-trimming raakt de teller niet (dat is het hele punt van een aparte teller). + for (let i = 0; i < 150; i++) rt.beginUndoable(state, { coalesceKey: `iter-${i}` }); + ok('teller blijft monotoon voorbij MAX_UNDO', rt.mutationSeq() >= 150); + + // Twee contexten hebben ELK hun eigen teller (geen module-global). + const rt2 = createStoreRuntime(); + ok('per context', rt2.mutationSeq() === 0 && rt.mutationSeq() > 0); +} + +console.log('-- check-mutation-seq: de vingerafdruk --'); +{ + const tasksA: unknown[] = []; + const inputA: FingerprintInput = { + tasks: tasksA, sequences: [], resources: [], assignments: [], calendar: {}, calendars: [], + project: {}, cpmResult: null, scheduleStale: false, datesAsRecorded: false, + }; + const fpA = documentFingerprint(inputA, 5); + const fpB = documentFingerprint(inputA, 5); + ok('zelfde referenties ⇒ zelfde vingerafdruk', fpA === fpB); + + const inputC: FingerprintInput = { ...inputA, tasks: [] }; + const fpC = documentFingerprint(inputC, 5); + ok('nieuwe tasks-referentie ⇒ andere vingerafdruk', fpA !== fpC); + + const fpD = documentFingerprint(inputA, 6); + ok('andere mutationSeq ⇒ andere vingerafdruk', fpA !== fpD); + + const inputE: FingerprintInput = { ...inputA, scheduleStale: true }; + const fpE = documentFingerprint(inputE, 5); + ok('primitief veld telt ook mee (scheduleStale)', fpA !== fpE); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK check-mutation-seq: alle checks groen (${checks})`); + process.exit(0); +} else { + console.log(`XX check-mutation-seq: ${diffs.length} afwijking(en) van ${checks}`); + for (const d of diffs) console.log(` - ${d}`); + process.exit(1); +} diff --git a/tests/planning/run.sh b/tests/planning/run.sh index a2362db17..5ebed4ad4 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -450,6 +450,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then APPLYLEVELINGSCOPECHECK="$DIR/.apply-leveling-scope.mjs" if bundle_check "$DIR/check-apply-leveling-scope.ts" "$APPLYLEVELINGSCOPECHECK"; then node "$APPLYLEVELINGSCOPECHECK" || STATUS=1; fi + # B1c-plan3 taak 4: de monotone mutatieteller op de store-runtime (beweegt óók binnen een + # coalesce-reeks, waar undoStack.length en het interne undo-volgnummer tekortschieten) plus de + # referentie-gebaseerde voorstel-vingerafdruk (`documentFingerprint`). + MUTATIONSEQCHECK="$DIR/.mutation-seq.mjs" + if bundle_check "$DIR/check-mutation-seq.ts" "$MUTATIONSEQCHECK"; then node "$MUTATIONSEQCHECK" || STATUS=1; fi + # Ribbon Baselines & Progress: drie overlays links en twee kleurcontrols rechts horen ieder in # een verticale stack; losse groepsitems worden horizontaal gerenderd en maken de rij te breed. OVERLAYRIBBONCHECK="$DIR/.ribbon-overlays.mjs" From 08a8f1520f7c276eb7c36485cf99cc6b70c987a7 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:22:33 +0200 Subject: [PATCH 009/108] feat(state): headless scratch-instantie voor slapende documenten, met stille host-events (B1c) Co-Authored-By: Claude Fable 5 --- src/state/appStore.ts | 6 +- src/state/runtime/scratchDocument.ts | 87 ++++++++++++ src/state/runtime/storeRuntime.ts | 21 ++- src/state/slices/documentSlice.ts | 14 +- src/state/slices/fileSlice.ts | 4 +- src/state/slices/projectSlice.ts | 6 +- src/state/slices/scheduleSlice.ts | 4 +- tests/planning/check-scratch-document.ts | 164 +++++++++++++++++++++++ tests/planning/run.sh | 7 + 9 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 src/state/runtime/scratchDocument.ts create mode 100644 tests/planning/check-scratch-document.ts diff --git a/src/state/appStore.ts b/src/state/appStore.ts index e49499bee..315ce1cf6 100644 --- a/src/state/appStore.ts +++ b/src/state/appStore.ts @@ -16,7 +16,7 @@ import { createDocumentSlice, type DocumentSlice } from './slices/documentSlice' import { createStructureSlice, type StructureSlice } from './slices/structureSlice'; import { createBaselineSlice, type BaselineSlice } from './slices/baselineSlice'; import { createLibrarySlice, type LibrarySlice } from './slices/librarySlice'; -import { createStoreRuntime, type StoreRuntime } from './runtime/storeRuntime'; +import { createStoreRuntime, type StoreRuntime, type StoreRuntimeOptions } from './runtime/storeRuntime'; // Consumenten blijven ExportFormat uit '@/state/appStore' importeren. export type { ExportFormat } from './slices/fileSlice'; @@ -65,8 +65,8 @@ export interface AppStoreContext { * Zustandfactory (plugininstances, eventbus en SDK-windowbinding) blijven bewust app-global; de * gemounte productinterface en React-selectors binden die aan `appStoreContext` hieronder. */ -export function createAppStoreContext(): AppStoreContext { - const runtime = createStoreRuntime(); +export function createAppStoreContext(opts?: StoreRuntimeOptions): AppStoreContext { + const runtime = createStoreRuntime(opts); const store = create()( immer((...a) => ({ ...createProjectSlice(runtime)(...a), diff --git a/src/state/runtime/scratchDocument.ts b/src/state/runtime/scratchDocument.ts new file mode 100644 index 000000000..8babb534b --- /dev/null +++ b/src/state/runtime/scratchDocument.ts @@ -0,0 +1,87 @@ +// scratchDocument.ts — B1c-plan3 taak 5 (spec §5, "Toepassen: schrijven in meerdere documenten"). +/** + * Draai een bewerking op een SLAPENDE documentpayload in een eigen, headless storecontext. + * + * Waarom niet gewoon de payload spreaden zoals `recalculateStaleSleepingDocuments` doet: die route + * omzeilt `beginUndoable` en moet `MAX_UNDO`-trimming en coalescing zelf naborgen. Hier draaien de + * ECHTE acties op een echte context, dus het documentcontract, de undo-semantiek en de + * transactie-runtime gelden vanzelf — en dat is precies wat "alles terugdraaien" (taak 6) nodig + * heeft: een échte undo-stap op de eigen stack van dat document. + * + * Twee singleton-randen staan dicht (spec §5): + * (a) host-events — de context wordt met `emitHostEvents: false` gebouwd, zodat extensies geen + * cijfers krijgen van een document waar de gebruiker niet naar kijkt; + * (b) meldingen — `ui.notifications` van deze context rendert niemand. Ze worden na afloop + * LEEGGEHAALD en aan de aanroeper teruggegeven, zodat een fout (cyclus, lege kalender) als + * blokkerende reden zichtbaar wordt in plaats van geruisloos te verdwijnen. + * + * De context is puur lokaal: hij wordt niet bewaard, niet geregistreerd en niet hergebruikt. App- + * globale registers (extensie-instanties, MCP, bibliotheek-persistentie) worden niet aangeraakt — + * die leven buiten de Zustand-factory. + */ +import { createAppStoreContext, type AppState, type AppStore } from '../appStore'; +import { hydratePayload, capturePayload, type DocumentPayload } from '../documentContract'; +import type { NotifyInput } from '../slices/types'; + +export interface ScratchRunResult { + /** De payload ná de bewerking — klaar om in `documents[i].payload` gezet te worden. Bij een + * mislukking (`ok: false`) is dit de ONGEWIJZIGDE invoerpayload. */ + payload: DocumentPayload; + result: T | undefined; + /** Wat de scratch-context in zijn onzichtbare meldingenkanaal zou hebben gezet — vorm van + * `NotifyInput` (zonder `id`/`count`) zodat de aanroeper ze rechtstreeks aan een echte `notify()` + * kan doorgeven. */ + notifications: NotifyInput[]; + /** `false` wanneer `fn` een exception gooide. */ + ok: boolean; + error?: string; +} + +/** Lees en leeg `ui.notifications` van de scratch-context, in `NotifyInput`-vorm (rand (b) hierboven: + * niemand rendert dit kanaal, dus de inhoud gaat terug naar de aanroeper in plaats van te verdwijnen). */ +function harvestNotifications(store: AppStore): NotifyInput[] { + const raw = store.getState().ui.notifications; + const out: NotifyInput[] = raw.map((n) => ({ + severity: n.severity, + messageKey: n.messageKey, + params: n.params, + detail: n.detail, + dedupeKey: n.dedupeKey, + helpArticleId: n.helpArticleId, + })); + if (raw.length > 0) store.setState((s) => { s.ui.notifications = []; }); + return out; +} + +export function runInScratchDocument( + payload: DocumentPayload, + fn: (state: AppState) => T, +): ScratchRunResult { + const ctx = createAppStoreContext({ emitHostEvents: false }); + // 1. Hydrateren via het documentcontract — dezelfde functie die `switchDocument` gebruikt, dus + // élk (ook toekomstig) documentveld rijdt automatisch mee. + ctx.store.setState((s) => { hydratePayload(s, payload); }); + // 2. De ECHTE acties draaien. `fn` krijgt de state (met alle acties erop) en mag alles doen wat een + // gewone gebruiker zou doen — inclusief `applyLeveling`, dat zelf zijn snapshot pusht en + // `runCPM` draait. + let result: T | undefined; + let ok = true; + let error: string | undefined; + try { + result = fn(ctx.store.getState()); + } catch (e) { + ok = false; + error = String(e); + } + // 3. Meldingen oogsten en het kanaal legen (rand (b)). + const notifications = harvestNotifications(ctx.store); + // 4. Terug capturen. Bij een mislukking geven we de ONGEWIJZIGDE invoerpayload terug: net als + // `recalculateStaleSleepingDocuments` mag een halve mutatie nooit terug de registry in. + return { + payload: ok ? capturePayload(ctx.store.getState()) : payload, + result, + notifications, + ok, + error, + }; +} diff --git a/src/state/runtime/storeRuntime.ts b/src/state/runtime/storeRuntime.ts index e0a7b3914..97e946b59 100644 --- a/src/state/runtime/storeRuntime.ts +++ b/src/state/runtime/storeRuntime.ts @@ -1,5 +1,6 @@ import { createSnapshot } from '../snapshot'; import type { AppState } from '../appStore'; +import { emitExtensionEvent, type HostEventName } from '@/services/extensionEvents'; /** Bovengrens op de undo-historie; blijft gedeeld beleid, niet gedeelde uitvoeringsstate. */ export const MAX_UNDO = 100; @@ -8,6 +9,13 @@ export interface McpTransactionLease { readonly token: symbol; } +export interface StoreRuntimeOptions { + /** `false` ⇒ deze context zendt GEEN host-events uit (spec §5, rand (a)). Gebruikt door de + * scratch-instantie (`scratchDocument.ts`): extensies zijn app-globaal geregistreerd en zouden + * anders cijfers krijgen van een document waar de gebruiker niet naar kijkt. Default `true`. */ + emitHostEvents?: boolean; +} + export interface StoreRuntime { beginUndoable(state: AppState, opts?: { coalesceKey?: string }): void; pushUndoSnapshot(state: AppState, base?: AppState): void; @@ -31,6 +39,15 @@ export interface StoreRuntime { * ooit langs `beginUndoable` te komen). */ mutationSeq(): number; + /** + * Zend een host-lifecycle-event uit namens DEZE context (B1c-plan3 taak 5, spec §5 rand (a)). + * Slices roepen dit aan in plaats van `emitExtensionEvent` rechtstreeks — de bus zelf blijft + * app-globaal (dat hoort zo: extensies zijn app-niveau), maar of een context er iets op zet is nu + * een eigenschap van die context. De scratch-instantie bouwt haar context met + * `emitHostEvents: false`, zodat een efemere run op een slapend document geen extensie-luisteraar + * bereikt. + */ + emitHostEvent(event: HostEventName, data?: unknown): void; } interface ActiveMcpLease extends McpTransactionLease { @@ -44,7 +61,7 @@ interface ActiveMcpLease extends McpTransactionLease { * of `DOCUMENT_FIELDS`. Ze mogen evenmin module-global zijn: dan kan een open batch in store B de * undo van store A onderdrukken. De closure houdt dezelfde semantiek lokaal per context. */ -export function createStoreRuntime(): StoreRuntime { +export function createStoreRuntime(opts?: StoreRuntimeOptions): StoreRuntime { let coalesce: { key: string; seq: number; docId: string } | null = null; let undoSeq = 0; let batchDepth = 0; @@ -139,6 +156,8 @@ export function createStoreRuntime(): StoreRuntime { mutationSeq() { return mutationSeq; }, + + emitHostEvent: opts?.emitHostEvents === false ? () => {} : emitExtensionEvent, }; return runtime; diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index c346cbda0..3605a4600 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -10,7 +10,7 @@ import { type DocumentPayload, type RecoveryDocInput, } from '../documentContract'; -import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import { HOST_EVENTS } from '@/services/extensionEvents'; import { documentTitle, untitledOrdinals } from '@/utils/documents'; import { solveProject, cloneTasksForSolve } from '@/engine/scheduler/solveProject'; @@ -176,7 +176,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => resetDocumentScopedUI(s); }); get().recomputeViewRows(); - emitExtensionEvent(HOST_EVENTS.projectNew); + runtime.emitHostEvent(HOST_EVENTS.projectNew); return newId; }, @@ -238,7 +238,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => resetDocumentScopedUI(s); }); get().recomputeViewRows(); - emitExtensionEvent(HOST_EVENTS.projectLoaded, { + runtime.emitHostEvent(HOST_EVENTS.projectLoaded, { tasks: copy.tasks.length, sequences: copy.sequences.length, resources: copy.resources.length, @@ -284,7 +284,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // actief document om te herberekenen). Bij activering hier onvoorwaardelijk herberekenen dicht // die hele klasse — niet alleen het pool-edit-geval, elke toekomstige dormant-mutatie ook. get().recomputeResourceLoad(); - emitExtensionEvent(HOST_EVENTS.projectLoaded, { + runtime.emitHostEvent(HOST_EVENTS.projectLoaded, { tasks: incoming.tasks.length, sequences: incoming.sequences.length, resources: incoming.resources.length, @@ -306,7 +306,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => resetDocumentScopedUI(s); }); get().recomputeViewRows(); - emitExtensionEvent(HOST_EVENTS.projectNew); + runtime.emitHostEvent(HOST_EVENTS.projectNew); return; } @@ -342,7 +342,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // Zie switchDocument hierboven: het net-geactiveerde buurdocument kan een verouderd // `resourceLoadResult` dragen (grens 3/4 ververste zijn payload terwijl het sliep). get().recomputeResourceLoad(); - emitExtensionEvent(HOST_EVENTS.projectLoaded, { + runtime.emitHostEvent(HOST_EVENTS.projectLoaded, { tasks: incoming.tasks.length, sequences: incoming.sequences.length, resources: incoming.resources.length, @@ -397,7 +397,7 @@ export const createDocumentSlice: AppSliceFactory = (runtime) => // hydrate/capture-wissel + volledige CPM-run kosten en de opstart lineair vertragen bij veel // herstelde documenten, terwijl de uitkomst pas zichtbaar is als je erheen switcht. get().runCPM(); - emitExtensionEvent(HOST_EVENTS.projectLoaded, { + runtime.emitHostEvent(HOST_EVENTS.projectLoaded, { tasks: active.tasks.length, sequences: active.sequences.length, resources: active.resources.length, diff --git a/src/state/slices/fileSlice.ts b/src/state/slices/fileSlice.ts index 8a84b2d2e..67aa0a533 100644 --- a/src/state/slices/fileSlice.ts +++ b/src/state/slices/fileSlice.ts @@ -6,7 +6,7 @@ import { writeP6XML } from '@/services/p6/p6xmlWriter'; import { openFileDialog, saveFileDialog, saveToRef, readFromRef, readBytesFromRef, type FileRef, type SaveOutcome } from '@/services/fileAccess'; import { openDialogFilters, binaryExtensions, readFormatForFile, parseOpenedFile, importErrorMessageKey, saveTargetFor, readFormatInput, type ExportFormat } from '@/services/formatRegistry'; import { loadRecents, addRecent, removeRecent, type RecentEntry } from '@/services/fileAccess/recentFiles'; -import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import { HOST_EVENTS } from '@/services/extensionEvents'; import type { AppSliceFactory } from './types'; import type { AppState } from '../appStore'; import { isTauri } from '@/utils/platform'; @@ -223,7 +223,7 @@ export const createFileSlice: AppSliceFactory = (runtime) => (set, ge dedupeKey: 'mpp-split-leveled', }); } - emitExtensionEvent(HOST_EVENTS.projectLoaded, { + runtime.emitHostEvent(HOST_EVENTS.projectLoaded, { tasks: parsed.tasks.length, sequences: parsed.sequences.length, resources: parsed.resources.length, diff --git a/src/state/slices/projectSlice.ts b/src/state/slices/projectSlice.ts index 3a3481a57..ff8d65ec5 100644 --- a/src/state/slices/projectSlice.ts +++ b/src/state/slices/projectSlice.ts @@ -22,7 +22,7 @@ import { import { finishMutation } from '../transaction'; import { syncProjectCalendar, promoteProjectCalendarToLibrary } from '../syncProjectCalendar'; import { freshPayload, hydratePayload } from '../documentContract'; -import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import { HOST_EVENTS } from '@/services/extensionEvents'; import { clearTimephasedLossNoticeForDoc } from '../timephasedLossNotice'; import type { AppSliceFactory } from './types'; import { deriveHoursPerDay } from '@/services/subdayIo'; @@ -445,7 +445,7 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s // vanuit `newDocument()`/een echte bestandsopen hoort te gebeuren). clearTimephasedLossNoticeForDoc(s.activeDocumentId); }); - emitExtensionEvent(HOST_EVENTS.projectNew); + runtime.emitHostEvent(HOST_EVENTS.projectNew); }, createNewProject: (opts) => { @@ -514,7 +514,7 @@ export const createProjectSlice: AppSliceFactory = (runtime) => (s // een no-op op het niet-pristine pad (newDocument() gaf daar al een vers, ongeregistreerd docId). clearTimephasedLossNoticeForDoc(s.activeDocumentId); }); - emitExtensionEvent(HOST_EVENTS.projectNew); + runtime.emitHostEvent(HOST_EVENTS.projectNew); }, setFilePath: (path) => diff --git a/src/state/slices/scheduleSlice.ts b/src/state/slices/scheduleSlice.ts index b782fc23a..8efb38a0c 100644 --- a/src/state/slices/scheduleSlice.ts +++ b/src/state/slices/scheduleSlice.ts @@ -9,7 +9,7 @@ import { type LevelingResult, } from '@/engine/scheduler/ResourceLeveler'; import { finishMutation, markScheduleStale } from '../transaction'; -import { emitExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import { HOST_EVENTS } from '@/services/extensionEvents'; import { notifyLevelingDelayRounded } from '../timephasedLossNotice'; import { clearLevelingGaps } from '@/utils/taskDefaults'; import type { Task } from '@/types/task'; @@ -171,7 +171,7 @@ export const createScheduleSlice: AppSliceFactory = (runtime) => } const cpm = get().cpmResult; - emitExtensionEvent(HOST_EVENTS.scheduleCalculated, { + runtime.emitHostEvent(HOST_EVENTS.scheduleCalculated, { hasError: !!cpm?.error, error: cpm?.error ?? null, criticalTasks: get().tasks.filter((t) => t.time.isCritical).length, diff --git a/tests/planning/check-scratch-document.ts b/tests/planning/check-scratch-document.ts new file mode 100644 index 000000000..add5e3d11 --- /dev/null +++ b/tests/planning/check-scratch-document.ts @@ -0,0 +1,164 @@ +// check-scratch-document.ts — B1c-plan3 taak 5 (spec §5). Een slapende payload wordt in een eigen +// storecontext bewerkt, en die context laat GEEN sporen na in de app-globale registers. +// +// Draait via run.sh. Exit 0 = alles groen. +import { runInScratchDocument } from '@/state/runtime/scratchDocument'; +import { createAppStoreContext, appStoreContext } from '@/state/appStore'; +import { DOCUMENT_FIELDS, capturePayload, freshPayload, type DocumentPayload } from '@/state/documentContract'; +import { createDefaultProject } from '@/state/defaults'; +import { subscribeExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import type { Task } from '@/types/task'; +import type { WorkCalendar } from '@/types/calendar'; + +let checks = 0; +const diffs: string[] = []; +function eq(label: string, got: unknown, want: unknown): void { + checks++; + if (JSON.stringify(got) !== JSON.stringify(want)) { + diffs.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); + } +} +function ok(label: string, cond: boolean): void { + checks++; + if (!cond) diffs.push(label); +} + +const PROJECT_CAL: WorkCalendar = { + id: 'cal-default', name: 'project', description: '', workDays: [1, 2, 3, 4, 5], + workStartHour: 8, workEndHour: 16, hoursPerDay: 8, holidays: [], +}; +function task(id: string, earlyStart: string, earlyFinish: string, durationDays: number, extra?: Partial): Task { + return { + id, name: id, description: '', wbsCode: '1', taskType: 'CONSTRUCTION', status: 'NOT_STARTED', + isMilestone: false, priority: 500, parentId: null, childIds: [], resourceIds: [], + time: { + durationType: 'WORKTIME', durationUnit: 'days', scheduleDuration: durationDays, + scheduleStart: earlyStart, scheduleFinish: earlyFinish, + earlyStart, earlyFinish, lateStart: earlyStart, lateFinish: earlyFinish, + freeFloat: 0, totalFloat: 0, isCritical: false, completion: 0, + }, + ...extra, + }; +} + +function basePayload(): DocumentPayload { + const taskA = task('A', '2026-06-01', '2026-06-03', 3); + const taskB = task('B', '2026-06-01', '2026-06-03', 3); + return { + ...freshPayload(), + project: { ...createDefaultProject(), name: 'Scratch', startDate: '2026-06-01', calendarId: 'cal-default' }, + calendar: PROJECT_CAL, + calendars: [PROJECT_CAL], + tasks: [taskA, taskB], + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 1: de payload gaat er heel in en heel uit — een no-op-functie mag GEEN enkel documentveld +// veranderen. Loop over DOCUMENT_FIELDS in plaats van met de hand op te sommen: een toekomstig +// documentveld rijdt automatisch mee. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- scratch-document: geval 1, round-trip --'); +{ + const payload = basePayload(); + const out = runInScratchDocument(payload, () => {}); + ok('geval 1: de run slaagt', out.ok); + for (const f of DOCUMENT_FIELDS) { + eq(`geval 1: round-trip ${f.key}`, (out.payload as unknown as Record)[f.key], (payload as unknown as Record)[f.key]); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 2: de echte acties draaien, met echte undo-semantiek. `applyLeveling` schrijft een delay op +// B, pusht ÉÉN snapshot op de EIGEN undo-stack van de scratch-context, en draait zelf `runCPM`. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- scratch-document: geval 2, echte acties + undo --'); +{ + const payload = basePayload(); + const beforeStart = payload.tasks.find(t => t.id === 'B')!.time.earlyStart; + const undoDepthBefore = payload.undoStack.length; + const out = runInScratchDocument(payload, (s) => { + s.applyLeveling({ delays: { B: 2 }, gaps: {} }); + }); + ok('geval 2: de run slaagt', out.ok); + eq('geval 2: één undo-stap op de eigen stack', out.payload.undoStack.length, undoDepthBefore + 1); + eq('geval 2: doorgerekend (scheduleStale false)', out.payload.scheduleStale, false); + ok('geval 2: nieuwe datums geschreven', out.payload.tasks.find(t => t.id === 'B')!.time.earlyStart !== beforeStart); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 3: de extensie-emitter zwijgt in de scratch-context, maar niet in een gewone context — de +// app-globale bus (`subscribeExtensionEvent`) is hetzelfde kanaal in beide gevallen; alleen de +// context bepaalt of hij er iets op zet. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- scratch-document: geval 3, de extensie-emitter zwijgt --'); +{ + const seen: unknown[] = []; + const unsub = subscribeExtensionEvent(HOST_EVENTS.scheduleCalculated, (data) => seen.push(data)); + const payload = basePayload(); + runInScratchDocument(payload, (s) => { s.runCPM(); }); + eq('geval 3: scratch-run vuurt geen extensie-event', seen.length, 0); + + const seenNormal: unknown[] = []; + const unsubNormal = subscribeExtensionEvent(HOST_EVENTS.scheduleCalculated, (data) => seenNormal.push(data)); + const normalCtx = createAppStoreContext(); + normalCtx.store.setState((s) => { + s.project = payload.project; + s.calendar = payload.calendar; + s.calendars = payload.calendars; + s.tasks = payload.tasks; + }); + normalCtx.store.getState().runCPM(); + ok('geval 3: een gewone context vuurt dat wél', seenNormal.length > 0); + + unsub(); + unsubNormal(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 4: meldingen komen naar buiten in plaats van te verdwijnen. Een relatiecyclus laat `runCPM` +// zelf `notify({ messageKey: 'notifications.scheduleFailed', ... })` aanroepen (scheduleSlice.ts) — +// in de scratch-context rendert niemand `ui.notifications`, dus die melding moet bij de AANROEPER +// terugkomen. Structureel gedekt: `DocumentPayload` bevat `ui.notifications` sowieso niet (het zit +// niet in DOCUMENT_FIELDS behalve `collapsedTaskIds`), dus de teruggegeven payload kan de melding +// per constructie nooit dragen — alleen `notifications` in het resultaat kan hem tonen. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- scratch-document: geval 4, meldingen bubbelen op --'); +{ + const taskC1 = task('C1', '2026-06-01', '2026-06-02', 1); + const taskC2 = task('C2', '2026-06-01', '2026-06-02', 1); + const payload: DocumentPayload = { + ...basePayload(), + tasks: [taskC1, taskC2], + sequences: [ + { id: 'seq-1', predecessorId: 'C1', successorId: 'C2', type: 'FINISH_START', lagDays: 0 }, + { id: 'seq-2', predecessorId: 'C2', successorId: 'C1', type: 'FINISH_START', lagDays: 0 }, + ], + }; + const out = runInScratchDocument(payload, (s) => { s.runCPM(); }); + ok('geval 4: de run slaagt (notify gooit niet)', out.ok); + ok('geval 4: fouten uit de scratch-run bubbelen op', + out.notifications.some(n => n.messageKey === 'notifications.scheduleFailed')); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 5: geen sporen in de app-globale registers — de gemounte productinterface (`appStoreContext`) +// blijft byte-identiek, want elke scratch-run bouwt zijn EIGEN, wegwerpbare context. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- scratch-document: geval 5, geen sporen in de app-store --'); +{ + const appBefore = JSON.stringify(capturePayload(appStoreContext.store.getState())); + runInScratchDocument(basePayload(), (s) => { s.applyLeveling({ delays: { B: 1 }, gaps: {} }); }); + const appAfter = JSON.stringify(capturePayload(appStoreContext.store.getState())); + eq('geval 5: de app-store is onaangeraakt', appAfter, appBefore); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (diffs.length === 0) { + console.log(`OK scratch-document: alle checks groen (${checks})`); + process.exit(0); +} else { + console.log(`XX scratch-document: ${diffs.length} afwijking(en) van ${checks}`); + for (const d of diffs) console.log(` - ${d}`); + process.exit(1); +} diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 5ebed4ad4..b546a7827 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -456,6 +456,13 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then MUTATIONSEQCHECK="$DIR/.mutation-seq.mjs" if bundle_check "$DIR/check-mutation-seq.ts" "$MUTATIONSEQCHECK"; then node "$MUTATIONSEQCHECK" || STATUS=1; fi + # B1c-plan3 taak 5: de headless scratch-instantie (`runInScratchDocument`) — round-trip via het + # documentcontract, echte acties met echte undo-semantiek, de context-bewuste host-event-emitter + # die in de scratch-context zwijgt, meldingen die opbubbelen i.p.v. verdwijnen, en geen sporen in + # de app-globale store. + SCRATCHDOCCHECK="$DIR/.scratch-document.mjs" + if bundle_check "$DIR/check-scratch-document.ts" "$SCRATCHDOCCHECK"; then node "$SCRATCHDOCCHECK" || STATUS=1; fi + # Ribbon Baselines & Progress: drie overlays links en twee kleurcontrols rechts horen ieder in # een verticale stack; losse groepsitems worden horizontaal gerenderd en maken de rij te breed. OVERLAYRIBBONCHECK="$DIR/.ribbon-overlays.mjs" From 722948672ab1e86418d0f3c8daabe04dd2526320 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:22:43 +0200 Subject: [PATCH 010/108] =?UTF-8?q?feat(library):=20verdeling=20toepassen?= =?UTF-8?q?=20en=20in=20=C3=A9=C3=A9n=20keer=20terugdraaien=20over=20meerd?= =?UTF-8?q?ere=20documenten=20(B1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/services/library/applyDistribution.ts | 81 ++++++++++ src/state/slices/librarySlice.ts | 152 ++++++++++++++++++ tests/library/check-apply-distribution.ts | 181 ++++++++++++++++++++++ tests/library/run.sh | 1 + tests/library/tsconfig.check.json | 1 + 5 files changed, 416 insertions(+) create mode 100644 src/services/library/applyDistribution.ts create mode 100644 tests/library/check-apply-distribution.ts diff --git a/src/services/library/applyDistribution.ts b/src/services/library/applyDistribution.ts new file mode 100644 index 000000000..a5cc28dd2 --- /dev/null +++ b/src/services/library/applyDistribution.ts @@ -0,0 +1,81 @@ +// applyDistribution.ts — B1c-plan3 taak 6 (spec §5, "Toepassen: schrijven in meerdere documenten"). +// Puur: geen store, geen I/O. Bepaalt WAT er per document geschreven moet worden, afgeleid uit een +// `DistributionProposal` — zodat de store-actie geen eigen interpretatie van `DistributionDocResult` +// heeft (en deze afleiding zelf los getest kan worden, zonder een store aan te raken). +import type { TaskSplitGap } from '@/types/task'; +import type { DistributionProposal } from './distribute'; + +/** Wat er per document geschreven moet worden. */ +export interface DistributionWrite { + docId: string; + scopeTaskIds: string[]; + write: { delays: Record; gaps: Record }; +} + +/** `ok: false` ⇒ dit voorstel mag NIET geschreven worden, met de reden erbij. Spec §3.1 (geblokkeerd), + * §4 stap 3 (een voorstel mét tekorten is een geldige preview maar blokkeert Toepassen) en het + * triviale geval dat er — na de twee poorten hierboven — simpelweg niets te schrijven valt (alle + * deelnemers gepind/#63/`cannotMove`, of niemand `participated`). */ +export type DistributionWritePlan = + | { ok: true; writes: DistributionWrite[] } + | { ok: false; reason: 'blocked' | 'shortfall' | 'nothing-to-write' }; + +/** Wat `applyDistribution` teruggeeft ná een geslaagd Toepassen — de bouwstenen voor de + * "toegepast"-strook, inclusief "alles terugdraaien" (`undoDistribution`). */ +export interface DistributionApplyRecord { + libraryItemId: string; + /** ISO — puur voor de strooktekst. */ + appliedAt: string; + docs: Array<{ + docId: string; + title: string; + /** `undoStack.length` NÁ het toepassen. "Alles terugdraaien" pakt alleen een document waarvan + * de stack nog op precies deze diepte staat — heeft de gebruiker er intussen zelf in gewerkt, + * dan zou blind terugpoppen de VERKEERDE stap ongedaan maken; dat document wordt dan + * overgeslagen (zie `DistributionUndoReport.skippedDocIds`). */ + undoDepthAfterApply: number; + }>; +} + +/** Resultaat van `undoDistribution`: welke documenten daadwerkelijk zijn teruggedraaid en welke zijn + * overgeslagen omdat hun undo-stack intussen is verschoven. */ +export interface DistributionUndoReport { + undoneDocIds: string[]; + skippedDocIds: string[]; +} + +/** + * Leid uit een voorstel af WAT er geschreven moet worden, per deelnemend document. + * + * Regels (letterlijk uit spec §3.1/§4 stap 3/§3.3a/§6): + * - `proposal.blocked !== null` ⇒ geblokkeerd (§3.1: nooit een stille uitsluiting, dus ook nooit + * een gedeeltelijk Toepassen van een geblokkeerd voorstel). + * - `proposal.hasShortfall` ⇒ een tekort blokkeert Toepassen (§4 stap 3) — het voorstel blijft een + * geldige PREVIEW, maar schrijven zou taken onopgelost achterlaten zonder dat de gebruiker dat op + * dat moment nog kan corrigeren. + * - Alleen documenten met `participated === true && cannotMove === false` leveren een write: + * gepinde, #63- en `cannotMove`-documenten worden per definitie NOOIT beschreven (§3.3a/§6) — hun + * `delays`/`gaps` in het voorstel zijn toch al leeg, maar deze poort maakt dat een invariant in + * plaats van toeval. + * - Blijft er na die twee poorten niets over om te schrijven, dan is Toepassen zinloos maar niet + * fout: `'nothing-to-write'`. + */ +export function planDistributionWrites( + proposal: DistributionProposal, + scopeTaskIdsByDoc: Record, +): DistributionWritePlan { + if (proposal.blocked !== null) return { ok: false, reason: 'blocked' }; + if (proposal.hasShortfall) return { ok: false, reason: 'shortfall' }; + + const writes: DistributionWrite[] = []; + for (const doc of proposal.docs) { + if (!doc.participated || doc.cannotMove) continue; + writes.push({ + docId: doc.docId, + scopeTaskIds: scopeTaskIdsByDoc[doc.docId] ?? [], + write: { delays: doc.delays, gaps: doc.gaps }, + }); + } + if (writes.length === 0) return { ok: false, reason: 'nothing-to-write' }; + return { ok: true, writes }; +} diff --git a/src/state/slices/librarySlice.ts b/src/state/slices/librarySlice.ts index a95a14c05..9dd6495bc 100644 --- a/src/state/slices/librarySlice.ts +++ b/src/state/slices/librarySlice.ts @@ -8,6 +8,14 @@ import { loadLibrary, saveLibrary, bumpPool, makeOrigin, copyCalendarToProject, import { finishMutation, markScheduleStale } from '../transaction'; import { syncProjectCalendar } from '../syncProjectCalendar'; import { appLog } from '@/services/debug/appLog'; +import { runInScratchDocument } from '../runtime/scratchDocument'; +import type { DocumentPayload } from '../documentContract'; +import { + planDistributionWrites, + type DistributionApplyRecord, + type DistributionUndoReport, +} from '@/services/library/applyDistribution'; +import type { DistributionProposal } from '@/services/library/distribute'; export interface RecognitionCandidate { kind: 'resource' | 'calendar'; @@ -186,6 +194,22 @@ export interface LibrarySlice { * (beginUndoable/finishMutation-patroon). No-op (geen undo-snapshot) op een onbekend id of een * resource zonder stempel. */ unlinkResourceFromLibrary: (resourceId: string) => void; + + /** Toepassen (spec §5, B1c-plan3 taak 6). Schrijft het voorstel in élk deelnemend document — het + * actieve via het gewone top-level-pad, de slapers via een headless scratch-instantie + * (`runInScratchDocument`) — en geeft een record terug waarmee de "toegepast"-strook alles in + * één keer kan terugdraaien. `null` ⇒ er is niets geschreven (geblokkeerd, tekort, of niets te + * doen; zie `planDistributionWrites`). */ + applyDistribution: ( + proposal: DistributionProposal, + scopeTaskIdsByDoc: Record, + ) => DistributionApplyRecord | null; + + /** "Alles terugdraaien" (spec §5): draait per beschreven document precies de undo-stap terug die + * `applyDistribution` daar heeft achtergelaten. Een document waarvan de undo-diepte intussen is + * verschoven (de gebruiker werkte er zelf in verder) wordt overgeslagen — blind terugpoppen zou + * daar de VERKEERDE stap ongedaan maken — en gemeld via `DistributionUndoReport.skippedDocIds`. */ + undoDistribution: (record: DistributionApplyRecord) => DistributionUndoReport; } /** @@ -1193,4 +1217,132 @@ export const createLibrarySlice: AppSliceFactory = (runtime) => (s // INHOUD blijven exact wat ze waren), dus geen datumimpact en geen belasting-/rijenherberekening // nodig — anders dan updateResource/removeResource hierboven raakt dit geen CPM- of tabelinvoer. }, + + applyDistribution: (proposal, scopeTaskIdsByDoc) => { + const plan = planDistributionWrites(proposal, scopeTaskIdsByDoc); + if (!plan.ok) return null; + + const state = get(); + const activeWrite = plan.writes.find((w) => w.docId === state.activeDocumentId); + const sleepingWrites = plan.writes.filter((w) => w.docId !== state.activeDocumentId); + + // Fase 1 — BUITEN de producer: draai elke slapende write in zijn eigen, wegwerpbare scratch- + // instantie (`runInScratchDocument`). Zolang hier niets geschreven is naar `s.documents`, is de + // hele actie nog terug te trekken — precies de atomiciteitsgarantie van + // `recalculateStaleSleepingDocuments`, hier toegepast op een schrijfpad dat WEL undo-stappen + // achterlaat (dat is exact wat taak 5's scratch-instantie oplevert t.o.v. de kloon-route daar). + const sleepingResults: { docId: string; payload: DocumentPayload; undoDepthAfterApply: number }[] = []; + for (const w of sleepingWrites) { + const entry = state.documents.find((d) => d.id === w.docId); + if (!entry?.payload) continue; // tussentijds gesloten/geactiveerd — dit document doet niet meer mee. + const out = runInScratchDocument(entry.payload, (s) => { + s.applyLeveling(w.write, { scopeTaskIds: w.scopeTaskIds }); + }); + // Meldingen bubbelen ALTIJD op (spec §5, rand (b)) — ook bij een geslaagde run kan `applyLeveling` + // een M10-afrondingsmelding of (bij een onverwachte cyclus) een schedule-fout hebben gezet; niets + // daarvan mag in het onzichtbare kanaal van de scratch-context blijven hangen. + for (const n of out.notifications) get().notify(n); + if (!out.ok) { + // Er is nog NIETS gemuteerd (het actieve document komt pas hierna aan de beurt, en de + // eerdere scratch-runs schreven alleen naar hun eigen, weggegooide payload) — dus de hele + // actie stopt hier, zonder halve staat. + return null; + } + sleepingResults.push({ + docId: w.docId, payload: out.payload, undoDepthAfterApply: out.payload.undoStack.length, + }); + } + + // Het actieve document (als het meedoet), via het GEWONE pad: `applyLeveling` pusht zelf zijn + // eigen snapshot en draait zelf `runCPM` — precies dezelfde actie als een gebruiker zou triggeren. + let activeUndoDepthAfterApply: number | null = null; + if (activeWrite) { + get().applyLeveling(activeWrite.write, { scopeTaskIds: activeWrite.scopeTaskIds }); + activeUndoDepthAfterApply = get().undoStack.length; + } + + // Fase 2 — ÉÉN producer: de nieuwe slaper-payloads terugschrijven. Sla een document over dat + // intussen gesloten of geactiveerd is (`entry.payload === null`), exact zoals + // `recalculateStaleSleepingDocuments` dat doet. + if (sleepingResults.length > 0) { + set((s) => { + for (const r of sleepingResults) { + const entry = s.documents.find((d) => d.id === r.docId); + if (!entry || entry.payload === null) continue; + entry.payload = r.payload; + } + }); + } + + // Bouw het record — alleen voor documenten die daadwerkelijk geschreven zijn (een tussentijds + // gesloten slaper werd hierboven al overgeslagen en staat dus niet in `sleepingResults`). + const docs: DistributionApplyRecord['docs'] = []; + if (activeWrite && activeUndoDepthAfterApply !== null) { + const docResult = proposal.docs.find((d) => d.docId === activeWrite.docId); + if (docResult) { + docs.push({ docId: activeWrite.docId, title: docResult.title, undoDepthAfterApply: activeUndoDepthAfterApply }); + } + } + for (const r of sleepingResults) { + const docResult = proposal.docs.find((d) => d.docId === r.docId); + if (!docResult) continue; + docs.push({ docId: r.docId, title: docResult.title, undoDepthAfterApply: r.undoDepthAfterApply }); + } + if (docs.length === 0) return null; + + return { libraryItemId: proposal.libraryItemId, appliedAt: new Date().toISOString(), docs }; + }, + + undoDistribution: (record) => { + const state = get(); + const undoneDocIds: string[] = []; + const skippedDocIds: string[] = []; + + const activeEntry = record.docs.find((d) => d.docId === state.activeDocumentId); + const sleepingEntries = record.docs.filter((d) => d.docId !== state.activeDocumentId); + + // Fase 1 — BUITEN de producer: de slapers, elk in zijn eigen scratch-instantie, en ALLEEN als + // hun undo-diepte nog exact klopt met wat `applyDistribution` er heeft achtergelaten — heeft de + // gebruiker het document intussen zelf bewerkt, dan zou blind `undo()` de VERKEERDE stap + // terugdraaien. Zo'n document wordt overgeslagen en gemeld (`skippedDocIds`), niet blind gepopt. + const sleepingUndos: { docId: string; payload: DocumentPayload }[] = []; + for (const d of sleepingEntries) { + const entry = state.documents.find((x) => x.id === d.docId); + if (!entry?.payload || entry.payload.undoStack.length !== d.undoDepthAfterApply) { + skippedDocIds.push(d.docId); + continue; + } + const out = runInScratchDocument(entry.payload, (s) => { s.undo(); }); + for (const n of out.notifications) get().notify(n); + if (!out.ok) { + skippedDocIds.push(d.docId); + continue; + } + sleepingUndos.push({ docId: d.docId, payload: out.payload }); + undoneDocIds.push(d.docId); + } + + // Het actieve document, via het gewone `undo()`-pad — dezelfde diepte-gate als de slapers. + if (activeEntry) { + if (state.undoStack.length === activeEntry.undoDepthAfterApply) { + get().undo(); + undoneDocIds.push(activeEntry.docId); + } else { + skippedDocIds.push(activeEntry.docId); + } + } + + // Fase 2 — ÉÉN producer. + if (sleepingUndos.length > 0) { + set((s) => { + for (const u of sleepingUndos) { + const entry = s.documents.find((x) => x.id === u.docId); + if (!entry || entry.payload === null) continue; + entry.payload = u.payload; + } + }); + } + + return { undoneDocIds, skippedDocIds }; + }, }); diff --git a/tests/library/check-apply-distribution.ts b/tests/library/check-apply-distribution.ts new file mode 100644 index 000000000..8c0141d97 --- /dev/null +++ b/tests/library/check-apply-distribution.ts @@ -0,0 +1,181 @@ +// check-apply-distribution.ts — B1c-plan3 taak 6 (spec §5/§9 "Store-niveau"). Toepassen schrijft in +// het actieve document én in de slapers; "alles terugdraaien" zet alles terug, inclusief de +// doorrekening. Fixtures via `createAppStore()` + `newDocument()` (echte documenten, echte +// payloads) — de `DistributionProposal` zelf wordt met de hand gebouwd (de afleiding uit een echte +// `computeDistribution`-run is al gedekt door `check-distribute.ts`; hier staat uitsluitend het +// schrijfpad op de proef). +// +// Draait via run.sh. Exit 0 = alles groen. +import { createAppStore } from '@/state/appStore'; +import { subscribeExtensionEvent, HOST_EVENTS } from '@/services/extensionEvents'; +import type { DistributionProposal, DistributionDocResult } from '@/services/library/distribute'; +import type { DistributionApplyRecord } from '@/services/library/applyDistribution'; +import type { TaskSplitGap } from '@/types/task'; + +declare const process: { exit(code: number): never }; + +let checks = 0; let fails = 0; +function assert(cond: boolean, msg: string): void { + checks++; + if (!cond) { fails++; console.log(` XX ${msg}`); } +} + +function baseDocResult(docId: string, title: string, extra?: Partial): DistributionDocResult { + return { + docId, title, participated: true, cannotMove: false, + delays: {}, gaps: {}, projectEndBefore: '2026-08-07', projectEndAfter: '2026-08-07', + endShiftWorkdays: 0, shortfalls: [], + ...extra, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Opzet: één store, twee documenten. "Slaper" wordt eerst gevuld en gaat daarna slapen (newDocument +// maakt hem niet-actief); "Actief" blijft het actieve document. +// ═══════════════════════════════════════════════════════════════════════════ +const store = createAppStore(); +const S = () => store.getState(); + +S().setProject({ name: 'Slaper', startDate: '2026-08-03' }); +const idS1 = S().addTask({ name: 'S1' }); +const sleepDocId = S().activeDocumentId; + +const activeDocId = S().newDocument(); +S().setProject({ name: 'Actief', startDate: '2026-08-03' }); +const idActiveIn = S().addTask({ name: 'ActiefIn' }); +const idBuiten = S().addTask({ name: 'Buiten' }); +S().updateTask(idBuiten, { levelingDelay: 3 }); + +const levelingGap: TaskSplitGap = { afterMinutes: 480, gapMinutes: 480, source: 'leveling' }; + +function makeProposal(overrides?: Partial): DistributionProposal { + return { + libraryItemId: 'lib-1', + blocked: null, + docs: [ + baseDocResult(activeDocId, 'Actief', { delays: { [idActiveIn]: 2 } }), + baseDocResult(sleepDocId, 'Slaper', { + delays: { [idS1]: 1 }, gaps: { [idS1]: [levelingGap] }, endShiftWorkdays: 1, + }), + ], + fixedLoadByDay: {}, residualByDay: {}, hasShortfall: false, + ...overrides, + }; +} +const scopeTaskIdsByDoc = { [activeDocId]: [idActiveIn], [sleepDocId]: [idS1] }; + +function sleepPayload() { + return S().documents.find(d => d.id === sleepDocId)!.payload!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 1: geblokkeerd voorstel schrijft NIETS. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 1, geblokkeerd --'); +{ + const blocked = makeProposal({ blocked: { reason: 'MATERIAL_ITEM', docIds: [] } }); + const before = JSON.stringify(S().tasks); + const record = S().applyDistribution(blocked, scopeTaskIdsByDoc); + assert(record === null, 'geval 1: blocked ⇒ geen record'); + assert(JSON.stringify(S().tasks) === before, 'geval 1: het actieve document is niet aangeraakt'); + assert(S().tasks.find(t => t.id === idActiveIn)?.levelingDelay === undefined, 'geval 1: ActiefIn heeft nog geen delay'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 2: een tekort blokkeert Toepassen (spec §4 stap 3) — een geldige preview, geen schrijfpad. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 2, tekort blokkeert --'); +{ + const shortfall = makeProposal({ hasShortfall: true }); + const record = S().applyDistribution(shortfall, scopeTaskIdsByDoc); + assert(record === null, 'geval 2: hasShortfall ⇒ geen record'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 3, 4, 5, 6: het echte Toepassen — actief + slapend, scope-behoud, gaten, undo-diepte. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 3-6, schrijven over actief + slapend --'); +let applyRecord: DistributionApplyRecord | null; +{ + const seen: unknown[] = []; + const unsub = subscribeExtensionEvent(HOST_EVENTS.scheduleCalculated, (d) => seen.push(d)); + + const activeDepthBefore = S().undoStack.length; + const sleepDepthBefore = sleepPayload().undoStack.length; + + applyRecord = S().applyDistribution(makeProposal(), scopeTaskIdsByDoc); + assert(applyRecord !== null, 'geval 3: applyDistribution levert een record'); + + const activeTaskAfter = S().tasks.find(t => t.id === idActiveIn); + assert(activeTaskAfter?.levelingDelay === 2, 'geval 3: het actieve document kreeg zijn delays'); + assert(sleepPayload().tasks.find(t => t.id === idS1)?.levelingDelay === 1, 'geval 3: het slapende document ook'); + assert(sleepPayload().scheduleStale === false, 'geval 3: de slaper is doorgerekend (scheduleStale false)'); + assert(sleepPayload().cpmResult !== null, 'geval 3: en cpmResult is gepersisteerd'); + assert(sleepPayload().isDirty === true, 'geval 3: het slapende document staat als gewijzigd'); + assert(S().isDirty === true, 'geval 3: het actieve document staat als gewijzigd'); + // Toepassen schrijft ONVOORWAARDELIJK, ook met "Automatisch berekenen" uit (spec §11.5) — de + // store-default is al uit; dit pint dat applyDistribution zelf geen gate op autoCalcCPM heeft. + assert(S().ui.autoCalcCPM === false, 'geval 3 opzet: automatisch berekenen staat standaard uit'); + + // Geval 4: out-of-scope 'Buiten' behoudt zijn delay — scope-behoud over de documentgrens. + assert(S().tasks.find(t => t.id === idBuiten)?.levelingDelay === 3, 'geval 4: out-of-scope delay overleeft Toepassen'); + + // Geval 5: onderbrekingen worden echt geschreven, met herkomst. + const gapTask = sleepPayload().tasks.find(t => t.id === idS1); + assert(gapTask?.splitGaps?.some(g => g.source === 'leveling') === true, 'geval 5: splitGaps geschreven met herkomst'); + + // Geval 6: per document ÉÉN undo-stap. + assert(S().undoStack.length === activeDepthBefore + 1, 'geval 6: actief — één stap erbij'); + assert(sleepPayload().undoStack.length === sleepDepthBefore + 1, 'geval 6: slapend — één stap erbij'); + + // Geval 9 (spec §9 "Store-niveau"): geen extensie-events voor het SLAPENDE document — hooguit één, + // van het actieve document dat via het gewone `applyLeveling`-pad draait. + assert(seen.length <= 1, 'geval 9: extensie-events zijn niet gevuurd voor het slapende document'); + unsub(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 7: "alles terugdraaien" zet beide documenten terug, inclusief de doorrekening. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 7, alles terugdraaien --'); +{ + const report = S().undoDistribution(applyRecord!); + assert(report.undoneDocIds.sort().join(',') === [activeDocId, sleepDocId].sort().join(','), + `geval 7: beide documenten teruggedraaid (kreeg ${JSON.stringify(report)})`); + assert(report.skippedDocIds.length === 0, 'geval 7: niets overgeslagen'); + assert(S().tasks.find(t => t.id === idActiveIn)?.levelingDelay === undefined, 'geval 7: actief document terug bij af'); + assert(sleepPayload().tasks.find(t => t.id === idS1)?.levelingDelay === undefined, 'geval 7: slapend document terug bij af'); + assert(sleepPayload().tasks.find(t => t.id === idS1)?.splitGaps === undefined, 'geval 7: en het leveling-gat is weg'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 8: terugdraaien weigert wat het niet meer herkent — bewerk het slapende document ná het +// toepassen (extra undo-stap erbovenop); "alles terugdraaien" mag dat document dan niet blind +// terugpoppen. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 8, gedeeltelijk terugdraaien --'); +{ + const record2 = S().applyDistribution(makeProposal(), scopeTaskIdsByDoc); + assert(record2 !== null, 'geval 8 opzet: opnieuw toegepast'); + + // Bewerk het slapende document ZELF (activeren, muteren, weer wegschakelen) — een extra undo-stap + // op zijn stack, bovenop wat applyDistribution er net op zette. + S().switchDocument(sleepDocId); + S().addTask({ name: 'Extra' }); + S().switchDocument(activeDocId); + + const report2 = S().undoDistribution(record2!); + assert(report2.skippedDocIds.join(',') === sleepDocId, `geval 8: het slapende document wordt overgeslagen (kreeg ${JSON.stringify(report2)})`); + assert(report2.undoneDocIds.join(',') === activeDocId, 'geval 8: het actieve document wordt WEL teruggedraaid'); + // De bewerking op het slapende document staat nog steeds (niet blind teruggepopt). + assert(sleepPayload().tasks.some(t => t.name === 'Extra'), 'geval 8: de tussentijdse bewerking blijft staan'); +} + +// ── Uitslag ────────────────────────────────────────────────────────────────── +if (fails === 0) { + console.log(`OK apply-distribution-check: alle checks groen (${checks})`); + process.exit(0); +} else { + console.log(`XX apply-distribution-check: ${fails} afwijking(en) van ${checks}`); + process.exit(1); +} diff --git a/tests/library/run.sh b/tests/library/run.sh index a9a0dc4fe..2faf46e1e 100755 --- a/tests/library/run.sh +++ b/tests/library/run.sh @@ -35,5 +35,6 @@ run_check check-ifc-hostile run_check check-i18n-plurals run_check check-occupancy run_check check-distribute +run_check check-apply-distribution exit "$STATUS" diff --git a/tests/library/tsconfig.check.json b/tests/library/tsconfig.check.json index 8df305fe4..dbce1b833 100644 --- a/tests/library/tsconfig.check.json +++ b/tests/library/tsconfig.check.json @@ -22,6 +22,7 @@ "check-ifc-hostile.ts", "check-i18n-plurals.ts", "check-occupancy.ts", + "check-apply-distribution.ts", "../../src/types/file-system-access.d.ts" ] } From 4dd065d185ecbb6fee00378452d379bddcc76770 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:03:31 +0200 Subject: [PATCH 011/108] feat(i18n): reden-taxonomie en verdeeldialoogteksten in veertien talen (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 3 taak 7: één gedeelde LevelingReason→sleutel-mapping (levelingReasonKey.ts, met satisfies-poort op de taxonomie), de vier ontbrekende reden-teksten en de volledige resource.distribution-sleutelset in alle veertien locales met CLDR-pluralcategorieën. Poorten: verify:i18n, tsc en tests/library/run.sh exit 0. Co-Authored-By: Claude Fable 5.1 --- src/components/dialogs/LevelingDialog.tsx | 18 ++-- src/i18n/locales/ar/common.json | 110 +++++++++++++++++++++- src/i18n/locales/de/common.json | 86 ++++++++++++++++- src/i18n/locales/en/common.json | 86 ++++++++++++++++- src/i18n/locales/es/common.json | 92 +++++++++++++++++- src/i18n/locales/fa/common.json | 86 ++++++++++++++++- src/i18n/locales/fr/common.json | 92 +++++++++++++++++- src/i18n/locales/it/common.json | 92 +++++++++++++++++- src/i18n/locales/ja/common.json | 80 +++++++++++++++- src/i18n/locales/ko/common.json | 80 +++++++++++++++- src/i18n/locales/nl/common.json | 86 ++++++++++++++++- src/i18n/locales/pl/common.json | 98 ++++++++++++++++++- src/i18n/locales/pt/common.json | 92 +++++++++++++++++- src/i18n/locales/tr/common.json | 86 ++++++++++++++++- src/i18n/locales/zh/common.json | 80 +++++++++++++++- src/utils/levelingReasonKey.ts | 33 +++++++ tests/library/tsconfig.check.json | 1 + 17 files changed, 1276 insertions(+), 22 deletions(-) create mode 100644 src/utils/levelingReasonKey.ts diff --git a/src/components/dialogs/LevelingDialog.tsx b/src/components/dialogs/LevelingDialog.tsx index 296426382..2ce087fec 100644 --- a/src/components/dialogs/LevelingDialog.tsx +++ b/src/components/dialogs/LevelingDialog.tsx @@ -6,6 +6,7 @@ import { Dialog } from '@/components/common/Dialog'; import type { LevelingResult } from '@/engine/scheduler/ResourceLeveler'; import { distributeUnits } from '@/engine/scheduler/ResourceLoad'; import { parseDate } from '@/utils/dateUtils'; +import { LEVELING_REASON_KEY } from '@/utils/levelingReasonKey'; function fmt(iso: string): string { if (!iso) return '—'; @@ -218,22 +219,23 @@ export function LevelingDialog() { const task = tasks.find(t => t.id === taskId); const reason = result?.unresolvedReasons[taskId]; const intrinsic = intrinsicByTask[taskId]; - // Reden-specifieke uitleg (A3), gededupliceerd met de leveler-classificatie: de - // leveler kiest de reden, de dialoog vult alleen de weergavedetails in. + // Reden-specifieke uitleg (A3, uitgebreid B1c-plan3 taak 7), gededupliceerd met + // de leveler-classificatie via `LEVELING_REASON_KEY`: de leveler kiest de reden, + // de dialoog vult alleen de weergavedetails in. `INTRINSIC_OVERRUN` draagt + // interpolatie (resource/peak/capacity) en krijgt daarom zijn eigen tak; de + // overige zes lopen via de gedeelde mapping. let explain: string | null = null; if (reason === 'INTRINSIC_OVERRUN' && intrinsic) { - explain = t('resource.leveling.intrinsicOverrun', { + explain = t(LEVELING_REASON_KEY.INTRINSIC_OVERRUN, { resource: intrinsic.resource, peak: numberFmt.format(intrinsic.peak), capacity: numberFmt.format(intrinsic.capacity), }); - } else if (reason === 'CALENDAR_MISMATCH') { - explain = t('resource.leveling.reason.calendarMismatch'); - } else if (reason === 'INSUFFICIENT_CAPACITY') { - explain = t('resource.leveling.reason.insufficientCapacity'); + } else if (reason) { + explain = t(LEVELING_REASON_KEY[reason]); } else if (intrinsic) { // Vangnet (geen reden meegegeven, maar intrinsiek gedetecteerd). - explain = t('resource.leveling.intrinsicOverrun', { + explain = t(LEVELING_REASON_KEY.INTRINSIC_OVERRUN, { resource: intrinsic.resource, peak: numberFmt.format(intrinsic.peak), capacity: numberFmt.format(intrinsic.capacity), diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index d18a346cc..19f88ba51 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -546,7 +546,11 @@ "conflictDays": "{{count}} يوم", "reason": { "calendarMismatch": "المورد لا يعمل في جميع الأيام التي تحتاجها هذه المهمة — الإزاحة لا تحل هذا.", - "insufficientCapacity": "لا توجد سعة حرة كافية ضمن الفائض لحل هذا التعارض." + "insufficientCapacity": "لا توجد سعة حرة كافية ضمن الفائض لحل هذا التعارض.", + "ceilingTooTight": "سقف التمديد يترك مساحة قليلة جدًا: لا توجد نافذة حرة ضمن عدد أيام العمل هذا.", + "ceilingUnreachable": "منح التمديد هنا لا يساعد — موعد نهائي أو قيد يبقي هذه المهمة في مكانها.", + "noWindowInHorizon": "لم يتم العثور على نافذة حرة ضمن الفترة التي تم البحث فيها؛ وما بعدها غير معروف.", + "residualFull": "كان لدى الالتزام الخاص مساحة، لكن السعة المتبقية للمكتبة استُنفدت — تشغلها مشاريع أخرى." } }, "curve": { @@ -589,6 +593,110 @@ "documents": "المستندات", "selectHint": "اختر موردًا لعرض المدرج التكراري." }, + "distribution": { + "open": "توزيع…", + "title": "التوزيع بين المشاريع", + "back": "العودة إلى الإشغال", + "subtitle": "{{item}} — توزيع السعة المتبقية على المشاريع المفتوحة", + "blocked": { + "uncounted": "احسب أولاً: {{docs}} لم يُحسب بعد، لذا لا يوجد ما يمكن توزيعه.", + "material": "هذا عنصر مادي؛ التوزيع يعمل فقط للأشخاص والمعدات.", + "noDemand": "لا يحجز أي من المشاريع عملاً هنا فعليًا." + }, + "tool": { + "title": "الأداة", + "allowSplits": "السماح بالانقطاعات", + "allowSplitsHint": "يمكن أن يحصل العمل على أيام توقف بدلاً من الانزياح دفعة واحدة. في MS Project يسمى هذا \"Leveling can create splits in remaining work\".", + "price_zero": "لا تكلف أي يوم عمل تمديدًا", + "price_one": "تكلف يوم عمل واحد تمديدًا", + "price_two": "تكلف يومي عمل تمديدًا", + "price_few": "تكلف {{count}} أيام عمل تمديدًا", + "price_many": "تكلف {{count}} يوم عمل تمديدًا", + "price_other": "تكلف {{count}} يوم عمل تمديدًا", + "priceNone": "لا يكلف أي تمديد", + "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب" + }, + "rank": { + "title": "من يُراعى أكثر؟", + "hint": "الأعلى ينزاح أقل. اسحب لتغيير الترتيب.", + "float": "الفائض: {{days}}", + "cost_zero": "ترك هذا المشروع وحده ينزاح لا يكلف أي يوم عمل", + "cost_one": "ترك هذا المشروع وحده ينزاح يكلف يوم عمل واحد", + "cost_two": "ترك هذا المشروع وحده ينزاح يكلف يومي عمل", + "cost_few": "ترك هذا المشروع وحده ينزاح يكلف {{count}} أيام عمل", + "cost_many": "ترك هذا المشروع وحده ينزاح يكلف {{count}} يوم عمل", + "cost_other": "ترك هذا المشروع وحده ينزاح يكلف {{count}} يوم عمل", + "costNone": "ترك هذا المشروع وحده ينزاح لا يكلف أي تمديد", + "moveUp": "نقل لأعلى", + "moveDown": "نقل لأسفل" + }, + "strip": { + "pin": "تثبيت", + "pinned": "مثبَّت — هذا المشروع لا ينزاح ويحتفظ أيضًا بأيام عمله", + "pinnedRecorded": "مثبَّت: التواريخ كما سُجِّلت", + "cannotMove": "لا يمكنه الانزياح — كل العمل هنا ثابت", + "fixedLoad": "الحمل الثابت", + "ceiling": "أقصى تمديد لتاريخ الانتهاء", + "ceilingUnlimited": "غير محدود", + "ceilingDays_zero": "سقف بلا أيام عمل", + "ceilingDays_one": "سقف يوم عمل واحد", + "ceilingDays_two": "سقف يومي عمل", + "ceilingDays_few": "سقف {{count}} أيام عمل", + "ceilingDays_many": "سقف {{count}} يوم عمل", + "ceilingDays_other": "سقف {{count}} يوم عمل", + "endUnchanged": "النهاية دون تغيير", + "endShift_zero": "النهاية +0 يوم", + "endShift_one": "النهاية +يوم واحد", + "endShift_two": "النهاية +يومين", + "endShift_few": "النهاية +{{count}} أيام", + "endShift_many": "النهاية +{{count}} يومًا", + "endShift_other": "النهاية +{{count}} يوم", + "requestedVsAchievable": "المطلوب {{requested}}، الأقرب القابل للتحقيق {{achievable}}", + "handleLabel": "سقف التمديد لـ {{doc}}" + }, + "compute": { + "recalculate": "إعادة الحساب", + "auto": "توزيع تلقائي", + "busy": "جارٍ التوزيع…", + "pressRecompute": "— اضغط على إعادة الحساب", + "degraded": "هذه نظرة عامة كبيرة؛ تُحسب فقط عند الضغط على إعادة الحساب." + }, + "stale": { + "rank": "تغيّر الترتيب — أعد الحساب لرؤية الأثر.", + "ceiling": "تغيّر سقف — أعد الحساب لرؤية الأثر.", + "pin": "تغيّر تثبيت — أعد الحساب لرؤية الأثر.", + "tool": "تغيّرت الأداة — أعد الحساب لرؤية الأثر.", + "edited": "حدث تغيير في {{docs}} — هذا الاقتراح لم يعد محدّثًا." + }, + "shortfall": { + "title": "المتبقي", + "doc_zero": "{{doc}}: لا توجد مهام غير مناسبة", + "doc_one": "{{doc}}: مهمة واحدة غير مناسبة", + "doc_two": "{{doc}}: مهمتان غير مناسبتين", + "doc_few": "{{doc}}: {{count}} مهام غير مناسبة", + "doc_many": "{{doc}}: {{count}} مهمة غير مناسبة", + "doc_other": "{{doc}}: {{count}} مهمة غير مناسبة" + }, + "preview": { + "before": "الآن", + "after": "بعد التوزيع", + "capacity": "السعة" + }, + "apply": "تطبيق", + "discard": "تجاهل", + "applyBlockedShortfall": "حل العجز أولاً — وسّع سقفًا، أو اسمح بالانقطاعات.", + "applyBlockedNothing": "لا يوجد ما يمكن تطبيقه: هذا الاقتراح لا يغيّر شيئًا.", + "applied_zero": "لم يُطبَّق في أي مشروع.", + "applied_one": "طُبِّق في مشروع واحد.", + "applied_two": "طُبِّق في مشروعين.", + "applied_few": "طُبِّق في {{count}} مشاريع.", + "applied_many": "طُبِّق في {{count}} مشروعًا.", + "applied_other": "طُبِّق في {{count}} مشروع.", + "undoAll": "التراجع عن الكل", + "undoneAll": "تم التراجع عن كل شيء.", + "undonePartial": "تم التراجع، باستثناء {{docs}} — فقد تم العمل عليها في هذه الأثناء.", + "selectHint": "اختر عنصر مكتبة به تعارض لبدء التوزيع." + }, "color": "اللون" }, "calendar": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index b16af1a83..f356987e3 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -522,7 +522,11 @@ "conflictDays": "{{count}} Tag(e)", "reason": { "calendarMismatch": "Die Ressource arbeitet nicht an allen Tagen, die diese Aufgabe benötigt — Verschieben löst dies nicht.", - "insufficientCapacity": "Nicht genug freie Kapazität innerhalb des Puffers, um diesen Konflikt zu lösen." + "insufficientCapacity": "Nicht genug freie Kapazität innerhalb des Puffers, um diesen Konflikt zu lösen.", + "ceilingTooTight": "Die Verschiebungsgrenze lässt zu wenig Raum: Innerhalb dieser Anzahl Arbeitstage ist kein freies Fenster.", + "ceilingUnreachable": "Mehr Verschiebung hilft hier nicht — ein Termin oder eine Randbedingung hält diesen Vorgang an seinem Platz.", + "noWindowInHorizon": "Innerhalb des durchsuchten Zeitraums wurde kein freies Fenster gefunden; weiter draußen ist es unbekannt.", + "residualFull": "Der eigene Einsatz hatte Raum, aber die Restkapazität der Bibliothek ist aufgebraucht — andere Projekte belegen sie." } }, "curve": { @@ -553,6 +557,86 @@ "documents": "Dokumente", "selectHint": "Wählen Sie eine Ressource aus, um das Histogramm zu sehen." }, + "distribution": { + "open": "Verteilen…", + "title": "Über Projekte verteilen", + "back": "Zurück zur Belegung", + "subtitle": "{{item}} — Restkapazität über die geöffneten Projekte verteilen", + "blocked": { + "uncounted": "Zuerst berechnen: {{docs}} ist noch nicht berechnet, daher gibt es nichts zu verteilen.", + "material": "Dies ist ein Materialartikel; Verteilen funktioniert nur für Personen und Geräte.", + "noDemand": "Keines der Projekte bucht hier tatsächlich Arbeit." + }, + "tool": { + "title": "Werkzeug", + "allowSplits": "Unterbrechungen zulassen", + "allowSplitsHint": "Die Arbeit darf Pausentage bekommen, statt sich am Stück zu verschieben. In MS Project heißt das \"Leveling can create splits in remaining work\".", + "price_one": "kostet {{count}} Arbeitstag Verzug", + "price_other": "kostet {{count}} Arbeitstage Verzug", + "priceNone": "kostet keinen Verzug", + "priceUnknown": "Preis unbekannt — Neu berechnen drücken" + }, + "rank": { + "title": "Wer wird am meisten geschont?", + "hint": "Oben weicht am wenigsten ab. Ziehen, um die Reihenfolge zu ändern.", + "float": "Puffer: {{days}}", + "cost_one": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstag", + "cost_other": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstage", + "costNone": "nur dieses Projekt verschieben zu lassen kostet keinen Verzug", + "moveUp": "Nach oben", + "moveDown": "Nach unten" + }, + "strip": { + "pin": "Anheften", + "pinned": "Angeheftet — dieses Projekt weicht nicht und behält auch seine Arbeitstage", + "pinnedRecorded": "Angeheftet: Datumsangaben wie gespeichert", + "cannotMove": "Kann nicht ausweichen — die gesamte Arbeit hier steht fest", + "fixedLoad": "Feste Last", + "ceiling": "Maximaler Verzug des Enddatums", + "ceilingUnlimited": "unbegrenzt", + "ceilingDays_one": "Obergrenze {{count}} Arbeitstag", + "ceilingDays_other": "Obergrenze {{count}} Arbeitstage", + "endUnchanged": "Ende unverändert", + "endShift_one": "Ende +{{count}} Tag", + "endShift_other": "Ende +{{count}} Tage", + "requestedVsAchievable": "angefragt {{requested}}, am ehesten erreichbar {{achievable}}", + "handleLabel": "Verzugsobergrenze für {{doc}}" + }, + "compute": { + "recalculate": "Neu berechnen", + "auto": "Automatisch verteilen", + "busy": "Verteilung läuft…", + "pressRecompute": "— Neu berechnen drücken", + "degraded": "Dies ist eine große Übersicht; sie rechnet nur, wenn Sie Neu berechnen drücken." + }, + "stale": { + "rank": "Die Reihenfolge hat sich geändert — neu berechnen, um die Wirkung zu sehen.", + "ceiling": "Eine Obergrenze hat sich geändert — neu berechnen, um die Wirkung zu sehen.", + "pin": "Eine Anheftung hat sich geändert — neu berechnen, um die Wirkung zu sehen.", + "tool": "Das Werkzeug hat sich geändert — neu berechnen, um die Wirkung zu sehen.", + "edited": "In {{docs}} hat sich etwas geändert — dieser Vorschlag ist nicht mehr aktuell." + }, + "shortfall": { + "title": "Bleibt übrig", + "doc_one": "{{doc}}: {{count}} Vorgang passt nicht", + "doc_other": "{{doc}}: {{count}} Vorgänge passen nicht" + }, + "preview": { + "before": "Jetzt", + "after": "Nach Verteilung", + "capacity": "Kapazität" + }, + "apply": "Anwenden", + "discard": "Verwerfen", + "applyBlockedShortfall": "Lösen Sie zuerst den Engpass — erweitern Sie eine Obergrenze, oder lassen Sie Unterbrechungen zu.", + "applyBlockedNothing": "Es gibt nichts anzuwenden: Dieser Vorschlag ändert nichts.", + "applied_one": "Angewendet in {{count}} Projekt.", + "applied_other": "Angewendet in {{count}} Projekten.", + "undoAll": "Alles rückgängig machen", + "undoneAll": "Alles wurde rückgängig gemacht.", + "undonePartial": "Rückgängig gemacht, außer {{docs}} — dort wurde inzwischen weitergearbeitet.", + "selectHint": "Wählen Sie einen Bibliotheksartikel mit einem Konflikt, um mit dem Verteilen zu beginnen." + }, "color": "Farbe" }, "calendar": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 1a1011238..d1040e987 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -522,7 +522,11 @@ "conflictDays": "{{count}} day(s)", "reason": { "calendarMismatch": "The resource does not work on all days this task needs — shifting cannot resolve this.", - "insufficientCapacity": "Not enough free capacity within the slack to resolve this conflict." + "insufficientCapacity": "Not enough free capacity within the slack to resolve this conflict.", + "ceilingTooTight": "The extension ceiling leaves too little room: no free window within that many workdays.", + "ceilingUnreachable": "Giving extension does not help here — a deadline or constraint keeps this task in place.", + "noWindowInHorizon": "No free window was found within the searched period; beyond that it is unknown.", + "residualFull": "The own workload had room, but the library's residual capacity is used up — other projects occupy it." } }, "curve": { @@ -553,6 +557,86 @@ "documents": "Documents", "selectHint": "Select a resource to see the histogram." }, + "distribution": { + "open": "Distribute…", + "title": "Distribute over projects", + "back": "Back to occupancy", + "subtitle": "{{item}} — distribute residual capacity over the open projects", + "blocked": { + "uncounted": "Calculate first: {{docs}} has not been calculated yet, so there is nothing to distribute.", + "material": "This is a material item; distributing only works for people and equipment.", + "noDemand": "None of the projects actually book work here." + }, + "tool": { + "title": "Tool", + "allowSplits": "Allow interruptions", + "allowSplitsHint": "The work may get pause days instead of shifting in one piece. In MS Project this is called \"Leveling can create splits in remaining work\".", + "price_one": "costs {{count}} workday of overrun", + "price_other": "costs {{count}} workdays of overrun", + "priceNone": "costs no overrun", + "priceUnknown": "price unknown — press Recalculate" + }, + "rank": { + "title": "Who gets spared the most?", + "hint": "Top of the list wiggles the least. Drag to change the order.", + "float": "slack: {{days}}", + "cost_one": "letting only this project shift costs {{count}} workday", + "cost_other": "letting only this project shift costs {{count}} workdays", + "costNone": "letting only this project shift costs no overrun", + "moveUp": "Move up", + "moveDown": "Move down" + }, + "strip": { + "pin": "Pin", + "pinned": "Pinned — this project does not shift, and keeps its workdays too", + "pinnedRecorded": "Pinned: dates as recorded", + "cannotMove": "Cannot shift — all the work here is fixed", + "fixedLoad": "Fixed load", + "ceiling": "Maximum overrun of the end date", + "ceilingUnlimited": "unlimited", + "ceilingDays_one": "ceiling {{count}} workday", + "ceilingDays_other": "ceiling {{count}} workdays", + "endUnchanged": "end unchanged", + "endShift_one": "end +{{count}} day", + "endShift_other": "end +{{count}} days", + "requestedVsAchievable": "requested {{requested}}, closest achievable {{achievable}}", + "handleLabel": "Overrun ceiling for {{doc}}" + }, + "compute": { + "recalculate": "Recalculate", + "auto": "Distribute automatically", + "busy": "Distributing…", + "pressRecompute": "— press Recalculate", + "degraded": "This is a large overview; it only calculates when you press Recalculate." + }, + "stale": { + "rank": "The order has changed — recalculate to see the effect.", + "ceiling": "A ceiling has changed — recalculate to see the effect.", + "pin": "A pin has changed — recalculate to see the effect.", + "tool": "The tool has changed — recalculate to see the effect.", + "edited": "Something changed in {{docs}} — this proposal is no longer current." + }, + "shortfall": { + "title": "Remaining", + "doc_one": "{{doc}}: {{count}} task does not fit", + "doc_other": "{{doc}}: {{count}} tasks do not fit" + }, + "preview": { + "before": "Now", + "after": "After distributing", + "capacity": "Capacity" + }, + "apply": "Apply", + "discard": "Discard", + "applyBlockedShortfall": "First resolve the shortfall — widen a ceiling, or allow interruptions.", + "applyBlockedNothing": "There is nothing to apply: this proposal changes nothing.", + "applied_one": "Applied in {{count}} project.", + "applied_other": "Applied in {{count}} projects.", + "undoAll": "Undo all", + "undoneAll": "Everything has been undone.", + "undonePartial": "Undone, except {{docs}} — those have since been worked on.", + "selectHint": "Choose a library item with a conflict to start distributing." + }, "color": "Color" }, "calendar": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index eaeffce26..bd92d5d15 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -528,7 +528,11 @@ "conflictDays": "{{count}} día(s)", "reason": { "calendarMismatch": "El recurso no trabaja todos los días que necesita esta tarea — desplazarla no resuelve esto.", - "insufficientCapacity": "No hay suficiente capacidad libre dentro de la holgura para resolver este conflicto." + "insufficientCapacity": "No hay suficiente capacidad libre dentro de la holgura para resolver este conflicto.", + "ceilingTooTight": "El techo de holgura deja muy poco margen: no hay ninguna ventana libre dentro de esos días laborables.", + "ceilingUnreachable": "Dar más margen no ayuda aquí — una fecha límite o una restricción mantiene esta tarea en su sitio.", + "noWindowInHorizon": "No se encontró ninguna ventana libre dentro del período explorado; más allá se desconoce.", + "residualFull": "El propio uso tenía margen, pero la capacidad residual de la biblioteca está agotada — otros proyectos la ocupan." } }, "curve": { @@ -562,6 +566,92 @@ "documents": "Documentos", "selectHint": "Selecciona un recurso para ver el histograma." }, + "distribution": { + "open": "Repartir…", + "title": "Repartir entre proyectos", + "back": "Volver a la ocupación", + "subtitle": "{{item}} — repartir la capacidad residual entre los proyectos abiertos", + "blocked": { + "uncounted": "Calcula primero: {{docs}} aún no está calculado, así que no hay nada que repartir.", + "material": "Esto es un artículo de material; el reparto solo funciona para personas y equipos.", + "noDemand": "Ninguno de los proyectos reserva trabajo realmente aquí." + }, + "tool": { + "title": "Herramienta", + "allowSplits": "Permitir interrupciones", + "allowSplitsHint": "El trabajo puede recibir días de pausa en lugar de desplazarse de una sola vez. En MS Project esto se llama \"Leveling can create splits in remaining work\".", + "price_one": "cuesta {{count}} día laborable de retraso", + "price_many": "cuesta {{count}} días laborables de retraso", + "price_other": "cuesta {{count}} días laborables de retraso", + "priceNone": "no cuesta ningún retraso", + "priceUnknown": "precio desconocido — pulsa Recalcular" + }, + "rank": { + "title": "¿A quién se respeta más?", + "hint": "Arriba es lo que menos se desplaza. Arrastra para cambiar el orden.", + "float": "holgura: {{days}}", + "cost_one": "dejar que solo este proyecto se desplace cuesta {{count}} día laborable", + "cost_many": "dejar que solo este proyecto se desplace cuesta {{count}} días laborables", + "cost_other": "dejar que solo este proyecto se desplace cuesta {{count}} días laborables", + "costNone": "dejar que solo este proyecto se desplace no cuesta ningún retraso", + "moveUp": "Subir", + "moveDown": "Bajar" + }, + "strip": { + "pin": "Fijar", + "pinned": "Fijado — este proyecto no se desplaza y también conserva sus días laborables", + "pinnedRecorded": "Fijado: fechas tal como se guardaron", + "cannotMove": "No puede desplazarse — todo el trabajo aquí está fijo", + "fixedLoad": "Carga fija", + "ceiling": "Retraso máximo de la fecha de fin", + "ceilingUnlimited": "sin límite", + "ceilingDays_one": "techo {{count}} día laborable", + "ceilingDays_many": "techo {{count}} días laborables", + "ceilingDays_other": "techo {{count}} días laborables", + "endUnchanged": "fin sin cambios", + "endShift_one": "fin +{{count}} día", + "endShift_many": "fin +{{count}} días", + "endShift_other": "fin +{{count}} días", + "requestedVsAchievable": "solicitado {{requested}}, más cercano alcanzable {{achievable}}", + "handleLabel": "Techo de retraso para {{doc}}" + }, + "compute": { + "recalculate": "Recalcular", + "auto": "Repartir automáticamente", + "busy": "Repartiendo…", + "pressRecompute": "— pulsa Recalcular", + "degraded": "Esta es una vista general grande; solo calcula cuando pulsas Recalcular." + }, + "stale": { + "rank": "El orden ha cambiado — recalcula para ver el efecto.", + "ceiling": "Un techo ha cambiado — recalcula para ver el efecto.", + "pin": "Una fijación ha cambiado — recalcula para ver el efecto.", + "tool": "La herramienta ha cambiado — recalcula para ver el efecto.", + "edited": "Algo ha cambiado en {{docs}} — esta propuesta ya no está actualizada." + }, + "shortfall": { + "title": "Sobra", + "doc_one": "{{doc}}: {{count}} tarea no cabe", + "doc_many": "{{doc}}: {{count}} tareas no caben", + "doc_other": "{{doc}}: {{count}} tareas no caben" + }, + "preview": { + "before": "Ahora", + "after": "Después de repartir", + "capacity": "Capacidad" + }, + "apply": "Aplicar", + "discard": "Descartar", + "applyBlockedShortfall": "Resuelve primero el déficit — amplía un techo, o permite interrupciones.", + "applyBlockedNothing": "No hay nada que aplicar: esta propuesta no cambia nada.", + "applied_one": "Aplicado en {{count}} proyecto.", + "applied_many": "Aplicado en {{count}} proyectos.", + "applied_other": "Aplicado en {{count}} proyectos.", + "undoAll": "Deshacer todo", + "undoneAll": "Todo se ha deshecho.", + "undonePartial": "Deshecho, excepto {{docs}} — en esos ya se ha trabajado mientras tanto.", + "selectHint": "Elige un artículo de biblioteca con un conflicto para empezar a repartir." + }, "color": "Color" }, "calendar": { diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index fc4e1d5a6..eb456475f 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -522,7 +522,11 @@ "conflictDays": "{{count}} روز", "reason": { "calendarMismatch": "این منبع در همه روزهایی که این وظیفه نیاز دارد کار نمی‌کند — جابجایی این مشکل را حل نمی‌کند.", - "insufficientCapacity": "ظرفیت آزاد کافی در محدوده شناوری برای حل این تعارض وجود ندارد." + "insufficientCapacity": "ظرفیت آزاد کافی در محدوده شناوری برای حل این تعارض وجود ندارد.", + "ceilingTooTight": "سقف تمدید فضای بسیار کمی باقی می‌گذارد: در این تعداد روز کاری هیچ بازه آزادی وجود ندارد.", + "ceilingUnreachable": "دادن تمدید در اینجا کمکی نمی‌کند — یک مهلت یا محدودیت این وظیفه را در جای خود نگه می‌دارد.", + "noWindowInHorizon": "در بازه جست‌وجوشده هیچ بازه آزادی یافت نشد؛ فراتر از آن نامشخص است.", + "residualFull": "تخصیص خودِ پروژه فضا داشت، اما ظرفیت باقی‌مانده کتابخانه تمام شده است — پروژه‌های دیگر آن را اشغال کرده‌اند." } }, "curve": { @@ -553,6 +557,86 @@ "documents": "اسناد", "selectHint": "برای دیدن هیستوگرام یک منبع را انتخاب کنید." }, + "distribution": { + "open": "توزیع…", + "title": "توزیع بین پروژه‌ها", + "back": "بازگشت به اشغال", + "subtitle": "{{item}} — توزیع ظرفیت باقی‌مانده بین پروژه‌های باز", + "blocked": { + "uncounted": "ابتدا محاسبه کنید: {{docs}} هنوز محاسبه نشده است، بنابراین چیزی برای توزیع وجود ندارد.", + "material": "این یک قلم مصالح است؛ توزیع فقط برای افراد و تجهیزات کار می‌کند.", + "noDemand": "هیچ‌کدام از پروژه‌ها در اینجا واقعاً کاری رزرو نمی‌کنند." + }, + "tool": { + "title": "ابزار", + "allowSplits": "اجازه دادن به وقفه‌ها", + "allowSplitsHint": "کار می‌تواند به‌جای جابه‌جایی یکجا، روزهای وقفه بگیرد. در MS Project به این «Leveling can create splits in remaining work» گفته می‌شود.", + "price_one": "{{count}} روز کاری تمدید هزینه دارد", + "price_other": "{{count}} روز کاری تمدید هزینه دارد", + "priceNone": "هیچ تمدیدی هزینه ندارد", + "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید" + }, + "rank": { + "title": "چه کسی بیشترین ملاحظه را می‌گیرد؟", + "hint": "بالاترین مورد کمترین جابه‌جایی را دارد. برای تغییر ترتیب بکشید.", + "float": "شناوری: {{days}}", + "cost_one": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", + "cost_other": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", + "costNone": "جابه‌جا کردن فقط این پروژه هیچ تمدیدی هزینه ندارد", + "moveUp": "انتقال به بالا", + "moveDown": "انتقال به پایین" + }, + "strip": { + "pin": "ثابت کردن", + "pinned": "ثابت‌شده — این پروژه جابه‌جا نمی‌شود و روزهای کاری خود را نیز حفظ می‌کند", + "pinnedRecorded": "ثابت‌شده: تاریخ‌ها همان‌طور که ثبت شده‌اند", + "cannotMove": "نمی‌تواند جابه‌جا شود — همه کارهای اینجا ثابت هستند", + "fixedLoad": "بار ثابت", + "ceiling": "حداکثر تمدید تاریخ پایان", + "ceilingUnlimited": "نامحدود", + "ceilingDays_one": "سقف {{count}} روز کاری", + "ceilingDays_other": "سقف {{count}} روز کاری", + "endUnchanged": "پایان بدون تغییر", + "endShift_one": "پایان +{{count}} روز", + "endShift_other": "پایان +{{count}} روز", + "requestedVsAchievable": "درخواست‌شده {{requested}}، نزدیک‌ترین قابل‌دستیابی {{achievable}}", + "handleLabel": "سقف تمدید برای {{doc}}" + }, + "compute": { + "recalculate": "محاسبه مجدد", + "auto": "توزیع خودکار", + "busy": "در حال توزیع…", + "pressRecompute": "— روی محاسبه مجدد بزنید", + "degraded": "این یک نمای کلی بزرگ است؛ فقط زمانی محاسبه می‌کند که روی محاسبه مجدد بزنید." + }, + "stale": { + "rank": "ترتیب تغییر کرده است — برای دیدن اثر آن، محاسبه مجدد کنید.", + "ceiling": "یک سقف تغییر کرده است — برای دیدن اثر آن، محاسبه مجدد کنید.", + "pin": "یک ثابت‌سازی تغییر کرده است — برای دیدن اثر آن، محاسبه مجدد کنید.", + "tool": "ابزار تغییر کرده است — برای دیدن اثر آن، محاسبه مجدد کنید.", + "edited": "چیزی در {{docs}} تغییر کرده است — این پیشنهاد دیگر به‌روز نیست." + }, + "shortfall": { + "title": "باقی‌مانده", + "doc_one": "{{doc}}: {{count}} وظیفه جا نمی‌شود", + "doc_other": "{{doc}}: {{count}} وظیفه جا نمی‌شود" + }, + "preview": { + "before": "اکنون", + "after": "پس از توزیع", + "capacity": "ظرفیت" + }, + "apply": "اعمال", + "discard": "رد کردن", + "applyBlockedShortfall": "ابتدا کمبود را برطرف کنید — یک سقف را افزایش دهید یا اجازه وقفه بدهید.", + "applyBlockedNothing": "چیزی برای اعمال وجود ندارد: این پیشنهاد چیزی را تغییر نمی‌دهد.", + "applied_one": "در {{count}} پروژه اعمال شد.", + "applied_other": "در {{count}} پروژه اعمال شد.", + "undoAll": "بازگردانی همه", + "undoneAll": "همه چیز بازگردانی شد.", + "undonePartial": "بازگردانی شد، به‌جز {{docs}} — که در این میان روی آن‌ها کار شده است.", + "selectHint": "برای شروع توزیع، یک قلم کتابخانه دارای تعارض انتخاب کنید." + }, "color": "رنگ" }, "calendar": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 86e8c80db..095482733 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -528,7 +528,11 @@ "conflictDays": "{{count}} jour(s)", "reason": { "calendarMismatch": "La ressource ne travaille pas tous les jours nécessaires à cette tâche — le décalage ne résout pas ce problème.", - "insufficientCapacity": "Capacité libre insuffisante dans la marge pour résoudre ce conflit." + "insufficientCapacity": "Capacité libre insuffisante dans la marge pour résoudre ce conflit.", + "ceilingTooTight": "Le plafond de décalage laisse trop peu de marge : aucune fenêtre libre dans ce nombre de jours ouvrables.", + "ceilingUnreachable": "Donner du décalage n'aide pas ici — une échéance ou une contrainte maintient cette tâche en place.", + "noWindowInHorizon": "Aucune fenêtre libre n'a été trouvée dans la période explorée ; au-delà, c'est inconnu.", + "residualFull": "L'engagement propre avait de la marge, mais la capacité résiduelle de la bibliothèque est épuisée — d'autres projets l'occupent." } }, "curve": { @@ -562,6 +566,92 @@ "documents": "Documents", "selectHint": "Sélectionnez une ressource pour voir l'histogramme." }, + "distribution": { + "open": "Répartir…", + "title": "Répartir entre les projets", + "back": "Retour à l'occupation", + "subtitle": "{{item}} — répartir la capacité résiduelle entre les projets ouverts", + "blocked": { + "uncounted": "Calculez d'abord : {{docs}} n'est pas encore calculé, il n'y a donc rien à répartir.", + "material": "Il s'agit d'un article matériel ; la répartition ne fonctionne que pour les personnes et le matériel.", + "noDemand": "Aucun des projets ne réserve réellement du travail ici." + }, + "tool": { + "title": "Outil", + "allowSplits": "Autoriser les interruptions", + "allowSplitsHint": "Le travail peut recevoir des jours de pause plutôt que de se décaler d'un bloc. Dans MS Project, cela s'appelle « Leveling can create splits in remaining work ».", + "price_one": "coûte {{count}} jour ouvrable de dépassement", + "price_many": "coûte {{count}} jours ouvrables de dépassement", + "price_other": "coûte {{count}} jours ouvrables de dépassement", + "priceNone": "ne coûte aucun dépassement", + "priceUnknown": "prix inconnu — appuyez sur Recalculer" + }, + "rank": { + "title": "Qui est le plus épargné ?", + "hint": "En haut, on bouge le moins. Faites glisser pour changer l'ordre.", + "float": "marge : {{days}}", + "cost_one": "décaler uniquement ce projet coûte {{count}} jour ouvrable", + "cost_many": "décaler uniquement ce projet coûte {{count}} jours ouvrables", + "cost_other": "décaler uniquement ce projet coûte {{count}} jours ouvrables", + "costNone": "décaler uniquement ce projet ne coûte aucun dépassement", + "moveUp": "Monter", + "moveDown": "Descendre" + }, + "strip": { + "pin": "Épingler", + "pinned": "Épinglé — ce projet ne se décale pas et conserve aussi ses jours de travail", + "pinnedRecorded": "Épinglé : dates telles qu'enregistrées", + "cannotMove": "Ne peut pas se décaler — tout le travail ici est figé", + "fixedLoad": "Charge fixe", + "ceiling": "Dépassement maximal de la date de fin", + "ceilingUnlimited": "illimité", + "ceilingDays_one": "plafond {{count}} jour ouvrable", + "ceilingDays_many": "plafond {{count}} jours ouvrables", + "ceilingDays_other": "plafond {{count}} jours ouvrables", + "endUnchanged": "fin inchangée", + "endShift_one": "fin +{{count}} jour", + "endShift_many": "fin +{{count}} jours", + "endShift_other": "fin +{{count}} jours", + "requestedVsAchievable": "demandé {{requested}}, le plus proche réalisable {{achievable}}", + "handleLabel": "Plafond de dépassement pour {{doc}}" + }, + "compute": { + "recalculate": "Recalculer", + "auto": "Répartir automatiquement", + "busy": "Répartition en cours…", + "pressRecompute": "— appuyez sur Recalculer", + "degraded": "Il s'agit d'un grand aperçu ; il ne calcule que lorsque vous appuyez sur Recalculer." + }, + "stale": { + "rank": "L'ordre a changé — recalculez pour voir l'effet.", + "ceiling": "Un plafond a changé — recalculez pour voir l'effet.", + "pin": "Une épingle a changé — recalculez pour voir l'effet.", + "tool": "L'outil a changé — recalculez pour voir l'effet.", + "edited": "Quelque chose a changé dans {{docs}} — cette proposition n'est plus à jour." + }, + "shortfall": { + "title": "Reste", + "doc_one": "{{doc}} : {{count}} tâche ne tient pas", + "doc_many": "{{doc}} : {{count}} tâches ne tiennent pas", + "doc_other": "{{doc}} : {{count}} tâches ne tiennent pas" + }, + "preview": { + "before": "Maintenant", + "after": "Après répartition", + "capacity": "Capacité" + }, + "apply": "Appliquer", + "discard": "Rejeter", + "applyBlockedShortfall": "Résolvez d'abord le manque — élargissez un plafond, ou autorisez les interruptions.", + "applyBlockedNothing": "Il n'y a rien à appliquer : cette proposition ne change rien.", + "applied_one": "Appliqué dans {{count}} projet.", + "applied_many": "Appliqué dans {{count}} projets.", + "applied_other": "Appliqué dans {{count}} projets.", + "undoAll": "Tout annuler", + "undoneAll": "Tout a été annulé.", + "undonePartial": "Annulé, sauf {{docs}} — on y a travaillé entre-temps.", + "selectHint": "Choisissez un article de bibliothèque en conflit pour commencer à répartir." + }, "color": "Couleur" }, "calendar": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 5b49e6c78..e14c97d42 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -528,7 +528,11 @@ "conflictDays": "{{count}} giorno/i", "reason": { "calendarMismatch": "La risorsa non lavora in tutti i giorni richiesti da questa attività — lo spostamento non risolve il problema.", - "insufficientCapacity": "Capacità libera insufficiente entro il margine per risolvere questo conflitto." + "insufficientCapacity": "Capacità libera insufficiente entro il margine per risolvere questo conflitto.", + "ceilingTooTight": "Il tetto di slittamento lascia troppo poco spazio: entro quel numero di giorni lavorativi non c'è una finestra libera.", + "ceilingUnreachable": "Concedere slittamento qui non aiuta — una scadenza o un vincolo tiene questa attività al suo posto.", + "noWindowInHorizon": "Nel periodo esplorato non è stata trovata alcuna finestra libera; oltre non si sa.", + "residualFull": "L'impegno proprio aveva spazio, ma la capacità residua della libreria è esaurita — altri progetti la occupano." } }, "curve": { @@ -562,6 +566,92 @@ "documents": "Documenti", "selectHint": "Seleziona una risorsa per vedere l'istogramma." }, + "distribution": { + "open": "Distribuisci…", + "title": "Distribuisci tra i progetti", + "back": "Torna all'occupazione", + "subtitle": "{{item}} — distribuisci la capacità residua tra i progetti aperti", + "blocked": { + "uncounted": "Calcola prima: {{docs}} non è ancora stato calcolato, quindi non c'è nulla da distribuire.", + "material": "Questo è un articolo di materiale; la distribuzione funziona solo per persone e attrezzature.", + "noDemand": "Nessuno dei progetti prenota effettivamente lavoro qui." + }, + "tool": { + "title": "Strumento", + "allowSplits": "Consenti interruzioni", + "allowSplitsHint": "Il lavoro può ricevere giorni di pausa invece di spostarsi in un unico blocco. In MS Project questo si chiama \"Leveling can create splits in remaining work\".", + "price_one": "costa {{count}} giorno lavorativo di ritardo", + "price_many": "costa {{count}} giorni lavorativi di ritardo", + "price_other": "costa {{count}} giorni lavorativi di ritardo", + "priceNone": "non costa alcun ritardo", + "priceUnknown": "prezzo sconosciuto — premi Ricalcola" + }, + "rank": { + "title": "Chi viene risparmiato di più?", + "hint": "In alto è chi si sposta meno. Trascina per cambiare l'ordine.", + "float": "margine: {{days}}", + "cost_one": "far slittare solo questo progetto costa {{count}} giorno lavorativo", + "cost_many": "far slittare solo questo progetto costa {{count}} giorni lavorativi", + "cost_other": "far slittare solo questo progetto costa {{count}} giorni lavorativi", + "costNone": "far slittare solo questo progetto non costa alcun ritardo", + "moveUp": "Sposta su", + "moveDown": "Sposta giù" + }, + "strip": { + "pin": "Blocca", + "pinned": "Bloccato — questo progetto non slitta e mantiene anche i suoi giorni lavorativi", + "pinnedRecorded": "Bloccato: date come registrate", + "cannotMove": "Non può slittare — tutto il lavoro qui è fisso", + "fixedLoad": "Carico fisso", + "ceiling": "Ritardo massimo della data di fine", + "ceilingUnlimited": "illimitato", + "ceilingDays_one": "tetto {{count}} giorno lavorativo", + "ceilingDays_many": "tetto {{count}} giorni lavorativi", + "ceilingDays_other": "tetto {{count}} giorni lavorativi", + "endUnchanged": "fine invariata", + "endShift_one": "fine +{{count}} giorno", + "endShift_many": "fine +{{count}} giorni", + "endShift_other": "fine +{{count}} giorni", + "requestedVsAchievable": "richiesto {{requested}}, il più vicino raggiungibile {{achievable}}", + "handleLabel": "Tetto di ritardo per {{doc}}" + }, + "compute": { + "recalculate": "Ricalcola", + "auto": "Distribuisci automaticamente", + "busy": "Distribuzione in corso…", + "pressRecompute": "— premi Ricalcola", + "degraded": "Questa è una panoramica ampia; calcola solo quando premi Ricalcola." + }, + "stale": { + "rank": "L'ordine è cambiato — ricalcola per vedere l'effetto.", + "ceiling": "Un tetto è cambiato — ricalcola per vedere l'effetto.", + "pin": "Un blocco è cambiato — ricalcola per vedere l'effetto.", + "tool": "Lo strumento è cambiato — ricalcola per vedere l'effetto.", + "edited": "Qualcosa è cambiato in {{docs}} — questa proposta non è più aggiornata." + }, + "shortfall": { + "title": "Rimane", + "doc_one": "{{doc}}: {{count}} attività non entra", + "doc_many": "{{doc}}: {{count}} attività non entrano", + "doc_other": "{{doc}}: {{count}} attività non entrano" + }, + "preview": { + "before": "Ora", + "after": "Dopo la distribuzione", + "capacity": "Capacità" + }, + "apply": "Applica", + "discard": "Scarta", + "applyBlockedShortfall": "Risolvi prima la carenza — allarga un tetto, oppure consenti le interruzioni.", + "applyBlockedNothing": "Non c'è nulla da applicare: questa proposta non cambia nulla.", + "applied_one": "Applicato in {{count}} progetto.", + "applied_many": "Applicato in {{count}} progetti.", + "applied_other": "Applicato in {{count}} progetti.", + "undoAll": "Annulla tutto", + "undoneAll": "Tutto è stato annullato.", + "undonePartial": "Annullato, tranne {{docs}} — lì nel frattempo si è lavorato.", + "selectHint": "Scegli un articolo di libreria con un conflitto per iniziare a distribuire." + }, "color": "Colore" }, "calendar": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 66d9042dd..36096e8e8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -516,7 +516,11 @@ "conflictDays": "{{count}} 日", "reason": { "calendarMismatch": "このリソースはこのタスクが必要とするすべての日に稼働していません — 移動では解決できません。", - "insufficientCapacity": "フロート内の空きキャパシティが不足しているため、この競合を解決できません。" + "insufficientCapacity": "フロート内の空きキャパシティが不足しているため、この競合を解決できません。", + "ceilingTooTight": "延長の上限では余裕が少なすぎます。その稼働日数以内に空いているウィンドウがありません。", + "ceilingUnreachable": "ここでは延長を与えても解決しません — 期限や制約がこのタスクを固定しています。", + "noWindowInHorizon": "検索した期間内に空いているウィンドウが見つかりませんでした。それ以降は不明です。", + "residualFull": "自プロジェクトの割り当てには余裕がありましたが、ライブラリの残キャパシティが尽きています — 他のプロジェクトが占有しています。" } }, "curve": { @@ -544,6 +548,80 @@ "documents": "ドキュメント", "selectHint": "ヒストグラムを表示するにはリソースを選択してください。" }, + "distribution": { + "open": "配分…", + "title": "プロジェクト間で配分", + "back": "稼働状況に戻る", + "subtitle": "{{item}} — 残キャパシティを開いているプロジェクト間で配分", + "blocked": { + "uncounted": "先に計算してください:{{docs}} はまだ計算されていないため、配分するものがありません。", + "material": "これは資材項目です。配分は人員と機材にのみ機能します。", + "noDemand": "どのプロジェクトもここで実際に作業を予約していません。" + }, + "tool": { + "title": "ツール", + "allowSplits": "中断を許可", + "allowSplitsHint": "作業は一気に移動する代わりに休止日を挟むことができます。MS Project ではこれを「Leveling can create splits in remaining work」と呼びます。", + "price_other": "{{count}} 稼働日の延長がかかります", + "priceNone": "延長はかかりません", + "priceUnknown": "価格不明 — 再計算を押してください" + }, + "rank": { + "title": "誰が最も優先的に守られますか?", + "hint": "上にあるほど動きが少なくなります。ドラッグして順序を変更します。", + "float": "フロート:{{days}}", + "cost_other": "このプロジェクトだけを動かすには {{count}} 稼働日かかります", + "costNone": "このプロジェクトだけを動かしても延長はかかりません", + "moveUp": "上へ移動", + "moveDown": "下へ移動" + }, + "strip": { + "pin": "固定", + "pinned": "固定済み — このプロジェクトは動かず、稼働日もそのまま維持されます", + "pinnedRecorded": "固定済み:記録された日付のとおり", + "cannotMove": "動かせません — ここの作業はすべて確定しています", + "fixedLoad": "固定負荷", + "ceiling": "終了日の最大延長", + "ceilingUnlimited": "無制限", + "ceilingDays_other": "上限 {{count}} 稼働日", + "endUnchanged": "終了日は変更なし", + "endShift_other": "終了日 +{{count}} 日", + "requestedVsAchievable": "要求 {{requested}}、達成可能な最も近い値 {{achievable}}", + "handleLabel": "{{doc}} の延長上限" + }, + "compute": { + "recalculate": "再計算", + "auto": "自動配分", + "busy": "配分中…", + "pressRecompute": "— 再計算を押してください", + "degraded": "これは大規模な概要です。再計算を押したときのみ計算されます。" + }, + "stale": { + "rank": "順序が変更されました — 効果を確認するには再計算してください。", + "ceiling": "上限が変更されました — 効果を確認するには再計算してください。", + "pin": "固定設定が変更されました — 効果を確認するには再計算してください。", + "tool": "ツールが変更されました — 効果を確認するには再計算してください。", + "edited": "{{docs}} で何かが変更されました — この提案はもう最新ではありません。" + }, + "shortfall": { + "title": "残り", + "doc_other": "{{doc}}:{{count}} 件のタスクが収まりません" + }, + "preview": { + "before": "現在", + "after": "配分後", + "capacity": "キャパシティ" + }, + "apply": "適用", + "discard": "破棄", + "applyBlockedShortfall": "先に不足を解消してください — 上限を広げるか、中断を許可してください。", + "applyBlockedNothing": "適用するものがありません:この提案は何も変更しません。", + "applied_other": "{{count}} 件のプロジェクトに適用されました。", + "undoAll": "すべて元に戻す", + "undoneAll": "すべて元に戻りました。", + "undonePartial": "{{docs}} を除いて元に戻りました — そちらはその間に作業が行われています。", + "selectHint": "配分を始めるには、競合のあるライブラリ項目を選択してください。" + }, "color": "色" }, "calendar": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index ab3f29acf..335be99a1 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -516,7 +516,11 @@ "conflictDays": "{{count}}일", "reason": { "calendarMismatch": "이 자원은 이 작업이 필요로 하는 모든 날짜에 근무하지 않습니다 — 이동으로는 해결할 수 없습니다.", - "insufficientCapacity": "여유 내 가용 용량이 부족하여 이 충돌을 해결할 수 없습니다." + "insufficientCapacity": "여유 내 가용 용량이 부족하여 이 충돌을 해결할 수 없습니다.", + "ceilingTooTight": "연장 상한이 여유를 너무 적게 남깁니다. 그 근무일 수 내에 빈 창이 없습니다.", + "ceilingUnreachable": "여기서는 연장을 주어도 도움이 되지 않습니다 — 마감일이나 제약 조건이 이 작업을 제자리에 고정하고 있습니다.", + "noWindowInHorizon": "검색한 기간 내에 빈 창을 찾지 못했습니다. 그 이후는 알 수 없습니다.", + "residualFull": "자체 투입에는 여유가 있었지만, 라이브러리의 잔여 용량이 소진되었습니다 — 다른 프로젝트가 이를 점유하고 있습니다." } }, "curve": { @@ -544,6 +548,80 @@ "documents": "문서", "selectHint": "히스토그램을 보려면 리소스를 선택하세요." }, + "distribution": { + "open": "분배…", + "title": "프로젝트 간 분배", + "back": "점유 현황으로 돌아가기", + "subtitle": "{{item}} — 열린 프로젝트 간에 잔여 용량 분배", + "blocked": { + "uncounted": "먼저 계산하세요: {{docs}} 이(가) 아직 계산되지 않아 분배할 것이 없습니다.", + "material": "이것은 자재 항목입니다. 분배는 인력과 장비에만 작동합니다.", + "noDemand": "어떤 프로젝트도 실제로 여기에서 작업을 예약하지 않습니다." + }, + "tool": { + "title": "도구", + "allowSplits": "중단 허용", + "allowSplitsHint": "작업이 한 번에 이동하는 대신 중단일을 가질 수 있습니다. MS Project에서는 이를 \"Leveling can create splits in remaining work\"라고 합니다.", + "price_other": "{{count}}근무일의 연장 비용이 듭니다", + "priceNone": "연장 비용이 들지 않습니다", + "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요" + }, + "rank": { + "title": "누가 가장 많이 보호받나요?", + "hint": "위에 있을수록 가장 적게 이동합니다. 드래그하여 순서를 변경하세요.", + "float": "여유: {{days}}", + "cost_other": "이 프로젝트만 이동하게 하면 {{count}}근무일이 듭니다", + "costNone": "이 프로젝트만 이동하게 해도 연장 비용이 들지 않습니다", + "moveUp": "위로 이동", + "moveDown": "아래로 이동" + }, + "strip": { + "pin": "고정", + "pinned": "고정됨 — 이 프로젝트는 이동하지 않으며 근무일도 그대로 유지합니다", + "pinnedRecorded": "고정됨: 기록된 날짜대로", + "cannotMove": "이동할 수 없음 — 여기의 모든 작업이 고정되어 있습니다", + "fixedLoad": "고정 부하", + "ceiling": "종료일의 최대 연장", + "ceilingUnlimited": "무제한", + "ceilingDays_other": "상한 {{count}}근무일", + "endUnchanged": "종료일 변경 없음", + "endShift_other": "종료일 +{{count}}일", + "requestedVsAchievable": "요청 {{requested}}, 가장 가까운 달성 가능값 {{achievable}}", + "handleLabel": "{{doc}}의 연장 상한" + }, + "compute": { + "recalculate": "다시 계산", + "auto": "자동 분배", + "busy": "분배 중…", + "pressRecompute": "— 다시 계산을 누르세요", + "degraded": "이것은 대규모 개요입니다. 다시 계산을 누를 때만 계산됩니다." + }, + "stale": { + "rank": "순서가 변경되었습니다 — 효과를 보려면 다시 계산하세요.", + "ceiling": "상한이 변경되었습니다 — 효과를 보려면 다시 계산하세요.", + "pin": "고정 설정이 변경되었습니다 — 효과를 보려면 다시 계산하세요.", + "tool": "도구가 변경되었습니다 — 효과를 보려면 다시 계산하세요.", + "edited": "{{docs}}에서 변경 사항이 있습니다 — 이 제안은 더 이상 최신이 아닙니다." + }, + "shortfall": { + "title": "남음", + "doc_other": "{{doc}}: {{count}}개 작업이 맞지 않습니다" + }, + "preview": { + "before": "현재", + "after": "분배 후", + "capacity": "용량" + }, + "apply": "적용", + "discard": "취소", + "applyBlockedShortfall": "먼저 부족분을 해결하세요 — 상한을 넓히거나 중단을 허용하세요.", + "applyBlockedNothing": "적용할 것이 없습니다: 이 제안은 아무것도 변경하지 않습니다.", + "applied_other": "{{count}}개 프로젝트에 적용되었습니다.", + "undoAll": "모두 실행 취소", + "undoneAll": "모두 실행 취소되었습니다.", + "undonePartial": "{{docs}}을(를) 제외하고 실행 취소되었습니다 — 그동안 해당 항목에서 작업이 진행되었습니다.", + "selectHint": "분배를 시작하려면 충돌이 있는 라이브러리 항목을 선택하세요." + }, "color": "색상" }, "calendar": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 9bcc51660..8ce1c1ec4 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -522,7 +522,11 @@ "conflictDays": "{{count}} dag(en)", "reason": { "calendarMismatch": "De resource werkt niet op alle dagen die deze taak nodig heeft — verschuiven lost dit niet op.", - "insufficientCapacity": "Onvoldoende vrije capaciteit binnen de speling om dit conflict op te lossen." + "insufficientCapacity": "Onvoldoende vrije capaciteit binnen de speling om dit conflict op te lossen.", + "ceilingTooTight": "Het uitloop-plafond laat te weinig ruimte: binnen dat aantal werkdagen is geen vrij venster.", + "ceilingUnreachable": "Uitloop geven helpt hier niet — een deadline of constraint houdt deze taak op zijn plek.", + "noWindowInHorizon": "Binnen de doorzochte periode is geen vrij venster gevonden; verderop is het onbekend.", + "residualFull": "De eigen inzet had ruimte, maar de restcapaciteit van de bibliotheek is op — andere projecten bezetten hem." } }, "curve": { @@ -553,6 +557,86 @@ "documents": "Documenten", "selectHint": "Selecteer een resource om het histogram te zien." }, + "distribution": { + "open": "Verdelen…", + "title": "Verdelen over projecten", + "back": "Terug naar bezetting", + "subtitle": "{{item}} — restcapaciteit verdelen over de geopende projecten", + "blocked": { + "uncounted": "Eerst doorrekenen: {{docs}} is nog niet berekend, dus er valt niets tegen te verdelen.", + "material": "Dit is een materiaal-item; verdelen werkt alleen voor mensen en materieel.", + "noDemand": "Geen van de projecten boekt hier daadwerkelijk werk op." + }, + "tool": { + "title": "Gereedschap", + "allowSplits": "Onderbrekingen toestaan", + "allowSplitsHint": "Het werk mag pauzedagen krijgen in plaats van in één stuk op te schuiven. In MS Project heet dit \"Leveling can create splits in remaining work\".", + "price_one": "kost {{count}} werkdag uitloop", + "price_other": "kost {{count}} werkdagen uitloop", + "priceNone": "kost geen uitloop", + "priceUnknown": "prijs onbekend — druk op Herbereken" + }, + "rank": { + "title": "Wie wordt het meest ontzien?", + "hint": "Bovenaan wijkt het minst. Sleep om de volgorde te veranderen.", + "float": "speling: {{days}}", + "cost_one": "alleen dit project laten opschuiven kost {{count}} werkdag", + "cost_other": "alleen dit project laten opschuiven kost {{count}} werkdagen", + "costNone": "alleen dit project laten opschuiven kost geen uitloop", + "moveUp": "Naar boven", + "moveDown": "Naar beneden" + }, + "strip": { + "pin": "Vastzetten", + "pinned": "Vastgezet — dit project wijkt niet en houdt ook zijn werkdagen", + "pinnedRecorded": "Vastgezet: datums zoals opgeslagen", + "cannotMove": "Kan niet wijken — al het werk hier staat vast", + "fixedLoad": "Vaste last", + "ceiling": "Maximale uitloop van de einddatum", + "ceilingUnlimited": "onbegrensd", + "ceilingDays_one": "plafond {{count}} werkdag", + "ceilingDays_other": "plafond {{count}} werkdagen", + "endUnchanged": "eind ongewijzigd", + "endShift_one": "eind +{{count}} dag", + "endShift_other": "eind +{{count}} dagen", + "requestedVsAchievable": "gevraagd {{requested}}, dichtst haalbare {{achievable}}", + "handleLabel": "Uitloop-plafond voor {{doc}}" + }, + "compute": { + "recalculate": "Herbereken", + "auto": "Verdeel automatisch", + "busy": "Bezig met verdelen…", + "pressRecompute": "— druk op Herbereken", + "degraded": "Dit is een groot overzicht; het rekent alleen wanneer je op Herbereken drukt." + }, + "stale": { + "rank": "De volgorde is gewijzigd — herbereken om het effect te zien.", + "ceiling": "Een plafond is gewijzigd — herbereken om het effect te zien.", + "pin": "Een vastzetting is gewijzigd — herbereken om het effect te zien.", + "tool": "Het gereedschap is gewijzigd — herbereken om het effect te zien.", + "edited": "Er is iets gewijzigd in {{docs}} — dit voorstel is niet meer actueel." + }, + "shortfall": { + "title": "Blijft over", + "doc_one": "{{doc}}: {{count}} taak past niet", + "doc_other": "{{doc}}: {{count}} taken passen niet" + }, + "preview": { + "before": "Nu", + "after": "Na verdelen", + "capacity": "Capaciteit" + }, + "apply": "Toepassen", + "discard": "Verwerpen", + "applyBlockedShortfall": "Los eerst het tekort op — of zet een plafond ruimer, of laat onderbrekingen toe.", + "applyBlockedNothing": "Er valt niets toe te passen: dit voorstel verandert niets.", + "applied_one": "Toegepast in {{count}} project.", + "applied_other": "Toegepast in {{count}} projecten.", + "undoAll": "Alles terugdraaien", + "undoneAll": "Alles is teruggedraaid.", + "undonePartial": "Teruggedraaid, behalve {{docs}} — daar is intussen in gewerkt.", + "selectHint": "Kies een bibliotheekitem met een conflict om te gaan verdelen." + }, "color": "Kleur" }, "calendar": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 59d1cc4d8..0cdfe4e10 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -534,7 +534,11 @@ "conflictDays": "{{count}} dzień/dni", "reason": { "calendarMismatch": "Zasób nie pracuje we wszystkie dni potrzebne temu zadaniu — przesunięcie tego nie rozwiąże.", - "insufficientCapacity": "Za mało wolnej zdolności w ramach zapasu, aby rozwiązać ten konflikt." + "insufficientCapacity": "Za mało wolnej zdolności w ramach zapasu, aby rozwiązać ten konflikt.", + "ceilingTooTight": "Pułap wydłużenia zostawia zbyt mało miejsca: w tej liczbie dni roboczych nie ma wolnego okna.", + "ceilingUnreachable": "Danie wydłużenia tu nie pomaga — termin lub ograniczenie trzyma to zadanie na miejscu.", + "noWindowInHorizon": "W przeszukanym okresie nie znaleziono wolnego okna; dalej jest to nieznane.", + "residualFull": "Własne zaangażowanie miało miejsce, ale rezydualna zdolność biblioteki jest wyczerpana — zajmują ją inne projekty." } }, "curve": { @@ -571,6 +575,98 @@ "documents": "Dokumenty", "selectHint": "Wybierz zasób, aby zobaczyć histogram." }, + "distribution": { + "open": "Rozdziel…", + "title": "Rozdziel między projekty", + "back": "Powrót do obłożenia", + "subtitle": "{{item}} — rozdziel zdolność rezydualną między otwarte projekty", + "blocked": { + "uncounted": "Najpierw przelicz: {{docs}} nie jest jeszcze obliczony, więc nie ma czego rozdzielać.", + "material": "To jest pozycja materiałowa; rozdzielanie działa tylko dla osób i sprzętu.", + "noDemand": "Żaden z projektów faktycznie tu nie rezerwuje pracy." + }, + "tool": { + "title": "Narzędzie", + "allowSplits": "Zezwól na przerwy", + "allowSplitsHint": "Praca może dostać dni przerwy zamiast przesuwać się w całości. W MS Project nazywa się to \"Leveling can create splits in remaining work\".", + "price_one": "kosztuje {{count}} dzień roboczy wydłużenia", + "price_few": "kosztuje {{count}} dni robocze wydłużenia", + "price_many": "kosztuje {{count}} dni roboczych wydłużenia", + "price_other": "kosztuje {{count}} dnia roboczego wydłużenia", + "priceNone": "nie kosztuje żadnego wydłużenia", + "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie" + }, + "rank": { + "title": "Kto jest najbardziej oszczędzany?", + "hint": "Na górze przesuwa się najmniej. Przeciągnij, aby zmienić kolejność.", + "float": "zapas: {{days}}", + "cost_one": "przesunięcie tylko tego projektu kosztuje {{count}} dzień roboczy", + "cost_few": "przesunięcie tylko tego projektu kosztuje {{count}} dni robocze", + "cost_many": "przesunięcie tylko tego projektu kosztuje {{count}} dni roboczych", + "cost_other": "przesunięcie tylko tego projektu kosztuje {{count}} dnia roboczego", + "costNone": "przesunięcie tylko tego projektu nie kosztuje żadnego wydłużenia", + "moveUp": "Przenieś w górę", + "moveDown": "Przenieś w dół" + }, + "strip": { + "pin": "Przypnij", + "pinned": "Przypięty — ten projekt się nie przesuwa i zachowuje też swoje dni robocze", + "pinnedRecorded": "Przypięty: daty tak jak zapisane", + "cannotMove": "Nie może się przesunąć — cała praca tutaj jest ustalona", + "fixedLoad": "Obciążenie stałe", + "ceiling": "Maksymalne wydłużenie daty zakończenia", + "ceilingUnlimited": "bez ograniczeń", + "ceilingDays_one": "pułap {{count}} dzień roboczy", + "ceilingDays_few": "pułap {{count}} dni robocze", + "ceilingDays_many": "pułap {{count}} dni roboczych", + "ceilingDays_other": "pułap {{count}} dnia roboczego", + "endUnchanged": "koniec bez zmian", + "endShift_one": "koniec +{{count}} dzień", + "endShift_few": "koniec +{{count}} dni", + "endShift_many": "koniec +{{count}} dni", + "endShift_other": "koniec +{{count}} dnia", + "requestedVsAchievable": "żądano {{requested}}, najbliższe osiągalne {{achievable}}", + "handleLabel": "Pułap wydłużenia dla {{doc}}" + }, + "compute": { + "recalculate": "Przelicz ponownie", + "auto": "Rozdziel automatycznie", + "busy": "Trwa rozdzielanie…", + "pressRecompute": "— naciśnij Przelicz ponownie", + "degraded": "To duży przegląd; oblicza tylko po naciśnięciu Przelicz ponownie." + }, + "stale": { + "rank": "Kolejność się zmieniła — przelicz ponownie, aby zobaczyć efekt.", + "ceiling": "Pułap się zmienił — przelicz ponownie, aby zobaczyć efekt.", + "pin": "Przypięcie się zmieniło — przelicz ponownie, aby zobaczyć efekt.", + "tool": "Narzędzie się zmieniło — przelicz ponownie, aby zobaczyć efekt.", + "edited": "Coś się zmieniło w {{docs}} — ta propozycja jest już nieaktualna." + }, + "shortfall": { + "title": "Pozostaje", + "doc_one": "{{doc}}: {{count}} zadanie nie mieści się", + "doc_few": "{{doc}}: {{count}} zadania nie mieszczą się", + "doc_many": "{{doc}}: {{count}} zadań nie mieści się", + "doc_other": "{{doc}}: {{count}} zadania nie mieści się" + }, + "preview": { + "before": "Teraz", + "after": "Po rozdzieleniu", + "capacity": "Zdolność" + }, + "apply": "Zastosuj", + "discard": "Odrzuć", + "applyBlockedShortfall": "Najpierw rozwiąż niedobór — poszerz pułap albo zezwól na przerwy.", + "applyBlockedNothing": "Nie ma nic do zastosowania: ta propozycja niczego nie zmienia.", + "applied_one": "Zastosowano w {{count}} projekcie.", + "applied_few": "Zastosowano w {{count}} projektach.", + "applied_many": "Zastosowano w {{count}} projektach.", + "applied_other": "Zastosowano w {{count}} projektach.", + "undoAll": "Cofnij wszystko", + "undoneAll": "Wszystko zostało cofnięte.", + "undonePartial": "Cofnięto, z wyjątkiem {{docs}} — tam w międzyczasie pracowano.", + "selectHint": "Wybierz pozycję biblioteki z konfliktem, aby rozpocząć rozdzielanie." + }, "color": "Kolor" }, "calendar": { diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 6c9abdc75..eb122a097 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -528,7 +528,11 @@ "conflictDays": "{{count}} dia(s)", "reason": { "calendarMismatch": "O recurso não trabalha em todos os dias que esta tarefa necessita — deslocar não resolve isto.", - "insufficientCapacity": "Não há capacidade livre suficiente dentro da folga para resolver este conflito." + "insufficientCapacity": "Não há capacidade livre suficiente dentro da folga para resolver este conflito.", + "ceilingTooTight": "O teto de atraso deixa pouco espaço: dentro desse número de dias úteis não há janela livre.", + "ceilingUnreachable": "Dar mais atraso não ajuda aqui — um prazo ou uma restrição mantém esta tarefa no lugar.", + "noWindowInHorizon": "Não foi encontrada nenhuma janela livre dentro do período pesquisado; mais além é desconhecido.", + "residualFull": "O envolvimento próprio tinha espaço, mas a capacidade residual da biblioteca está esgotada — outros projetos ocupam-na." } }, "curve": { @@ -562,6 +566,92 @@ "documents": "Documentos", "selectHint": "Selecione um recurso para ver o histograma." }, + "distribution": { + "open": "Distribuir…", + "title": "Distribuir entre projetos", + "back": "Voltar à ocupação", + "subtitle": "{{item}} — distribuir a capacidade residual entre os projetos abertos", + "blocked": { + "uncounted": "Calcule primeiro: {{docs}} ainda não foi calculado, por isso não há nada para distribuir.", + "material": "Este é um item de material; a distribuição só funciona para pessoas e equipamentos.", + "noDemand": "Nenhum dos projetos reserva efetivamente trabalho aqui." + }, + "tool": { + "title": "Ferramenta", + "allowSplits": "Permitir interrupções", + "allowSplitsHint": "O trabalho pode receber dias de pausa em vez de se deslocar de uma só vez. No MS Project isto chama-se \"Leveling can create splits in remaining work\".", + "price_one": "custa {{count}} dia útil de atraso", + "price_many": "custa {{count}} dias úteis de atraso", + "price_other": "custa {{count}} dias úteis de atraso", + "priceNone": "não custa nenhum atraso", + "priceUnknown": "preço desconhecido — prima Recalcular" + }, + "rank": { + "title": "Quem é mais poupado?", + "hint": "No topo desloca-se menos. Arraste para mudar a ordem.", + "float": "folga: {{days}}", + "cost_one": "deixar apenas este projeto deslocar-se custa {{count}} dia útil", + "cost_many": "deixar apenas este projeto deslocar-se custa {{count}} dias úteis", + "cost_other": "deixar apenas este projeto deslocar-se custa {{count}} dias úteis", + "costNone": "deixar apenas este projeto deslocar-se não custa nenhum atraso", + "moveUp": "Mover para cima", + "moveDown": "Mover para baixo" + }, + "strip": { + "pin": "Fixar", + "pinned": "Fixado — este projeto não se desloca e mantém também os seus dias úteis", + "pinnedRecorded": "Fixado: datas como registadas", + "cannotMove": "Não pode deslocar-se — todo o trabalho aqui está fixo", + "fixedLoad": "Carga fixa", + "ceiling": "Atraso máximo da data de fim", + "ceilingUnlimited": "ilimitado", + "ceilingDays_one": "teto {{count}} dia útil", + "ceilingDays_many": "teto {{count}} dias úteis", + "ceilingDays_other": "teto {{count}} dias úteis", + "endUnchanged": "fim inalterado", + "endShift_one": "fim +{{count}} dia", + "endShift_many": "fim +{{count}} dias", + "endShift_other": "fim +{{count}} dias", + "requestedVsAchievable": "pedido {{requested}}, mais próximo alcançável {{achievable}}", + "handleLabel": "Teto de atraso para {{doc}}" + }, + "compute": { + "recalculate": "Recalcular", + "auto": "Distribuir automaticamente", + "busy": "A distribuir…", + "pressRecompute": "— prima Recalcular", + "degraded": "Esta é uma visão geral grande; só calcula quando prime Recalcular." + }, + "stale": { + "rank": "A ordem mudou — recalcule para ver o efeito.", + "ceiling": "Um teto mudou — recalcule para ver o efeito.", + "pin": "Uma fixação mudou — recalcule para ver o efeito.", + "tool": "A ferramenta mudou — recalcule para ver o efeito.", + "edited": "Algo mudou em {{docs}} — esta proposta já não está atual." + }, + "shortfall": { + "title": "Sobra", + "doc_one": "{{doc}}: {{count}} tarefa não cabe", + "doc_many": "{{doc}}: {{count}} tarefas não cabem", + "doc_other": "{{doc}}: {{count}} tarefas não cabem" + }, + "preview": { + "before": "Agora", + "after": "Depois de distribuir", + "capacity": "Capacidade" + }, + "apply": "Aplicar", + "discard": "Descartar", + "applyBlockedShortfall": "Resolva primeiro o défice — alargue um teto, ou permita interrupções.", + "applyBlockedNothing": "Não há nada para aplicar: esta proposta não muda nada.", + "applied_one": "Aplicado em {{count}} projeto.", + "applied_many": "Aplicado em {{count}} projetos.", + "applied_other": "Aplicado em {{count}} projetos.", + "undoAll": "Anular tudo", + "undoneAll": "Tudo foi anulado.", + "undonePartial": "Anulado, exceto {{docs}} — nesses entretanto já se trabalhou.", + "selectHint": "Escolha um item de biblioteca com um conflito para começar a distribuir." + }, "color": "Cor" }, "calendar": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 3654e284f..e6ffe8c52 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -522,7 +522,11 @@ "conflictDays": "{{count}} gün", "reason": { "calendarMismatch": "Kaynak bu görevin ihtiyaç duyduğu tüm günlerde çalışmıyor — kaydırma bunu çözmez.", - "insufficientCapacity": "Bu çakışmayı çözmek için bolluk içinde yeterli boş kapasite yok." + "insufficientCapacity": "Bu çakışmayı çözmek için bolluk içinde yeterli boş kapasite yok.", + "ceilingTooTight": "Kaydırma tavanı çok az yer bırakıyor: bu kadar iş günü içinde boş pencere yok.", + "ceilingUnreachable": "Burada kaydırma vermek yardımcı olmuyor — bir teslim tarihi veya kısıt bu görevi yerinde tutuyor.", + "noWindowInHorizon": "Taranan dönem içinde boş pencere bulunamadı; ötesinde bilinmiyor.", + "residualFull": "Kendi kullanımın yer vardı, ama kitaplığın kalan kapasitesi tükendi — başka projeler onu kullanıyor." } }, "curve": { @@ -553,6 +557,86 @@ "documents": "Belgeler", "selectHint": "Histogramı görmek için bir kaynak seçin." }, + "distribution": { + "open": "Dağıt…", + "title": "Projeler arasında dağıt", + "back": "Doluluğa dön", + "subtitle": "{{item}} — kalan kapasiteyi açık projeler arasında dağıt", + "blocked": { + "uncounted": "Önce hesapla: {{docs}} henüz hesaplanmadı, bu yüzden dağıtılacak bir şey yok.", + "material": "Bu bir malzeme kalemi; dağıtım yalnızca kişiler ve ekipman için çalışır.", + "noDemand": "Projelerin hiçbiri burada gerçekten iş rezerve etmiyor." + }, + "tool": { + "title": "Araç", + "allowSplits": "Kesintilere izin ver", + "allowSplitsHint": "İş, tek parça halinde kaymak yerine ara günler alabilir. MS Project'te buna \"Leveling can create splits in remaining work\" denir.", + "price_one": "{{count}} iş günü kaydırma maliyeti var", + "price_other": "{{count}} iş günü kaydırma maliyeti var", + "priceNone": "kaydırma maliyeti yok", + "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın" + }, + "rank": { + "title": "En çok kim korunuyor?", + "hint": "En üstteki en az kayar. Sırayı değiştirmek için sürükleyin.", + "float": "bolluk: {{days}}", + "cost_one": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", + "cost_other": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", + "costNone": "yalnızca bu projenin kaymasına izin vermek hiçbir kaydırma maliyetine mal olmaz", + "moveUp": "Yukarı taşı", + "moveDown": "Aşağı taşı" + }, + "strip": { + "pin": "Sabitle", + "pinned": "Sabitlendi — bu proje kaymaz ve iş günlerini de korur", + "pinnedRecorded": "Sabitlendi: kayıtlı tarihler", + "cannotMove": "Kayamaz — buradaki tüm iş sabit", + "fixedLoad": "Sabit yük", + "ceiling": "Bitiş tarihinin azami kayması", + "ceilingUnlimited": "sınırsız", + "ceilingDays_one": "tavan {{count}} iş günü", + "ceilingDays_other": "tavan {{count}} iş günü", + "endUnchanged": "bitiş değişmedi", + "endShift_one": "bitiş +{{count}} gün", + "endShift_other": "bitiş +{{count}} gün", + "requestedVsAchievable": "istenen {{requested}}, en yakın ulaşılabilir {{achievable}}", + "handleLabel": "{{doc}} için kaydırma tavanı" + }, + "compute": { + "recalculate": "Yeniden hesapla", + "auto": "Otomatik dağıt", + "busy": "Dağıtılıyor…", + "pressRecompute": "— Yeniden Hesapla'ya basın", + "degraded": "Bu büyük bir genel görünüm; yalnızca Yeniden Hesapla'ya bastığınızda hesaplar." + }, + "stale": { + "rank": "Sıra değişti — etkisini görmek için yeniden hesaplayın.", + "ceiling": "Bir tavan değişti — etkisini görmek için yeniden hesaplayın.", + "pin": "Bir sabitleme değişti — etkisini görmek için yeniden hesaplayın.", + "tool": "Araç değişti — etkisini görmek için yeniden hesaplayın.", + "edited": "{{docs}} içinde bir şey değişti — bu öneri artık güncel değil." + }, + "shortfall": { + "title": "Kalan", + "doc_one": "{{doc}}: {{count}} görev sığmıyor", + "doc_other": "{{doc}}: {{count}} görev sığmıyor" + }, + "preview": { + "before": "Şimdi", + "after": "Dağıtımdan sonra", + "capacity": "Kapasite" + }, + "apply": "Uygula", + "discard": "At", + "applyBlockedShortfall": "Önce eksikliği çözün — bir tavanı genişletin ya da kesintilere izin verin.", + "applyBlockedNothing": "Uygulanacak bir şey yok: bu öneri hiçbir şeyi değiştirmiyor.", + "applied_one": "{{count}} projede uygulandı.", + "applied_other": "{{count}} projede uygulandı.", + "undoAll": "Tümünü geri al", + "undoneAll": "Her şey geri alındı.", + "undonePartial": "{{docs}} hariç geri alındı — orada bu arada çalışılmış.", + "selectHint": "Dağıtmaya başlamak için çakışan bir kitaplık öğesi seçin." + }, "color": "Renk" }, "calendar": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 80542974d..116e81269 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -516,7 +516,11 @@ "conflictDays": "{{count}} 天", "reason": { "calendarMismatch": "该资源并非在此任务所需的所有天数中都工作 — 移动无法解决此问题。", - "insufficientCapacity": "浮动范围内的空闲产能不足,无法解决此冲突。" + "insufficientCapacity": "浮动范围内的空闲产能不足,无法解决此冲突。", + "ceilingTooTight": "延误上限空间太小:在该工作日数内没有空闲窗口。", + "ceilingUnreachable": "给予延误在此无济于事——截止日期或约束条件使该任务保持原位。", + "noWindowInHorizon": "在搜索的时间段内未找到空闲窗口;再往后则未知。", + "residualFull": "自身投入本有空间,但资源库的剩余产能已用尽——被其他项目占用。" } }, "curve": { @@ -544,6 +548,80 @@ "documents": "文档", "selectHint": "选择一个资源以查看直方图。" }, + "distribution": { + "open": "分配…", + "title": "在项目间分配", + "back": "返回占用情况", + "subtitle": "{{item}} — 在打开的项目之间分配剩余产能", + "blocked": { + "uncounted": "请先计算:{{docs}} 尚未计算,因此没有可分配的内容。", + "material": "这是一个材料项;分配仅适用于人员和设备。", + "noDemand": "没有任何项目在此实际预订工作。" + }, + "tool": { + "title": "工具", + "allowSplits": "允许中断", + "allowSplitsHint": "工作可以插入暂停日,而不是整体顺延。在 MS Project 中这称为\"Leveling can create splits in remaining work\"。", + "price_other": "需要 {{count}} 个工作日的延误", + "priceNone": "不需要延误", + "priceUnknown": "价格未知 — 按下重新计算" + }, + "rank": { + "title": "谁得到最大程度的照顾?", + "hint": "排在最上面的移动最少。拖动以更改顺序。", + "float": "浮动:{{days}}", + "cost_other": "仅让此项目顺延需要 {{count}} 个工作日", + "costNone": "仅让此项目顺延不需要延误", + "moveUp": "上移", + "moveDown": "下移" + }, + "strip": { + "pin": "固定", + "pinned": "已固定 — 此项目不顺延,也保留其工作日", + "pinnedRecorded": "已固定:按记录的日期", + "cannotMove": "无法顺延 — 这里的所有工作都已固定", + "fixedLoad": "固定负荷", + "ceiling": "结束日期的最大延误", + "ceilingUnlimited": "无限制", + "ceilingDays_other": "上限 {{count}} 个工作日", + "endUnchanged": "结束日期不变", + "endShift_other": "结束日期 +{{count}} 天", + "requestedVsAchievable": "请求 {{requested}},最接近可实现 {{achievable}}", + "handleLabel": "{{doc}} 的延误上限" + }, + "compute": { + "recalculate": "重新计算", + "auto": "自动分配", + "busy": "正在分配…", + "pressRecompute": "— 按下重新计算", + "degraded": "这是一个较大的概览;仅在按下重新计算时才会计算。" + }, + "stale": { + "rank": "顺序已更改 — 重新计算以查看效果。", + "ceiling": "上限已更改 — 重新计算以查看效果。", + "pin": "固定状态已更改 — 重新计算以查看效果。", + "tool": "工具已更改 — 重新计算以查看效果。", + "edited": "{{docs}} 中发生了变化 — 此方案已不是最新的。" + }, + "shortfall": { + "title": "剩余", + "doc_other": "{{doc}}:{{count}} 项任务无法安排" + }, + "preview": { + "before": "当前", + "after": "分配后", + "capacity": "产能" + }, + "apply": "应用", + "discard": "放弃", + "applyBlockedShortfall": "请先解决缺口——放宽上限,或允许中断。", + "applyBlockedNothing": "没有可应用的内容:此方案不会改变任何东西。", + "applied_other": "已应用于 {{count}} 个项目。", + "undoAll": "全部撤销", + "undoneAll": "已全部撤销。", + "undonePartial": "已撤销,{{docs}} 除外 — 那些项目在此期间已被修改。", + "selectHint": "选择一个存在冲突的资源库项目开始分配。" + }, "color": "颜色" }, "calendar": { diff --git a/src/utils/levelingReasonKey.ts b/src/utils/levelingReasonKey.ts new file mode 100644 index 000000000..46d59b75c --- /dev/null +++ b/src/utils/levelingReasonKey.ts @@ -0,0 +1,33 @@ +import type { LevelingReason } from '@/engine/scheduler/ResourceLeveler'; +import type { DistributionBlockReason } from '@/services/library/distribute'; + +/** + * `LevelingReason` → i18n-sleutel. Eén mapping voor de nivelleerdialoog én de B1c-verdeeldialoog; + * vóór B1c-etappe-3 stond dit als een if/else-keten in `LevelingDialog.tsx` die drie van de zeven + * codes kende en de rest ZONDER uitleg liet — een horizon-uitputting las daar als "onvoldoende + * capaciteit". + * + * `INTRINSIC_OVERRUN` wijst bewust naar de BESTAANDE sleutel `resource.leveling.intrinsicOverrun` + * (niet naar een nieuwe `reason.intrinsicOverrun`) — die sleutel bestaat al in alle veertien locales + * en draagt de interpolatie (resource/peak/capacity) die `LevelingDialog.tsx` er apart bij geeft; + * verplaatsen zou veertien bestanden een key-rename opleggen zonder functionele winst. + * + * Het `satisfies Record` is de poort: een nieuw lid in de taxonomie zonder + * sleutel geeft een COMPILE-fout in plaats van een stilzwijgend lege uitleg. + */ +export const LEVELING_REASON_KEY = { + CALENDAR_MISMATCH: 'resource.leveling.reason.calendarMismatch', + INSUFFICIENT_CAPACITY:'resource.leveling.reason.insufficientCapacity', + INTRINSIC_OVERRUN: 'resource.leveling.intrinsicOverrun', + CEILING_TOO_TIGHT: 'resource.leveling.reason.ceilingTooTight', + CEILING_UNREACHABLE: 'resource.leveling.reason.ceilingUnreachable', + NO_WINDOW_IN_HORIZON: 'resource.leveling.reason.noWindowInHorizon', + RESIDUAL_FULL: 'resource.leveling.reason.residualFull', +} as const satisfies Record; + +/** `DistributionBlockReason` → i18n-sleutel; zelfde `satisfies`-poort. */ +export const DISTRIBUTION_BLOCK_KEY = { + UNCOUNTED_DOCUMENT: 'resource.distribution.blocked.uncounted', + MATERIAL_ITEM: 'resource.distribution.blocked.material', + NO_DEMAND: 'resource.distribution.blocked.noDemand', +} as const satisfies Record; diff --git a/tests/library/tsconfig.check.json b/tests/library/tsconfig.check.json index dbce1b833..911bbd46e 100644 --- a/tests/library/tsconfig.check.json +++ b/tests/library/tsconfig.check.json @@ -22,6 +22,7 @@ "check-ifc-hostile.ts", "check-i18n-plurals.ts", "check-occupancy.ts", + "check-distribute.ts", "check-apply-distribution.ts", "../../src/types/file-system-access.d.ts" ] From f1b659768d05925a3cf8ec1b85f442dee846e608 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:24:41 +0200 Subject: [PATCH 012/108] docs(b1c): plan-taken 4, 5, 6 en 12 bijgewerkt na de merge met main (sessiehistorie) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geen herschrijving — alleen gemarkeerde noten bij wat feitelijk anders is nu `undoStack`/`redoStack` per document vervangen zijn door de app-globale sessiechronologie: de mutatieteller bumpt in main's `beginUndoable`, de scratch-instantie levert geen undo-stap meer op, en het toegepast-record draagt per document een `historyEventId` in plaats van een undo-diepte. Co-Authored-By: Claude Fable 5.1 --- .../2026-08-31-b1c-plan3-schrijfpad-paneel.md | 96 +++++++++++++++---- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md index 55f3e81da..41062436a 100644 --- a/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md +++ b/docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md @@ -687,6 +687,16 @@ voor slapers, en voor het actieve document een monotone mutatieteller die de sto (nieuw, klein — het interne undo-volgnummer zit in een closure en `undoStack.length` is onbruikbaar door `MAX_UNDO`-trimming)."* +> **Aangepast na merge met main (sessiehistorie, 2026-09-04).** `undoStack`/`redoStack` per document +> bestaan niet meer; undo/redo is één app-globale sessiechronologie (`AppState.historyEvents`, +> `src/state/sessionHistory.ts`). De teller blijft even nodig en om dezelfde reden: een +> gecoalesceerde reeks schrijft alleen het `after` van een BESTAAND event bij, dus `historyEvents` +> beweegt dan niet, en `pruneSessionHistory` trimt van onderaf op +> `MAX_SESSION_HISTORY_EVENTS_PER_SCOPE`. De bump staat nu als eerste regel van main's +> `beginUndoable` (vóór de batch-/lease-guard én vóór de pending/nesting-tak); `pushUndoSnapshot` +> is verdwenen. De testfragmenten hieronder zijn in `check-mutation-seq.ts` omgezet naar +> `beginUndoable`/`finishUndoable` + `historyEvents`. + **KEUZE VAN DIT PLAN — de teller alléén is niet genoeg.** In de huidige runtime bumpt `undoSeq` uitsluitend in `pushUndoSnapshot`, en `beginUndoable` slaat die aanroep over zodra een **coalesce-reeks** loopt (een Gantt-sleep, een reeks tikken in een invoerveld). Een hele sleep zou de @@ -845,7 +855,12 @@ for (const f of DOCUMENT_FIELDS) eq(`round-trip: ${f.key}`, /* out[f.key] */, /* // ── Geval 2: de echte acties draaien, met echte undo-semantiek ─────────────────────────────────── // applyLeveling in de scratch-context ⇒ delays geschreven, ÉÉN undo-stap op de EIGEN stack, en // `runCPM` heeft gedraaid (cpmResult vers, scheduleStale false). -eq('één undo-stap op de eigen stack', out.undoStack.length, before.undoStack.length + 1); +// AANGEPAST NA MERGE MET MAIN (2026-09-04): de scratch-context heeft geen eigen undo-STACK meer om +// op te tellen — zijn `historyEvents` worden mét de context weggegooid (zie de noot bij taak 6). Wat +// blijft is dat de ECHTE actie draait: `check-scratch-document.ts` toetst nu de geschreven delay, +// `isDirty`, de verse `cpmResult` en de doorgerekende datums, plus dat een payload per constructie +// geen sessiehistorie kan dragen. +eq('doorgerekend + geschreven', out.payload.tasks.find(t => t.id === 'B')!.levelingDelay, 2); eq('doorgerekend', out.scheduleStale, false); ok('nieuwe datums geschreven', out.tasks.find(t => t.id === 'B')!.time.earlyStart !== beforeStart); @@ -912,8 +927,13 @@ ongewijzigd. * Waarom niet gewoon de payload spreaden zoals `recalculateStaleSleepingDocuments` doet: die route * omzeilt `beginUndoable` en moet `MAX_UNDO`-trimming en coalescing zelf naborgen. Hier draaien de * ECHTE acties op een echte context, dus het documentcontract, de undo-semantiek en de - * transactie-runtime gelden vanzelf — en dat is precies wat "alles terugdraaien" (taak 6) nodig - * heeft: een échte undo-stap op de eigen stack van dat document. + * transactie-runtime gelden vanzelf. + * + * (AANGEPAST NA MERGE MET MAIN, 2026-09-04: hier stond "en dat is precies wat 'alles terugdraaien' + * (taak 6) nodig heeft: een échte undo-stap op de eigen stack van dat document". Dat klopt niet + * meer — de sessiehistorie is app-globaal en die van een scratch-context wordt weggegooid. De + * scratch-instantie blijft nuttig omdat hij de ECHTE acties draait; het history-event registreert + * de aanroeper zelf, zie taak 6.) * * Twee singleton-randen staan dicht (spec §5): * (a) host-events — de context wordt met `emitHostEvents: false` gebouwd, zodat extensies geen @@ -999,6 +1019,33 @@ Co-Authored-By: Claude Fable 5 " document, met per document een échte undo-stap, en de "toegepast"-strook kan alles in één keer terugdraaien. +> **Aangepast na merge met main (sessiehistorie, 2026-09-04).** Wat de spec vraagt verandert niet +> ("per document een gewone undo-stap; 'alles terugdraaien' draait de undo-stap van elk beschreven +> document terug"), maar het mechanisme wel — er is geen `undoStack` per document meer. +> +> - `DistributionApplyRecord.docs[]` draagt in plaats van `undoDepthAfterApply` het +> **`historyEventId`** (+ `historySequence`) van het event dat het toepassen voor dát document +> achterliet. Een diepte is geen identiteit meer: de chronologie is app-globaal, dus een +> bewerking in een ánder document verschuift 'm ook, en `pruneSessionHistory` trimt van onderaf. +> - Voor een SLAPEND document registreert `applyDistribution` dat event zelf, in dezelfde producer +> waarin het de payload terugschrijft: `recordSessionHistoryDeltas(s, 'Verdeling toepassen', +> [{ kind: 'document-data', documentId, before: snapshotOfPayload(oudePayload), +> after: snapshotOfPayload(nieuwePayload) }])`. `snapshotOfPayload` (nieuw in +> `src/state/snapshot.ts`) is de payload-tegenhanger van `createSnapshot`: key-gedreven over +> `DOCUMENT_FIELDS`, dezelfde rolregel, alles per referentie. +> - Voor het ACTIEVE document blijft `get().applyLeveling(write, { scopeTaskIds })` het pad; het +> event wordt daarna teruggezocht als het jongste toegepaste `document-data`-event voor dat +> document met `sequence >= nextHistorySequence-van-vóór-de-aanroep`. Dat `applyLeveling` → +> `runCPM` het `after` van datzelfde event ververst (`refreshLatestDocumentDataHistoryAfter`) is +> gewenst: de doorgerekende datums horen in dezelfde ene undo-stap. +> - `undoDistribution` poort per document op: het event bestaat, staat op `applied`, en is het event +> dat `selectUndoHistoryEvent(historyEvents, docId)` NU zou kiezen. Anders `skippedDocIds`. Het +> actieve document gaat via `get().undo()`; een slaper krijgt de `before`-snapshot over zijn +> payload gespreid (`isDirty: true`, `resourceLoadResult: null`) en zijn event op `undone`, zodat +> redo daarna gewoon via `redo()` loopt zodra je dat document activeert. +> - Een GESLOTEN document valt vanzelf in `skippedDocIds`: `removeSessionHistoryForDocument` heeft +> zijn events dan al opgeruimd. + **Plaatsing:** op `librarySlice` (die is app-globaal, net als de verdeler zelf, en importeert al `services/library`). Er komt **geen** nieuw top-level state-veld bij: het toegepast-record woont in `ui` (taak 8). Zo blijven de contract-asserties in `documentContract.ts` ongewijzigd. @@ -1043,8 +1090,11 @@ eq('out-of-scope delay overleeft Toepassen', sleepingPayload.tasks.find(t => t.i eq('splitGaps geschreven met herkomst', gapTask.splitGaps?.some(g => g.source === 'leveling'), true); // ── Geval 6: per document ÉÉN undo-stap, en gewone Ctrl+Z werkt ───────────────────────────────── -eq('actief: één stap erbij', store.undoStack.length, activeDepthBefore + 1); -eq('slapend: één stap erbij', sleepingPayload.undoStack.length, sleepDepthBefore + 1); +// Sessiehistorie (2026-09-04): geen stack-lengtes meer maar event-tellingen — `historyDepthsFor +// ActiveScope(...).undoDepth` voor het actieve document, en een filter op `document-data`-deltas +// voor de slaper (diens events liggen buiten de actieve scope). +eq('actief: één stap erbij', historyDepthsForActiveScope(store).undoDepth, activeDepthBefore + 1); +eq('slapend: één history-event erbij', eventsFor(sleepDocId, 'applied'), sleepEventsBefore + 1); // Activeer het slapende document en druk gewoon undo: dat draait ALLEEN dat document terug. ok('per-document-undo herstelt dat ene document', /* … */ true); @@ -1125,12 +1175,15 @@ export interface DistributionApplyRecord { docs: Array<{ docId: string; title: string; - /** `undoStack.length` NÁ het toepassen. "Alles terugdraaien" pakt alleen een document waarvan de - * stack nog op precies deze diepte staat — heeft de gebruiker er intussen zelf in gewerkt, dan - * zou blind terugpoppen de VERKEERDE stap ongedaan maken. Dan slaan we dat document over en - * melden we het (`DistributionUndoReport.skippedDocIds`). In de praktijk zeldzaam: §6a laat het - * voorstel al vervallen zodra een betrokken document muteert, en de strook verdwijnt daarmee. */ - undoDepthAfterApply: number; + /** AANGEPAST NA MERGE MET MAIN (2026-09-04): was `undoDepthAfterApply: number`. Nu het + * history-event zelf. "Alles terugdraaien" pakt alleen een document waarvan dit event er nog + * is, nog op `applied` staat, én nog het event is dat een gewone Ctrl+Z voor dat document zou + * kiezen — heeft de gebruiker er intussen zelf in gewerkt, dan zou terugdraaien de VERKEERDE + * stap ongedaan maken. Dan slaan we dat document over en melden we het + * (`DistributionUndoReport.skippedDocIds`). In de praktijk zeldzaam: §6a laat het voorstel al + * vervallen zodra een betrokken document muteert, en de strook verdwijnt daarmee. */ + historyEventId: string; + historySequence: number; }>; } export interface DistributionUndoReport { undoneDocIds: string[]; skippedDocIds: string[] } @@ -1150,11 +1203,19 @@ atomiciteitsgarantie, gespiegeld aan `recalculateStaleSleepingDocuments`): 4. **Fase 2 — één producer:** de nieuwe slaper-payloads in `s.documents[i].payload` zetten. Sla een document over dat intussen gesloten of geactiveerd is (`entry.payload === null`), exact zoals `recalculateStaleSleepingDocuments` dat doet. -5. Bouw en retourneer het record (met `undoStack.length` per document ná het schrijven). +5. Bouw en retourneer het record (met het `historyEventId` per document — 2026-09-04: was + `undoStack.length` ná het schrijven). + +Roep vóór stap 2 `runtime.resetUndoCoalescing()` aan: een lopende coalesce-reeks (Gantt-sleep, +tikken in een invoerveld) zou anders het `after` van dát oudere event bijschrijven in plaats van een +eigen event op te leveren — en dan is er geen event om in het record te zetten. -`undoDistribution`: per document uit het record — het actieve via `get().undo()`, een slaper via -`runInScratchDocument(payload, s => s.undo())` — maar **alleen** wanneer de undo-diepte nog klopt. -Verzamel `undoneDocIds`/`skippedDocIds`. Ook hier: eerst alle scratch-runs, dan één producer. +`undoDistribution`: per document uit het record — het actieve via `get().undo()`, een slaper door de +`before`-snapshot van zijn event over de payload te spreiden en dat event op `undone` te zetten — +maar **alleen** wanneer de poort hierboven slaagt. Verzamel `undoneDocIds`/`skippedDocIds`. Ook hier: +eerst alles puur bepalen, dan één producer. (2026-09-04: was "een slaper via +`runInScratchDocument(payload, s => s.undo())` … wanneer de undo-diepte nog klopt". Een scratch-undo +kan niet meer: de undo-historie van een slaper leeft in de ECHTE store, niet in zijn payload.) - [ ] **Step 4: Draai** @@ -2083,8 +2144,9 @@ stilzwijgend laten staan als dode sleutel. ```ts // B1c (spec §6a): de verdeeldialoog kijkt naar een MOMENTOPNAME van meerdere documenten. Een // documentwissel of een gesloten document maakt zijn tune-state (rangorde/pins/plafonds op docId) - // en zijn toegepast-record onbetrouwbaar: het record verwijst naar undo-diepten van documenten die - // er misschien niet meer zijn. BESLUIT EIGENAAR 2026-08-31: dat betekent hier dat de DIALOOG SLUIT + // en zijn toegepast-record onbetrouwbaar: het record verwijst naar history-events van documenten + // die er misschien niet meer zijn (2026-09-04, aangepast na merge met main: was "undo-diepten" — + // zie de noot bij taak 6; sluiten ruimt die events bovendien op). BESLUIT EIGENAAR 2026-08-31: dat betekent hier dat de DIALOOG SLUIT // (niet dat hij openblijft met een vervallen voorstel — dat was de vraag in de vorige versie van dit // plan, tégen de toen nog gekozen drill-down; met een losse dialoog is sluiten het enige zinnige // gevolg, want er is geen "eronder" om op terug te vallen zoals bij een drill-down). Het From f19a39dfca64ce5ed8b9f8316e14482275fa990a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:30:47 +0200 Subject: [PATCH 013/108] =?UTF-8?q?fix(state):=20scratch-instantie=20krijg?= =?UTF-8?q?t=20zijn=20contextfabriek=20gekoppeld=20i.p.v.=20ge=C3=AFmporte?= =?UTF-8?q?erd=20(B1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify:cycles` vond na de merge een echte importcyclus: appStore → librarySlice → scratchDocument → appStore. Die ontstond doordat `scratchDocument.ts` `createAppStoreContext` als WAARDE importeerde terwijl `librarySlice` (die appStore samenstelt) hem gebruikt. Plan-taak 5 stap 4 voorzag dit geval al en wees de route aan: de fabriek losmaken. Opgelost met hetzelfde patroon als `bindDefaultGridTransactionStore`: een late binding (`bindScratchDocumentContextFactory`) die `appStore.ts` één keer zet, naast de gridtransactie-binding. De typen blijven rechtstreeks geïmporteerd — `import type` wordt geërase en telt niet in de importgraaf, wat precies is waarom `verify:cycles` ná type-erasure meet. Co-Authored-By: Claude Fable 5.1 --- src/state/appStore.ts | 5 +++++ src/state/runtime/scratchDocument.ts | 24 ++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/state/appStore.ts b/src/state/appStore.ts index 87127ebdf..56c88ae80 100644 --- a/src/state/appStore.ts +++ b/src/state/appStore.ts @@ -23,6 +23,7 @@ import { type GridTransactionSlice, } from './gridTransaction'; import { createStoreRuntime, type StoreRuntime, type StoreRuntimeOptions } from './runtime/storeRuntime'; +import { bindScratchDocumentContextFactory } from './runtime/scratchDocument'; // Consumenten blijven ExportFormat uit '@/state/appStore' importeren. export type { ExportFormat } from './slices/fileSlice'; @@ -108,3 +109,7 @@ export function createAppStore(): AppStore { export const appStoreContext = createAppStoreContext(); export const useAppStore = appStoreContext.store; bindDefaultGridTransactionStore(useAppStore.getState, useAppStore.setState); +// De headless scratch-instantie (`runtime/scratchDocument.ts`) bouwt eigen, wegwerpbare contexten. +// Hij krijgt de fabriek hier aangereikt in plaats van 'm te importeren — zie het docblok daar voor +// waarom een directe import een importcyclus zou zijn. +bindScratchDocumentContextFactory(createAppStoreContext); diff --git a/src/state/runtime/scratchDocument.ts b/src/state/runtime/scratchDocument.ts index ef43a04df..d671b5dda 100644 --- a/src/state/runtime/scratchDocument.ts +++ b/src/state/runtime/scratchDocument.ts @@ -29,10 +29,29 @@ * globale registers (extensie-instanties, MCP, bibliotheek-persistentie) worden niet aangeraakt — * die leven buiten de Zustand-factory. */ -import { createAppStoreContext, type AppState, type AppStore } from '../appStore'; +import type { AppState, AppStore, AppStoreContext } from '../appStore'; import { hydratePayload, capturePayload, type DocumentPayload } from '../documentContract'; +import type { StoreRuntimeOptions } from './storeRuntime'; import type { NotifyInput } from '../slices/types'; +/** + * De contextfabriek wordt LAAT gekoppeld in plaats van rechtstreeks geïmporteerd — zelfde patroon + * als `bindDefaultGridTransactionStore` (`state/gridTransaction.ts`), en om dezelfde reden. + * `appStore.ts` importeert `librarySlice`, die deze module importeert; een gewone waarde-import van + * `createAppStoreContext` hier maakt daar een echte importcyclus van + * (`appStore → librarySlice → scratchDocument → appStore`), en `npm run verify:cycles` vangt die — + * terecht: zo'n cyclus werkt alleen zolang de betrokken bindingen gehoist zijn. Typen mogen wél + * rechtstreeks: `import type` wordt geërase en telt niet mee in de importgraaf. + */ +type AppStoreContextFactory = (opts?: StoreRuntimeOptions) => AppStoreContext; +let contextFactory: AppStoreContextFactory | null = null; + +/** Koppel de echte fabriek. Wordt één keer aangeroepen vanuit `appStore.ts`, naast + * `bindDefaultGridTransactionStore`. */ +export function bindScratchDocumentContextFactory(factory: AppStoreContextFactory): void { + contextFactory = factory; +} + export interface ScratchRunResult { /** De payload ná de bewerking — klaar om in `documents[i].payload` gezet te worden. Bij een * mislukking (`ok: false`) is dit de ONGEWIJZIGDE invoerpayload. */ @@ -67,7 +86,8 @@ export function runInScratchDocument( payload: DocumentPayload, fn: (state: AppState) => T, ): ScratchRunResult { - const ctx = createAppStoreContext({ emitHostEvents: false }); + if (!contextFactory) throw new Error('De scratch-documentcontextfabriek is nog niet gekoppeld'); + const ctx = contextFactory({ emitHostEvents: false }); // 1. Hydrateren via het documentcontract — dezelfde functie die `switchDocument` gebruikt, dus // élk (ook toekomstig) documentveld rijdt automatisch mee. ctx.store.setState((s) => { hydratePayload(s, payload); }); From 61d4aee4fab657d67a9e15ef643bbafa95cf6af0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:08:07 +0200 Subject: [PATCH 014/108] test(renderer): mijlpaal-met-duur-check prikt op de getekende balk i.p.v. een vaste x (weekend-flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De fixture hangt aan vandaag: op een weekenddag lag x=20 px vóór de balk en gaf getTaskBarBounds null (gemeten 2026-09-05, groen op 09-04 en 09-07). Pre-existent op main, los van B1c. Co-Authored-By: Claude Fable 5.1 --- tests/planning/check-milestone-duration-render.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/planning/check-milestone-duration-render.ts b/tests/planning/check-milestone-duration-render.ts index ac95aafb9..a4ed65e42 100644 --- a/tests/planning/check-milestone-duration-render.ts +++ b/tests/planning/check-milestone-duration-render.ts @@ -141,10 +141,18 @@ if (renderError === null) { // 3. getTaskBarBounds: mijlpaal-met-duur is sleep-/resize-baar zoals elke balk-taak; de echte // mijlpaal blijft geweigerd (geen visuele breedte om aan te slepen). + // De fixture hangt aan vandaag: op een weekenddag begint de balk van rij 1 pas op de eerstvolgende + // werkdag, waardoor een vaste x=20px vóór de balk lag en getTaskBarBounds null gaf (gemeten + // 2026-09-05, groen op 09-04/09-07). Prik daarom op de daadwerkelijk getekende roundRect van rij 1. const rowMidY = (i: number) => HDRH + i * ROWH + ROWH / 2; - const hitWithDuration = renderer.getTaskBarBounds(TTW + 20, rowMidY(1)); + const withDurationBar = msWithDurationBars[0]; + ok('mijlpaal-met-duur: roundRect beschikbaar om op te prikken', withDurationBar !== undefined); + const hitX = withDurationBar ? withDurationBar.x + Math.min(10, withDurationBar.w / 2) : TTW + 20; + const hitWithDuration = renderer.getTaskBarBounds(hitX, rowMidY(1)); ok(`mijlpaal-met-duur: getTaskBarBounds accepteert haar (kreeg: ${JSON.stringify(hitWithDuration && { edge: hitWithDuration.edge })})`, hitWithDuration !== null && hitWithDuration.task.id === 'ms-met-duur'); - const hitReal = renderer.getTaskBarBounds(TTW + 20, rowMidY(2)); + // De echte mijlpaal moet null blijven — prik op dezelfde x als rij 1 (de ruit staat op de + // startdag) zodat dit niet toevallig slaagt doordat er daar sowieso niets ligt. + const hitReal = renderer.getTaskBarBounds(hitX, rowMidY(2)); ok('echte mijlpaal: getTaskBarBounds weigert haar nog steeds', hitReal === null); } From eb945b3ae7fdbef1b8212c60ea7498573108894a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:51:56 +0200 Subject: [PATCH 015/108] =?UTF-8?q?feat(library):=20verdeeldialoog=20?= =?UTF-8?q?=E2=80=94=20ingang=20vanuit=20de=20conflictregel,=20voorstel=20?= =?UTF-8?q?op=20discrete=20momenten=20(B1c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/App.tsx | 5 + .../DistributionDialog/DistributionDialog.tsx | 316 +++++++++++++++++ .../useDistributionProposal.ts | 321 ++++++++++++++++++ src/components/layout/Ribbon/ribbonConfig.tsx | 12 +- .../panels/ResourceOccupancyView.tsx | 46 ++- src/hooks/keyboard/shortcutRegistry.ts | 2 +- src/services/library/distribute.ts | 9 +- src/services/mcp/tools/runtime.ts | 3 +- src/state/slices/types.ts | 49 +++ src/state/slices/uiSlice.ts | 3 + tests/browser/leveling-distribution.spec.ts | 166 +++++++++ 11 files changed, 920 insertions(+), 12 deletions(-) create mode 100644 src/components/dialogs/DistributionDialog/DistributionDialog.tsx create mode 100644 src/components/dialogs/DistributionDialog/useDistributionProposal.ts create mode 100644 tests/browser/leveling-distribution.spec.ts diff --git a/src/App.tsx b/src/App.tsx index 747aae154..0c9ad872e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,9 @@ const JustUpdatedDialog = lazy(() => import('@/components/dialogs/JustUpdatedDia const ExtensionConsentDialog = lazy(() => import('@/components/dialogs/ExtensionConsentDialog').then(m => ({ default: m.ExtensionConsentDialog }))); const FeedbackDialog = lazy(() => import('@/components/dialogs/FeedbackDialog').then(m => ({ default: m.FeedbackDialog }))); const LevelingDialog = lazy(() => import('@/components/dialogs/LevelingDialog').then(m => ({ default: m.LevelingDialog }))); +// B1c — de verdeeldialoog (spec §7): losse dialoog, patroon `showLevelingDialog`; het +// bezettingsoverzicht blijft er gewoon onder staan. +const DistributionDialog = lazy(() => import('@/components/dialogs/DistributionDialog/DistributionDialog').then(m => ({ default: m.DistributionDialog }))); const BaselineDialog = lazy(() => import('@/components/dialogs/BaselineDialog').then(m => ({ default: m.BaselineDialog }))); const MoveProjectDialog = lazy(() => import('@/components/dialogs/MoveProjectDialog').then(m => ({ default: m.MoveProjectDialog }))); const ColumnsDialog = lazy(() => import('@/components/dialogs/ColumnsDialog').then(m => ({ default: m.ColumnsDialog }))); @@ -87,6 +90,7 @@ function AppContent() { const showResourcePanel = useAppStore(s => s.ui.showResourcePanel); const resourcePanelDocked = useAppStore(s => s.ui.resourcePanelDocked); const showLevelingDialog = useAppStore(s => s.ui.showLevelingDialog); + const showDistributionDialog = useAppStore(s => s.ui.showDistributionDialog); const showBaselineDialog = useAppStore(s => s.ui.showBaselineDialog); const showMoveProjectDialog = useAppStore(s => s.ui.showMoveProjectDialog); const showColumnsDialog = useAppStore(s => s.ui.showColumnsDialog); @@ -337,6 +341,7 @@ function AppContent() { {showStructureDialog && } {showFeedbackDialog && } {showLevelingDialog && } + {showDistributionDialog && } {showBaselineDialog && } {showMoveProjectDialog && } {showColumnsDialog && activeTab !== 'table' && } diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx new file mode 100644 index 000000000..324d18c29 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -0,0 +1,316 @@ +// B1c-plan3 taak 8 — de verdeeldialoog (spec §7 "plek in de UI", §3.4 "discrete rekenmomenten"). +// +// LOSSE DIALOOG, GEEN DRILL-DOWN (besluit eigenaar 2026-08-31). Het patroon is letterlijk dat van +// `LevelingDialog`: een `ui.show*`-vlag, gemount vanuit `App.tsx`, de gedeelde `Dialog` (focus-trap, +// Escape, backdrop) en opgenomen in `hasBlockingDialogOpen()`. Het bezettingsoverzicht blijft +// gewoon zichtbaar ONDER de dialoog — de verdeling is een handeling óp dat overzicht, geen +// vervanging ervan. +// +// WAT HIER (NOG) NIET STAAT. Taak 9 vervangt de minimale strook-regels door de echte fasestroken met +// sleepbare plafondhandles, en vult het voor/na-histogram. Taak 12 bedraadt "Toepassen" op +// `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET REDEN — een knop die er +// niet is laat de gebruiker raden of de functie bestaat. +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronUp, X } from 'lucide-react'; +import { useAppStore } from '@/state/appStore'; +import { Dialog } from '@/components/common/Dialog'; +import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; +import { planDistributionWrites } from '@/services/library/applyDistribution'; +import { documentFloatOn, useDistributionProposal } from './useDistributionProposal'; + +export function DistributionDialog() { + const { t, i18n } = useTranslation('common'); + const tune = useAppStore(s => s.ui.levelingDistribution); + const pools = useAppStore(s => s.pools); + const setUI = useAppStore(s => s.setUI); + + const { proposal, busy, staleReason, lastStaleReason, degraded, recompute, inputs } = + useDistributionProposal(tune); + + const close = () => setUI({ showDistributionDialog: false }); + + const poolItem = tune ? pools[tune.companyId]?.resources.find(r => r.id === tune.libraryItemId) : undefined; + const itemName = poolItem?.name || tune?.libraryItemId || ''; + + const numberFmt = useMemo( + () => new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }), + [i18n.language], + ); + + // De rangordelijst leest UITSLUITEND uit `tune.order` — de bron van waarheid voor de rangorde. + // Documenten die na het openen zijn bijgekomen sluiten achteraan aan (zie `buildDistributionInputs`). + const rankRows = useMemo(() => { + if (!tune) return []; + const byId = new Map(inputs.map(doc => [doc.docId, doc])); + const ordered = [...tune.order.filter(id => byId.has(id)), ...inputs.map(d => d.docId).filter(id => !tune.order.includes(id))]; + return ordered.map(docId => { + const doc = byId.get(docId)!; + return { docId, title: doc.title, float: documentFloatOn(doc, tune.companyId, tune.libraryItemId) }; + }); + }, [tune, inputs]); + + const move = (docId: string, delta: -1 | 1) => { + if (!tune) return; + const order = rankRows.map(row => row.docId); + const from = order.indexOf(docId); + const to = from + delta; + if (from < 0 || to < 0 || to >= order.length) return; + [order[from], order[to]] = [order[to], order[from]]; + setUI({ levelingDistribution: { ...tune, order } }); + }; + + const toggleSplits = () => { + if (!tune) return; + setUI({ levelingDistribution: { ...tune, allowSplits: !tune.allowSplits } }); + }; + + // Waarom Toepassen (nog) uit staat. De eerste drie redenen zijn ECHT en blijven na taak 12 + // bestaan; de vierde is de tijdelijke: het schrijfpad is er (taak 6) maar wordt pas in taak 12 + // bedraad. Er is bewust geen eigen sleutel voor die vierde toestand aangemaakt — hij verdwijnt + // in taak 12 en zou dan veertien locales met een dode sleutel achterlaten. + const applyBlockReason = useMemo(() => { + if (!proposal) return t('resource.distribution.compute.pressRecompute'); + if (proposal.blocked) return t(DISTRIBUTION_BLOCK_KEY[proposal.blocked.reason]); + const plan = planDistributionWrites(proposal, {}); + if (!plan.ok && plan.reason === 'shortfall') return t('resource.distribution.applyBlockedShortfall'); + return t('resource.distribution.applyBlockedNothing'); + }, [proposal, t]); + + const blockedDocTitles = useMemo(() => { + if (!proposal?.blocked) return ''; + const byId = new Map(inputs.map(doc => [doc.docId, doc.title])); + return proposal.blocked.docIds.map(id => byId.get(id) ?? id).join(', '); + }, [proposal, inputs]); + + const showStale = staleReason !== null || busy; + + return ( + + {/* (1) Kop */} +
+
+
+ {tune ? itemName : t('resource.distribution.title')} +
+
+ {tune ? t('resource.distribution.subtitle', { item: itemName }) : t('resource.distribution.selectHint')} +
+
+ +
+ +
+ {!tune ? ( +
+ {t('resource.distribution.selectHint')} +
+ ) : proposal?.blocked ? ( + /* (2) Blokkade — één vorm voor alle drie de redenen (§3.1: nooit een stille uitsluiting). */ +
+ {t(DISTRIBUTION_BLOCK_KEY[proposal.blocked.reason], { docs: blockedDocTitles })} +
+ ) : ( + <> + {/* (3) Gereedschap — schakelaar met prijskaartje. De prijs is `computeDistribution` + opnieuw draaien met de andere stand (§6); tot taak 9 die vergelijking maakt staat er + eerlijk "prijs onbekend". */} +
+ + {t('resource.distribution.tool.title')} + +
+ + {t('resource.distribution.tool.allowSplits')} + + {t('resource.distribution.tool.priceUnknown')} + +
+ {t('resource.distribution.tool.allowSplitsHint')} +
+ + {/* (4) Rangorde */} +
+ + {t('resource.distribution.rank.title')} + + {t('resource.distribution.rank.hint')} +
+ {rankRows.map((row, index) => ( +
+ {index + 1} + {row.title} + + {t('resource.distribution.rank.float', { + days: row.float === null ? '—' : numberFmt.format(row.float), + })} + + + +
+ ))} +
+
+ + {/* (5) Fasestroken — taak 9 vult ze; hier alleen de regel per document met het effect. */} +
+ {(proposal?.docs ?? []).map(doc => ( +
+ {doc.title} + {!doc.participated && ( + + {doc.pinnedReason === 'dates-as-recorded' + ? t('resource.distribution.strip.pinnedRecorded') + : t('resource.distribution.strip.pinned')} + + )} + {doc.cannotMove && ( + {t('resource.distribution.strip.cannotMove')} + )} + + {doc.endShiftWorkdays === 0 + ? t('resource.distribution.strip.endUnchanged') + : t('resource.distribution.strip.endShift', { count: doc.endShiftWorkdays })} + +
+ ))} +
+ + {/* (6) Voor/na-histogram — plaatshouder; taak 9 tekent hem. */} +
+ {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} +
+ + {/* Tekorten (§4 stap 3): een geldige preview, maar Toepassen blijft uit. */} + {proposal?.hasShortfall && ( +
+ + {t('resource.distribution.shortfall.title')} + + {proposal.docs.filter(doc => doc.shortfalls.length > 0).map(doc => ( + + {t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.shortfalls.length })} + + ))} +
+ )} + + )} +
+ + {/* (7) Stale-strook + knoppenbalk */} +
+ {degraded && ( +
+ {t('resource.distribution.compute.degraded')} +
+ )} + {showStale && ( +
+ {staleReason + ? `${t(`resource.distribution.stale.${staleReason}`)}${busy ? ` ${t('resource.distribution.compute.busy')}` : degraded ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''}` + : t('resource.distribution.compute.busy')} +
+ )} +
+ + + {applyBlockReason} + + + +
+
+
+ ); +} diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts new file mode 100644 index 000000000..049660d60 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -0,0 +1,321 @@ +// B1c-plan3 taak 8 — de voorstel-hook van de verdeeldialoog (spec §3.4). +// +// DRIE DINGEN, EN NIETS MEER. (1) De invoer bouwen: van de open documenten naar +// `DistributionDocInput[]`, met de VOLLEDIGE payloadvelden. (2) Het voorstel op DISCRETE momenten +// rekenen, met een zichtbare bezig-toestand. (3) De schaal-degradatie: op een groot overzicht +// rekent alleen "Herbereken" nog. +// +// WAAROM DISCREET EN NIET REACTIEF (§3.4). `computeDistribution` draait per deelnemend document een +// volledige CPM-solve plus een nivelleerpass. Dat is geen render-werk. De hook abonneert zich daarom +// bewust NIET op de documentinhoud: hij leest de store pas op het moment dat er echt gerekend wordt +// (`useAppStore.getState()` binnen de run). Dat is dezelfde keuze als `runCPM` zelf — handmatig, niet +// reactief — en het is de reden dat er een stale-strook bestaat: het voorstel MAG achterlopen op de +// documenten, mits de gebruiker dat ziet. +// +// DE BEZIG-TOESTAND IS ECHT, GEEN COSMETICA. `setBusy(true)` gebeurt synchroon, het rekenen pas in +// een `setTimeout(…, 0)` daarna — anders blokkeert de synchrone solve de paint en ziet de gebruiker +// nooit dat er iets gebeurt. Er loopt er precies één tegelijk; een verzoek dat tijdens een run +// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer — een sleepbeweging over de +// plafondhandle in taak 9 levert anders een wachtrij die minutenlang naloopt). +// +// SCHAAL-DEGRADATIE (§3.4). Boven `MAX_TASKS_AUTO` taken in één deelnemend document of +// `MAX_BOOKING_TASKS_AUTO` boekende taken op dít poolitem is automatisch doorrekenen bij elke +// tune-wijziging niet meer te doen. Dan blijft alleen de EXPLICIETE route over: het openen (één keer) +// en "Herbereken". De dialoog toont dat met `compute.degraded` + `compute.pressRecompute`; de +// stale-strook blijft dan gewoon staan tot de gebruiker drukt — precies de bedoeling. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppStore, type AppState } from '@/state/appStore'; +import type { DistributionUiState } from '@/state/slices/types'; +import { + computeDistribution, + scopeTaskIdsFor, + type DistributionDocInput, + type DistributionProposal, +} from '@/services/library/distribute'; +import { documentTitle, untitledOrdinals, displayDocumentTitle } from '@/utils/documents'; + +/** Meer taken dan dit in één deelnemend document ⇒ handmatig herberekenen (§3.4). */ +export const MAX_TASKS_AUTO = 1000; +/** Meer taken dan dit die op dít poolitem boeken ⇒ handmatig herberekenen (§3.4). */ +export const MAX_BOOKING_TASKS_AUTO = 40; + +/** Waarom het huidige voorstel niet meer actueel is — 1-op-1 de `resource.distribution.stale.*` + * sleutels. `'edited'` (een document is intussen gewijzigd) hoort bij de vingerafdruk-invalidatie + * van taak 10 en wordt hier nog niet gezet; de sleutel bestaat wel. */ +export type DistributionStaleReason = 'rank' | 'ceiling' | 'pin' | 'tool' | 'edited'; + +export interface DistributionProposalState { + /** Het laatst berekende voorstel, of `null` zolang er nog nooit gerekend is. */ + proposal: DistributionProposal | null; + /** Er loopt een berekening (§3.4 bezig-toestand). */ + busy: boolean; + /** Niet-null ⇒ het voorstel hoort niet meer bij de bediening; de dialoog toont de reden. */ + staleReason: DistributionStaleReason | null; + /** De LAATSTE reden sinds het openen, ook nadat de strook alweer verdwenen is. Puur voor + * observeerbaarheid (browsertest): een automatische herberekening op een klein project is + * binnen één macrotask klaar, dus de strook zelf is dan niet betrouwbaar te vangen. */ + lastStaleReason: DistributionStaleReason | null; + /** Te groot om automatisch door te rekenen (§3.4). */ + degraded: boolean; + /** De expliciete route: "Verdeel automatisch" / "Herbereken". */ + recompute: () => void; + /** De invoer waarop het HUIDIGE voorstel gerekend is — de dialoog leest hier de documenttitels + * en de float per document uit, zodat rangordelijst en strook nooit uit een andere bron komen. */ + inputs: DistributionDocInput[]; +} + +/** + * Bouw de `DistributionDocInput` van élk open document, met de VOLLEDIGE payloadvelden. + * + * DIT IS DE SCHERPSTE VAL VAN DEZE TAAK. `ResourceOccupancyView` voedt `computeLibraryOccupancy` + * bewust met een bibliotheek-SNIT (§7-cache): alleen de resources/toewijzingen/taken die op deze + * pool boeken. Voor een AGGREGATIE is dat aantoonbaar betekenis-behoudend. Voor een VERDELING niet: + * `computeDistribution` draait per document een echte CPM-solve plus nivellering, en die zien met een + * gesnoeide takenlijst en zonder relaties een totaal andere planning dan `runCPM` in dat document. + * Hier gaan dus de hele `tasks`/`sequences`/`resources`/`assignments` mee — referenties, geen + * kopieën; de kosten vallen pas bij een echte solve, en die kloont zelf. + * + * Het ACTIEVE document leeft top-level (`s.project`/`s.tasks`/…), de slapers in `payload`. + */ +export function buildDistributionInputs( + s: AppState, + untitledLabel: string, + tune: Pick, +): DistributionDocInput[] { + const payloads = s.documents.map(document => ( + document.id === s.activeDocumentId + ? { + id: document.id, + payload: { + project: s.project, filePath: s.filePath, + resources: s.resources, assignments: s.assignments, tasks: s.tasks, + sequences: s.sequences, calendar: s.calendar, calendars: s.calendars, + scheduleStale: s.scheduleStale, datesAsRecorded: s.datesAsRecorded, + }, + } + : { + id: document.id, + payload: { + project: document.payload!.project, filePath: document.payload!.filePath, + resources: document.payload!.resources, assignments: document.payload!.assignments, + tasks: document.payload!.tasks, sequences: document.payload!.sequences, + calendar: document.payload!.calendar, calendars: document.payload!.calendars, + scheduleStale: document.payload!.scheduleStale, + datesAsRecorded: document.payload!.datesAsRecorded, + }, + } + )); + // Zelfde titel-afleiding als de tabbladen en het bezettingsoverzicht (rauwe titel → volgnummer + // voor naamloze documenten → vertaald label eromheen). + const rawTitles = payloads.map(({ payload }) => documentTitle(payload.filePath, payload.project.name)); + const ordinals = untitledOrdinals(rawTitles); + return payloads.map(({ id, payload }, i) => ({ + docId: id, + title: displayDocumentTitle(rawTitles[i], ordinals[i], untitledLabel), + scheduleStale: payload.scheduleStale, + companyId: payload.project.companyId ?? null, + resources: payload.resources, + assignments: payload.assignments, + tasks: payload.tasks, + calendar: payload.calendar, + calendars: payload.calendars, + solveInput: { + tasks: payload.tasks, sequences: payload.sequences, dataDate: payload.project.statusDate, + progressMode: payload.project.progressMode, schedulingOptions: payload.project.schedulingOptions, + }, + levelInput: { + tasks: payload.tasks, sequences: payload.sequences, dataDate: payload.project.statusDate, + progressMode: payload.project.progressMode, schedulingOptions: payload.project.schedulingOptions, + }, + // Een document dat de gebruiker nog niet in de rangorde heeft gezien (`indexOf` = -1) krijgt + // rang 0 en plaatst dus vóór de rest — bewust: dat kan alleen een document zijn dat ná het + // openen is bijgekomen, en dat is per definitie het minst "ingedeeld". + rank: tune.order.indexOf(id) + 1, + pinned: tune.pinned[id] === true, + datesAsRecorded: payload.datesAsRecorded, + ceilingWorkdays: tune.ceilings[id] ?? null, + })); +} + +/** + * De startvolgorde bij openen (§4 stap 1): float-gesorteerd. De float van een DOCUMENT is de + * KLEINSTE totale float over zijn taken die op dit poolitem boeken — dus het document met de minste + * speling staat bovenaan en wordt het meest ontzien. Documenten zonder boekende taak sluiten + * achteraan aan; gelijke float breekt stabiel op `docId`. + */ +export function documentFloatOn( + doc: DistributionDocInput, + companyId: string, + libraryItemId: string, +): number | null { + const scope = new Set(scopeTaskIdsFor(doc, companyId, libraryItemId)); + if (scope.size === 0) return null; + let min: number | null = null; + for (const task of doc.tasks) { + if (!scope.has(task.id)) continue; + const float = task.time.totalFloat; + if (typeof float !== 'number' || Number.isNaN(float)) continue; + if (min === null || float < min) min = float; + } + return min; +} + +/** `documentFloatOn` over alle documenten → de docIds in startvolgorde. */ +export function floatSortedOrder( + inputs: DistributionDocInput[], + companyId: string, + libraryItemId: string, +): string[] { + return inputs + .map(doc => ({ docId: doc.docId, float: documentFloatOn(doc, companyId, libraryItemId) })) + .sort((a, b) => { + if (a.float === null && b.float === null) return a.docId.localeCompare(b.docId); + if (a.float === null) return 1; + if (b.float === null) return -1; + return (a.float - b.float) || a.docId.localeCompare(b.docId); + }) + .map(entry => entry.docId); +} + +/** + * Verse tune-state voor één poolitem: float-gesorteerde rangorde, niets gepind, geen plafonds, + * niets toegepast. Gedeeld door de ingang in het bezettingsoverzicht en de lintknop, zodat er maar + * één definitie van "openen op dit item" bestaat. + */ +export function freshDistributionUi( + s: AppState, + untitledLabel: string, + companyId: string, + libraryItemId: string, +): DistributionUiState { + const inputs = buildDistributionInputs(s, untitledLabel, { order: [], pinned: {}, ceilings: {} }); + return { + companyId, + libraryItemId, + allowSplits: false, + order: floatSortedOrder(inputs, companyId, libraryItemId), + pinned: {}, + ceilings: {}, + applied: null, + }; +} + +/** Welke tune-as is er veranderd? Volgorde van benoemen = volgorde van de spec-taxonomie. */ +function diffReason( + prev: DistributionUiState, + next: DistributionUiState, +): DistributionStaleReason | null { + if (prev.allowSplits !== next.allowSplits) return 'tool'; + if (prev.order.join('') !== next.order.join('')) return 'rank'; + if (JSON.stringify(prev.pinned) !== JSON.stringify(next.pinned)) return 'pin'; + if (JSON.stringify(prev.ceilings) !== JSON.stringify(next.ceilings)) return 'ceiling'; + return null; +} + +/** + * De voorstel-hook. `tune` is `ui.levelingDistribution`; is die `null` (de dialoog staat open zonder + * gekozen poolitem — de `selectHint`-toestand), dan rekent de hook niets. + */ +export function useDistributionProposal(tune: DistributionUiState | null): DistributionProposalState { + const { t } = useTranslation('common'); + const untitledLabel = t('project.untitled'); + + const [proposal, setProposal] = useState(null); + const [inputs, setInputs] = useState([]); + const [busy, setBusy] = useState(false); + const [staleReason, setStaleReason] = useState(null); + const [lastStaleReason, setLastStaleReason] = useState(null); + + // De tune-state ALS REF, zodat de run-callback stabiel blijft (hij mag niet bij elke + // rangorde-wijziging opnieuw gemaakt worden — dat zou de in-flight-bewaking om zeep helpen). + const tuneRef = useRef(tune); + tuneRef.current = tune; + + // Het vertaalde label mag de run-callback niet instabiel maken; een taalwissel tijdens een + // openstaande dialoog landt vanzelf in de volgende run. + const untitledLabelRef = useRef(untitledLabel); + untitledLabelRef.current = untitledLabel; + + const busyRef = useRef(false); + const pendingRef = useRef(false); + const runRef = useRef<() => void>(() => {}); + + runRef.current = () => { + const current = tuneRef.current; + if (!current) return; + if (busyRef.current) { pendingRef.current = true; return; } + busyRef.current = true; + setBusy(true); + // Eerst de paint (bezig-toestand), dán het echte rekenwerk — zie het moduleblok. + setTimeout(() => { + try { + const s = useAppStore.getState(); + const pool = s.pools[current.companyId]; + const built = buildDistributionInputs(s, untitledLabelRef.current, current); + setInputs(built); + setProposal(pool + ? computeDistribution(current.companyId, pool, current.libraryItemId, built, { + allowSplits: current.allowSplits, + }) + : null); + setStaleReason(null); + } finally { + busyRef.current = false; + setBusy(false); + if (pendingRef.current) { pendingRef.current = false; runRef.current(); } + } + }, 0); + }; + + const recompute = useCallback(() => { runRef.current(); }, []); + + // Schaal-degradatie (§3.4): gemeten op de invoer van het LAATSTE voorstel — vóór de eerste run is + // er niets te degraderen (die run is immers het openen zelf, altijd een expliciet moment). + const degraded = useMemo(() => { + if (!tune || inputs.length === 0) return false; + for (const doc of inputs) { + if (doc.tasks.length > MAX_TASKS_AUTO) return true; + if (scopeTaskIdsFor(doc, tune.companyId, tune.libraryItemId).length > MAX_BOOKING_TASKS_AUTO) return true; + } + return false; + }, [inputs, tune]); + const degradedRef = useRef(degraded); + degradedRef.current = degraded; + + // Rekenmoment 1: OPENEN, en het wisselen naar een ander poolitem. Beide zijn "een nieuw onderwerp", + // dus het oude voorstel gaat weg vóór de nieuwe run. + const subjectKey = tune ? `${tune.companyId}${tune.libraryItemId}` : null; + const lastSubjectRef = useRef(null); + const lastTuneRef = useRef(null); + useEffect(() => { + if (subjectKey === lastSubjectRef.current) return; + lastSubjectRef.current = subjectKey; + lastTuneRef.current = tune; + setProposal(null); + setInputs([]); + setStaleReason(null); + setLastStaleReason(null); + if (subjectKey !== null) runRef.current(); + // `tune` bewust buiten de deps: alleen het ONDERWERP is hier de trigger, niet elke tune-tik. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subjectKey]); + + // Rekenmoment 2: een PIN-, RANGORDE-, PLAFOND- of GEREEDSCHAPSwijziging. Die zet eerst de reden + // (zodat de gebruiker ziet wat er is veranderd) en plant meteen de herberekening; de strook blijft + // staan tot die run klaar is. Op een gedegradeerd overzicht wordt er NIET automatisch gerekend — + // dan blijft de strook staan tot "Herbereken". + useEffect(() => { + if (!tune) return; + const prev = lastTuneRef.current; + lastTuneRef.current = tune; + if (!prev || prev.companyId !== tune.companyId || prev.libraryItemId !== tune.libraryItemId) return; + const reason = diffReason(prev, tune); + if (reason === null) return; + setStaleReason(reason); + setLastStaleReason(reason); + if (!degradedRef.current) runRef.current(); + }, [tune]); + + return { proposal, busy, staleReason, lastStaleReason, degraded, recompute, inputs }; +} diff --git a/src/components/layout/Ribbon/ribbonConfig.tsx b/src/components/layout/Ribbon/ribbonConfig.tsx index 3e280054d..6b703a7d8 100644 --- a/src/components/layout/Ribbon/ribbonConfig.tsx +++ b/src/components/layout/Ribbon/ribbonConfig.tsx @@ -7,7 +7,7 @@ import { Eye, EyeOff, SaveAll, Tags, ListOrdered, Hash, IndentIncrease, IndentDecrease, - Users, BarChart3, Scale, Eraser, ChevronLeft, ChevronRight, + Users, BarChart3, Scale, Eraser, ChevronLeft, ChevronRight, Split, ArrowLeftToLine, ArrowRightToLine, LayoutGrid, TrendingUp, CalendarDays, Palette, Keyboard, PanelRight, CalendarClock, ChevronsDownUp, ChevronsUpDown, Columns3, AlertTriangle, @@ -580,6 +580,16 @@ const resourcesTab: RibbonTabConfig = [ kind: 'button', id: 'levelResources', icon: , labelKey: 'menu:ribbon.levelResourcesDialog', use: () => { const setUI = useAppStore(s => s.setUI); return { onClick: () => setUI({ showLevelingDialog: true }) }; }, }, + { + // B1c-plan3 taak 8 — de verdeeldialoog (spec §7). Opent op het LAATST geopende poolitem + // (`ui.levelingDistribution` overleeft een sluiting); is er nog nooit een item gekozen, dan + // opent de dialoog met `selectHint` en verwijst hij naar het bezettingsoverzicht. Bewust + // GEEN `disabled` in dat geval: de knop is de plek waar je leert dat deze functie bestaat. + // `labelKey` staat in de `common`-namespace omdat het letterlijk dezelfde tekst is als de + // ingang in het bezettingsoverzicht — één sleutel, geen tweede vertaling die kan afwijken. + kind: 'button', id: 'distributeOverProjects', icon: , labelKey: 'common:resource.distribution.open', + use: () => { const setUI = useAppStore(s => s.setUI); return { onClick: () => setUI({ showDistributionDialog: true }) }; }, + }, { kind: 'button', id: 'clearLeveling', icon: , labelKey: 'menu:ribbon.clearLeveling', use: () => { diff --git a/src/components/panels/ResourceOccupancyView.tsx b/src/components/panels/ResourceOccupancyView.tsx index af42d4179..c965f3c25 100644 --- a/src/components/panels/ResourceOccupancyView.tsx +++ b/src/components/panels/ResourceOccupancyView.tsx @@ -12,6 +12,7 @@ import { type OccupancyRow, } from '@/services/library/occupancy'; import { documentTitle, untitledOrdinals, displayDocumentTitle, DOC_PALETTE } from '@/utils/documents'; +import { freshDistributionUi } from '@/components/dialogs/DistributionDialog/useDistributionProposal'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; import { parseDate, formatDate, addCalendarDays, diffDays } from '@/utils/dateUtils'; @@ -172,6 +173,7 @@ export function ResourceOccupancyView({ companyId, pool }: { companyId: string; const autoCalcCPM = useAppStore(s => s.ui.autoCalcCPM); const activeDocumentId = useAppStore(s => s.activeDocumentId); const recalculateStaleSleepingDocuments = useAppStore(s => s.recalculateStaleSleepingDocuments); + const setUI = useAppStore(s => s.setUI); const untitledLabel = t('project.untitled'); @@ -468,13 +470,43 @@ export function ResourceOccupancyView({ companyId, pool }: { companyId: string; {hasConflict && ( - - {t('resource.occupancy.conflictDays', { count: row.conflictDays.length })} - +
+ + {t('resource.occupancy.conflictDays', { count: row.conflictDays.length })} + + {/* B1c-plan3 taak 8 — de INGANG van de verdeelflow (spec §7). Alleen bij een + echt conflict: zonder conflict valt er niets te verdelen. Bestaat er al + tune-state voor DIT poolitem, dan blijft die staan (inclusief de + "toegepast"-strook uit taak 6) en wordt alleen de dialoog geopend; een + ANDER item krijgt verse, float-gesorteerde tune-state. + `stopPropagation`: de rij-klik selecteert de chart eronder — dat mag een + klik op deze knop niet ook doen. */} + +
)} diff --git a/src/hooks/keyboard/shortcutRegistry.ts b/src/hooks/keyboard/shortcutRegistry.ts index 42b2140bf..511aa079e 100644 --- a/src/hooks/keyboard/shortcutRegistry.ts +++ b/src/hooks/keyboard/shortcutRegistry.ts @@ -72,7 +72,7 @@ export function hasBlockingDialogOpen(ui: UIState = useAppStore.getState().ui): ui.showTaskDialog || ui.showProjectSettings || ui.showProjectInfoDialog || ui.showSettingsDialog || ui.showCalendarDialog || ui.showUpdateDialog || ui.showNewProjectDialog || ui.showFeedbackDialog || ui.showStructureDialog || - ui.showLevelingDialog || ui.showBaselineDialog || ui.showColumnsDialog || + ui.showLevelingDialog || ui.showDistributionDialog || ui.showBaselineDialog || ui.showColumnsDialog || ui.showFilterDialog || ui.showLayoutsDialog || ui.showProjectOverview || ui.presentationMode || ui.showTourOverlay || ui.showWelcomeDialog || // K-item 38: de toestemmingsvraag bij een extensie-installatie is net zo goed modaal — hij diff --git a/src/services/library/distribute.ts b/src/services/library/distribute.ts index 7eebc7acd..e5d90cfde 100644 --- a/src/services/library/distribute.ts +++ b/src/services/library/distribute.ts @@ -217,8 +217,13 @@ function endShiftWorkdays(before: string, after: string, calendar: WorkCalendar) /** Alle taak-id's in dit document die via een resource stempel naar `libraryItemId` (in `companyId`) * daadwerkelijk op deze pool boeken — de `scopeTaskIds` die de motor scope-behoudend nivelleert - * (B1c-plan-2 taak 3), zodat taken die niets met dit poolitem te maken hebben nooit verschuiven. */ -function scopeTaskIdsFor(doc: DistributionDocInput, companyId: string, libraryItemId: string): string[] { + * (B1c-plan-2 taak 3), zodat taken die niets met dit poolitem te maken hebben nooit verschuiven. + * + * GEËXPORTEERD (B1c-plan3 taak 8) omdat de verdeeldialoog exact dezelfde snit nodig heeft: de + * startvolgorde is float-gesorteerd over precies de BOEKENDE taken van elk document (§4 stap 1), + * en het schrijfpad (taak 6/12) geeft dezelfde scope door aan `applyDistribution`. Een tweede, + * handgeschreven versie in de UI zou stilzwijgend van deze kunnen afwijken. */ +export function scopeTaskIdsFor(doc: DistributionDocInput, companyId: string, libraryItemId: string): string[] { const stampedResourceIds = new Set( doc.resources .filter(r => r.libraryOrigin !== undefined diff --git a/src/services/mcp/tools/runtime.ts b/src/services/mcp/tools/runtime.ts index efb5b9430..67afb374e 100644 --- a/src/services/mcp/tools/runtime.ts +++ b/src/services/mcp/tools/runtime.ts @@ -110,7 +110,8 @@ export function buildEnvelope(ctx: McpContext): McpEnvelope { const BLOCKING_UI_FLAGS = [ 'showTaskDialog', 'showProjectSettings', 'showProjectInfoDialog', 'showSettingsDialog', 'showCalendarDialog', 'showUpdateDialog', 'showNewProjectDialog', 'showFeedbackDialog', - 'showStructureDialog', 'showLevelingDialog', 'showBaselineDialog', 'showColumnsDialog', + 'showStructureDialog', 'showLevelingDialog', 'showDistributionDialog', 'showBaselineDialog', + 'showColumnsDialog', 'showFilterDialog', 'showLayoutsDialog', 'showProjectOverview', 'presentationMode', 'showTourOverlay', 'showWelcomeDialog', ] as const; diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 30cd8deb2..8a0ddbde3 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -33,6 +33,10 @@ export type { BarColorSelection }; import type { McpServerStatus } from '@/services/mcp/contracts'; export type { McpServerStatus }; +// B1c — het toegepast-record van de verdeeldialoog. Type-only import: geen runtime-cyclus (na +// type-erasure blijft er niets van over, zie `verify:cycles`). +import type { DistributionApplyRecord } from '@/services/library/applyDistribution'; + export type WeekStartDay = 'monday' | 'sunday'; // Fase 2.10 (golf 1, sneltoetsen-fundament §"Nieuwe store-acties"): richting voor @@ -231,6 +235,30 @@ export type NotifyInput = Omit; /** Een gridprepare verzamelt meldingen in deze vorm en toont ze pas ná een geslaagde commit. */ export type DeferredNotification = NotifyInput; +/** + * B1c — de tune-state van de verdeeldialoog (spec §6): welk poolitem er verdeeld wordt, in welke + * rangorde, met welke pins en plafonds, en met welk gereedschap. Zie `UIState.levelingDistribution` + * voor de levensduur; het VOORSTEL is afgeleide data en staat hier bewust niet in. + */ +export interface DistributionUiState { + /** De bibliotheek (`companyId`) waarin het poolitem leeft. */ + companyId: string; + /** Het poolitem waarvan de boeking verdeeld wordt. Een ANDER item ⇒ verse tune-state. */ + libraryItemId: string; + /** "Onderbrekingen toestaan" (§4 stap 0 / §11.4) — het gereedschap. */ + allowSplits: boolean; + /** docIds in rangorde; nr. 1 (index 0) wordt het MEEST ontzien en plaatst als eerste. + * Startvolgorde bij openen: float-gesorteerd (§4 stap 1). */ + order: string[]; + /** docId → gepind (§6: bevriest einddatum ÉN werkdagen; telt als vaste last). */ + pinned: Record; + /** docId → "maximale uitloop van de einddatum" in werkdagen; `null`/afwezig = onbegrensd. */ + ceilings: Record; + /** Het laatst TOEGEPASTE record (B1c-plan3 taak 6), of `null`. Overleeft een sluiting van de + * dialoog, zodat opnieuw openen de "toegepast"-strook met "Alles terugdraaien" nog toont. */ + applied: DistributionApplyRecord | null; +} + export interface UIState { showTaskDialog: boolean; editingTaskId: string | null; @@ -390,6 +418,27 @@ export interface UIState { * of 'occupancy' (B1b: bezetting van de bibliotheek over álle open documenten — leesvenster). * Default afgeleid: bij inhoud in de pool 'company', anders 'project' (spec §4). */ resourcesView: 'company' | 'project' | 'occupancy'; + /** session — B1c (spec §7, besluit eigenaar 2026-08-31): de VERDEELDIALOOG staat open. Losse + * dialoog volgens het `showLevelingDialog`-patroon (gedeelde `Dialog` met focus-trap, gemount + * vanuit `App.tsx`, opgenomen in `hasBlockingDialogOpen()` en de MCP-blokkeerlijst) — géén + * drill-down binnen het bezettingsoverzicht, dat blijft gewoon zichtbaar eronder. + * + * BEWUST GESCHEIDEN van `levelingDistribution` hieronder: de open/dicht-vlag mag een sluiting + * NIET overleven, de tune-state juist wél. */ + showDistributionDialog: boolean; + /** session — B1c: de tune-state van de verdeeldialoog (welk poolitem, rangorde, pins, plafonds, + * gereedschap) plus het laatst toegepaste record. App-globaal, NIET per document (de verdeling + * gaat per definitie ÓVER documenten heen) en dus NIET in `DOCUMENT_FIELDS`; ook niet in + * `settingsRegistry` — het is sessiestate, geen instelling. + * + * Overleeft het SLUITEN van de dialoog: opnieuw openen op dezelfde conflictregel toont de + * eerder gekozen rangorde/plafonds én de "toegepast"-strook terug. Leeggemaakt door precies + * twee dingen: het kiezen van een ÁNDER poolitem (dan is de tune-state betekenisloos) en + * `resetDocumentScopedUI` bij een documentwissel (B1c-plan3 taak 12, spec §6a). + * + * Het VOORSTEL zelf staat hier bewust niet in: dat is afgeleide data (een pure functie van deze + * tune-state + de documenten) en woont in de componentstate van `useDistributionProposal`. */ + levelingDistribution: DistributionUiState | null; /** session — eenmalig verzoek (issue #48-1) om in het resource-paneel een CONCEPT-rij te openen, * i.p.v. meteen een naamloze resource te persisteren. Gezet door de lintknop "Nieuwe resource", * geconsumeerd (en direct weer op false gezet) door `ResourcePanel`, dat er zijn lokale diff --git a/src/state/slices/uiSlice.ts b/src/state/slices/uiSlice.ts index c6b437f62..92e62e85a 100644 --- a/src/state/slices/uiSlice.ts +++ b/src/state/slices/uiSlice.ts @@ -160,6 +160,9 @@ export function createDefaultUI(): UIState { showLibraryLinkDialog: false, libraryRefreshNotice: null, resourcesView: 'project', + // B1c — verdeeldialoog: dicht, zonder tune-state (zie `UIState.levelingDistribution`). + showDistributionDialog: false, + levelingDistribution: null, // Issue #48-1: ephemeral verzoek-vlag voor een concept-rij in het resource-paneel (zie UIState). pendingNewResource: false, // Fase 2.10 onderdeel 3: first-startup — ephemeral, bootstrap-hook in App.tsx zet diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts new file mode 100644 index 000000000..167df42c2 --- /dev/null +++ b/tests/browser/leveling-distribution.spec.ts @@ -0,0 +1,166 @@ +// B1c-plan3 taak 8 — de verdeeldialoog: ingang, voorstel-berekening, focus-trap en blokkade. +// +// De `window.__OPS__`-brug bouwt UITSLUITEND de deterministische fixture (bibliotheek, poolitem, +// documenten, boekingen) en leest state; elke geteste handeling — het openen van de +// Bezetting-weergave, het klikken op "Verdelen…", de gereedschapsschakelaar, "Herbereken", Escape — +// is een echt browserevent. +import type { Page } from '@playwright/test'; +import { expect, seedProject, state, test } from './fixtures/ops'; + +interface Library { + companyId: string; + poolResourceId: string; +} + +/** Maak de app-globale bibliotheek met één poolitem van `maxUnits` capaciteit. */ +async function createLibrary(page: Page, maxUnits: number): Promise { + return page.evaluate(units => { + const s = window.__OPS__!.store.getState(); + const companyId = s.addCompany('Verdeelbibliotheek'); + const poolResourceId = s.addPoolResource(companyId, { + name: 'Gedeelde kraan', + type: 'LABOR', + description: '', + maxUnits: units, + }); + if (!poolResourceId) throw new Error('poolresource kon niet worden aangemaakt'); + return { companyId, poolResourceId }; + }, maxUnits); +} + +/** Koppel het ACTIEVE document aan de bibliotheek en boek `unitsPerDay` op `taskId`. */ +async function bookOnPoolItem( + page: Page, + library: Library, + taskId: string, + unitsPerDay: number, + { calculate = true }: { calculate?: boolean } = {}, +): Promise { + await page.evaluate(({ companyId, poolResourceId, id, units, calculate: doCalc }) => { + const s = window.__OPS__!.store.getState(); + s.bindProjectToCompany(companyId); + const materialized = s.addLibraryResourceToProject(companyId, poolResourceId); + if (!materialized.resourceId) throw new Error('poolresource kon niet worden gematerialiseerd'); + s.assignResource(id, materialized.resourceId, units); + if (doCalc) window.__OPS__!.store.getState().runCPM(); + }, { ...library, id: taskId, units: unitsPerDay, calculate }); +} + +/** Twee documenten die op dezelfde dagen elk 1/dag op één poolitem met capaciteit 1 boeken. */ +async function seedTwoConflictingDocuments(page: Page): Promise { + const [taskA] = await seedProject(page, [{ + name: 'Project A taak', start: '2026-09-07', finish: '2026-09-18', durationDays: 10, + }], 'Project A'); + const library = await createLibrary(page, 1); + await bookOnPoolItem(page, library, taskA, 1); + + await page.evaluate(() => window.__OPS__!.store.getState().newDocument()); + const [taskB] = await seedProject(page, [{ + name: 'Project B taak', start: '2026-09-07', finish: '2026-09-18', durationDays: 10, + }], 'Project B'); + await bookOnPoolItem(page, library, taskB, 1); + return library; +} + +/** + * Eén gerekend document mét conflict (capaciteit 0,5 tegen een boeking van 1/dag) plus één SLAPEND + * document dat op hetzelfde poolitem boekt maar een relatiecyclus draagt: dat kan niet efemeer + * doorgerekend worden, meldt dus `counted: false`, en dat blokkeert de hele verdeling (spec §3.1). + */ +async function seedUncountedDocument(page: Page): Promise { + const [taskA] = await seedProject(page, [{ + name: 'Gerekend project taak', start: '2026-09-07', finish: '2026-09-18', durationDays: 10, + }], 'Gerekend project'); + const library = await createLibrary(page, 0.5); + await bookOnPoolItem(page, library, taskA, 1); + const countedDocumentId = (await state(page)).activeDocumentId; + + await page.evaluate(() => window.__OPS__!.store.getState().newDocument()); + const [cycleA, cycleB] = await seedProject(page, [ + { name: 'Cyclus taak 1', start: '2026-09-07', finish: '2026-09-18', durationDays: 10 }, + { name: 'Cyclus taak 2', start: '2026-09-07', finish: '2026-09-18', durationDays: 10 }, + ], 'Ongerekend project'); + await bookOnPoolItem(page, library, cycleA, 1); + // De cyclus als LAATSTE, en zonder runCPM erna: het document blijft `scheduleStale` staan, dus de + // bezettingskern probeert hem efemeer door te rekenen — en faalt op de cyclus. + await page.evaluate(({ a, b }) => { + const s = window.__OPS__!.store.getState(); + s.addSequence({ predecessorId: a, successorId: b, type: 'FINISH_START', lagDays: 0 }); + s.addSequence({ predecessorId: b, successorId: a, type: 'FINISH_START', lagDays: 0 }); + }, { a: cycleA, b: cycleB }); + + await page.evaluate(id => window.__OPS__!.store.getState().switchDocument(id), countedDocumentId); + return library; +} + +/** De Bezetting-weergave openen met een echte klik (patroon uit theme-render.spec.ts). */ +async function openOccupancyWithRealClick(page: Page): Promise { + await page.evaluate(() => { + window.__OPS__!.store.getState().setUI({ + showResourcePanel: true, + resourcePanelDocked: false, + resourcesView: 'project', + }); + }); + await page.locator('[data-ops-occupancy-view-button]').click(); + await expect(page.locator('[data-ops-occupancy-view]')).toBeVisible(); +} + +/** De echte gebruikersroute naar de dialoog: de "Verdelen…"-knop op de conflictregel. */ +async function openDistributionFromConflictRow(page: Page): Promise { + await openOccupancyWithRealClick(page); + await page.locator('[data-ops-occupancy-row]').first() + .getByRole('button', { name: /Verdelen|Distribute/ }).click(); + await expect(page.locator('[data-ops-distribution-dialog]')).toBeVisible(); +} + +test('verdeeldialoog: openen vanuit de conflictregel, een voorstel rekenen, focus-trap en sluiten', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + + // Focus-trap: de gedeelde `Dialog` legt de focus meteen binnen het paneel. + await expect(page.locator('[data-ops-distribution-dialog] :focus')).toHaveCount(1); + // Losse dialoog, geen drill-down: het overzicht blijft eronder gewoon staan. + await expect(page.locator('[data-ops-occupancy-row]').first()).toBeVisible(); + // §3.4: het openen IS een rekenmoment — beide deelnemende documenten krijgen een strook. + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + await page.keyboard.press('Escape'); + await expect(page.locator('[data-ops-distribution-dialog]')).toHaveCount(0); + // De tune-state overleeft de sluiting (§7): opnieuw openen begint niet van voren af aan. + expect(await page.evaluate(() => window.__OPS__!.store.getState().ui.levelingDistribution !== null)).toBe(true); +}); + +test('verdeeldialoog: de gereedschapsschakelaar laat het voorstel vervallen met reden', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const toggle = page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ }); + await toggle.click(); + + // §3.4 noemt een gereedschapswijziging een DISCREET REKENMOMENT: de wijziging zet de reden én + // plant meteen de herberekening, en de stale-strook blijft staan tot die run klaar is. Op deze + // minieme fixture is die run binnen één macrotask voorbij, dus de strook zelf is niet + // betrouwbaar te vangen. Wat wél stabiel is: de dialoog houdt de LAATSTE reden vast, zodat + // aantoonbaar blijft dat de gebruiker `stale.tool` te zien kreeg. + await expect(page.locator('[data-ops-distribution-dialog]')) + .toHaveAttribute('data-ops-distribution-last-stale-reason', 'tool'); + expect(await page.evaluate(() => window.__OPS__!.store.getState().ui.levelingDistribution!.allowSplits)).toBe(true); + + // "Herbereken" is de expliciete route; daarna staat er geen stale-strook meer. + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); +}); + +test('verdeeldialoog: een geblokkeerd voorstel legt uit waarom en biedt geen Toepassen', async ({ page, ops: _ops }) => { + await seedUncountedDocument(page); + await openDistributionFromConflictRow(page); + + await expect(page.locator('[data-ops-distribution-blocked]')).toContainText(/doorrekenen|calculate/i); + await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); + // Geblokkeerd ⇒ geen stroken en geen gereedschapsschakelaar: er valt niets te bedienen. + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(0); + await expect(page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ })).toHaveCount(0); +}); From 2cf13219d6ee175e43db6a22fd39a0ec87822151 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:04:19 +0200 Subject: [PATCH 016/108] docs: gids voor het verdelen over projecten (B1c) Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/library.md | 26 +++- .../docs/en/gids-verdelen-restcapaciteit.md | 122 ++++++++++++++++ public/docs/manifest.json | 21 +++ .../docs/nl/gids-verdelen-restcapaciteit.md | 130 ++++++++++++++++++ 5 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 public/docs/en/gids-verdelen-restcapaciteit.md create mode 100644 public/docs/nl/gids-verdelen-restcapaciteit.md diff --git a/README.md b/README.md index 279c067b2..d305f33bc 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ src/ extensions/ # Extensiesysteem (types, api, loader, service) i18n/ # Vertalingen, 14 talen × 4 namespaces hooks/ types/ utils/ styles/ -public/docs/ # In-app handleiding: 32 artikelen × 14 talen (voedt ook de wiki) +public/docs/ # In-app handleiding: 33 artikelen × 14 talen (voedt ook de wiki) examples/ # Voorbeeldplanningen in IFC tests/ # planning · library · mcp · dev-server · browser src-tauri/ # De Rust-schil (dun: precies één native command) diff --git a/docs/library.md b/docs/library.md index 7d046b0f9..74e4a8dc1 100644 --- a/docs/library.md +++ b/docs/library.md @@ -404,7 +404,7 @@ Alle drie komen voort uit dezelfde wortel — **er is geen gedeelde opslag tusse vast id geven) staat als openstaand punt in `docs/TODO.md` — vergt een migratie voor bestaande installaties en opgeslagen stempels, daarom nu niet gedaan. -5. **Verdeler-kern bestaat, nog zonder schrijfpad/paneel.** Sinds B1c-etappe 2 +5. **Verdelen over projecten: rekenkern, schrijfpad en dialoog.** Sinds B1c-etappe 2 (`src/services/library/distribute.ts`) is er een pure rekenkern die, gegeven een poolitem en de geopende documenten die erop boeken, een verdelingsvoorstel berekent: documenten worden één voor één, in een opgegeven rangorde, tegen de restcapaciteit geplaatst — nummer 1 nivelleert alleen @@ -414,8 +414,28 @@ Alle drie komen voort uit dezelfde wortel — **er is geen gedeelde opslag tusse bezettingsoverzicht hierboven). Past een taak niet, dan wordt ze als tekort geregistreerd en boekt ze niets in het gedeelde grootboek — een tekort cascadeert dus niet naar de volgende documenten in de rangorde. Deze kern draait volledig puur (`computeDistribution`, headless getest in - `tests/library/check-distribute.ts`); het schrijfpad (de gevonden verschuivingen daadwerkelijk - toepassen) en het paneel dat dit voorstel toont, bestaan nog niet. + `tests/library/check-distribute.ts`). + + Sinds B1c-etappe 3 heeft dit voorstel ook een schrijfpad en een bediening: een dialoog + ("Verdelen over projecten", geen drill-down — besluit eigenaar 2026-08-31), te openen vanuit de + conflictregel in het bezettingsoverzicht of vanuit de Resources-ribbon. De gebruiker stuurt het + voorstel bij met een rangorde ("wie wordt het meest ontzien"), een schakelaar voor onderbrekingen, + en per project twee begrenzingen: **vastzetten** (pin) bevriest een project volledig — einddatum + én werkdagen blijven ongewijzigd, het telt mee als vaste last — en een **plafond** begrenst alleen + hóéveel een project mag opschuiven; een plafond van 0 laat de bestaande speling nog wel toe, in + tegenstelling tot vastzetten. Toepassen schrijft de verschuiving in álle deelnemende documenten + tegelijk, ook als **Automatisch berekenen** daar uitstaat, en elk document krijgt daarbij een + gewone ongedaan-maken-stap — sinds de merge met main een event in de app-globale sessiehistorie, + net als elke andere bewerking. "Alles terugdraaien" draait die stappen in één keer terug, behalve + in een document waar intussen zelf verder gewerkt is; dat wordt met naam gemeld en blijft op zijn + nieuwe stand staan. + + De rangorde, pins en plafonds horen bij de verdeelsessie zelf, niet bij het project: het zijn geen + `DOCUMENT_FIELDS` en ze round-trippen dus niet door het IFC, overleven geen undo in het actieve + document en beginnen na een herstart weer neutraal (zie de scope-bak "Waar de tune-state woont" in + `docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md` voor de volledige afweging). Zie + de gebruikersgids [Verdelen over projecten](../public/docs/nl/gids-verdelen-restcapaciteit.md) voor + het gebruikersperspectief. ## Bekende kleine punten diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md new file mode 100644 index 000000000..49f3af082 --- /dev/null +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -0,0 +1,122 @@ +# Distributing across projects + +The [occupancy overview](docs://gids-bezettingsoverzicht) shows which resource is double-booked +across your open projects. This guide covers the next step: actually resolving that double booking, +without having to open every project separately and shift things by hand. + +## When you use this + +Use "Distribute over projects" as soon as the occupancy overview shows a conflict row: a library item +whose combined bookings across several projects exceed the company capacity. Instead of tackling that +conflict project by project, this dialog looks at all the involved projects at once and proposes a +single shift that fits within what's actually available. + +## Opening the dialog + +There are two entry points: + +- From a conflict row in the occupancy overview: the **Distribute…** button. +- From the Resources ribbon, whenever a conflict is open. + +The dialog opens with the title "Distribute over projects" and shows which library item it concerns +at the top. A **Back to occupancy** link takes you back to the overview without changing anything. + +A few situations block the distribution right away, with a clear reason: + +- One of the involved projects hasn't been calculated yet — calculate it first (F5) in that project. +- The chosen item is a material item; distributing only works for people and equipment. +- None of the projects actually book work here — there's nothing to distribute. + +At the top of the dialog you'll see a before-and-after histogram: how the load currently runs against +capacity, and how that looks once you apply the proposal. + +## Who gets spared the most? + +Below the histogram sits the ranking list **"Who gets spared the most?"**. This is the order in which +projects get priority: the project at the top wiggles the least, and each project below it gives way +first whenever a choice has to be made. Drag a project to change the order, or use the arrow buttons +to move it up or down one place. + +Next to each project you'll see the slack it still has, and what it would cost to let only this +project shift — in workdays of overrun. That way you can see straight away which project is the +cheapest place to absorb the shift, instead of having to guess. + +## Allow interruptions + +The **"Allow interruptions"** toggle determines how a task is allowed to give way when there isn't +enough capacity. With the toggle off, a task that doesn't fit shifts as a whole to a later moment. +With it on, a task may also get pause days — whole workdays without deployment in between the days it +is deployed — instead of shifting in one piece. This is exactly the same switch as "Leveling can +create splits in remaining work" in Microsoft Project. + +Work that has already started is never interrupted, whether this toggle is on or off: that part can +only give way through overrun. Whatever the toggle is set to, the dialog shows the price tag in +workdays of overrun, so you can weigh the effect before you apply. + +## Pinning or a ceiling + +For each project in the ranking list you have two ways to limit the room it gives up: + +- **Pin** freezes a project completely: both its end date and its workdays stay exactly as they are + now. A pinned project never gives up room — it counts in the calculation as a fixed load that the + other projects have to plan around. +- The **ceiling** ("Maximum overrun of the end date") only limits *how much* a project may shift, not + *whether* it may shift. A ceiling of 0 workdays means the end date may not move, but the slack + already present within the existing schedule may still be used — that's different from pinning, + where even the workdays within the task no longer change. + +The ceiling is a draggable handle on each project's phase strip: drag it, or use the arrow keys to +move it one workday at a time, Home for a ceiling of 0, and End for unlimited. The label next to the +handle shows what that means for the project's end date, and if less overrun turned out to be needed +than you had allowed, the label says so explicitly: "requested X, closest achievable Y". + +## Why it sometimes doesn't work out + +A task that can't be shifted gets a plain-language reason instead of just a red mark: + +- **The resource doesn't work on the days the task needs.** The resource's calendar excludes the days + the task requires; shifting within this slack doesn't fix that. +- **Not enough free capacity within the slack.** There is some room, but not enough to resolve this + conflict within the available slack. +- **At its peak, the task demands more than the capacity allows**, no matter how you shift it — this + is a task you can only fix by lowering the demand itself, not by scheduling around it. +- **The ceiling is too tight.** Within the allowed number of workdays of overrun, no free window can + be found. Widen the ceiling, or allow interruptions. +- **A deadline or another scheduling constraint holds the task in place** — allowing extra overrun + won't help then, because the task isn't allowed to move from there regardless. +- **No free window was found within the searched period.** Further out in time it's unknown whether + there's room — this isn't a definitive "no", it just means the search period wasn't long enough. +- **The library's residual capacity is used up.** This project's own demand still had room, but other + projects already occupy the resource up to the company capacity. Give that other project a lower + spot in the ranking, or pin it so the rest can plan around it. + +A project with **["Dates as recorded"](docs://datums-zoals-opgeslagen)** turned on never takes part in +a distribution — leave that mode in that project first before adding it to a distribution proposal. + +Any change to the ranking, a ceiling, a pin, or the toggle immediately invalidates the current +proposal; press **Recalculate** afterwards, or use **Distribute automatically** to have that happen on +its own each time. If something is edited in one of the involved projects while the dialog is open, +the proposal lapses for the same reason. If a shortfall remains despite every setting, the dialog +shows which tasks don't fit per project, and **Apply** stays disabled with the reason next to it. + +## Applying and undoing + +Once the proposal is valid and everything fits, **Apply** writes the shift into all the involved +projects at once — even into a project where **Calculate automatically** is switched off. Each +project gets an ordinary undo step for it, just as if you had shifted things there by hand yourself. + +After applying, a strip "Applied in N projects" appears with an **Undo all** button. That strip stays +in place even if you keep working elsewhere in the app in the meantime, so you don't have to decide +right away. Undoing reverts the step in every project — except one where you yourself worked further, +after applying: that project is then named explicitly and stays at its new state, while the rest is +reverted normally. + +The choices you make in this dialog — ranking, pins, ceilings — belong to this one distribution +session. They aren't stored anywhere in the project: close the dialog or restart the app, and next +time you start again with a neutral ranking, no pins, no ceilings. + +## The boundary + +"Distribute over projects" only sees, just like the occupancy overview it grows out of, the documents +that are open in this app right now. A project that isn't open doesn't take part, even if it's linked +to the same library; and a colleague planning on a different machine never counts here. diff --git a/public/docs/manifest.json b/public/docs/manifest.json index 1afee11ab..6c9af00ce 100644 --- a/public/docs/manifest.json +++ b/public/docs/manifest.json @@ -147,6 +147,27 @@ "layer": "gidsen", "cluster": "bezettingsoverzicht" }, + { + "id": "gids-verdelen-restcapaciteit", + "title": { + "nl": "Verdelen over projecten", + "en": "Distributing across projects", + "de": "Über Projekte verteilen", + "fr": "Répartir entre les projets", + "es": "Distribuir entre proyectos", + "zh": "跨项目分配", + "it": "Distribuire tra i progetti", + "pt": "Distribuir entre projetos", + "pl": "Rozdzielanie między projekty", + "tr": "Projeler arasında dağıtma", + "ar": "التوزيع بين المشاريع", + "ja": "プロジェクト間で分配", + "ko": "프로젝트 간 분배", + "fa": "توزیع بین پروژه‌ها" + }, + "layer": "gidsen", + "cluster": "bezettingsoverzicht" + }, { "id": "gids-baselines-voortgang", "title": { diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md new file mode 100644 index 000000000..e4699c2b7 --- /dev/null +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -0,0 +1,130 @@ +# Verdelen over projecten + +Het [bezettingsoverzicht](docs://gids-bezettingsoverzicht) laat zien wélke resource over al je +geopende projecten heen dubbel geboekt staat. Deze gids gaat over de stap erna: die dubbele boeking +daadwerkelijk oplossen, zonder ieder project apart te moeten openen en handmatig te schuiven. + +## Wanneer je dit gebruikt + +Gebruik "Verdelen over projecten" zodra het bezettingsoverzicht een conflictregel toont: een +bibliotheekitem waarvan de som van de boekingen in meerdere projecten boven de bedrijfscapaciteit +uitkomt. In plaats van dat conflict project voor project te lijf te gaan, kijkt deze dialoog naar +alle betrokken projecten tegelijk en stelt in één keer een verschuiving voor die past binnen wat er +werkelijk beschikbaar is. + +## De dialoog openen + +Er zijn twee ingangen: + +- Vanuit een conflictregel in het bezettingsoverzicht: de knop **Verdelen…**. +- Vanuit de Resources-ribbon, wanneer er een conflict openstaat. + +De dialoog opent met de titel "Verdelen over projecten" en toont bovenaan om welk bibliotheekitem +het gaat. Een link **Terug naar bezetting** brengt je terug naar het overzicht zonder iets te +wijzigen. + +Een paar situaties blokkeren de verdeling meteen, met een duidelijke reden: + +- Een van de betrokken projecten is nog niet doorgerekend — reken het eerst door (F5) in dat + project. +- Het gekozen item is een materiaal-item; verdelen werkt alleen voor mensen en materieel. +- Geen van de projecten boekt hier daadwerkelijk werk op — er is dan niets te verdelen. + +Bovenin de dialoog zie je een histogram van vóór en ná: hoe de belasting nu tegen de capaciteit +aanloopt, en hoe dat eruitziet als je het voorstel toepast. + +## Wie wordt het meest ontzien? + +Onder het histogram staat de rangordelijst **"Wie wordt het meest ontzien?"**. Dit is de volgorde +waarin de projecten voorrang krijgen: het project bovenaan wijkt het minst, elk project daaronder +levert eerder in als er een keuze gemaakt moet worden. Versleep een project om de volgorde te +wijzigen, of gebruik de pijltjes om het een plek omhoog of omlaag te zetten. + +Bij elk project staat de speling die het nog heeft, en wat het zou kosten om alléén dít project te +laten opschuiven — in werkdagen uitloop. Zo zie je meteen welk project de goedkoopste plek is om de +verschuiving te laten landen, in plaats van dat te moeten gissen. + +## Onderbrekingen toestaan + +De schakelaar **"Onderbrekingen toestaan"** bepaalt hoe een taak mag wijken wanneer er niet genoeg +capaciteit is. Staat de schakelaar uit, dan schuift een taak die niet past in zijn geheel op naar een +later moment. Staat hij aan, dan mag een taak ook pauzedagen krijgen — hele werkdagen zonder inzet +tussen de wel-ingezette dagen door — in plaats van in één stuk te verschuiven. Dit is precies dezelfde +knop als "Leveling can create splits in remaining work" in Microsoft Project. + +Werk dat al begonnen is, wordt nooit onderbroken, met of zonder deze schakelaar aan: dat deel kan +alleen nog via uitloop wijken. Bij elke instelling van de schakelaar toont de dialoog het prijskaartje +in werkdagen uitloop, zodat je het effect kunt afwegen vóór je toepast. + +## Vastzetten of een plafond + +Per project in de rangordelijst heb je twee manieren om de ruimte te begrenzen: + +- **Vastzetten** (de pin) bevriest een project volledig: zowel de einddatum als de werkdagen blijven + precies zoals ze nu zijn. Een vastgezet project levert dus nooit ruimte in — het telt in de + berekening mee als een vaste last waar de andere projecten omheen moeten plannen. +- Het **plafond** ("Maximale uitloop van de einddatum") begrenst alleen hóéveel een project mag + opschuiven, niet óf het mag opschuiven. Een plafond van 0 werkdagen betekent dat de einddatum niet + mag verschuiven, maar de speling binnen de bestaande planning nog wel benut mag worden — dat is + iets anders dan vastzetten, waar zelfs de werkdagen binnen de taak niet meer wijzigen. + +Het plafond is een sleepbare handle op de fasestrook van elk project: sleep hem, of gebruik de +pijltjestoetsen om per werkdag te verschuiven, Home voor een plafond van 0, en End voor onbegrensd. +Het label bij de handle toont wat dat betekent voor de einddatum van het project, en als er minder +uitloop nodig bleek dan je had toegestaan, meldt het label dat expliciet: "gevraagd X, dichtst +haalbare Y". + +## Waarom het soms niet lukt + +Een taak die niet verschoven kan worden, krijgt een reden in gewone taal in plaats van alleen een rood +vinkje: + +- **De resource werkt niet op de benodigde dagen.** De kalender van de resource sluit de dagen uit + die de taak nodig heeft; verschuiven binnen deze speling lost dat niet op. +- **Onvoldoende vrije capaciteit binnen de speling.** Er is nog wel ruimte, maar niet genoeg om dit + conflict binnen de beschikbare speling op te lossen. +- **De taak vraagt op haar piek meer dan de capaciteit toelaat**, ongeacht hoe je schuift — dit is een + taak die je alleen kunt oplossen door de inzet zelf te verlagen, niet door plannen. +- **Het plafond is te krap.** Binnen het toegestane aantal werkdagen uitloop is geen vrij venster te + vinden. Zet het plafond ruimer, of sta onderbrekingen toe. +- **Een deadline of andere planningsbeperking houdt de taak op zijn plek** — extra uitloop toestaan + helpt dan niet, want de taak mag daar sowieso niet vandaan. +- **Binnen de doorzochte periode is geen vrij venster gevonden.** Verderop in de tijd is het onbekend + of er wel ruimte is — dit is geen definitief "nee", maar de zoekperiode was niet lang genoeg. +- **De restcapaciteit van de bibliotheek is op.** De eigen inzet van dit project had nog ruimte, maar + andere projecten bezetten de resource al tot aan de bedrijfscapaciteit. Geef zo'n ander project een + lagere plek in de rangorde, of zet het vast zodat de rest eromheen plant. + +Een project met **["Datums zoals opgeslagen"](docs://datums-zoals-opgeslagen)** aan doet nooit mee in +een verdeling — verlaat die modus eerst in dat project voordat je het aan een verdeelvoorstel +toevoegt. + +Elke wijziging aan de rangorde, een plafond, een vastzetting of de schakelaar maakt het huidige +voorstel meteen ongeldig; druk daarna op **Herbereken**, of gebruik **Verdeel automatisch** om dat +telkens vanzelf te laten gebeuren. Wordt er in een van de betrokken projecten iets bewerkt terwijl de +dialoog openstaat, dan vervalt het voorstel om diezelfde reden. Blijft er, ondanks alle instellingen, +een tekort over, dan toont de dialoog per project welke taken niet passen, en blijft **Toepassen** +uitgeschakeld met de reden erbij. + +## Toepassen en terugdraaien + +Is het voorstel geldig en past alles, dan schrijft **Toepassen** de verschuiving in álle betrokken +projecten tegelijk — ook in een project waar **Automatisch berekenen** uitstaat. Elk project krijgt +daarbij een gewone ongedaan-maken-stap, alsof je daar zelf handmatig had geschoven. + +Na het toepassen verschijnt een strook "Toegepast in N projecten" met de knop **Alles terugdraaien**. +Die strook blijft staan, ook als je intussen elders in de app verder werkt, zodat je niet meteen hoeft +te beslissen. Terugdraaien maakt de stap in elk project ongedaan — behalve in een project waarin je +zelf, ná het toepassen, alweer verder gewerkt hebt: dat project wordt dan met naam gemeld en blijft op +zijn nieuwe stand staan, terwijl de rest gewoon teruggaat. + +De keuzes die je in deze dialoog maakt — rangorde, vastzettingen, plafonds — horen bij deze ene +verdeelsessie. Ze worden nergens in het project opgeslagen: sluit je de dialoog of herstart je de +app, dan begin je de volgende keer weer met een neutrale rangorde, zonder pins of plafonds. + +## De grens + +"Verdelen over projecten" ziet, net als het bezettingsoverzicht waar het uit voortkomt, uitsluitend +de documenten die op dít moment in dít programma geopend staan. Een project dat niet open staat doet +niet mee, ook niet als het aan dezelfde bibliotheek gekoppeld is; en een collega die op een andere +machine plant, telt hier nooit mee. From 3d03677605cf01cd1006cc84beab99f78e8f39ee Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:22:17 +0200 Subject: [PATCH 017/108] feat(library): fasestroken met pin en plafond-handle in de verdeeldialoog (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De tijdas van het bezettingsoverzicht is letterlijk uit `OccupancyHistogram` getrokken naar `occupancyAxis.ts` (domein, gatcompressie, dagbreedte, segmenten/breuken), zodat de stroken en het histogram per constructie op dezelfde x-posities uitkomen. Per document één SVG-rij: de vaste last als achtergrondband, de eigen boeking als blokken (gaten blijven gaten), een gestippelde staart voor toegestaan-maar-niet-benut, en een `role="slider"`-handle met volledige toetsenbordbediening (pijltjes/PageUp/PageDown/Home/End). Het label bij de handle toont het EINDDATUM-effect, niet de sleepafstand; blijft er ruimte over, dan staat er apart bij wat er gevraagd en wat er haalbaar was. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 143 ++++++++-- .../dialogs/DistributionDialog/PhaseStrip.tsx | 269 ++++++++++++++++++ .../panels/ResourceOccupancyView.tsx | 80 +----- src/components/panels/occupancyAxis.ts | 119 ++++++++ tests/browser/leveling-distribution.spec.ts | 92 ++++++ 5 files changed, 605 insertions(+), 98 deletions(-) create mode 100644 src/components/dialogs/DistributionDialog/PhaseStrip.tsx create mode 100644 src/components/panels/occupancyAxis.ts diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 324d18c29..0b71200c4 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -6,10 +6,10 @@ // gewoon zichtbaar ONDER de dialoog — de verdeling is een handeling óp dat overzicht, geen // vervanging ervan. // -// WAT HIER (NOG) NIET STAAT. Taak 9 vervangt de minimale strook-regels door de echte fasestroken met -// sleepbare plafondhandles, en vult het voor/na-histogram. Taak 12 bedraadt "Toepassen" op -// `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET REDEN — een knop die er -// niet is laat de gebruiker raden of de functie bestaat. +// WAT HIER (NOG) NIET STAAT. Taak 10 maakt de plafondhandle van de fasestroken sleepbaar met de +// pointer (het toetsenbord is er al, zie `PhaseStrip`); taak 11 vult het voor/na-histogram. Taak 12 +// bedraadt "Toepassen" op `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET +// REDEN — een knop die er niet is laat de gebruiker raden of de functie bestaat. import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, X } from 'lucide-react'; @@ -17,7 +17,12 @@ import { useAppStore } from '@/state/appStore'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; +import { computeLibraryOccupancy } from '@/services/library/occupancy'; +import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; +import { buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; +import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; import { documentFloatOn, useDistributionProposal } from './useDistributionProposal'; +import { PhaseStrip } from './PhaseStrip'; export function DistributionDialog() { const { t, i18n } = useTranslation('common'); @@ -65,6 +70,78 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, allowSplits: !tune.allowSplits } }); }; + const setPinned = (docId: string, value: boolean) => { + if (!tune) return; + setUI({ levelingDistribution: { ...tune, pinned: { ...tune.pinned, [docId]: value } } }); + }; + const setCeiling = (docId: string, value: number | null) => { + if (!tune) return; + setUI({ levelingDistribution: { ...tune, ceilings: { ...tune.ceilings, [docId]: value } } }); + }; + + // De BOEKING per document per dag (§6, de gevulde blokken van een fasestrook). Die komt uit + // dezelfde kern als het bezettingsoverzicht — `computeDistribution` draait `computeLibraryOccupancy` + // intern al, maar geeft de per-document-dagcijfers niet terug, en een tweede, met de hand + // geschreven dagverdeling in de UI zou stilzwijgend van die ene bron kunnen afwijken. Daarom + // nogmaals dezelfde pure functie op dezelfde `inputs`: de deps zijn bewust NIET `tune` (die tikt + // bij elke pin- en plafondwijziging), maar het ONDERWERP plus de invoer van het huidige voorstel. + const companyId = tune?.companyId ?? null; + const libraryItemId = tune?.libraryItemId ?? null; + const bookingByDoc = useMemo(() => { + const empty = new Map>(); + if (companyId === null || libraryItemId === null || inputs.length === 0) return empty; + const pool = pools[companyId]; + if (!pool) return empty; + const row = computeLibraryOccupancy(companyId, pool, inputs).rows + .find(r => r.libraryItemId === libraryItemId); + if (!row) return empty; + return new Map(row.docs.map(doc => [doc.docId, doc.dailyLoad])); + }, [companyId, libraryItemId, pools, inputs]); + + // De GEDEELDE tijdas van alle stroken plus de verticale schaal. De as loopt door tot voorbij de + // laatste geboekte dag, zodat een gestippelde staart (toegestaan-maar-niet-benut) er nog binnen + // past; werkdagen worden daarbij als kolommen van één dagbreedte getekend. + const stripView = useMemo(() => { + if (!tune || !proposal || proposal.blocked || !poolItem) return null; + const rankIndex = new Map(rankRows.map((row, index) => [row.docId, index])); + const docs = [...proposal.docs].sort((a, b) => + (rankIndex.get(a.docId) ?? rankRows.length) - (rankIndex.get(b.docId) ?? rankRows.length)); + + const days = new Set(Object.keys(proposal.fixedLoadByDay)); + for (const doc of docs) for (const iso of Object.keys(bookingByDoc.get(doc.docId) ?? {})) days.add(iso); + if (days.size === 0) return { axis: null, docs, scaleMax: 1 }; + + const outlook = docs.reduce( + (n, doc) => Math.max(n, doc.endShiftWorkdays, tune.ceilings[doc.docId] ?? 0), 0); + if (outlook > 0) { + const last = [...days].sort()[days.size - 1]; + // Werkdagen → kalenderdagen (5/7) plus een marge, en hoe dan ook begrensd: de staart mag de + // as verbreden, niet laten ontsporen. + const extra = Math.min(90, Math.ceil(outlook * 7 / 5) + 2); + for (const iso of expandDays( + formatDate(addCalendarDays(parseDate(last), 1)), + formatDate(addCalendarDays(parseDate(last), extra)), + )) days.add(iso); + } + + const axis = buildOccupancyAxis([...days], { targetWidth: 560 }); + let scaleMax = 1; + if (axis) { + for (const segment of axis.segments) { + for (const iso of segment.days) { + const capacity = maxUnitsOn(poolItem, iso); + if (capacity > scaleMax) scaleMax = capacity; + const fixed = proposal.fixedLoadByDay[iso] ?? 0; + for (const doc of docs) { + const stacked = fixed + (bookingByDoc.get(doc.docId)?.[iso] ?? 0); + if (stacked > scaleMax) scaleMax = stacked; + } + } + } + } + return { axis, docs, scaleMax }; + }, [tune, proposal, poolItem, rankRows, bookingByDoc]); + // Waarom Toepassen (nog) uit staat. De eerste drie redenen zijn ECHT en blijven na taak 12 // bestaan; de vierde is de tijdelijke: het schrijfpad is er (taak 6) maar wordt pas in taak 12 // bedraad. Er is bewust geen eigen sleutel voor die vierde toestand aangemaakt — hij verdwijnt @@ -203,33 +280,39 @@ export function DistributionDialog() {
- {/* (5) Fasestroken — taak 9 vult ze; hier alleen de regel per document met het effect. */} + {/* (5) Fasestroken (§6). Eén rij per document, in RANGORDE — dezelfde volgorde als de + lijst hierboven, zodat een pin of plafond de stroken niet onder de muis vandaan + herschikt. De legenda benoemt de achtergrondband; de blokken zijn de eigen boeking. */}
- {(proposal?.docs ?? []).map(doc => ( -
- {doc.title} - {!doc.participated && ( - - {doc.pinnedReason === 'dates-as-recorded' - ? t('resource.distribution.strip.pinnedRecorded') - : t('resource.distribution.strip.pinned')} - - )} - {doc.cannotMove && ( - {t('resource.distribution.strip.cannotMove')} - )} - - {doc.endShiftWorkdays === 0 - ? t('resource.distribution.strip.endUnchanged') - : t('resource.distribution.strip.endShift', { count: doc.endShiftWorkdays })} - -
- ))} + + + {t('resource.distribution.strip.fixedLoad')} + + {(stripView?.docs ?? []).map(doc => { + const recorded = doc.pinnedReason === 'dates-as-recorded'; + return ( + setPinned(doc.docId, tune.pinned[doc.docId] !== true)} + onCeilingChange={next => setCeiling(doc.docId, next)} + /> + ); + })}
{/* (6) Voor/na-histogram — plaatshouder; taak 9 tekent hem. */} diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx new file mode 100644 index 000000000..0c99352e2 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -0,0 +1,269 @@ +// B1c-plan3 taak 9 — de FASESTROOK van één document in de verdeeldialoog (spec §6, minus het +// pointer-slepen: dat is taak 10). +// +// WAT DE STROOK TOONT. Eén SVG-rij op de GEDEELDE tijdas (`occupancyAxis.ts` — dezelfde as als het +// histogram in het bezettingsoverzicht, zodat een strook en dat histogram per constructie boven +// elkaar uitkomen): +// - de VASTE LAST als achtergrondband: wat gepinde/#63-documenten al van het poolitem opeisen; +// - de BOEKING van dít document als gevulde blokken over de dagen waarop het echt boekt. Dagen +// zonder boeking binnen de spanne blijven leeg — dat is precies wat een bestaande split of een +// ingevoegde pauze zichtbaar maakt; +// - een GESTIPPELDE STAART van het einde van de benutte uitloop tot het plafond: toegestaan, maar +// niet benut. +// +// HET LABEL BIJ DE HANDLE TOONT HET EINDDATUM-EFFECT, NIET DE SLEEPAFSTAND (§6). Een gebruiker +// verplaatst een plafond om te zien wat er met zijn EINDDATUM gebeurt; "handle 3 werkdagen naar +// rechts" is een handeling, geen uitkomst. Staat er meer toe dan er benut wordt, dan zegt de strook +// dat er apart bij ("gevraagd 3, dichtst haalbare 1") — anders leest een ongebruikte ruimte als een +// mislukking. +// +// TOETSENBORD IS EEN VOLWAARDIGE ROUTE, GEEN NAZORG. De handle is een `role="slider"` met +// `aria-valuetext` (plafond, benutting én einddatum-effect); pijltjes verzetten één werkdag, +// PageUp/PageDown vijf, Home zet het plafond op 0 en End maakt het onbegrensd. Snappen op hele +// werkdagen is inherent: het plafond ís een geheel aantal werkdagen. +// +// WAAROM `ceiling ?? endShiftWorkdays` HET STAPPUNT IS. Een onbegrensd plafond is geen getal op de +// as maar de End-stand; de handle staat dan visueel aan het einde van wat er BENUT wordt. Een +// pijltje pakt hem dus op waar hij staat. Dat is ook de enige lezing waarin de handle niet +// verspringt op het moment dat je hem voor het eerst aanraakt. +import { useTranslation } from 'react-i18next'; +import { Pin } from 'lucide-react'; +import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; + +/** Eindige bovengrens voor `aria-valuemax`. Het plafond zélf kent ook `null` = ONBEGRENSD (de + * End-toets); dat is geen 61e waarde maar het ontbreken van een grens, en wordt aan + * hulptechnologie gerapporteerd als deze `valuemax` mét een `aria-valuetext` die "onbegrensd" + * zegt — een slider zonder eindige `valuemax` is voor screenreaders betekenisloos. */ +export const CEILING_MAX_WORKDAYS = 60; + +/** Hoogte van de tekenstrook (viewBox-eenheden ≈ px). */ +const STRIP_HEIGHT = 24; + +export interface PhaseStripProps { + docId: string; + title: string; + /** De gedeelde tijdas, of `null` wanneer er geen enkele geboekte dag te tekenen valt. */ + axis: OccupancyAxis | null; + /** ISO-dag → eenheden die dít document op het poolitem boekt (uit `computeLibraryOccupancy`). */ + dailyLoad: Record; + /** ISO-dag → vaste last van gepinde/#63-documenten (uit `DistributionProposal`). */ + fixedLoadByDay: Record; + /** Bovengrens van de verticale as in eenheden (capaciteit of hoogste stapeling). */ + scaleMax: number; + /** Werkdagen die de einddatum van dít document opschuift in het huidige voorstel. */ + endShiftWorkdays: number; + /** Het ingestelde plafond in werkdagen; `null` = onbegrensd. */ + ceiling: number | null; + pinned: boolean; + /** #63 "datums zoals opgeslagen": impliciet gepind, en niet met een pin-knop te ontgrendelen. */ + recorded: boolean; + /** Alle betrokken taken staan vast (priority 1000) — het document KAN niet wijken. */ + cannotMove: boolean; + /** Gedegradeerd overzicht (§3.4): een plafondwijziging rekent niet automatisch door. */ + degraded: boolean; + onTogglePin: () => void; + onCeilingChange: (next: number | null) => void; +} + +export function PhaseStrip({ + docId, title, axis, dailyLoad, fixedLoadByDay, scaleMax, + endShiftWorkdays, ceiling, pinned, recorded, cannotMove, degraded, + onTogglePin, onCeilingChange, +}: PhaseStripProps) { + const { t } = useTranslation('common'); + + const dayWidth = axis?.dayWidth ?? 0; + const stripWidth = axis?.width ?? AXIS.padLeft + 200; + const yOf = (units: number) => + STRIP_HEIGHT * (1 - Math.min(1, Math.max(0, units) / Math.max(0.01, scaleMax))); + + // Blokken per kalenderdag van de as: de vaste last onderaan, de eigen boeking daarbovenop. + const fixedRects: { key: string; x: number; y: number; h: number }[] = []; + const bookedRects: { key: string; x: number; y: number; h: number }[] = []; + let lastBookedX: number | null = null; + if (axis !== null) { + for (const segment of axis.segments) { + for (let i = 0; i < segment.days.length; i++) { + const iso = segment.days[i]; + const x = segment.x0 + i * dayWidth; + const booked = dailyLoad[iso] ?? 0; + // Een gepind document zit ZELF in de vaste last (dat is wat pinnen betekent). Zonder deze + // aftrek zou zijn eigen boeking twee keer in dezelfde staaf staan. + const fixed = Math.max(0, (fixedLoadByDay[iso] ?? 0) - (pinned ? booked : 0)); + if (fixed > 0) { + const y = yOf(fixed); + fixedRects.push({ key: iso, x, y, h: STRIP_HEIGHT - y }); + } + if (booked > 0) { + const y0 = yOf(fixed); + const y1 = yOf(fixed + booked); + bookedRects.push({ key: iso, x, y: y1, h: Math.max(1, y0 - y1) }); + lastBookedX = x; + } + } + } + } + + // Rechterrand van de benutte boeking; de handle en de staart hangen daaraan. + const usedEndX = lastBookedX === null ? AXIS.padLeft : lastBookedX + dayWidth; + // Het plafond ligt `ceiling - benutte uitloop` werkdagen voorbij die rand (kan negatief zijn: + // een plafond dat krapper is dan wat er nu benut wordt — dan valt de handle ín de strook). + const tailWorkdays = ceiling === null ? 0 : ceiling - endShiftWorkdays; + const handleX = Math.max( + AXIS.padLeft, + Math.min(stripWidth - AXIS.padRight, usedEndX + tailWorkdays * dayWidth), + ); + + const ceilingText = ceiling === null + ? t('resource.distribution.strip.ceilingUnlimited') + : t('resource.distribution.strip.ceilingDays', { count: ceiling }); + const endEffectText = endShiftWorkdays === 0 + ? t('resource.distribution.strip.endUnchanged') + : t('resource.distribution.strip.endShift', { count: endShiftWorkdays }); + // "Toegestaan maar niet benut" (§6): pas melden zodra er echt ruimte overblijft. + const achievableText = ceiling !== null && endShiftWorkdays < ceiling + ? t('resource.distribution.strip.requestedVsAchievable', { + requested: ceiling, achievable: endShiftWorkdays, + }) + : null; + // Gedegradeerd overzicht (§3.4): de waarde is gezet, maar er is niet doorgerekend — dan zou het + // oude effect een LEUGEN zijn bij de nieuwe handle-stand. + const effectText = degraded ? t('resource.distribution.compute.pressRecompute') : endEffectText; + const valueText = [ceilingText, effectText, achievableText].filter(Boolean).join(' — '); + + const clamp = (value: number) => Math.max(0, Math.min(CEILING_MAX_WORKDAYS, value)); + // Zie het moduleblok: onbegrensd is geen getal, dus een pijltje pakt de handle op waar hij staat. + const stepBase = ceiling ?? Math.max(0, endShiftWorkdays); + + const onHandleKey = (event: React.KeyboardEvent) => { + if (pinned) return; + let next: number | null; + switch (event.key) { + case 'ArrowRight': case 'ArrowUp': next = clamp(stepBase + 1); break; + case 'ArrowLeft': case 'ArrowDown': next = clamp(stepBase - 1); break; + case 'PageUp': next = clamp(stepBase + 5); break; + case 'PageDown': next = clamp(stepBase - 5); break; + case 'Home': next = 0; break; + case 'End': next = null; break; + default: return; + } + // De dialoog scrollt; pijltjes en Home/End mogen die scroll niet óók verzetten. + event.preventDefault(); + event.stopPropagation(); + onCeilingChange(next); + }; + + return ( +
+
+ {title} + + {recorded ? ( + // #63: geen bedienbare pin — het document doet pas mee als de gebruiker dáár herberekent. + + {t('resource.distribution.strip.pinnedRecorded')} + + ) : ( + + )} + {pinned && !recorded && ( + {t('resource.distribution.strip.pinned')} + )} + {cannotMove && ( + {t('resource.distribution.strip.cannotMove')} + )} + + {achievableText !== null && ( + + {achievableText} + + )} + + {effectText} + +
+ + {/* Geforceerd LTR, net als het histogram: een tijdas spiegelt nergens in dit product. */} +
+
+ + + {/* Vaste last: wat gepinde/#63-documenten al opeisen (§6 achtergrondband). */} + {fixedRects.map(r => ( + + ))} + {/* De boeking van dít document; boekingsloze dagen binnen de spanne blijven leeg. */} + {bookedRects.map(r => ( + + ))} + {/* Toegestaan maar niet benut. */} + {tailWorkdays > 0 && dayWidth > 0 && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/components/panels/ResourceOccupancyView.tsx b/src/components/panels/ResourceOccupancyView.tsx index c965f3c25..9e25bed76 100644 --- a/src/components/panels/ResourceOccupancyView.tsx +++ b/src/components/panels/ResourceOccupancyView.tsx @@ -14,7 +14,7 @@ import { import { documentTitle, untitledOrdinals, displayDocumentTitle, DOC_PALETTE } from '@/utils/documents'; import { freshDistributionUi } from '@/components/dialogs/DistributionDialog/useDistributionProposal'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; -import { parseDate, formatDate, addCalendarDays, diffDays } from '@/utils/dateUtils'; +import { AXIS, buildOccupancyAxis } from './occupancyAxis'; /** Maximaal getoonde conflictdatums in de badge-tooltip/subregel (§5: "max. ~5, dan …"). */ const MAX_CONFLICT_DATES_SHOWN = 5; @@ -615,43 +615,20 @@ export function ResourceOccupancyView({ companyId, pool }: { companyId: string; // --- §5a: SVG-histogram per geselecteerd poolitem ------------------------------------------------ -/** Vaste tekenmaten van het histogram (viewBox-eenheden ≈ px; horizontaal scrollbaar). */ +/** Vaste tekenmaten van het histogram (viewBox-eenheden ≈ px; horizontaal scrollbaar). De + * horizontale maten komen uit de GEDEELDE as (`occupancyAxis.ts`) — het histogram en de + * fasestroken van de verdeeldialoog tekenen op precies dezelfde x-indeling. */ const CHART = { plotHeight: 130, axisGap: 16, // ruimte onder de plot voor datumlabels - padLeft: 34, // ruimte links voor de y-as-waarden - padRight: 8, + padLeft: AXIS.padLeft, + padRight: AXIS.padRight, padTop: 8, - minDayWidth: 4, - maxDayWidth: 16, - targetWidth: 760, // richtbreedte; meer dagen ⇒ breder (scroll), minder ⇒ bredere staven - breakWidth: 14, // breedte van de "⋯"-breukmarkering tussen segmenten }; -/** Gaten langer dan dit aantal kalenderdagen zonder enige boeking worden ingeklapt (§5a). */ -const GAP_COMPRESS_DAYS = 30; - /** Minimale horizontale ruimte tussen twee datumlabels (viewBox-eenheden ≈ px bij fontSize 8). */ const MIN_LABEL_GAP = 48; -interface ChartSegment { - /** Alle kalenderdagen van het segment (granulariteit één dag, incl. boekingsloze dagen). */ - days: string[]; - /** x-positie (viewBox) van de eerste dag. */ - x0: number; -} - -/** Alle ISO-kalenderdagen van `from` t/m `to` (inclusief). */ -function expandDays(from: string, to: string): string[] { - const days: string[] = []; - for (let d = parseDate(from); ; d = addCalendarDays(d, 1)) { - const iso = formatDate(d); - if (iso > to) break; - days.push(iso); - } - return days; -} - /** * Gestapeld daghistogram voor één poolitem (§5a, herzien): per ISO-dag de bijdrage per GETELD * document (vaste kleur per document + legenda), de capaciteitslijn van het poolitem via @@ -684,45 +661,12 @@ function OccupancyHistogram({ row, poolItem, untitledLabel, docColors }: { // geen cijfers en dragen dus ook geen as-domein bij). const bookedDays = new Set(); for (const d of countedDocs) for (const iso of Object.keys(d.dailyLoad)) bookedDays.add(iso); - const sortedBooked = [...bookedDays].sort(); - if (sortedBooked.length === 0) return null; - - // Gatcompressie: een aaneengesloten reeks kalenderdagen ZONDER boeking die langer is dan - // GAP_COMPRESS_DAYS breekt het domein in twee segmenten. `diffDays(prev, iso) - 1` is precies - // de lengte van dat gat (opeenvolgende dagen ⇒ 0). Binnen een segment worden álle - // kalenderdagen getoond, ook boekingsloze, zodat de tijd daar proportioneel blijft. - const ranges: { from: string; to: string }[] = []; - let from = sortedBooked[0]; - let prev = from; - for (let i = 1; i < sortedBooked.length; i++) { - const iso = sortedBooked[i]; - if (diffDays(prev, iso) - 1 > GAP_COMPRESS_DAYS) { - ranges.push({ from, to: prev }); - from = iso; - } - prev = iso; - } - ranges.push({ from, to: prev }); - - const totalDays = ranges.reduce((n, r) => n + diffDays(r.from, r.to) + 1, 0); - const dayWidth = Math.max( - CHART.minDayWidth, - Math.min(CHART.maxDayWidth, Math.floor(CHART.targetWidth / totalDays)), - ); - - const segments: ChartSegment[] = []; - const breaks: number[] = []; // x-middens van de breukmarkeringen tussen de segmenten - let cursor = CHART.padLeft; - for (const range of ranges) { - if (segments.length > 0) { - breaks.push(cursor + CHART.breakWidth / 2); - cursor += CHART.breakWidth; - } - const days = expandDays(range.from, range.to); - segments.push({ days, x0: cursor }); - cursor += days.length * dayWidth; - } - const width = cursor + CHART.padRight; + + // Domein, gatcompressie, dagbreedte en segmenten: de GEDEELDE as (`occupancyAxis.ts`), dezelfde + // die de fasestroken van de verdeeldialoog gebruiken. + const axis = buildOccupancyAxis([...bookedDays]); + if (axis === null) return null; + const { segments, breaks, dayWidth, width } = axis; const height = CHART.padTop + CHART.plotHeight + CHART.axisGap; // Belasting en capaciteit per getoonde dag; y-schaal over beide. diff --git a/src/components/panels/occupancyAxis.ts b/src/components/panels/occupancyAxis.ts new file mode 100644 index 000000000..c94d7a1ca --- /dev/null +++ b/src/components/panels/occupancyAxis.ts @@ -0,0 +1,119 @@ +// B1c-plan3 taak 9 — de GEDEELDE tijdas van het bezettingsoverzicht en de verdeeldialoog. +// +// Deze module is LETTERLIJK de as-opbouw die tot nu toe binnen `OccupancyHistogram`s memo stond +// (`ResourceOccupancyView.tsx`, §5a): domein → gatcompressie → dagbreedte → segmenten/breuken. +// Er is bewust geen gedragswijziging bij de verhuizing: het histogram roept nu dezelfde code aan +// die de fasestroken van de verdeeldialoog gebruiken, zodat een strook en het histogram eronder +// per constructie op dezelfde x-posities uitkomen. Een tweede, handgeschreven as in de dialoog zou +// daar stilzwijgend van kunnen afwijken. +// +// PUUR, GEEN REACT. De aanroeper bepaalt zelf wat er ín die as getekend wordt (staven, banden, +// handles) en hoe hoog de plot is; deze module kent alleen de horizontale indeling. +import { parseDate, formatDate, addCalendarDays, diffDays } from '@/utils/dateUtils'; + +/** Horizontale tekenmaten van de as (viewBox-eenheden ≈ px). Verticale maten horen bij de + * aanroeper — het histogram is 130 hoog, een fasestrook een fractie daarvan. */ +export const AXIS = { + padLeft: 34, // ruimte links voor de y-as-waarden van het histogram + padRight: 8, + minDayWidth: 4, + maxDayWidth: 16, + targetWidth: 760, // richtbreedte; meer dagen ⇒ breder (scroll), minder ⇒ bredere staven + breakWidth: 14, // breedte van de "⋯"-breukmarkering tussen segmenten +}; + +/** Gaten langer dan dit aantal kalenderdagen zonder enige boeking worden ingeklapt (§5a). */ +export const GAP_COMPRESS_DAYS = 30; + +export interface OccupancyAxisSegment { + /** Alle kalenderdagen van het segment (granulariteit één dag, incl. boekingsloze dagen). */ + days: string[]; + /** x-positie (viewBox) van de eerste dag. */ + x0: number; +} + +export interface OccupancyAxis { + segments: OccupancyAxisSegment[]; + /** x-middens van de breukmarkeringen tussen de segmenten. */ + breaks: number[]; + dayWidth: number; + width: number; + /** x-positie van de LINKERrand van die dag, of `null` wanneer de dag buiten het domein valt of + * in een ingeklapt gat verdwenen is. */ + xOf(iso: string): number | null; +} + +/** Alle ISO-kalenderdagen van `from` t/m `to` (inclusief). */ +export function expandDays(from: string, to: string): string[] { + const days: string[] = []; + for (let d = parseDate(from); ; d = addCalendarDays(d, 1)) { + const iso = formatDate(d); + if (iso > to) break; + days.push(iso); + } + return days; +} + +/** + * Bouw de tijdas over `bookedDays` (de dagen die er echt toe doen; volgorde en duplicaten maken + * niet uit). Een aaneengesloten gat van meer dan `GAP_COMPRESS_DAYS` kalenderdagen zonder boeking + * breekt het domein in twee segmenten; binnen een segment blijft de granulariteit één kalenderdag, + * zodat de tijd daar proportioneel blijft. + * + * Geeft `null` wanneer er geen enkele dag is — de aanroeper toont dan zijn eigen lege toestand. + */ +export function buildOccupancyAxis( + bookedDays: string[], + opts?: { targetWidth?: number }, +): OccupancyAxis | null { + const sortedBooked = [...new Set(bookedDays)].sort(); + if (sortedBooked.length === 0) return null; + + // Gatcompressie: `diffDays(prev, iso) - 1` is precies de lengte van het gat (opeenvolgende + // dagen ⇒ 0). + const ranges: { from: string; to: string }[] = []; + let from = sortedBooked[0]; + let prev = from; + for (let i = 1; i < sortedBooked.length; i++) { + const iso = sortedBooked[i]; + if (diffDays(prev, iso) - 1 > GAP_COMPRESS_DAYS) { + ranges.push({ from, to: prev }); + from = iso; + } + prev = iso; + } + ranges.push({ from, to: prev }); + + const totalDays = ranges.reduce((n, r) => n + diffDays(r.from, r.to) + 1, 0); + const dayWidth = Math.max( + AXIS.minDayWidth, + Math.min(AXIS.maxDayWidth, Math.floor((opts?.targetWidth ?? AXIS.targetWidth) / totalDays)), + ); + + const segments: OccupancyAxisSegment[] = []; + const breaks: number[] = []; + let cursor = AXIS.padLeft; + for (const range of ranges) { + if (segments.length > 0) { + breaks.push(cursor + AXIS.breakWidth / 2); + cursor += AXIS.breakWidth; + } + const days = expandDays(range.from, range.to); + segments.push({ days, x0: cursor }); + cursor += days.length * dayWidth; + } + const width = cursor + AXIS.padRight; + + const xByIso = new Map(); + for (const segment of segments) { + for (let i = 0; i < segment.days.length; i++) xByIso.set(segment.days[i], segment.x0 + i * dayWidth); + } + + return { + segments, + breaks, + dayWidth, + width, + xOf: (iso: string) => xByIso.get(iso) ?? null, + }; +} diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 167df42c2..2205c7b27 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -164,3 +164,95 @@ test('verdeeldialoog: een geblokkeerd voorstel legt uit waarom en biedt geen Toe await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(0); await expect(page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ })).toHaveCount(0); }); + +// --- B1c-plan3 taak 9 — de fasestroken (spec §6, minus het pointer-slepen van taak 10) ---------- + +/** + * `seedProject` maakt HANDMATIG GEPLANDE taken (`manuallyScheduled: true`) — daar rekent de + * CPM-solver de datums niet van, dus een nivelleringsdelay verschuift wel de boeking maar NIET de + * einddatum. Voor een fixture die het einddatum-effect meet moet de taak dus door de planner + * gestuurd worden; verder blijft alles bij het oude (duur en projectstartdatum staan al goed). + */ +async function makeScheduled(page: Page, taskId: string): Promise { + await page.evaluate(id => { + window.__OPS__!.store.getState().updateTask(id, { manuallyScheduled: false }); + window.__OPS__!.store.getState().runCPM(); + }, taskId); +} + +/** + * Twee documenten met elk ÉÉN werkdag werk op DEZELFDE dag, op een poolitem met capaciteit 1. + * Het document met rang 2 kan die maandag niet krijgen en schuift bij een plafond ≥ 1 precies één + * werkdag op — het kleinste fixture waarin het einddatum-effect een vast, controleerbaar getal is. + */ +async function seedTwoSingleDayDocuments(page: Page): Promise { + const [taskA] = await seedProject(page, [{ + name: 'Eendaagse A', start: '2026-09-07', finish: '2026-09-07', durationDays: 1, + }], 'Eendaags project A'); + await makeScheduled(page, taskA); + const library = await createLibrary(page, 1); + await bookOnPoolItem(page, library, taskA, 1); + + await page.evaluate(() => window.__OPS__!.store.getState().newDocument()); + const [taskB] = await seedProject(page, [{ + name: 'Eendaagse B', start: '2026-09-07', finish: '2026-09-07', durationDays: 1, + }], 'Eendaags project B'); + await makeScheduled(page, taskB); + await bookOnPoolItem(page, library, taskB, 1); + return library; +} + +test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const strip = page.locator('[data-ops-distribution-strip]').first(); + // De pin is een echte toggle-knop: STABIELE naam, toestand uitsluitend via `aria-pressed` (§6). + const pin = strip.getByRole('button', { name: /Vastzetten|Pin/ }); + await expect(pin).toHaveAttribute('aria-pressed', 'false'); + await pin.click(); + await expect(pin).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => page.evaluate(() => Object.values( + window.__OPS__!.store.getState().ui.levelingDistribution!.pinned).filter(Boolean).length)).toBe(1); + + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(strip).toHaveAttribute('data-ops-distribution-pinned', 'true'); + + await pin.click(); // pin weer los — een gepinde strook heeft geen bedienbaar plafond + await expect(pin).toHaveAttribute('aria-pressed', 'false'); + + const handle = strip.getByRole('slider'); + await handle.focus(); + await handle.press('ArrowRight'); + await expect.poll(() => page.evaluate(() => window.__OPS__!.store.getState() + .ui.levelingDistribution!.ceilings[Object.keys(window.__OPS__!.store.getState().ui.levelingDistribution!.ceilings)[0]])) + .toBe(1); + + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await handle.press('End'); + await expect(handle).toHaveAttribute('aria-valuetext', /onbegrensd|unlimited/i); +}); + +test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // De stroken staan in RANGORDE; nr. 2 is degene die moet wijken. + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.getByRole('slider'); + await handle.focus(); + // Home = plafond 0 — een deterministisch startpunt, ongeacht de benutte uitloop. + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + for (let step = 0; step < 3; step++) await handle.press('ArrowRight'); + await expect(handle).toHaveAttribute('aria-valuenow', '3'); + + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + // Het label meldt wat er met de EINDDATUM gebeurt (+1 werkdag), niet hoe ver de handle stond (3). + await expect(strip.locator('[data-ops-distribution-effect]')).toContainText(/eind \+1 dag|end \+1 day/i); + // Toegestaan maar niet benut: gevraagd 3, dichtst haalbare 1. + await expect(strip.locator('[data-ops-distribution-achievable]')).toBeVisible(); +}); From 52d58d521e6e321752b3b42e648a970e5aa9718d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:33:28 +0200 Subject: [PATCH 018/108] feat(library): voor- en na-boeking per document uit dezelfde boekhouding als het grootboek (B1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DistributionProposal krijgt bookingByDay (letterlijk row.docs[].dailyLoad per document) en afterLoadByDay (wat er via bookPlaced daadwerkelijk geboekt wordt, incl. de ongewijzigde boeking van gepinde/#63-documenten) plus afterIncomplete. Zo hoeft de verdeeldialoog computeLibraryOccupancy niet nog een keer aan te roepen voor de "voor"-stand. Meegenomen: tests/library/check-apply-distribution.ts se hand-gebouwde makeProposal() kreeg de drie nieuwe verplichte velden erbij — zonder die aanvulling faalde de tsc-compile-afdwinging in tests/library/run.sh voor de hele suite, niet alleen voor dit werk. Co-Authored-By: Claude Fable 5.1 --- src/services/library/distribute.ts | 47 +++++++- tests/library/check-apply-distribution.ts | 4 + tests/library/check-distribute.ts | 133 ++++++++++++++++++++++ 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/src/services/library/distribute.ts b/src/services/library/distribute.ts index e5d90cfde..de2b212a4 100644 --- a/src/services/library/distribute.ts +++ b/src/services/library/distribute.ts @@ -132,6 +132,18 @@ export interface DistributionProposal { residualByDay: Record; /** Minstens één document houdt een tekort ⇒ Toepassen blijft uit (etappe 3). */ hasShortfall: boolean; + /** docId → ISO-dag → boeking VÓÓR de verdeling (letterlijk `dailyLoad` uit `computeLibraryOccupancy`, + * voor elk document dat op dit poolitem boekt — ook gepind/#63/cannotMove). Voedt de fasestroken en + * de "voor"-stand van de preview zonder tweede bezettingsberekening. */ + bookingByDay: Record>; + /** docId → ISO-dag → boeking NA de verdeling (spec §7, voor/na-preview). Dezelfde boekhouding als het + * grootboek zelf: elke boeking die in `residualOn` meetelt komt hier langs, en geen andere. Gepinde/ + * #63-documenten staan er met hun ongewijzigde boeking in (= hun aandeel in `fixedLoadByDay`). */ + afterLoadByDay: Record>; + /** `true` ⇒ minstens één taak kon niet geplaatst worden, dus haar vraag staat NERGENS in + * `afterLoadByDay`. Loopt altijd gelijk op met `hasShortfall`; apart benoemd omdat de preview er iets + * anders mee doet dan de Toepassen-knop. */ + afterIncomplete: boolean; } /** Injecteerbare motor-rand, zelfde patroon als `OccupancyEphemeralSolve` — de default draait de @@ -300,6 +312,7 @@ export function computeDistribution( ): DistributionProposal { const empty: DistributionProposal = { libraryItemId, blocked: null, docs: [], fixedLoadByDay: {}, residualByDay: {}, hasShortfall: false, + bookingByDay: {}, afterLoadByDay: {}, afterIncomplete: false, }; const poolItem = pool.resources.find(r => r.id === libraryItemId); @@ -334,6 +347,18 @@ export function computeDistribution( // Stap 3 (vaste last): gepinde + #63-documenten. "Een document dat wél op het poolitem boekt maar // níét als deelnemer meedoet" heeft in dit contract maar één mechanisme — de aanroeper levert het // gewoon mee met `pinned: true` (zie `DistributionDocInput.pinned`s docblok). + // `bookingByDay` (B1c-plan3 taak 11): de "voor"-boeking, letterlijk `row.docs[].dailyLoad` — voor + // ELK boekend document, ongeacht gepind/#63/cannotMove/deelnemer. Één kopie per document zodat de + // aanroeper nooit per ongeluk `computeLibraryOccupancy`s eigen map muteert. + const bookingByDay: Record> = {}; + for (const booking of row.docs) bookingByDay[booking.docId] = { ...booking.dailyLoad }; + + // `afterLoadByDay` (B1c-plan3 taak 11): de na-stand, per document. Gevuld op twee manieren die + // samen dezelfde boekhouding als het grootboek vormen: gepinde/#63-documenten hieronder met hun + // ONGEWIJZIGDE boeking (zij veranderen niet — hun aandeel in `fixedLoadByDay`); deelnemers verderop + // via `bookPlaced`, die exact meeloopt met wat er in `placedByItem` (dus `residualOn`) terechtkomt. + const afterLoadByDay: Record> = {}; + const fixedLoadByDay: Record = {}; for (const booking of row.docs) { const input = docById.get(booking.docId); @@ -341,6 +366,7 @@ export function computeDistribution( for (const [iso, units] of Object.entries(booking.dailyLoad)) { fixedLoadByDay[iso] = (fixedLoadByDay[iso] ?? 0) + units; } + afterLoadByDay[booking.docId] = { ...booking.dailyLoad }; } // Het gedeelde poolitem-grootboek (§4 stap 2), PER ITEM geboekt (B1c-plan3 taak 1, bevinding 6). @@ -351,9 +377,14 @@ export function computeDistribution( // accumuleert over ALLE deelnemende documenten, in rangorde — dat IS de sequentiële kern van het // protocol. const placedByItem: Record> = {}; - const bookPlaced = (itemId: string, iso: string, units: number) => { + // `docId` erbij (B1c-plan3 taak 11): elke boeking die hier langskomt telt zowel in het gedeelde + // grootboek (`placedByItem`, ongewijzigd t.o.v. vóór taak 11) als onder het boekende document in + // `afterLoadByDay` — dezelfde boekhouding, twee aanzichten, één plek die ze bijhoudt. + const bookPlaced = (itemId: string, iso: string, units: number, docId: string) => { const bucket = placedByItem[itemId] ?? (placedByItem[itemId] = {}); bucket[iso] = (bucket[iso] ?? 0) + units; + const docBucket = afterLoadByDay[docId] ?? (afterLoadByDay[docId] = {}); + docBucket[iso] = (docBucket[iso] ?? 0) + units; }; const residualOn = (itemId: string, iso: string): number => itemId === libraryItemId @@ -377,7 +408,7 @@ export function computeDistribution( return libraryItemId; }, residualOn: (itemId: string, iso: string): number => residualOn(itemId, iso), - book: (itemId: string, iso: string, units: number): void => bookPlaced(itemId, iso, units), + book: (itemId: string, iso: string, units: number): void => bookPlaced(itemId, iso, units, doc.docId), horizonIso, }; }; @@ -438,7 +469,7 @@ export function computeDistribution( // bestaande boeking rechtstreeks in het grootboek (het document bezet de pool wél — het is // gewoon niet verplaatsbaar), geen motor-run. const booking = bookingByDocId.get(doc.docId)!; - for (const [iso, units] of Object.entries(booking.dailyLoad)) bookPlaced(libraryItemId, iso, units); + for (const [iso, units] of Object.entries(booking.dailyLoad)) bookPlaced(libraryItemId, iso, units, doc.docId); const end = currentProjectEndFor(doc); results.push({ docId: doc.docId, title: doc.title, participated: true, cannotMove: true, @@ -483,12 +514,20 @@ export function computeDistribution( residualByDay[iso] = residualOn(libraryItemId, iso); } + const hasShortfall = results.some(d => d.shortfalls.length > 0); + return { libraryItemId, blocked: null, docs: results, fixedLoadByDay, residualByDay, - hasShortfall: results.some(d => d.shortfalls.length > 0), + hasShortfall, + bookingByDay, + afterLoadByDay, + // Loopt per definitie gelijk op met `hasShortfall` (zie het veldcommentaar): een niet-geplaatste + // taak wordt nergens via `bookPlaced` geboekt, dus haar vraag ontbreekt automatisch in + // `afterLoadByDay` — er is geen aparte berekening nodig. + afterIncomplete: hasShortfall, }; } diff --git a/tests/library/check-apply-distribution.ts b/tests/library/check-apply-distribution.ts index 95ceb6f3a..959d61f16 100644 --- a/tests/library/check-apply-distribution.ts +++ b/tests/library/check-apply-distribution.ts @@ -65,6 +65,10 @@ function makeProposal(overrides?: Partial): DistributionPr }), ], fixedLoadByDay: {}, residualByDay: {}, hasShortfall: false, + // B1c-plan3 taak 11: `bookingByDay`/`afterLoadByDay`/`afterIncomplete` zijn niet relevant voor + // dit schrijfpad-fixture (dat toetst `applyDistribution`, niet `computeDistribution` — dat laatste + // is al gedekt door check-distribute.ts), maar wel verplicht op het type. + bookingByDay: {}, afterLoadByDay: {}, afterIncomplete: false, ...overrides, }; } diff --git a/tests/library/check-distribute.ts b/tests/library/check-distribute.ts index 3d8859378..5782c0eae 100644 --- a/tests/library/check-distribute.ts +++ b/tests/library/check-distribute.ts @@ -6,7 +6,9 @@ // // Draait via run.sh. Exit 0 = alles groen. import { computeDistribution, type DistributionDocInput, type DistributionLevelRun } from '@/services/library/distribute'; +import { computeLibraryOccupancy } from '@/services/library/occupancy'; import type { LevelingPoolLedger } from '@/engine/scheduler/ResourceLeveler'; +import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; import { writeIFC } from '@/services/ifc/ifcWriter'; import { readIFC } from '@/services/ifc/ifcReader'; import { createDefaultProject } from '@/state/slices/projectSlice'; @@ -635,6 +637,137 @@ console.log('-- distribute: het poolitem-grootboek boekt PER ITEM (bevinding 6) ); } +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 11 (a)+(b): `afterLoadByDay`/`bookingByDay`, sluitende boekhouding. Zelfde fixture +// als case 1 (geen tekort): d0 (gepind) bezet ma 08-03 volledig; d1 se A wijkt één dag om d0 heen. +// (a) Voor elke dag in `residualByDay` is Σ_doc afterLoadByDay[doc][iso] + residualByDay[iso] gelijk +// aan de poolcapaciteit — het gepinde document (d0) staat ZELF in `afterLoadByDay`, dus de som +// daarover dekt zowel de vaste last als de geplaatste boeking, geen dubbeltelling nodig. +// (b) d0 (gepind) staat met zijn ONGEWIJZIGDE boeking in de na-stand: gelijk aan zijn voor-stand. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: afterLoadByDay + residualByDay = capaciteit (taak 11a) --'); +{ + const p = pool([poolRes('lib-1', 'Kraan', 2)]); + const d0 = distDoc('d0', { + pinned: true, + resources: [stamped('d0-r1', 'lib-1')], + tasks: [task('x0', '2026-08-03', '2026-08-03', 1)], + assignments: [assign('d0-a1', 'x0', 'd0-r1', 2)], + }); + const taskZ = task('z', '2026-08-03', '2026-08-05', 3); + const taskA = task('a', '2026-08-03', '2026-08-04', 2); + const d1 = distDoc('d1', { + rank: 1, + resources: [stamped('d1-r1', 'lib-1')], + tasks: [taskZ, taskA], + assignments: [assign('d1-a1', 'a', 'd1-r1', 1)], + }); + const result = computeDistribution('c1', p, 'lib-1', [d0, d1], OPTS_OFF); + const poolItem = p.resources[0]; + const docIds = result.docs.map(d => d.docId); + for (const iso of Object.keys(result.residualByDay)) { + const sumAfter = docIds.reduce((sum, id) => sum + (result.afterLoadByDay[id]?.[iso] ?? 0), 0); + const cap = maxUnitsOn(poolItem, iso); + assert( + sumAfter + result.residualByDay[iso] === cap, + `taak 11a: dag ${iso}: Σafter (${sumAfter}) + rest (${result.residualByDay[iso]}) !== capaciteit (${cap})`, + ); + } + assert( + JSON.stringify(result.afterLoadByDay['d0']) === JSON.stringify(result.bookingByDay['d0']), + `taak 11b: gepind document staat ongewijzigd in de na-stand (na: ${JSON.stringify(result.afterLoadByDay['d0'])}, voor: ${JSON.stringify(result.bookingByDay['d0'])})`, + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 11 (c): bij een tekort is `afterIncomplete === true` en de niet-geplaatste vraag +// ontbreekt in `afterLoadByDay`. LET OP: dit vraagt om een tekort waar de POOL zelf nog ruimte heeft +// (anders toont de som toch de volle capaciteit — gewoon omdat een ANDER document 'm al volmaakt, niet +// omdat de ontbrekende vraag ontbreekt). Daarom hier het CEILING_TOO_TIGHT-type tekort van case 4b +// (eigen krappe projectinzet, GEEN pool-blokkade), met de poolcapaciteit gelijkgetrokken aan a1+a2 +// samen (2): had a2 wél geplaatst, dan was de pool exact vol; nu ontbreekt precies haar eenheid. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: tekort ⇒ afterIncomplete en ontbrekende vraag (taak 11c) --'); +{ + const p = pool([poolRes('lib-1', 'Kraan', 2)]); // a1 (1) + a2 (1) = 2, zou de pool precies vullen + const d1 = distDoc('d1', { + rank: 1, ceilingWorkdays: 0, + resources: [stamped('d1-r1', 'lib-1', 'c1', { maxUnits: 1 })], // krappe PROJECTinzet blokkeert a2, niet de pool + tasks: [ + task('a1', '2026-08-03', '2026-08-03', 1, { priority: 900 }), + task('a2', '2026-08-03', '2026-08-03', 1, { priority: 100, deadline: '2026-08-03' }), + ], + assignments: [assign('d1-a1', 'a1', 'd1-r1', 1), assign('d1-a2', 'a2', 'd1-r1', 1)], + }); + const result = computeDistribution('c1', p, 'lib-1', [d1], OPTS_OFF); + const shortfallA2 = result.docs[0].shortfalls.find(s => s.taskId === 'a2'); + assert( + shortfallA2?.reason === 'CEILING_TOO_TIGHT', + `taak 11c sanity: tekort door het eigen plafond, niet door de pool (kreeg ${shortfallA2?.reason})`, + ); + assert(result.hasShortfall === true, 'taak 11c sanity: hasShortfall staat'); + assert(result.afterIncomplete === true, `taak 11c: afterIncomplete volgt hasShortfall (kreeg ${result.afterIncomplete})`); + const poolItem = p.resources[0]; + const conflictIso = '2026-08-03'; + const sumAfter = result.docs.reduce((sum, d) => sum + (result.afterLoadByDay[d.docId]?.[conflictIso] ?? 0), 0); + const cap = maxUnitsOn(poolItem, conflictIso); + assert(sumAfter < cap, `taak 11c: de niet-geplaatste vraag (a2) ontbreekt uit afterLoadByDay — som (${sumAfter}) < capaciteit (${cap})`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 11 (d): een `blocked`-voorstel levert lege boekhoudingen — zelfde fixture als case 16 +// (MATERIAL_ITEM). +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: blocked-voorstel ⇒ bookingByDay/afterLoadByDay leeg (taak 11d) --'); +{ + const p = pool([poolRes('lib-mat', 'Beton', 1, { type: 'MATERIAL' })]); + const mk = (id: string, rank: number) => distDoc(id, { + rank, + resources: [stamped(`${id}-r1`, 'lib-mat', 'c1', { type: 'MATERIAL' })], + tasks: [task(`${id}-t1`, '2026-08-03', '2026-08-03', 1)], + assignments: [assign(`${id}-a1`, `${id}-t1`, `${id}-r1`, 1)], + }); + const result = computeDistribution('c1', p, 'lib-mat', [mk('d1', 1), mk('d2', 2)], OPTS_OFF); + assert(result.blocked?.reason === 'MATERIAL_ITEM', 'taak 11d sanity: geblokkeerd (zelfde fixture als case 16)'); + assert(JSON.stringify(result.bookingByDay) === '{}', `taak 11d: bookingByDay is leeg bij blokkade (kreeg ${JSON.stringify(result.bookingByDay)})`); + assert(JSON.stringify(result.afterLoadByDay) === '{}', `taak 11d: afterLoadByDay is leeg bij blokkade (kreeg ${JSON.stringify(result.afterLoadByDay)})`); + assert(result.afterIncomplete === false, 'taak 11d: afterIncomplete is false bij blokkade'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// B1c-plan3 taak 11 (e): `bookingByDay` bevat élk boekend document (ook gepind), met dezelfde waarden +// als `computeLibraryOccupancy`s eigen `row.docs[].dailyLoad` — geen tweede, kunnen-divergeren +// berekening. Zelfde fixture als taak 11a/b. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: bookingByDay bevat elk boekend document, gelijk aan de bezetting (taak 11e) --'); +{ + const p = pool([poolRes('lib-1', 'Kraan', 2)]); + const d0 = distDoc('d0', { + pinned: true, + resources: [stamped('d0-r1', 'lib-1')], + tasks: [task('x0', '2026-08-03', '2026-08-03', 1)], + assignments: [assign('d0-a1', 'x0', 'd0-r1', 2)], + }); + const taskZ = task('z', '2026-08-03', '2026-08-05', 3); + const taskA = task('a', '2026-08-03', '2026-08-04', 2); + const d1 = distDoc('d1', { + rank: 1, + resources: [stamped('d1-r1', 'lib-1')], + tasks: [taskZ, taskA], + assignments: [assign('d1-a1', 'a', 'd1-r1', 1)], + }); + const result = computeDistribution('c1', p, 'lib-1', [d0, d1], OPTS_OFF); + const { rows } = computeLibraryOccupancy('c1', p, [d0, d1]); + const row = rows.find(r => r.libraryItemId === 'lib-1')!; + assert(row.docs.length > 0, 'taak 11e sanity: er boeken documenten op dit poolitem'); + for (const booking of row.docs) { + assert( + JSON.stringify(result.bookingByDay[booking.docId]) === JSON.stringify(booking.dailyLoad), + `taak 11e: bookingByDay['${booking.docId}'] == row.docs[].dailyLoad (kreeg ${JSON.stringify(result.bookingByDay[booking.docId])}, verwacht ${JSON.stringify(booking.dailyLoad)})`, + ); + } +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (fails === 0) { console.log(`OK distribute: alle checks groen (${checks})`); From 2ef77ea93be59cc82a6e31ab25a2d525d02279e6 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:40:53 +0200 Subject: [PATCH 019/108] feat(library): plafond-handle en rangorde met de muis, rekenen pas bij loslaten (B1c) Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 67 ++++++++++++- .../dialogs/DistributionDialog/PhaseStrip.tsx | 93 ++++++++++++++++--- tests/browser/leveling-distribution.spec.ts | 74 +++++++++++++++ 3 files changed, 218 insertions(+), 16 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 0b71200c4..8cb665ec4 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -6,11 +6,19 @@ // gewoon zichtbaar ONDER de dialoog — de verdeling is een handeling óp dat overzicht, geen // vervanging ervan. // -// WAT HIER (NOG) NIET STAAT. Taak 10 maakt de plafondhandle van de fasestroken sleepbaar met de -// pointer (het toetsenbord is er al, zie `PhaseStrip`); taak 11 vult het voor/na-histogram. Taak 12 -// bedraadt "Toepassen" op `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET -// REDEN — een knop die er niet is laat de gebruiker raden of de functie bestaat. -import { useMemo } from 'react'; +// WAT HIER (NOG) NIET STAAT. Taak 11 vult het voor/na-histogram. Taak 12 bedraadt "Toepassen" op +// `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET REDEN — een knop die er +// niet is laat de gebruiker raden of de functie bestaat. +// +// RANGORDE MET DE MUIS (taak 10, spec §4 stap 1). Native HTML5 drag-and-drop — hetzelfde mechanisme +// als `DataGridHeader`'s kolomherordening (`draggable` + `onDragStart`/`onDragOver`/`onDrop`), niet +// een tweede pointer-events-sleepmechaniek erbij. De lijst is klein en niet virtualized, dus een +// per-rij `onDragOver`/`onDrop` volstaat hier — de window-brede dragover/drop-vangnet-luisteraars die +// de kolomkop nodig heeft (voor een sleep die de headerrij verlaat) zijn voor een rangordelijst met +// eigen scrolgebied niet nodig. De "naar boven/beneden"-knoppen (`move`) blijven de toetsenbordroute +// en het testanker; slepen (`reorderTo`) roept dezelfde `setUI` aan, dus een herordening met de muis +// zet net als de knoppen `staleReason = 'rank'` via `diffReason` in `useDistributionProposal`. +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; @@ -65,6 +73,25 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, order } }); }; + // Slepen (zie het moduleblok): `draggedDocId` is de rij die vastgehouden wordt, `dropTarget` de + // rij + plaatsing (boven/onder de rijmidden) waar hij op dit moment op zou landen — puur voor + // visuele feedback tijdens het slepen, `reorderTo` op `onDrop` is het enige commit-moment. + const [draggedDocId, setDraggedDocId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ docId: string; placement: 'before' | 'after' } | null>(null); + + const reorderTo = (docId: string, targetDocId: string, placement: 'before' | 'after') => { + if (!tune || docId === targetDocId) return; + const order = rankRows.map(row => row.docId); + const from = order.indexOf(docId); + if (from < 0) return; + order.splice(from, 1); + let to = order.indexOf(targetDocId); + if (to < 0) return; + if (placement === 'after') to += 1; + order.splice(to, 0, docId); + setUI({ levelingDistribution: { ...tune, order } }); + }; + const toggleSplits = () => { if (!tune) return; setUI({ levelingDistribution: { ...tune, allowSplits: !tune.allowSplits } }); @@ -247,6 +274,36 @@ export function DistributionDialog() { className="flex items-center gap-2 px-2 py-1 border-b border-border-light last:border-b-0" data-ops-distribution-rank-row data-ops-doc-id={row.docId} + draggable + data-ops-distribution-rank-dragging={draggedDocId === row.docId ? 'true' : undefined} + data-ops-distribution-rank-drop-before={ + dropTarget?.docId === row.docId && dropTarget.placement === 'before' ? 'true' : undefined + } + data-ops-distribution-rank-drop-after={ + dropTarget?.docId === row.docId && dropTarget.placement === 'after' ? 'true' : undefined + } + onDragStart={event => { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.docId); + setDraggedDocId(row.docId); + }} + onDragOver={event => { + if (draggedDocId === null || draggedDocId === row.docId) return; + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + const placement: 'before' | 'after' = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + setDropTarget({ docId: row.docId, placement }); + }} + onDrop={event => { + event.preventDefault(); + if (draggedDocId !== null && dropTarget !== null) reorderTo(draggedDocId, dropTarget.docId, dropTarget.placement); + setDraggedDocId(null); + setDropTarget(null); + }} + onDragEnd={() => { + setDraggedDocId(null); + setDropTarget(null); + }} > {index + 1} {row.title} diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 0c99352e2..613201c39 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -1,5 +1,4 @@ -// B1c-plan3 taak 9 — de FASESTROOK van één document in de verdeeldialoog (spec §6, minus het -// pointer-slepen: dat is taak 10). +// B1c-plan3 taak 9/10 — de FASESTROOK van één document in de verdeeldialoog (spec §6). // // WAT DE STROOK TOONT. Eén SVG-rij op de GEDEELDE tijdas (`occupancyAxis.ts` — dezelfde as als het // histogram in het bezettingsoverzicht, zodat een strook en dat histogram per constructie boven @@ -25,7 +24,25 @@ // WAAROM `ceiling ?? endShiftWorkdays` HET STAPPUNT IS. Een onbegrensd plafond is geen getal op de // as maar de End-stand; de handle staat dan visueel aan het einde van wat er BENUT wordt. Een // pijltje pakt hem dus op waar hij staat. Dat is ook de enige lezing waarin de handle niet -// verspringt op het moment dat je hem voor het eerst aanraakt. +// verspringt op het moment dat je hem voor het eerst aanraakt. De pointer-route (taak 10) deelt +// hetzelfde stappunt — één definitie voor toetsenbord én muis. +// +// POINTER-SLEPEN (taak 10, spec §6/§3.4). `setPointerCapture` op de handle zelf, GEEN document-brede +// listener: het slepen loopt door zodra de muis het element verlaat (zoals `ContourPhaseStrip.tsx` +// dat ook doet), en stopt gegarandeerd op pointerup/pointercancel zonder dat er iets kan "vastplakken". +// Tijdens het slepen is er een LOKALE `dragValue` — de aria-valuenow, de plafondtekst en de +// handlepositie volgen die waarde live, maar `endEffectText` (het label bij `data-ops-distribution- +// effect`) blijft het effect van de vorige, WÉL doorgerekende stand tonen: dat klopt, want het effect +// van de nieuwe stand is nog niet berekend (§3.4, "nooit per sleep-pixel"). Pas op pointerup gaat de +// waarde via `onCeilingChange` de ui-state in — hetzelfde discrete rekenmoment als een toetsaanslag of +// de pin-knop, en in de gedegradeerde modus dus ook: de waarde wordt gezet, er wordt niet doorgerekend. +// Snappen op hele werkdagen: de sleepafstand in pixels deelt door `dayWidth` en rondt af — dezelfde +// dayWidth-per-werkdag-conventie die de tekenpositie van de staart/handle hierboven al gebruikt (taak +// 9); een tweede, kalenderdaggetrouwe omrekening zou de handle tijdens het slepen van zijn eigen +// getekende positie laten afwijken. Een zuivere klik (pointerdown/-up zonder tussenliggende move) +// commit NIETS: anders zou een klik op een onbegrensde handle 'm stiekem naar een concreet getal +// verzetten. +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pin } from 'lucide-react'; import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; @@ -72,6 +89,12 @@ export function PhaseStrip({ }: PhaseStripProps) { const { t } = useTranslation('common'); + // Sleepstate (taak 10): `dragRef` is de ene bron van waarheid TIJDENS het slepen (geen staleness + // over event-grenzen heen), `dragValue` is de renderbare afgeleide ervan. Niet-`null` ⇒ er wordt nu + // gesleept; zie het moduleblok hierboven voor waarom effect/plafond dan uit elkaar lopen. + const dragRef = useRef<{ pointerId: number; startX: number; startCeiling: number; moved: boolean; value: number } | null>(null); + const [dragValue, setDragValue] = useState(null); + const dayWidth = axis?.dayWidth ?? 0; const stripWidth = axis?.width ?? AXIS.padLeft + 200; const yOf = (units: number) => @@ -106,24 +129,27 @@ export function PhaseStrip({ // Rechterrand van de benutte boeking; de handle en de staart hangen daaraan. const usedEndX = lastBookedX === null ? AXIS.padLeft : lastBookedX + dayWidth; - // Het plafond ligt `ceiling - benutte uitloop` werkdagen voorbij die rand (kan negatief zijn: - // een plafond dat krapper is dan wat er nu benut wordt — dan valt de handle ín de strook). - const tailWorkdays = ceiling === null ? 0 : ceiling - endShiftWorkdays; + // Tijdens het slepen toont de handle de LOKALE waarde, niet het gecommitte plafond — zie het + // moduleblok. Buiten het slepen is dat gewoon `ceiling`. + const displayCeiling = dragValue !== null ? dragValue : ceiling; + // Het plafond ligt `displayCeiling - benutte uitloop` werkdagen voorbij die rand (kan negatief + // zijn: een plafond dat krapper is dan wat er nu benut wordt — dan valt de handle ín de strook). + const tailWorkdays = displayCeiling === null ? 0 : displayCeiling - endShiftWorkdays; const handleX = Math.max( AXIS.padLeft, Math.min(stripWidth - AXIS.padRight, usedEndX + tailWorkdays * dayWidth), ); - const ceilingText = ceiling === null + const ceilingText = displayCeiling === null ? t('resource.distribution.strip.ceilingUnlimited') - : t('resource.distribution.strip.ceilingDays', { count: ceiling }); + : t('resource.distribution.strip.ceilingDays', { count: displayCeiling }); const endEffectText = endShiftWorkdays === 0 ? t('resource.distribution.strip.endUnchanged') : t('resource.distribution.strip.endShift', { count: endShiftWorkdays }); // "Toegestaan maar niet benut" (§6): pas melden zodra er echt ruimte overblijft. - const achievableText = ceiling !== null && endShiftWorkdays < ceiling + const achievableText = displayCeiling !== null && endShiftWorkdays < displayCeiling ? t('resource.distribution.strip.requestedVsAchievable', { - requested: ceiling, achievable: endShiftWorkdays, + requested: displayCeiling, achievable: endShiftWorkdays, }) : null; // Gedegradeerd overzicht (§3.4): de waarde is gezet, maar er is niet doorgerekend — dan zou het @@ -153,11 +179,51 @@ export function PhaseStrip({ onCeilingChange(next); }; + // Pointer-slepen (taak 10): `setPointerCapture` op de handle zelf — geen document-brede listener, + // zie het moduleblok. Alleen ÉÉN actieve pointer per handle: een tweede pointerdown terwijl er al + // gesleept wordt (multitouch) wordt genegeerd zolang de eerste nog loopt. + const onHandlePointerDown = (event: React.PointerEvent) => { + if (pinned || dragRef.current) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startCeiling: stepBase, moved: false, value: stepBase }; + setDragValue(stepBase); + }; + + const onHandlePointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + drag.moved = true; + if (dayWidth <= 0) return; + // Snappen op hele werkdagen: zie het moduleblok voor de dayWidth-per-werkdag-conventie. + const deltaWorkdays = Math.round((event.clientX - drag.startX) / dayWidth); + drag.value = clamp(drag.startCeiling + deltaWorkdays); + setDragValue(drag.value); + }; + + const onHandlePointerUp = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + setDragValue(null); + // DISCREET rekenmoment (§3.4): een zuivere klik (geen tussenliggende move) commit niets — zie + // het moduleblok. + if (drag.moved) onCeilingChange(drag.value); + }; + + const onHandlePointerCancel = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragValue(null); + }; + return (
@@ -245,11 +311,15 @@ export function PhaseStrip({ aria-label={t('resource.distribution.strip.handleLabel', { doc: title })} aria-valuemin={0} aria-valuemax={CEILING_MAX_WORKDAYS} - aria-valuenow={ceiling ?? CEILING_MAX_WORKDAYS} + aria-valuenow={displayCeiling ?? CEILING_MAX_WORKDAYS} aria-valuetext={valueText} aria-disabled={pinned || undefined} title={t('resource.distribution.strip.ceiling')} onKeyDown={onHandleKey} + onPointerDown={onHandlePointerDown} + onPointerMove={onHandlePointerMove} + onPointerUp={onHandlePointerUp} + onPointerCancel={onHandlePointerCancel} data-ops-distribution-handle className="absolute rounded-[2px]" style={{ @@ -260,6 +330,7 @@ export function PhaseStrip({ background: pinned ? 'var(--theme-text-dim)' : 'var(--theme-accent)', cursor: pinned ? 'not-allowed' : 'ew-resize', opacity: pinned ? 0.5 : 1, + touchAction: 'none', }} />
diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 2205c7b27..1b229bce6 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -256,3 +256,77 @@ test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', // Toegestaan maar niet benut: gevraagd 3, dichtst haalbare 1. await expect(strip.locator('[data-ops-distribution-achievable]')).toBeVisible(); }); + +// --- B1c-plan3 taak 10 — pointer-slepen op de plafond-handle, en rangorde met de muis ------------- + +test('plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // De stroken staan in RANGORDE; nr. 2 is degene die moet wijken (zelfde fixture als de vorige + // test). Home = plafond 0 — een deterministisch startpunt voor de sleep, ongeacht de benutte + // uitloop; expliciet herberekend zodat het effectlabel al bij díé stand hoort vóór de sleep begint. + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + await handle.focus(); + await handle.press('Home'); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + + // Geen magisch getal: de dagbreedte komt uit de gedeelde tijdas (`occupancyAxis.ts`), zichtbaar via + // hetzelfde `data-ops-distribution-day-width`-attribuut dat de sleeppositie zelf ook gebruikt. + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + expect(dayWidth).toBeGreaterThan(0); + + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + + // Tijdens het slepen: de PLAFOND-waarde beweegt mee, het EFFECT-label niet (spec §3.4 — "nooit per + // sleep-pixel"). + const effectBefore = await strip.locator('[data-ops-distribution-effect]').textContent(); + await page.mouse.move(box.x + box.width / 2 + 3 * dayWidth, box.y + box.height / 2, { steps: 6 }); + await expect(handle).toHaveAttribute('aria-valuenow', '3'); + expect(await strip.locator('[data-ops-distribution-effect]').textContent()).toBe(effectBefore); + + // De muis verlaat het element en het slepen loopt door — `setPointerCapture` op de handle, geen + // document-brede listener nodig. + await page.mouse.move(box.x + box.width / 2 + 5 * dayWidth, box.y - 200, { steps: 4 }); + await expect(handle).toHaveAttribute('aria-valuenow', '5'); + await page.mouse.up(); + + // Loslaten is een discreet rekenmoment (§3.4) ⇒ de waarde gaat de ui-state in en het overzicht + // rekent automatisch door (dezelfde route als een toetsaanslag of de pin-knop) — het effectlabel + // wordt dus uiteindelijk bijgewerkt, zonder dat de test zelf op "Herbereken" hoeft te klikken. + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()).not.toBe(effectBefore); + const shiftedDocId = await strip.getAttribute('data-ops-doc-id') ?? ''; + await expect.poll(() => page.evaluate(docId => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return ui.ceilings[docId]; + }, shiftedDocId)).toBe(5); +}); + +test('rangorde: slepen verandert de volgorde en laat het voorstel vervallen', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + + const rows = page.locator('[data-ops-distribution-rank-row]'); + await expect(rows).toHaveCount(2); + const firstDocId = await rows.nth(0).getAttribute('data-ops-doc-id'); + const secondDocId = await rows.nth(1).getAttribute('data-ops-doc-id'); + + // Sleep rij 2 boven rij 1 — native HTML5 drag-and-drop, HETZELFDE mechanisme als + // `DataGridHeader`'s kolomherordening (`draggable` + dragover/drop), geen los pointer-events- + // sleepmechanisme ernaast. `targetPosition` mikt op het BOVENSTE stuk van rij 1, zodat de drop als + // "voor" telt — een drop op het midden zou de rij weer op zijn oude plek laten vallen. + await rows.nth(1).dragTo(rows.nth(0), { targetPosition: { x: 20, y: 2 } }); + + await expect(page.locator('[data-ops-distribution-rank-row]').first()) + .toHaveAttribute('data-ops-doc-id', secondDocId!); + await expect(page.locator('[data-ops-distribution-rank-row]').last()) + .toHaveAttribute('data-ops-doc-id', firstDocId!); + // Een herordening is een rangordewijziging ⇒ hetzelfde `staleReason = 'rank'` als de knoppen. + await expect(page.locator('[data-ops-distribution-dialog]')) + .toHaveAttribute('data-ops-distribution-last-stale-reason', 'rank'); +}); From 13ad45e4e25f704cff23497cd5c3d997725ef4bd Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:55:12 +0200 Subject: [PATCH 020/108] feat(library): voor/na-preview van de verdeling op de gedeelde as (B1c) Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/BeforeAfterChart.tsx | 269 ++++++++++++++++++ .../DistributionDialog/DistributionDialog.tsx | 57 ++-- tests/browser/leveling-distribution.spec.ts | 13 + 3 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx diff --git a/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx b/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx new file mode 100644 index 000000000..0a95e6471 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx @@ -0,0 +1,269 @@ +// B1c-plan3 taak 11b — de voor/na-preview van de verdeeldialoog (spec §7). +// +// Twee gestapelde histogrammen, VOOR (`proposal.bookingByDay`) boven NA (`proposal.afterLoadByDay`), +// op de GEDEELDE tijdas (`occupancyAxis.ts` — dezelfde instantie als de fasestroken erboven, zodat +// een strook en de grafiek per constructie op dezelfde x-posities uitkomen; spec §7 wil één as voor +// stroken én grafiek, geen tweede berekening). De geometrie hieronder is een kleine, eigen kopie van +// wat `ResourceOccupancyView.tsx`s `OccupancyHistogram` doet (gestapelde staven per document, +// capaciteits-traplijn via `maxUnitsOn`, conflictband) — bewust GEEN gedeelde afhankelijkheid tussen +// de twee: die histogram-memo zit vast aan zijn EIGEN as-opbouw (gatcompressie, datumlabels) en heeft +// hier geen weerslag; een kleine, eigen versie is goedkoper dan een generieke component ervoor. +// +// CONFLICTDEFINITIE (ongewijzigd t.o.v. het bezettingsoverzicht): som van de boeking over alle +// documenten op een dag STRIKT GROTER dan `maxUnitsOn(poolItem, dag)` — geen tweede definitie. Voor +// de VOOR-stand is dat vrijwel altijd de bron van het conflict dat de gebruiker naar deze dialoog +// bracht; voor de NA-stand hoort er GEEN enkele conflictdag meer te zijn (dat is precies wat +// `computeDistribution` garandeert: `residualOn` blijft altijd ≥ 0) — behalve wanneer de motor een +// taak niet kon plaatsen (`afterIncomplete`): haar vraag staat dan NERGENS in `afterLoadByDay`, dus +// de na-som onderschat de werkelijke behoefte. Daarom een aparte, expliciete tekortmarkering bij de +// na-grafiek in plaats van een (foutieve) conflictband. +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Resource } from '@/types/resource'; +import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; +import { DOC_PALETTE } from '@/utils/documents'; +import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; + +const CHART = { + plotHeight: 72, + padTop: 4, + padLeft: AXIS.padLeft, + padRight: AXIS.padRight, +}; + +export interface BeforeAfterChartDoc { + docId: string; + title: string; +} + +export interface BeforeAfterChartShortfall { + docId: string; + title: string; + count: number; +} + +export interface BeforeAfterChartProps { + poolItem: Resource; + /** De GEDEELDE as — dezelfde instantie als de fasestroken erboven; `null` ⇒ niets te tekenen. */ + axis: OccupancyAxis | null; + /** Bovengrens van de verticale as, gedeeld tussen VOOR en NA zodat de twee grafieken echt + * vergelijkbaar zijn (dezelfde schaal die de dialoog al voor de fasestroken berekent). */ + scaleMax: number; + /** Alle documenten die op dit poolitem boeken, in RANGORDE — zelfde volgorde en zelfde docId's als + * de fasestroken erboven, zodat de documentkleur overal hetzelfde is. */ + docs: BeforeAfterChartDoc[]; + /** `DistributionProposal.bookingByDay` — de VOOR-stand, letterlijk het grootboek. */ + bookingByDay: Record>; + /** `DistributionProposal.afterLoadByDay` — de NA-stand, dezelfde boekhouding. */ + afterLoadByDay: Record>; + /** `DistributionProposal.afterIncomplete` — minstens één taak kon niet geplaatst worden. */ + afterIncomplete: boolean; + /** Per document met een tekort: hoeveel taken er niet pasten (`DistributionDocResult.shortfalls`). */ + shortfallDocs: BeforeAfterChartShortfall[]; +} + +/** Stabiele, onderling onderscheidbare kleur per document — dezelfde toewijzingslogica (eerste- + * gezien-volgorde over `DOC_PALETTE`) als `ResourceOccupancyView.tsx`s `docColors`, hier opnieuw + * toegepast op de rangorde van DEZE dialoog zodat strook en grafiek altijd matchen. */ +function assignDocColors(docIds: string[]): Map { + const colors = new Map(); + for (const docId of docIds) { + if (!colors.has(docId)) colors.set(docId, DOC_PALETTE[colors.size % DOC_PALETTE.length]); + } + return colors; +} + +interface StackedChart { + bars: { key: string; x: number; y: number; w: number; h: number; fill: string }[]; + conflictDays: { key: string; x: number }[]; + capPaths: string[]; + baselineY: number; +} + +/** Bouwt de geometrie van ÉÉN gestapeld histogram (VOOR of NA) op de gedeelde `axis`. Puur — geen + * React, geen state; aangeroepen voor beide standen met dezelfde `docsOrder`/`docColors`/`scaleMax` + * zodat de twee grafieken per constructie vergelijkbaar zijn. */ +function buildStack( + axis: OccupancyAxis, + poolItem: Resource, + loadByDoc: Map>, + docsOrder: string[], + docColors: Map, + scaleMax: number, +): StackedChart { + const yOf = (units: number) => + CHART.padTop + CHART.plotHeight * (1 - Math.min(1, Math.max(0, units) / Math.max(0.01, scaleMax))); + const bars: StackedChart['bars'] = []; + const conflictDays: StackedChart['conflictDays'] = []; + const capPaths: string[] = []; + + for (const segment of axis.segments) { + let capPath = ''; + let prevCap: number | null = null; + for (let i = 0; i < segment.days.length; i++) { + const iso = segment.days[i]; + const x = segment.x0 + i * axis.dayWidth; + + let acc = 0; + for (const docId of docsOrder) { + const units = loadByDoc.get(docId)?.[iso] ?? 0; + if (units <= 0) continue; + const y0 = yOf(acc); + acc += units; + const y1 = yOf(acc); + bars.push({ + key: `${iso}-${docId}`, + x: x + 0.5, + y: y1, + w: Math.max(1, axis.dayWidth - 1), + h: Math.max(0.5, y0 - y1), + fill: docColors.get(docId) ?? DOC_PALETTE[0], + }); + } + + // Conflictdefinitie (moduleblok hierboven): som STRIKT GROTER dan de capaciteit — géén tweede + // definitie t.o.v. het bezettingsoverzicht. + const cap = maxUnitsOn(poolItem, iso); + if (acc > cap) conflictDays.push({ key: iso, x }); + + const y = yOf(cap); + if (prevCap === null) capPath += `M ${x} ${y}`; + else if (cap !== prevCap) capPath += ` L ${x} ${y}`; + capPath += ` L ${x + axis.dayWidth} ${y}`; + prevCap = cap; + } + capPaths.push(capPath); + } + + return { bars, conflictDays, capPaths, baselineY: yOf(0) }; +} + +/** Eén van de twee gestapelde histogrammen (VOOR of NA), inclusief het `data-ops-distribution- + * chart-before`/`-after`-anker dat de browsertest gebruikt. */ +function MiniHistogram({ + kind, label, axis, chart, width, +}: { + kind: 'before' | 'after'; + label: string; + axis: OccupancyAxis; + chart: StackedChart; + width: number; +}) { + const height = CHART.padTop + CHART.plotHeight; + return ( +
+ {label} + {/* Geforceerd LTR, net als het bezettingshistogram en de fasestroken: een tijdas spiegelt + nergens in dit product. */} +
+ + {chart.conflictDays.map(r => ( + + ))} + {chart.bars.map(b => ( + + ))} + + {chart.capPaths.map((d, i) => ( + + ))} + {axis.breaks.map((x, i) => ( + + ))} + +
+
+ ); +} + +export function BeforeAfterChart({ + poolItem, axis, scaleMax, docs, bookingByDay, afterLoadByDay, afterIncomplete, shortfallDocs, +}: BeforeAfterChartProps) { + const { t } = useTranslation('common'); + + const docColors = useMemo(() => assignDocColors(docs.map(d => d.docId)), [docs]); + const docIds = useMemo(() => docs.map(d => d.docId), [docs]); + + const charts = useMemo(() => { + if (axis === null) return null; + const before = buildStack(axis, poolItem, new Map(Object.entries(bookingByDay)), docIds, docColors, scaleMax); + const after = buildStack(axis, poolItem, new Map(Object.entries(afterLoadByDay)), docIds, docColors, scaleMax); + return { before, after }; + }, [axis, poolItem, bookingByDay, afterLoadByDay, docIds, docColors, scaleMax]); + + if (axis === null || charts === null) { + return ( +
+ {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} +
+ ); + } + + return ( +
+ + +
+ {docs.map(doc => ( + + + {doc.title} + + ))} + + + {t('resource.distribution.preview.capacity')} + +
+ {/* NA-onvolledigheid (moduleblok hierboven): de na-balken missen de vraag van niet-geplaatste + taken — dat moet bij de na-grafiek zelf staan, niet alleen in het aparte tekortblok + onderaan de dialoog, anders leest een lezer de na-stand als "opgelost". */} + {afterIncomplete && ( +
+ + {t('resource.distribution.shortfall.title')} + + {shortfallDocs.map(doc => ( + + {t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })} + + ))} +
+ )} +
+ ); +} diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 8cb665ec4..4866d22fd 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -25,12 +25,12 @@ import { useAppStore } from '@/state/appStore'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; -import { computeLibraryOccupancy } from '@/services/library/occupancy'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; import { buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; import { documentFloatOn, useDistributionProposal } from './useDistributionProposal'; import { PhaseStrip } from './PhaseStrip'; +import { BeforeAfterChart } from './BeforeAfterChart'; export function DistributionDialog() { const { t, i18n } = useTranslation('common'); @@ -106,24 +106,16 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, ceilings: { ...tune.ceilings, [docId]: value } } }); }; - // De BOEKING per document per dag (§6, de gevulde blokken van een fasestrook). Die komt uit - // dezelfde kern als het bezettingsoverzicht — `computeDistribution` draait `computeLibraryOccupancy` - // intern al, maar geeft de per-document-dagcijfers niet terug, en een tweede, met de hand - // geschreven dagverdeling in de UI zou stilzwijgend van die ene bron kunnen afwijken. Daarom - // nogmaals dezelfde pure functie op dezelfde `inputs`: de deps zijn bewust NIET `tune` (die tikt - // bij elke pin- en plafondwijziging), maar het ONDERWERP plus de invoer van het huidige voorstel. - const companyId = tune?.companyId ?? null; - const libraryItemId = tune?.libraryItemId ?? null; + // De BOEKING per document per dag (§6, de gevulde blokken van een fasestrook + taak 11b de + // VOOR-stand van de voor/na-grafiek). `computeDistribution` levert dat zelf terug als + // `proposal.bookingByDay` (letterlijk het grootboek dat de kern al opbouwt) — een tweede aanroep + // van `computeLibraryOccupancy` hier zou stilzwijgend van diezelfde bron kunnen afwijken, dus die + // is verwijderd; dit leest uitsluitend uit het voorstel dat toch al berekend wordt. const bookingByDoc = useMemo(() => { const empty = new Map>(); - if (companyId === null || libraryItemId === null || inputs.length === 0) return empty; - const pool = pools[companyId]; - if (!pool) return empty; - const row = computeLibraryOccupancy(companyId, pool, inputs).rows - .find(r => r.libraryItemId === libraryItemId); - if (!row) return empty; - return new Map(row.docs.map(doc => [doc.docId, doc.dailyLoad])); - }, [companyId, libraryItemId, pools, inputs]); + if (!proposal || proposal.blocked) return empty; + return new Map(Object.entries(proposal.bookingByDay)); + }, [proposal]); // De GEDEELDE tijdas van alle stroken plus de verticale schaal. De as loopt door tot voorbij de // laatste geboekte dag, zodat een gestippelde staart (toegestaan-maar-niet-benut) er nog binnen @@ -187,6 +179,16 @@ export function DistributionDialog() { return proposal.blocked.docIds.map(id => byId.get(id) ?? id).join(', '); }, [proposal, inputs]); + // Taak 11b (voor/na-grafiek): welke documenten een tekort houden, met hun teller — dezelfde + // `DistributionDocResult.shortfalls` als het bestaande tekortblok (7), hier omgezet naar de vorm + // die `BeforeAfterChart` nodig heeft om de NA-onvolledigheid bij de na-grafiek zelf te melden. + const shortfallDocs = useMemo(() => { + if (!proposal || proposal.blocked) return []; + return proposal.docs + .filter(doc => doc.shortfalls.length > 0) + .map(doc => ({ docId: doc.docId, title: doc.title, count: doc.shortfalls.length })); + }, [proposal]); + const showStale = staleReason !== null || busy; return ( @@ -372,12 +374,27 @@ export function DistributionDialog() { })} - {/* (6) Voor/na-histogram — plaatshouder; taak 9 tekent hem. */} + {/* (6) Voor/na-histogram (taak 11b, spec §7): dezelfde as als de fasestroken hierboven. */}
- {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} + {poolItem ? ( + ({ docId: doc.docId, title: doc.title }))} + bookingByDay={proposal?.bookingByDay ?? {}} + afterLoadByDay={proposal?.afterLoadByDay ?? {}} + afterIncomplete={proposal?.afterIncomplete ?? false} + shortfallDocs={shortfallDocs} + /> + ) : ( + + {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} + + )}
{/* Tekorten (§4 stap 3): een geldige preview, maar Toepassen blijft uit. */} diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 1b229bce6..ed6348a56 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -330,3 +330,16 @@ test('rangorde: slepen verandert de volgorde en laat het voorstel vervallen', as await expect(page.locator('[data-ops-distribution-dialog]')) .toHaveAttribute('data-ops-distribution-last-stale-reason', 'rank'); }); + +// --- B1c-plan3 taak 11b — de voor/na-grafiek (spec §7) --------------------------------------------- + +test('voor/na-preview: de na-stand blijft binnen de capaciteitslijn', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + + await expect(page.locator('[data-ops-distribution-chart-before]')).toBeVisible(); + await expect(page.locator('[data-ops-distribution-chart-after]')).toBeVisible(); + // De conflictdagen staan in de VOOR-stand rood en in de NA-stand niet meer. + await expect(page.locator('[data-ops-distribution-chart-before] [data-ops-conflict-day]')).not.toHaveCount(0); + await expect(page.locator('[data-ops-distribution-chart-after] [data-ops-conflict-day]')).toHaveCount(0); +}); From d6685ad33fa8064616c4f3a0ea359735ac2fa253 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:25:36 +0200 Subject: [PATCH 021/108] feat(library): verdeling toepassen vanuit de dialoog, met een permanente terugweg en voorstel-invalidatie (B1c) Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 143 +++++++++++++++--- .../useDistributionProposal.ts | 100 +++++++++++- src/state/slices/documentSlice.ts | 9 ++ src/state/slices/types.ts | 9 +- tests/browser/leveling-distribution.spec.ts | 113 ++++++++++++++ tests/library/check-apply-distribution.ts | 53 +++++++ 6 files changed, 402 insertions(+), 25 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 4866d22fd..684eb58fd 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -6,9 +6,12 @@ // gewoon zichtbaar ONDER de dialoog — de verdeling is een handeling óp dat overzicht, geen // vervanging ervan. // -// WAT HIER (NOG) NIET STAAT. Taak 11 vult het voor/na-histogram. Taak 12 bedraadt "Toepassen" op -// `applyDistribution`. Tot dan staat de knop er wél, maar uitgeschakeld MET REDEN — een knop die er -// niet is laat de gebruiker raden of de functie bestaat. +// TOEPASSEN EN DE TERUGWEG (taak 12, spec §5). De knop is nooit "gewoon uit": hij is +// UITGESCHAKELD MET REDEN, in `applyGate` hieronder — een knop die er niet is (of er grijs staat +// zonder uitleg) laat de gebruiker raden. Na een geslaagd Toepassen woont de terugweg HIER, in een +// permanente strook met "Alles terugdraaien", en niet in het meldingenkanaal: dat kent geen +// actieknoppen en ruimt een `info` na 5 s op. De melding die er daarnaast uit gaat is puur +// informatief. // // RANGORDE MET DE MUIS (taak 10, spec §4 stap 1). Native HTML5 drag-and-drop — hetzelfde mechanisme // als `DataGridHeader`'s kolomherordening (`draggable` + `onDragStart`/`onDragOver`/`onDrop`), niet @@ -25,6 +28,7 @@ import { useAppStore } from '@/state/appStore'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; +import { scopeTaskIdsFor } from '@/services/library/distribute'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; import { buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; @@ -37,8 +41,11 @@ export function DistributionDialog() { const tune = useAppStore(s => s.ui.levelingDistribution); const pools = useAppStore(s => s.pools); const setUI = useAppStore(s => s.setUI); + const notify = useAppStore(s => s.notify); + const applyDistribution = useAppStore(s => s.applyDistribution); + const undoDistribution = useAppStore(s => s.undoDistribution); - const { proposal, busy, staleReason, lastStaleReason, degraded, recompute, inputs } = + const { proposal, busy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs } = useDistributionProposal(tune); const close = () => setUI({ showDistributionDialog: false }); @@ -161,24 +168,93 @@ export function DistributionDialog() { return { axis, docs, scaleMax }; }, [tune, proposal, poolItem, rankRows, bookingByDoc]); - // Waarom Toepassen (nog) uit staat. De eerste drie redenen zijn ECHT en blijven na taak 12 - // bestaan; de vierde is de tijdelijke: het schrijfpad is er (taak 6) maar wordt pas in taak 12 - // bedraad. Er is bewust geen eigen sleutel voor die vierde toestand aangemaakt — hij verdwijnt - // in taak 12 en zou dan veertien locales met een dode sleutel achterlaten. - const applyBlockReason = useMemo(() => { - if (!proposal) return t('resource.distribution.compute.pressRecompute'); - if (proposal.blocked) return t(DISTRIBUTION_BLOCK_KEY[proposal.blocked.reason]); - const plan = planDistributionWrites(proposal, {}); - if (!plan.ok && plan.reason === 'shortfall') return t('resource.distribution.applyBlockedShortfall'); - return t('resource.distribution.applyBlockedNothing'); - }, [proposal, t]); - const blockedDocTitles = useMemo(() => { if (!proposal?.blocked) return ''; const byId = new Map(inputs.map(doc => [doc.docId, doc.title])); return proposal.blocked.docIds.map(id => byId.get(id) ?? id).join(', '); }, [proposal, inputs]); + // De taken die dit poolitem daadwerkelijk boeken, per document — de scope waarbinnen + // `applyLeveling` mag schrijven (§5, scope-behoudend toepassen). Bewust dezelfde `scopeTaskIdsFor` + // op dezelfde `inputs` als `computeDistribution` intern gebruikt: `DistributionDocResult` draagt de + // lijst niet, en een tweede afleiding zou stilzwijgend van de gerekende snit kunnen afwijken. + const scopeTaskIdsByDoc = useMemo(() => { + const out: Record = {}; + if (!tune) return out; + for (const doc of inputs) out[doc.docId] = scopeTaskIdsFor(doc, tune.companyId, tune.libraryItemId); + return out; + }, [inputs, tune]); + + // Waarom Toepassen wel of niet mag — en zo niet, met welke tekst (spec §4 stap 3: validatie wijst + // altijd een uitweg aan). De volgorde is die van de taakomschrijving: eerst "er is nog niets", + // dan de blokkade, dan het tekort, dan een vervallen voorstel, en pas daarna het triviale + // "er valt niets te schrijven". + const applyGate = useMemo<{ ok: boolean; reason: string }>(() => { + if (busy) return { ok: false, reason: t('resource.distribution.compute.busy') }; + if (!proposal) return { ok: false, reason: t('resource.distribution.compute.pressRecompute') }; + if (proposal.blocked) { + return { ok: false, reason: t(DISTRIBUTION_BLOCK_KEY[proposal.blocked.reason], { docs: blockedDocTitles }) }; + } + if (proposal.hasShortfall) return { ok: false, reason: t('resource.distribution.applyBlockedShortfall') }; + if (staleReason !== null) { + return { ok: false, reason: t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs }) }; + } + const plan = planDistributionWrites(proposal, scopeTaskIdsByDoc); + if (!plan.ok) { + return { + ok: false, + reason: plan.reason === 'shortfall' + ? t('resource.distribution.applyBlockedShortfall') + : t('resource.distribution.applyBlockedNothing'), + }; + } + return { ok: true, reason: '' }; + }, [busy, proposal, staleReason, staleDocs, blockedDocTitles, scopeTaskIdsByDoc, t]); + + /** Het laatst toegepaste record — leeft in de tune-state en overleeft dus een sluiting van de + * dialoog (§7), zodat opnieuw openen op hetzelfde poolitem de terugweg nog toont. */ + const applied = tune?.applied ?? null; + + const onApply = () => { + if (!proposal || !applyGate.ok) return; + const record = applyDistribution(proposal, scopeTaskIdsByDoc); + // `null` betekent dat de store dezelfde `planDistributionWrites`-poort alsnog dichtdeed (bv. + // omdat er tussen render en klik een document is gesloten). Geen strook, geen melding. + if (!record) return; + const current = useAppStore.getState().ui.levelingDistribution; + if (current) setUI({ levelingDistribution: { ...current, applied: record } }); + // Puur informatief (spec §5) — de terugweg is de strook hieronder, niet deze melding. + notify({ + severity: 'info', + messageKey: 'resource.distribution.applied', + params: { count: record.docs.length }, + }); + // Er is nu in élk beschreven document gemuteerd; de vingerafdruk-bewaking in + // `useDistributionProposal` ziet dat vanzelf en zet `staleReason = 'edited'`. Dat is exact wat + // §6a voorschrijft en vraagt hier dus geen eigen tak: het voorstel op het scherm hóórt niet + // meer bij de documenten, en Toepassen gaat daarmee op slot tot "Herbereken". + }; + + const onUndoAll = () => { + if (!applied) return; + const report = undoDistribution(applied); + const current = useAppStore.getState().ui.levelingDistribution; + if (current) setUI({ levelingDistribution: { ...current, applied: null } }); + if (report.skippedDocIds.length > 0) { + const byId = new Map(applied.docs.map(doc => [doc.docId, doc.title])); + notify({ + // Het meldingenkanaal kent alleen `error` en `info` (K8a). Een gedeeltelijke terugdraaiing + // is geen fout, maar mág niet na 5 s verdwijnen: de gebruiker denkt anders dat álles terug + // is terwijl één project zijn vertraging houdt. `error` is de enige plakkende severity. + severity: 'error', + messageKey: 'resource.distribution.undonePartial', + params: { docs: report.skippedDocIds.map(id => byId.get(id) ?? id).join(', ') }, + }); + } else { + notify({ severity: 'info', messageKey: 'resource.distribution.undoneAll' }); + } + }; + // Taak 11b (voor/na-grafiek): welke documenten een tekort houden, met hun teller — dezelfde // `DistributionDocResult.shortfalls` als het bestaande tekortblok (7), hier omgezet naar de vorm // die `BeforeAfterChart` nodig heeft om de NA-onvolledigheid bij de na-grafiek zelf te melden. @@ -433,10 +509,36 @@ export function DistributionDialog() { data-ops-distribution-stale-reason={staleReason ?? ''} > {staleReason - ? `${t(`resource.distribution.stale.${staleReason}`)}${busy ? ` ${t('resource.distribution.compute.busy')}` : degraded ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''}` + ? `${t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs })}${busy ? ` ${t('resource.distribution.compute.busy')}` : degraded || staleReason === 'edited' ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''}` : t('resource.distribution.compute.busy')}
)} + + {/* Spec §5: de terugweg woont HIER, niet in het meldingenkanaal (dat kent geen actieknoppen + en ruimt info na 5 s op). Permanent zolang het record geldig is — hij verdwijnt alleen + door "Alles terugdraaien" of doordat een NIEUW Toepassen hem vervangt. */} + {applied && ( +
+ + {t('resource.distribution.applied', { count: applied.docs.length })} + + +
+ )}
diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts index 049660d60..2f12b7ab1 100644 --- a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -23,9 +23,16 @@ // tune-wijziging niet meer te doen. Dan blijft alleen de EXPLICIETE route over: het openen (één keer) // en "Herbereken". De dialoog toont dat met `compute.degraded` + `compute.pressRecompute`; de // stale-strook blijft dan gewoon staan tot de gebruiker drukt — precies de bedoeling. +// +// DE VOORSTEL-INVALIDATIE (taak 12, spec §6a). Naast de vier tune-assen hierboven vervalt een +// voorstel ook wanneer een BETROKKEN DOCUMENT verandert — van buiten de dialoog, want de dialoog +// zelf is modaal met een focus-trap: auto-save-herstel, een MCP-tool, een extensie, of het +// Toepassen van dit paneel zelf. De bewaking is `documentFingerprint` per document (taak 4): +// referenties van de velden die `computeDistribution` leest, plus `runtime.mutationSeq()` als +// grofmazige backstop voor het actieve document. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useAppStore, type AppState } from '@/state/appStore'; +import { appStoreContext, useAppStore, type AppState } from '@/state/appStore'; import type { DistributionUiState } from '@/state/slices/types'; import { computeDistribution, @@ -33,6 +40,7 @@ import { type DistributionDocInput, type DistributionProposal, } from '@/services/library/distribute'; +import { documentFingerprint } from '@/services/library/proposalFingerprint'; import { documentTitle, untitledOrdinals, displayDocumentTitle } from '@/utils/documents'; /** Meer taken dan dit in één deelnemend document ⇒ handmatig herberekenen (§3.4). */ @@ -41,10 +49,42 @@ export const MAX_TASKS_AUTO = 1000; export const MAX_BOOKING_TASKS_AUTO = 40; /** Waarom het huidige voorstel niet meer actueel is — 1-op-1 de `resource.distribution.stale.*` - * sleutels. `'edited'` (een document is intussen gewijzigd) hoort bij de vingerafdruk-invalidatie - * van taak 10 en wordt hier nog niet gezet; de sleutel bestaat wel. */ + * sleutels. De vier tune-assen komen uit `diffReason`; `'edited'` komt uit de + * vingerafdruk-bewaking (taak 12, spec §6a). + * + * "Document geopend/gesloten" en "documentwissel" staan hier BEWUST NIET bij: die lopen alle vijf + * via `resetDocumentScopedUI` (`documentSlice`) en sluiten de hele dialoog (besluit eigenaar + * 2026-08-31) in plaats van een reden te tonen. Daarom heeft de i18n-familie ook geen + * `stale.documents`-sleutel. */ export type DistributionStaleReason = 'rank' | 'ceiling' | 'pin' | 'tool' | 'edited'; +/** De velden die `documentFingerprint` van één document leest, uit de LIVE top-level state (het + * actieve document) of uit een slapende payload. Beide vormen hebben exact deze tien velden. */ +function fingerprintOfActive(s: AppState, mutationSeq: number): string { + return documentFingerprint({ + tasks: s.tasks, sequences: s.sequences, resources: s.resources, assignments: s.assignments, + calendar: s.calendar, calendars: s.calendars, project: s.project, cpmResult: s.cpmResult, + scheduleStale: s.scheduleStale, datesAsRecorded: s.datesAsRecorded, + }, mutationSeq); +} + +/** + * De vingerafdruk van ELK geopend document (spec §6a). Het actieve document leest uit top-level + * plus de mutatieteller van deze storecontext; een slaper leest uit zijn payload — daar ís geen + * eigen teller voor, en die is er ook niet nodig: een slapende payload wordt altijd in zijn geheel + * VERVANGEN (`recalculateStaleSleepingDocuments`, `applyDistribution`, `undoDistribution`), dus de + * referenties zijn daar per constructie sluitend. Vandaar `mutationSeq = 0` voor slapers. + */ +export function documentFingerprints(s: AppState, mutationSeq: number): Record { + const out: Record = {}; + for (const document of s.documents) { + out[document.id] = document.id === s.activeDocumentId + ? fingerprintOfActive(s, mutationSeq) + : document.payload === null ? '' : documentFingerprint(document.payload, 0); + } + return out; +} + export interface DistributionProposalState { /** Het laatst berekende voorstel, of `null` zolang er nog nooit gerekend is. */ proposal: DistributionProposal | null; @@ -56,6 +96,9 @@ export interface DistributionProposalState { * observeerbaarheid (browsertest): een automatische herberekening op een klein project is * binnen één macrotask klaar, dus de strook zelf is dan niet betrouwbaar te vangen. */ lastStaleReason: DistributionStaleReason | null; + /** De documenttitels die bij `staleReason === 'edited'` in de tekst horen (`stale.edited` heeft + * een `{{docs}}`-parameter); leeg bij elke andere reden. */ + staleDocs: string; /** Te groot om automatisch door te rekenen (§3.4). */ degraded: boolean; /** De expliciete route: "Verdeel automatisch" / "Herbereken". */ @@ -226,6 +269,14 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const [busy, setBusy] = useState(false); const [staleReason, setStaleReason] = useState(null); const [lastStaleReason, setLastStaleReason] = useState(null); + const [staleDocs, setStaleDocs] = useState(''); + + // De vingerafdrukken waarop het HUIDIGE voorstel gerekend heeft (§6a). `null` = er valt niets te + // bewaken: er is nog nooit gerekend, óf er is al een `'edited'` gemeld en die reden staat nog. + const fingerprintsRef = useRef | null>(null); + // De invoer van diezelfde run, als ref — de bewaker draait buiten React's renderlus en heeft de + // documenttitels nodig zonder van `inputs` als dependency af te hangen. + const inputsRef = useRef([]); // De tune-state ALS REF, zodat de run-callback stabiel blijft (hij mag niet bij elke // rangorde-wijziging opnieuw gemaakt worden — dat zou de in-flight-bewaking om zeep helpen). @@ -253,13 +304,18 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const s = useAppStore.getState(); const pool = s.pools[current.companyId]; const built = buildDistributionInputs(s, untitledLabelRef.current, current); + inputsRef.current = built; setInputs(built); setProposal(pool ? computeDistribution(current.companyId, pool, current.libraryItemId, built, { allowSplits: current.allowSplits, }) : null); + // De vingerafdruk hoort bij PRECIES deze momentopname: dezelfde `s` en dezelfde teller + // waarop zojuist gerekend is (§6a). + fingerprintsRef.current = documentFingerprints(s, appStoreContext.runtime.mutationSeq()); setStaleReason(null); + setStaleDocs(''); } finally { busyRef.current = false; setBusy(false); @@ -294,8 +350,11 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr lastTuneRef.current = tune; setProposal(null); setInputs([]); + inputsRef.current = []; + fingerprintsRef.current = null; setStaleReason(null); setLastStaleReason(null); + setStaleDocs(''); if (subjectKey !== null) runRef.current(); // `tune` bewust buiten de deps: alleen het ONDERWERP is hier de trigger, niet elke tune-tik. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -317,5 +376,38 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr if (!degradedRef.current) runRef.current(); }, [tune]); - return { proposal, busy, staleReason, lastStaleReason, degraded, recompute, inputs }; + // Rekenmoment 3 — of juist NIET (spec §6a): een MUTATIE in een betrokken document. De bewaking + // hangt aan de store zelf en niet aan de renderlus, want een mutatie van buiten de dialoog (een + // MCP-tool, een extensie, het herstel na een crash, of het Toepassen van dit paneel zélf) hoeft + // geen re-render van de dialoog te veroorzaken. De vergelijking is een handvol WeakMap-lookups + // per document; goedkoop genoeg om aan élke store-tik te hangen. + // + // BEWUSTE AFWIJKING VAN HET PLAN (taak 12 stap 3 noemde alle vijf redenen "gewone + // hertriggering"): `'edited'` rekent NIET automatisch door. Twee redenen, en de tweede is de + // doorslaggevende. (1) De vier tune-redenen zijn handelingen ván de gebruiker ín deze dialoog — + // daar is meteen doorrekenen precies wat hij vraagt. Een mutatie van buiten is dat niet: het + // voorstel dat hij op dat moment staat te lezen zou onder zijn ogen door een ander vervangen + // worden, en een stroom externe mutaties (een MCP-draaiboek) zou een reeks volledige + // CPM-solves uitlokken. (2) Het plan eist in stap 1 tegelijk dat de stale-strook zichtbaar is + // én dat Toepassen uitgeschakeld blijft; met een automatische herberekening zijn beide binnen + // één macrotask weer weg. "Herbereken" is hier het discrete rekenmoment (§3.4). + useEffect(() => { + const unsubscribe = useAppStore.subscribe(() => { + const known = fingerprintsRef.current; + if (known === null) return; + const s = useAppStore.getState(); + const now = documentFingerprints(s, appStoreContext.runtime.mutationSeq()); + const changed = Object.keys(known).filter(docId => now[docId] !== known[docId]); + if (changed.length === 0) return; + // Eén melding per voorstel: tot de volgende geslaagde run valt er niets meer te bewaken. + fingerprintsRef.current = null; + const titles = new Map(inputsRef.current.map(doc => [doc.docId, doc.title])); + setStaleDocs(changed.map(docId => titles.get(docId) ?? docId).join(', ')); + setStaleReason('edited'); + setLastStaleReason('edited'); + }); + return unsubscribe; + }, []); + + return { proposal, busy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs }; } diff --git a/src/state/slices/documentSlice.ts b/src/state/slices/documentSlice.ts index d184379d7..04a1c1d28 100644 --- a/src/state/slices/documentSlice.ts +++ b/src/state/slices/documentSlice.ts @@ -72,6 +72,15 @@ function resetDocumentScopedUI(s: AppState): void { // twee alléén AAN, dus zonder reset toont een volgend document het scherm van zijn voorganger. s.ui.showLibraryLinkDialog = false; s.ui.libraryRefreshNotice = null; + // B1c (spec §6a): de verdeeldialoog kijkt naar een MOMENTOPNAME van meerdere documenten. Een + // documentwissel of een gesloten/geopend document maakt zowel zijn tune-state (rangorde, pins en + // plafonds, alle op docId) als zijn toegepast-record onbetrouwbaar — dat record verwijst naar + // history-events van documenten die er misschien niet meer zijn (`closeDocument` ruimt die events + // bovendien op). BESLUIT EIGENAAR 2026-08-31: de DIALOOG SLUIT dan, en blijft niet open staan met + // een vervallen voorstel; er is bij een losse dialoog immers geen "eronder" om op terug te vallen. + // Het bezettingsoverzicht blijft gewoon staan, dus de gebruiker opent opnieuw op de conflictregel. + s.ui.showDistributionDialog = false; + s.ui.levelingDistribution = null; } function publishActivation(s: AppState, activation: DocumentActivationMaterialization): void { diff --git a/src/state/slices/types.ts b/src/state/slices/types.ts index 8a0ddbde3..a5fbc2012 100644 --- a/src/state/slices/types.ts +++ b/src/state/slices/types.ts @@ -206,7 +206,14 @@ export type NotificationMessageKey = // B1c-plan-2 taak 1 (M10, eigenaarsbesluit 2026-08-31): nivelleren/wissen overschrijft de // `.mpp`-eigen sub-dag-nivelleervertraging (`levelingDelayMinutes`/`levelingDelayElapsed`) met // hele werkdagen — zie `src/state/timephasedLossNotice.ts`s `notifyLevelingDelayRounded`. - | 'notifications.levelingDelayRoundedToWorkdays'; + | 'notifications.levelingDelayRoundedToWorkdays' + // B1c-plan3 taak 12 (spec §5): de afloop van "Verdelen over projecten". Deze drie zijn PUUR + // INFORMATIEF — de terugweg zelf ("Alles terugdraaien") woont in de verdeeldialoog, want het + // meldingenkanaal kent geen actieknoppen. `applied` is een meervoudfamilie (`{{count}}` + // projecten), `undonePartial` benoemt de documenten die hun vertraging hielden. + | 'resource.distribution.applied' + | 'resource.distribution.undoneAll' + | 'resource.distribution.undonePartial'; export interface AppNotification { /** Stabiele id — uitsluitend voor de React-key en voor `dismissNotification`. */ diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index ed6348a56..a25a7ef84 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -343,3 +343,116 @@ test('voor/na-preview: de na-stand blijft binnen de capaciteitslijn', async ({ p await expect(page.locator('[data-ops-distribution-chart-before] [data-ops-conflict-day]')).not.toHaveCount(0); await expect(page.locator('[data-ops-distribution-chart-after] [data-ops-conflict-day]')).toHaveCount(0); }); + +// --- B1c-plan3 taak 12 — Toepassen, de terugweg en de voorstel-invalidatie (spec §5/§6a) --------- + +/** + * Het aantal taken MET een nivelleringsvertraging over ALLE geopende documenten — het actieve op + * top-level, de slapers in hun payload. Bewust niet alleen `state.tasks`: bij twee conflicterende + * documenten wijkt er precies één, en welke dat is hangt van de float-sortering af. De belofte van + * Toepassen ("het schrijft over de documentgrens heen") toets je dus op de som, niet op het toevallig + * actieve tabblad. + */ +function delayedTaskCount(page: Page): Promise { + return page.evaluate(() => { + const s = window.__OPS__!.store.getState(); + let count = s.tasks.filter(t => t.levelingDelay !== undefined).length; + for (const document of s.documents) { + if (document.payload) count += document.payload.tasks.filter(t => t.levelingDelay !== undefined).length; + } + return count; + }); +} + +/** + * Eén document dat MEER vraagt dan het poolitem ooit kan leveren (1/dag tegen een capaciteit van + * 0,5). Geen enkele verschuiving lost dat op — de verdeler levert een geldige preview mét tekort, + * en juist dat moet Toepassen tegenhouden (spec §4 stap 3). + */ +async function seedUnsolvableShortfall(page: Page): Promise { + const [taskA] = await seedProject(page, [{ + name: 'Overvraagd', start: '2026-09-07', finish: '2026-09-18', durationDays: 10, + }], 'Overvraagd project'); + await makeScheduled(page, taskA); + const library = await createLibrary(page, 0.5); + await bookOnPoolItem(page, library, taskA, 1); + return library; +} + +test('toepassen: schrijft in beide projecten en biedt daarna "alles terugdraaien"', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const apply = page.getByRole('button', { name: /^(Toepassen|Apply)$/ }); + await expect(apply).toBeEnabled(); + await apply.click(); + + // De strook is PERMANENT (spec §5) — anders dan een `info`-melding, die het meldingenkanaal na + // 5 s opruimt. Vandaar de wachttijd van 6 s: die overleeft die opruiming aantoonbaar. + await expect(page.locator('[data-ops-distribution-applied]')).toBeVisible(); + await page.waitForTimeout(6000); + await expect(page.locator('[data-ops-distribution-applied]')).toBeVisible(); + await expect.poll(() => delayedTaskCount(page)).toBeGreaterThan(0); + + // §6a: de documenten zijn nu gemuteerd, dus het voorstel op het scherm hoort er niet meer bij en + // Toepassen gaat op slot tot "Herbereken". + await expect(page.locator('[data-ops-distribution-stale]')).toContainText(/niet meer actueel|no longer/i); + await expect(apply).toBeDisabled(); + + await page.getByRole('button', { name: /Alles terugdraaien|Undo all/ }).click(); + await expect.poll(() => delayedTaskCount(page)).toBe(0); + await expect(page.locator('[data-ops-distribution-applied]')).toHaveCount(0); +}); + +test('toepassen is uitgeschakeld-met-reden zolang er een tekort is', async ({ page, ops: _ops }) => { + await seedUnsolvableShortfall(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-shortfall]')).toBeVisible(); + + await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); + await expect(page.locator('[data-ops-distribution-apply-reason]')).toContainText(/tekort|shortfall/i); +}); + +test('het voorstel vervalt met reden zodra er in een betrokken document gewerkt wordt', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); + + // WAAROM HIER GEEN ECHTE GEBRUIKERSHANDELING STAAT. De verdeeldialoog is modaal met een + // focus-trap: het lint en de tabel zijn onbereikbaar zolang hij open staat, dus een bewerking ín + // een betrokken document kán tijdens dit scenario alleen van BUITEN komen — auto-save-herstel, + // een MCP-tool of een extensie. De store-route is daarmee geen vervanging van een klik maar de + // enige realistische bron van precies deze gebeurtenis. + await page.evaluate(() => { + const s = window.__OPS__!.store.getState(); + const task = s.tasks[0]; + s.updateTask(task.id, { time: { ...task.time, scheduleDuration: 4 } }); + }); + + await expect(page.locator('[data-ops-distribution-stale]')).toContainText(/niet meer actueel|no longer/i); + await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); + + // "Herbereken" is het discrete rekenmoment dat de reden opruimt (§3.4). + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); +}); + +test('van document wisselen sluit de dialoog', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // Ctrl+1 springt naar het EERSTE document; het actieve is hier het tweede. De sneltoets hangt aan + // `window` en heeft geen `hasBlockingDialogOpen`-guard, dus hij komt langs de focus-trap heen — + // een echte toetsaanslag, geen store-aanroep. + await page.keyboard.press('Control+1'); + + await expect(page.locator('[data-ops-distribution-dialog]')).toHaveCount(0); + // §6a/besluit eigenaar: niet alleen dicht, ook de tune-state is weg — die verwees naar docIds en + // history-events van een momentopname die niet meer geldt. + expect(await page.evaluate(() => window.__OPS__!.store.getState().ui.levelingDistribution)).toBe(null); + // Het bezettingsoverzicht blijft gewoon staan; de gebruiker opent opnieuw op de conflictregel. + await expect(page.locator('[data-ops-occupancy-view]')).toBeVisible(); +}); diff --git a/tests/library/check-apply-distribution.ts b/tests/library/check-apply-distribution.ts index 959d61f16..765fff9ad 100644 --- a/tests/library/check-apply-distribution.ts +++ b/tests/library/check-apply-distribution.ts @@ -270,6 +270,59 @@ console.log('-- apply-distribution: geval 11, gesloten document --'); assert(S().tasks.find(t => t.id === idActiveIn)?.levelingDelay === undefined, 'geval 11: en dat is ook echt gebeurd'); } +// ═══════════════════════════════════════════════════════════════════════════ +// Geval 12 (B1c-plan3 taak 12, spec §6a): een documentwissel/-sluiting/-opening laat het voorstel +// niet "vervallen met reden" maar SLUIT de verdeeldialoog en gooit de tune-state weg (besluit +// eigenaar 2026-08-31). Alle drie de wegen lopen via `resetDocumentScopedUI` in `documentSlice`; +// deze check staat hier — bij de rest van het verdeel-schrijfpad — en niet in +// check-document-activation.ts, omdat het weggegooide `applied`-record uit ditzelfde schrijfpad komt. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- apply-distribution: geval 12, documentwissel sluit de verdeeldialoog --'); +{ + const openDialog = (): void => { + S().setUI({ + showDistributionDialog: true, + levelingDistribution: { + companyId: 'bibliotheek-1', libraryItemId: 'lib-1', allowSplits: false, + order: [], pinned: {}, ceilings: {}, applied: null, + }, + }); + }; + const assertClosed = (via: string): void => { + assert(S().ui.showDistributionDialog === false, `geval 12: ${via} sluit de dialoog`); + assert(S().ui.levelingDistribution === null, `geval 12: ${via} gooit de tune-state weg`); + }; + + openDialog(); + const extraDocId = S().newDocument(); + assertClosed('newDocument'); + + openDialog(); + S().switchDocument(activeDocId); + assertClosed('switchDocument'); + + // Het ACTIEVE document sluiten wisselt naar een buur en loopt dus óók langs + // `resetDocumentScopedUI`. + openDialog(); + S().switchDocument(extraDocId); + openDialog(); + S().closeDocument(extraDocId); + assertClosed('closeDocument van het actieve document'); + + // GRENS, bewust vastgelegd: een NIET-actief document sluiten raakt `resetDocumentScopedUI` niet + // (dat pad wisselt geen document en mag dus ook de TaskDialog van het actieve document niet + // dichtgooien). De dialoog blijft daar open; het voorstel vervalt in dat geval via de + // vingerafdruk-bewaking in `useDistributionProposal` — het verdwenen document levert een + // afwijkende vingerafdruk en dus `stale.edited` met zijn titel. + const spareDocId = S().newDocument(); + S().switchDocument(activeDocId); + openDialog(); + S().closeDocument(spareDocId); + assert(S().ui.showDistributionDialog === true, + 'geval 12: een INACTIEF document sluiten laat de dialoog staan (invalidatie loopt via de vingerafdruk)'); + assert(S().ui.levelingDistribution !== null, 'geval 12: en laat de tune-state staan'); +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (fails === 0) { console.log(`OK apply-distribution-check: alle checks groen (${checks})`); From 7831409585dd760f05ff5c960ab70152f0af227f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:45:21 +0200 Subject: [PATCH 022/108] feat(library): kostenlabels per project en prijskaartjes per gereedschapsstand (B1c) Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 48 +++++- .../useDistributionProposal.ts | 139 ++++++++++++++++-- tests/browser/leveling-distribution.spec.ts | 74 ++++++++++ 3 files changed, 244 insertions(+), 17 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 684eb58fd..cb7563637 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -45,8 +45,38 @@ export function DistributionDialog() { const applyDistribution = useAppStore(s => s.applyDistribution); const undoDistribution = useAppStore(s => s.undoDistribution); - const { proposal, busy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs } = - useDistributionProposal(tune); + const { + proposal, busy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs, + costByDoc, toolPrice, + } = useDistributionProposal(tune); + + // Taak 13 (spec §4 stap 1 / §6): de labels zijn gecachet (zie de hook), maar mogen NOOIT getoond + // worden zolang het voorstel zelf niet meer bij de documenten hoort — een gecachet getal bij een + // vervallen voorstel is misleidender dan geen getal. Dus: staleReason of degraded ⇒ altijd de + // "druk op Herbereken"-tekst, ongeacht wat er nog in de cache staat. + const labelsValid = staleReason === null && !degraded; + const costLabel = (docId: string): string => { + const cost = labelsValid ? costByDoc[docId] : undefined; + if (cost === undefined) return t('resource.distribution.compute.pressRecompute'); + return cost === 0 + ? t('resource.distribution.rank.costNone') + : t('resource.distribution.rank.cost', { count: cost }); + }; + const priceText = (workdays: number): string => (workdays === 0 + ? t('resource.distribution.tool.priceNone') + : t('resource.distribution.tool.price', { count: workdays })); + const toolPriceLabel = (): string => (!labelsValid || !toolPrice) + ? t('resource.distribution.tool.priceUnknown') + : `${priceText(toolPrice.off)} · ${priceText(toolPrice.on)}`; + // Gepind/#63/cannotMove-documenten krijgen GEEN kostenlabel (§4 stap 1: "ze wijken niet") — dat + // leest rechtstreeks uit het LAATST BEREKENDE voorstel (`participated`/`cannotMove`), niet uit + // `costByDoc`: die twee vragen zijn onafhankelijk van elkaar (een document kan best deelnemen + // terwijl zijn label nog niet — of niet meer — gecached is). + const isCostCandidate = (docId: string): boolean => { + if (!proposal || proposal.blocked) return false; + const docResult = proposal.docs.find(d => d.docId === docId); + return docResult !== undefined && docResult.participated && !docResult.cannotMove; + }; const close = () => setUI({ showDistributionDialog: false }); @@ -309,9 +339,10 @@ export function DistributionDialog() {
) : ( <> - {/* (3) Gereedschap — schakelaar met prijskaartje. De prijs is `computeDistribution` - opnieuw draaien met de andere stand (§6); tot taak 9 die vergelijking maakt staat er - eerlijk "prijs onbekend". */} + {/* (3) Gereedschap — schakelaar met prijskaartje (taak 13, spec §6): `computeDistribution` + één keer met `allowSplits: false` en één keer met `true` — de prijs is de grootste + `endShiftWorkdays` over de deelnemers. Gecachet in de hook tot invalidatie; zolang het + voorstel niet actueel is staat er eerlijk "prijs onbekend". */}
{t('resource.distribution.tool.title')} @@ -333,7 +364,7 @@ export function DistributionDialog() { {t('resource.distribution.tool.allowSplits')} - {t('resource.distribution.tool.priceUnknown')} + {toolPriceLabel()}
{t('resource.distribution.tool.allowSplitsHint')} @@ -390,6 +421,11 @@ export function DistributionDialog() { days: row.float === null ? '—' : numberFmt.format(row.float), })} + {isCostCandidate(row.docId) && ( + + {costLabel(row.docId)} + + )} + + ) : ( + <> {degraded && (
{t('resource.distribution.compute.degraded')} @@ -637,11 +668,28 @@ export function DistributionDialog() {
)}
+ {/* Terug naar de kiezer. Geen bevestigingsdialoog (die zou over een dialoog heen moeten, + en `hasBlockingDialogOpen` weert dat terecht) — in plaats daarvan ZEGT de knop wat er + verdwijnt zodra er een `applied`-record staat: twee losse sleutels, want de waarschuwende + variant is een andere zin en geen ingevulde parameter. `levelingDistribution: null` is + de bestaande regel "ander poolitem = verse tune-state": het `applied`-record hoort bij + dít item en vervalt dus mee. De terugdraai-strook is daarmee weg, de schrijfactie in de + documenten blijft staan (die draai je terug met de gewone undo per document). */} +
+ + )} ); diff --git a/src/components/dialogs/DistributionDialog/DistributionPicker.tsx b/src/components/dialogs/DistributionDialog/DistributionPicker.tsx new file mode 100644 index 000000000..65d21c484 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/DistributionPicker.tsx @@ -0,0 +1,124 @@ +// De KIEZER-STAND van de verdeeldialoog (bedieningsreparatie, 2026-09-11). +// +// WAAROM DIT BESTAAT. De lintknop "Verdelen…" opende de dialoog ook zonder eerder gekozen poolitem, +// en die stand toonde niets dan de hint `selectHint` plus drie uitgeschakelde knoppen. Dat is een +// doodlopende dialoog: er is geen handeling die vanaf daar vooruit leidt. Nu toont dezelfde lege +// stand de LIJST van bibliotheekitems die op dit moment een conflict hebben — één klik is de start. +// +// ÉÉN DEFINITIE VAN "CONFLICT". De cijfers komen uit `computeLibraryOccupancy` op dezelfde invoer +// als het bezettingsoverzicht: dezelfde bibliotheek (de koppeling van het actieve project), +// dezelfde `skipEphemeralSolve` voor het actieve document, dezelfde weergavefilters +// (`conflictDays > 0` én minstens één GETELDE booking) en dezelfde sortering. Er is hier bewust +// geen tweede, eigen conflictbegrip — die zou stilzwijgend van het overzicht kunnen afwijken. +// +// ÉÉN REKENMOMENT, GEEN ABONNEMENT. De invoerbouw hergebruikt `buildDistributionInputs`, en die +// kan per stale document een efemere CPM-solve kosten. De kiezer rekent daarom exact één keer: bij +// het MONTEREN, via een lazy `useState`-initialisator (synchroon, dus zonder lege flits). Hij is +// alleen gemount zolang er geen poolitem gekozen is, dus "Ander item kiezen…" levert vanzelf een +// verse meting op — dat is dezelfde discrete-rekenmomenten-keuze als de rest van deze dialoog. +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppStore, type AppState } from '@/state/appStore'; +import { computeLibraryOccupancy, type OccupancyDocInput } from '@/services/library/occupancy'; +import { buildDistributionInputs, freshDistributionUi } from './useDistributionProposal'; + +/** Eén regel van de kiezer: een poolitem met een actueel conflict over de geopende documenten. */ +export interface DistributionPickRow { + libraryItemId: string; + name: string; + /** Aantal documenten dat op dit poolitem boekt — `resource.occupancy.docCount`. */ + docCount: number; + /** Aantal conflictdagen — `resource.occupancy.conflictDays`. */ + conflictDays: number; +} + +/** + * De conflicterende poolitems van de bibliotheek waaraan het ACTIEVE document gekoppeld is. + * Zonder koppeling (of zonder pool) is er niets te kiezen: dan is de lijst leeg en toont de dialoog + * de "geen conflicten"-regel. + */ +export function collectDistributionPicks( + s: AppState, + untitledLabel: string, + language: string, +): { companyId: string | null; rows: DistributionPickRow[] } { + const companyId = s.project.companyId ?? null; + const pool = companyId === null ? undefined : s.pools[companyId]; + if (companyId === null || pool === undefined) return { companyId, rows: [] }; + + const inputs = buildDistributionInputs(s, untitledLabel, { order: [], pinned: {}, ceilings: {} }); + // `skipEphemeralSolve` voor het actieve document: exact de perf-poort van `ResourceOccupancyView`, + // zodat de cijfers hier niet uit een ándere (hybride) planning komen dan daar. + const docs: OccupancyDocInput[] = inputs.map(doc => ({ + ...doc, + skipEphemeralSolve: doc.docId === s.activeDocumentId, + })); + const { rows } = computeLibraryOccupancy(companyId, pool, docs); + + return { + companyId, + rows: rows + // Zelfde weergave-eis als de conflictbadge in het overzicht: uitsluitend ongetelde boekingen + // leveren nooit een conflict om te verdelen. + .filter(row => row.conflictDays.length > 0 && row.docs.some(doc => doc.counted)) + .sort((a, b) => + (b.conflictDays.length - a.conflictDays.length) || a.name.localeCompare(b.name, language)) + .map(row => ({ + libraryItemId: row.libraryItemId, + name: row.name, + docCount: row.docs.length, + conflictDays: row.conflictDays.length, + })), + }; +} + +/** De kiezerlijst zoals de dialoog hem toont wanneer er nog geen poolitem gekozen is. */ +export function DistributionPicker() { + const { t, i18n } = useTranslation('common'); + const setUI = useAppStore(s => s.setUI); + const untitledLabel = t('project.untitled'); + + // Zie het moduleblok: één meting bij het monteren. + const [picks] = useState(() => collectDistributionPicks(useAppStore.getState(), untitledLabel, i18n.language)); + + if (picks.companyId === null || picks.rows.length === 0) { + return ( +
+ {t('resource.distribution.noConflicts')} +
+ ); + } + + const companyId = picks.companyId; + return ( +
+ + {t('resource.distribution.selectHint')} + + {picks.rows.map(row => ( + + ))} +
+ ); +} diff --git a/src/components/layout/Ribbon/ribbonConfig.tsx b/src/components/layout/Ribbon/ribbonConfig.tsx index 6b703a7d8..5ad7bf01e 100644 --- a/src/components/layout/Ribbon/ribbonConfig.tsx +++ b/src/components/layout/Ribbon/ribbonConfig.tsx @@ -583,8 +583,11 @@ const resourcesTab: RibbonTabConfig = [ { // B1c-plan3 taak 8 — de verdeeldialoog (spec §7). Opent op het LAATST geopende poolitem // (`ui.levelingDistribution` overleeft een sluiting); is er nog nooit een item gekozen, dan - // opent de dialoog met `selectHint` en verwijst hij naar het bezettingsoverzicht. Bewust - // GEEN `disabled` in dat geval: de knop is de plek waar je leert dat deze functie bestaat. + // opent de dialoog in zijn KIEZER-stand: de lijst met bibliotheekitems die nú een conflict + // hebben over de geopende documenten, één klik van een voorstel af (bedieningsreparatie + // 2026-09-11 — die stand toonde eerder alleen een hint plus uitgeschakelde knoppen). Bewust + // GEEN `disabled`: de knop is de plek waar je leert dat deze functie bestaat, en hij leidt + // nu ook zonder voorkennis ergens heen. // `labelKey` staat in de `common`-namespace omdat het letterlijk dezelfde tekst is als de // ingang in het bezettingsoverzicht — één sleutel, geen tweede vertaling die kan afwijken. kind: 'button', id: 'distributeOverProjects', icon: , labelKey: 'common:resource.distribution.open', diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index ee03dde55..71a50c75a 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -797,7 +797,10 @@ "undoAll": "التراجع عن الكل", "undoneAll": "تم التراجع عن كل شيء.", "undonePartial": "تم التراجع، باستثناء {{docs}} — فقد تم العمل عليها في هذه الأثناء.", - "selectHint": "اختر عنصر مكتبة به تعارض لبدء التوزيع." + "selectHint": "اختر عنصر مكتبة به تعارض لبدء التوزيع.", + "noConflicts": "لا يوجد حاليًا أي عنصر مكتبة محجوز مرتين عبر المشاريع المفتوحة.", + "pickAnother": "اختيار عنصر آخر…", + "pickAnotherApplied": "اختيار عنصر آخر (سيختفي شريط التراجع)…" }, "color": "اللون" }, diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 7bbf319dd..df9a73a74 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -725,7 +725,10 @@ "undoAll": "Alles rückgängig machen", "undoneAll": "Alles wurde rückgängig gemacht.", "undonePartial": "Rückgängig gemacht, außer {{docs}} — dort wurde inzwischen weitergearbeitet.", - "selectHint": "Wählen Sie einen Bibliotheksartikel mit einem Konflikt, um mit dem Verteilen zu beginnen." + "selectHint": "Wählen Sie einen Bibliotheksartikel mit einem Konflikt, um mit dem Verteilen zu beginnen.", + "noConflicts": "Zurzeit ist kein Bibliothekselement über die geöffneten Projekte hinweg doppelt belegt.", + "pickAnother": "Anderes Element wählen…", + "pickAnotherApplied": "Anderes Element wählen (die Rückgängig-Leiste entfällt)…" }, "color": "Farbe" }, diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 59d9505b4..8d73ae9aa 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -725,7 +725,10 @@ "undoAll": "Undo all", "undoneAll": "Everything has been undone.", "undonePartial": "Undone, except {{docs}} — those have since been worked on.", - "selectHint": "Choose a library item with a conflict to start distributing." + "selectHint": "Choose a library item with a conflict to start distributing.", + "noConflicts": "No library item is currently double-booked across the open projects.", + "pickAnother": "Choose another item…", + "pickAnotherApplied": "Choose another item (the undo bar goes away)…" }, "color": "Color" }, diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 32303770b..5ec9a72bf 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -743,7 +743,10 @@ "undoAll": "Deshacer todo", "undoneAll": "Todo se ha deshecho.", "undonePartial": "Deshecho, excepto {{docs}} — en esos ya se ha trabajado mientras tanto.", - "selectHint": "Elige un artículo de biblioteca con un conflicto para empezar a repartir." + "selectHint": "Elige un artículo de biblioteca con un conflicto para empezar a repartir.", + "noConflicts": "Ningún elemento de la biblioteca tiene actualmente doble reserva en los proyectos abiertos.", + "pickAnother": "Elegir otro elemento…", + "pickAnotherApplied": "Elegir otro elemento (la barra de deshacer desaparece)…" }, "color": "Color" }, diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 076efd1c7..02cce91ca 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -725,7 +725,10 @@ "undoAll": "بازگردانی همه", "undoneAll": "همه چیز بازگردانی شد.", "undonePartial": "بازگردانی شد، به‌جز {{docs}} — که در این میان روی آن‌ها کار شده است.", - "selectHint": "برای شروع توزیع، یک قلم کتابخانه دارای تعارض انتخاب کنید." + "selectHint": "برای شروع توزیع، یک قلم کتابخانه دارای تعارض انتخاب کنید.", + "noConflicts": "در حال حاضر هیچ عنصر کتابخانه‌ای در پروژه‌های باز به‌صورت دوگانه رزرو نشده است.", + "pickAnother": "انتخاب عنصر دیگر…", + "pickAnotherApplied": "انتخاب عنصر دیگر (نوار بازگردانی حذف می‌شود)…" }, "color": "رنگ" }, diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 5f6b6b232..3b585279a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -743,7 +743,10 @@ "undoAll": "Tout annuler", "undoneAll": "Tout a été annulé.", "undonePartial": "Annulé, sauf {{docs}} — on y a travaillé entre-temps.", - "selectHint": "Choisissez un article de bibliothèque en conflit pour commencer à répartir." + "selectHint": "Choisissez un article de bibliothèque en conflit pour commencer à répartir.", + "noConflicts": "Aucun élément de bibliothèque n'est actuellement réservé en double sur les projets ouverts.", + "pickAnother": "Choisir un autre élément…", + "pickAnotherApplied": "Choisir un autre élément (la barre d'annulation disparaît)…" }, "color": "Couleur" }, diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 0d3d80ab9..984f2a244 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -743,7 +743,10 @@ "undoAll": "Annulla tutto", "undoneAll": "Tutto è stato annullato.", "undonePartial": "Annullato, tranne {{docs}} — lì nel frattempo si è lavorato.", - "selectHint": "Scegli un articolo di libreria con un conflitto per iniziare a distribuire." + "selectHint": "Scegli un articolo di libreria con un conflitto per iniziare a distribuire.", + "noConflicts": "Nessun elemento della libreria è attualmente prenotato due volte nei progetti aperti.", + "pickAnother": "Scegli un altro elemento…", + "pickAnotherApplied": "Scegli un altro elemento (la barra di annullamento scompare)…" }, "color": "Colore" }, diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index eaaa8814d..cb4fa3b91 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -707,7 +707,10 @@ "undoAll": "すべて元に戻す", "undoneAll": "すべて元に戻りました。", "undonePartial": "{{docs}} を除いて元に戻りました — そちらはその間に作業が行われています。", - "selectHint": "配分を始めるには、競合のあるライブラリ項目を選択してください。" + "selectHint": "配分を始めるには、競合のあるライブラリ項目を選択してください。", + "noConflicts": "現在、開いているプロジェクト間で二重予約されているライブラリ項目はありません。", + "pickAnother": "別の項目を選ぶ…", + "pickAnotherApplied": "別の項目を選ぶ(元に戻すバーは消えます)…" }, "color": "色" }, diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 81a0b45f3..f0c41da1d 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -707,7 +707,10 @@ "undoAll": "모두 실행 취소", "undoneAll": "모두 실행 취소되었습니다.", "undonePartial": "{{docs}}을(를) 제외하고 실행 취소되었습니다 — 그동안 해당 항목에서 작업이 진행되었습니다.", - "selectHint": "분배를 시작하려면 충돌이 있는 라이브러리 항목을 선택하세요." + "selectHint": "분배를 시작하려면 충돌이 있는 라이브러리 항목을 선택하세요.", + "noConflicts": "현재 열려 있는 프로젝트에서 이중 예약된 라이브러리 항목이 없습니다.", + "pickAnother": "다른 항목 선택…", + "pickAnotherApplied": "다른 항목 선택(되돌리기 막대가 사라집니다)…" }, "color": "색상" }, diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index ead7e78e4..0ee8ac3b8 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -725,7 +725,10 @@ "undoAll": "Alles terugdraaien", "undoneAll": "Alles is teruggedraaid.", "undonePartial": "Teruggedraaid, behalve {{docs}} — daar is intussen in gewerkt.", - "selectHint": "Kies een bibliotheekitem met een conflict om te gaan verdelen." + "selectHint": "Kies een bibliotheekitem met een conflict om te gaan verdelen.", + "noConflicts": "Er is nu geen enkel bibliotheekitem dubbel geboekt over de geopende projecten.", + "pickAnother": "Ander item kiezen…", + "pickAnotherApplied": "Ander item kiezen (de terugdraai-strook vervalt)…" }, "color": "Kleur" }, diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 6d4a45b7f..0b121e3c0 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -761,7 +761,10 @@ "undoAll": "Cofnij wszystko", "undoneAll": "Wszystko zostało cofnięte.", "undonePartial": "Cofnięto, z wyjątkiem {{docs}} — tam w międzyczasie pracowano.", - "selectHint": "Wybierz pozycję biblioteki z konfliktem, aby rozpocząć rozdzielanie." + "selectHint": "Wybierz pozycję biblioteki z konfliktem, aby rozpocząć rozdzielanie.", + "noConflicts": "Żaden element biblioteki nie jest obecnie podwójnie zarezerwowany w otwartych projektach.", + "pickAnother": "Wybierz inny element…", + "pickAnotherApplied": "Wybierz inny element (pasek cofania zniknie)…" }, "color": "Kolor" }, diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 0864696e8..91db36e21 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -743,7 +743,10 @@ "undoAll": "Anular tudo", "undoneAll": "Tudo foi anulado.", "undonePartial": "Anulado, exceto {{docs}} — nesses entretanto já se trabalhou.", - "selectHint": "Escolha um item de biblioteca com um conflito para começar a distribuir." + "selectHint": "Escolha um item de biblioteca com um conflito para começar a distribuir.", + "noConflicts": "Nenhum item da biblioteca está atualmente com reserva dupla nos projetos abertos.", + "pickAnother": "Escolher outro item…", + "pickAnotherApplied": "Escolher outro item (a barra de desfazer desaparece)…" }, "color": "Cor" }, diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 61bde5046..06ece9b0a 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -725,7 +725,10 @@ "undoAll": "Tümünü geri al", "undoneAll": "Her şey geri alındı.", "undonePartial": "{{docs}} hariç geri alındı — orada bu arada çalışılmış.", - "selectHint": "Dağıtmaya başlamak için çakışan bir kitaplık öğesi seçin." + "selectHint": "Dağıtmaya başlamak için çakışan bir kitaplık öğesi seçin.", + "noConflicts": "Şu anda açık projeler arasında çift rezerve edilmiş bir kitaplık öğesi yok.", + "pickAnother": "Başka bir öğe seç…", + "pickAnotherApplied": "Başka bir öğe seç (geri alma çubuğu kaybolur)…" }, "color": "Renk" }, diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 60c8cff36..22be9d669 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -707,7 +707,10 @@ "undoAll": "全部撤销", "undoneAll": "已全部撤销。", "undonePartial": "已撤销,{{docs}} 除外 — 那些项目在此期间已被修改。", - "selectHint": "选择一个存在冲突的资源库项目开始分配。" + "selectHint": "选择一个存在冲突的资源库项目开始分配。", + "noConflicts": "当前没有任何资源库条目在已打开的项目之间被重复预订。", + "pickAnother": "选择其他条目…", + "pickAnotherApplied": "选择其他条目(撤销条将消失)…" }, "color": "颜色" }, From 8a018d5b8e449f258ee8da4e5ad91dd905d5bd2a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:37:33 +0200 Subject: [PATCH 040/108] Browsertests voor de kiezer-stand van de verdeeldialoog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie gevallen langs de echte lintknop: met conflict (kiezer toont de regel, klik levert twee stroken), zonder conflict (de "niets te verdelen"-zin en geen Toepassen), en de weg terug met "Ander item kiezen…" vanuit een gevulde dialoog. Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index bfc4c2430..5513888cd 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -114,6 +114,64 @@ async function openDistributionFromConflictRow(page: Page): Promise { await expect(page.locator('[data-ops-distribution-dialog]')).toBeVisible(); } +/** De ANDERE echte route: de lintknop "Verdelen…" op het Resources-tabblad, zonder eerder gekozen + * poolitem — de stand die vóór de reparatie van 2026-09-11 doodliep. */ +async function openDistributionFromRibbon(page: Page): Promise { + await page.locator('.ribbon-tab').filter({ hasText: /^Resources$/ }).click(); + await page.locator('.ribbon-content').getByRole('button', { name: /Verdelen|Distribute/ }).click(); + await expect(page.locator('[data-ops-distribution-dialog]')).toBeVisible(); +} + +test('lintknop zonder gekozen item: de kiezer toont de conflicten en één klik start het voorstel', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + // Niets gekozen: de dialoog opent in zijn kiezer-stand. + expect(await page.evaluate(() => window.__OPS__!.store.getState().ui.levelingDistribution)).toBe(null); + await openDistributionFromRibbon(page); + + const picks = page.locator('[data-ops-distribution-pick]'); + await expect(picks).toHaveCount(1); + await expect(picks.first()).toContainText('Gedeelde kraan'); + // In de kiezer-stand bestaan de voorstel-knoppen niet — er valt nog niets te herberekenen of toe + // te passen (dat waren juist de drie grijze knoppen die de gebruiker deden vastlopen). + await expect(page.locator('[data-ops-distribution-dialog]') + .getByRole('button', { name: /Toepassen|Apply/ })).toHaveCount(0); + await expect(page.locator('[data-ops-distribution-dialog]') + .getByRole('button', { name: /Verdeel automatisch|Herbereken|Distribute automatically|Recalculate/ })).toHaveCount(0); + + // Klikken = dezelfde start als de conflictregel in het overzicht: beide documenten krijgen een strook. + await picks.first().click(); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); +}); + +test('lintknop zonder conflict: de kiezer zegt dat er niets te verdelen valt', async ({ page, ops: _ops }) => { + // Eén document dat ruim binnen de capaciteit boekt — geen dubbele boeking, dus geen keuze. + const [taskA] = await seedProject(page, [{ + name: 'Solo taak', start: '2026-09-07', finish: '2026-09-18', durationDays: 10, + }], 'Solo project'); + const library = await createLibrary(page, 2); + await bookOnPoolItem(page, library, taskA, 1); + + await openDistributionFromRibbon(page); + await expect(page.locator('[data-ops-distribution-no-conflicts]')).toBeVisible(); + await expect(page.locator('[data-ops-distribution-pick]')).toHaveCount(0); + await expect(page.locator('[data-ops-distribution-dialog]') + .getByRole('button', { name: /Toepassen|Apply/ })).toHaveCount(0); +}); + +test('"Ander item kiezen…" brengt de gevulde dialoog terug naar de kiezer', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + await page.locator('[data-ops-distribution-pick-another]').click(); + + // Geen bevestigingsdialoog ertussen: de tune-state is meteen weg en de kiezer staat er weer. + expect(await page.evaluate(() => window.__OPS__!.store.getState().ui.levelingDistribution)).toBe(null); + await expect(page.locator('[data-ops-distribution-dialog]')).toBeVisible(); + await expect(page.locator('[data-ops-distribution-pick]')).toHaveCount(1); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(0); +}); + test('verdeeldialoog: openen vanuit de conflictregel, een voorstel rekenen, focus-trap en sluiten', async ({ page, ops: _ops }) => { await seedTwoConflictingDocuments(page); await openDistributionFromConflictRow(page); From a4a040f06a7429dee11b6465417869d5133f5924 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:38:34 +0200 Subject: [PATCH 041/108] Bezettingsoverzicht-demo: poort op de exacte set rode rijen, plus uitleg in de gids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De check bewaakte alleen dat Masonry crew rood stond — niet dat de rest groen was. Daardoor kon de demo stilzwijgend verzanden in een muur van rode rijen. - check-showcase-occupancy: exacte set rode rijen (Masonry crew, Plasterers), Plasterers ook als echt kruis-project-conflict en als verdeelbaar zonder tekort (bewust zonder de bescheidenheidsgrens: GROOT schuift ~27 werkdagen door zijn eigen, beloofde interne stukadoors-overallocatie) - check-demo-library: migratie van een oude demo-pool (capaciteiten bij, ids gelijk, niets verwijderd, tweede aanroep is een no-op) - de gids "Verdelen over projecten" vertelt nu welke twee rijen rood horen te staan en waarom ze verschillend aanvoelen Co-Authored-By: Claude Fable 5.1 --- .../docs/en/gids-verdelen-restcapaciteit.md | 15 ++++- .../docs/nl/gids-verdelen-restcapaciteit.md | 15 ++++- tests/library/check-demo-library.ts | 50 +++++++++++++++- tests/library/check-showcase-occupancy.ts | 58 +++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index 19ae57074..daa11d53a 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -29,9 +29,18 @@ The bundled showcases already carry this conflict. Via **File → Examples**, op — [Verbouwing & Aanbouw Eengezinswoning](examples://showcase-verbouwing-eengezinswoning.ifc), [Nieuwbouw 6 Rijwoningen De Akkers](examples://showcase-rijwoningen-de-akkers.ifc) and [Nieuwbouw Appartementencomplex De Vaart](examples://showcase-appartementencomplex.ifc) — each in its -own tab, press F5 in every project and then go to **Resources → Occupancy**. The **Masonry crew** row -is red: there is only one such crew, and in early June it is laying the walls of the family home -extension and of house 6 in De Akkers at the same time. Click **Distribute…** on that row and let +own tab, press F5 in every project and then go to **Resources → Occupancy**. Exactly two rows are +red, and that is deliberate: on every other point the company is comfortably big enough for these +three projects combined. + +The first is **Masonry crew**: there is only one such crew, and in early June it is laying the walls +of the family home extension and of house 6 in De Akkers at the same time. That is the textbook +case — two projects, one crew, neatly resolved by moving one of them slightly. The second is +**Plasterers**: a single plastering crew that appears in all three projects, and that the apartment +complex alone already wants on three towers at once. That one is resolvable too, but it costs the +apartment complex a sizeable delay — instructive to compare with the first case. + +Click **Masonry crew**, then **Distribute…** on that row and let **Distribute automatically** build the proposal; then change the ranking, or put a ceiling on one of the projects, and watch what happens to the proposed shift. diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index ebcfb1696..176f0f1ec 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -30,9 +30,18 @@ De meegeleverde showcases dragen dit conflict zelf al. Open via **Bestand → Vo de showcases — [Verbouwing & Aanbouw Eengezinswoning](examples://showcase-verbouwing-eengezinswoning.ifc), [Nieuwbouw 6 Rijwoningen De Akkers](examples://showcase-rijwoningen-de-akkers.ifc) en [Nieuwbouw Appartementencomplex De Vaart](examples://showcase-appartementencomplex.ifc) — elk in een -eigen tabblad, druk in elk project F5 en ga dan naar **Resources → Bezetting**. De rij **Masonry -crew** staat rood: die ploeg is er maar één, en begin juni metselt hij tegelijk aan de uitbouw van de -eengezinswoning én aan woning 6 van De Akkers. Klik daar op **Verdelen…** en laat **Verdeel +eigen tabblad, druk in elk project F5 en ga dan naar **Resources → Bezetting**. Precies twee rijen +staan rood, en dat is zo bedoeld: het bedrijf is op alle andere punten ruim genoeg voor deze drie +projecten samen. + +De eerste is **Masonry crew**: die ploeg is er maar één, en begin juni metselt hij tegelijk aan de +uitbouw van de eengezinswoning én aan woning 6 van De Akkers. Dat is het schoolvoorbeeld — twee +projecten, één ploeg, netjes op te lossen door een van beide iets op te schuiven. De tweede is +**Plasterers**: één stukadoorsploeg die in alle drie de projecten meedoet, én binnen het +appartementencomplex al aan drie torens tegelijk gevraagd wordt. Die is ook oplosbaar, maar kost het +appartementencomplex een flink stuk uitloop — leerzaam om naast het eerste geval te leggen. + +Klik op **Masonry crew** en dan op **Verdelen…**, en laat **Verdeel automatisch** het voorstel maken; verander daarna eens de rangorde of zet een plafond op een project, en kijk wat er met de voorgestelde verschuiving gebeurt. diff --git a/tests/library/check-demo-library.ts b/tests/library/check-demo-library.ts index d7a85d8fb..c8cc4c50a 100644 --- a/tests/library/check-demo-library.ts +++ b/tests/library/check-demo-library.ts @@ -2,7 +2,7 @@ // één gedeelde pool ("dezelfde ploeg in twee projecten"). Headless store-batterij (patroon // check-library-slice.ts): draait de ECHTE Zustand-store op Node. Exitcode = poort. import { useAppStore } from '@/state/appStore'; -import { DEMO_COMPANY_ID, buildDemoLibrarySeed } from '@/services/library/demoLibrary'; +import { DEMO_COMPANY_ID, DEMO_LIBRARY_SEED_VERSION, buildDemoLibrarySeed } from '@/services/library/demoLibrary'; import { applyDemoLibraryToShowcaseProject } from '@/state/demoLibraryShowcase'; let checks = 0; let fails = 0; @@ -137,5 +137,53 @@ function assert(cond: boolean, msg: string): void { assert(schilder?.libraryOrigin?.companyId === DEMO_COMPANY_ID, 'F2 negatieve controle: resource-koppeling (Painters) werkt nog gewoon'); } +// --- (e) Seed-VERSIE: een bestaande demo-pool van vóór de capaciteitscorrectie wordt bijgewerkt --- +// Waarom: `seedDemoLibrary` was idempotent op de AANWEZIGHEID van het bedrijf, dus een installatie +// die de demo-bibliotheek ooit geseed had kreeg een latere inhoudscorrectie nooit te zien — en zag +// in het bezettingsoverzicht een muur van rode rijen. De migratie mag daarbij geen id's veranderen +// (herkomststempels in open/opgeslagen projecten wijzen daarnaar) en niets verwijderen. +{ + useAppStore.setState({ companies: [], pools: {} } as never); + const { company, pool } = buildDemoLibrarySeed(); + // Simuleer een pool van vóór deze versie: oude (te krappe) capaciteiten, geen seedVersion, en + // één item dat de oude seed nog niet kende. + const oud = { + ...pool, + seedVersion: undefined, + poolVersion: 3, + resources: pool.resources + .filter((r) => r.name !== 'Lift supplier') + .map((r) => r.name === 'Carpenters' ? { ...r, maxUnits: 4, description: 'oude omschrijving' } + : r.name === 'Tilers' ? { ...r, maxUnits: 3 } + : r.name === 'Kitchen fitters' ? { ...r, maxUnits: 2 } : r), + }; + const idsVoor = new Map(oud.resources.map((r) => [r.name, r.id])); + useAppStore.setState({ companies: [company], pools: { [DEMO_COMPANY_ID]: oud } } as never); + + useAppStore.getState().seedDemoLibrary(); + const na = useAppStore.getState().pools[DEMO_COMPANY_ID]; + const byName = (n: string) => na.resources.find((r) => r.name === n); + + assert(na.seedVersion === DEMO_LIBRARY_SEED_VERSION, 'migratie: seedVersion gezet op de huidige inhoudsversie'); + assert(byName('Carpenters')?.maxUnits === 14, `migratie: Carpenters-capaciteit bijgewerkt (heeft: ${byName('Carpenters')?.maxUnits})`); + assert(byName('Tilers')?.maxUnits === 16, `migratie: Tilers-capaciteit bijgewerkt (heeft: ${byName('Tilers')?.maxUnits})`); + assert(byName('Kitchen fitters')?.maxUnits === 9, `migratie: Kitchen fitters-capaciteit bijgewerkt (heeft: ${byName('Kitchen fitters')?.maxUnits})`); + assert(byName('Carpenters')?.description !== 'oude omschrijving', 'migratie: omschrijving bijgewerkt'); + assert(!!byName('Lift supplier'), 'migratie: ontbrekend item toegevoegd'); + assert(na.resources.length === 14, `migratie: niets verwijderd, geen duplicaten (heeft: ${na.resources.length})`); + for (const [naam, id] of idsVoor) { + assert(byName(naam)?.id === id, `migratie: id van "${naam}" ongewijzigd (stempels blijven geldig)`); + } + assert(na.poolVersion === 4, `migratie: pool gebumpt zodat gekoppelde projecten herclassificeren (heeft: ${na.poolVersion})`); + assert(byName('Plasterers')?.maxUnits === 3, 'migratie: het bedoelde knelpunt Plasterers blijft krap (3)'); + assert(byName('Masonry crew')?.maxUnits === 1, 'migratie: het bedoelde knelpunt Masonry crew blijft krap (1)'); + + // Tweede aanroep ⇒ no-op: geen bump, dezelfde objectreferentie. + const poolNaEerste = useAppStore.getState().pools[DEMO_COMPANY_ID]; + useAppStore.getState().seedDemoLibrary(); + assert(useAppStore.getState().pools[DEMO_COMPANY_ID] === poolNaEerste, + 'migratie (2e aanroep): no-op — zelfde pool-objectreferentie, geen extra bump'); +} + console.log(`demo-library: ${checks - fails}/${checks} groen`); process.exit(fails > 0 ? 1 : 0); diff --git a/tests/library/check-showcase-occupancy.ts b/tests/library/check-showcase-occupancy.ts index 334fcd044..9a6b43cf1 100644 --- a/tests/library/check-showcase-occupancy.ts +++ b/tests/library/check-showcase-occupancy.ts @@ -95,6 +95,21 @@ const inputs: OccupancyDocInput[] = payloads.map(({ docId, payload }) => ({ }, })); +/** Het TWEEDE bedoelde knelpunt: één stukadoorsploeg die in alle drie de showcases meedoet. Anders + * dan `CONFLICT_ITEM` is dit conflict NIET zonder tekort te verdelen — MIDDEL en GROOT hebben elk + * hun eigen, in hun spec gedocumenteerde interne stukadoors-overallocatie (GROOT vraagt op één dag + * 9 van de 3), en geen enkele volgorde van projecten neemt die weg. De eis hieronder is daarom + * alleen het KRUIS-deel: er moet een conflict zijn dat uit ≥2 documenten komt. */ +const CROSS_ITEM = 'Plasterers'; + +/** De EXACTE set poolitems die met de drie showcases open rood mag staan. Waarom exact en niet + * "minstens": de demo was een ruïne — bijna élke rij stond rood omdat de demo-bibliotheek kleiner + * gedimensioneerd was dan wat de showcases er samen op boeken, waardoor het bedoelde verhaal + * (dezelfde metselploeg in twee projecten, de stukadoors in drie) verdronk in ruis. Een "minstens + * deze twee"-assert had dat niet gezien. Deze set is dus de poort die een toekomstige regeneratie + * van de voorbeelden — of een krappere bibliotheekcapaciteit — dwingt bewust te zijn. */ +const EXPECTED_CONFLICT_ITEMS = [CONFLICT_ITEM, CROSS_ITEM]; + const poolItem = pool?.resources.find((r) => r.name === CONFLICT_ITEM); assert(!!poolItem, `poolitem "${CONFLICT_ITEM}" aanwezig in de demo-bibliotheek`); @@ -116,6 +131,33 @@ if (row) { assert(row.capacityAtPeak > 0, `"${CONFLICT_ITEM}" heeft een bedrijfscapaciteit > 0 op de piekdag`); } +// ═══ (a2) ALLEEN de bedoelde items staan rood — exacte set ═══════════════════════════════════════ +{ + const rood = occupancy.rows.filter((r) => r.conflictDays.length > 0).map((r) => r.name).sort(); + const verwacht = [...EXPECTED_CONFLICT_ITEMS].sort(); + assert( + rood.length === verwacht.length && rood.every((n, i) => n === verwacht[i]), + `rode rijen in het bezettingsoverzicht: [${rood.join(', ')}] — verwacht exact [${verwacht.join(', ')}]. ` + + 'Een item dat hier onbedoeld bij staat maakt de demo onleesbaar; verhoog de capaciteit in ' + + 'src/services/library/demoLibrary.ts (en bump DEMO_LIBRARY_SEED_VERSION) of neem de ' + + 'overboeking in de showcase-spec weg.', + ); +} + +// ═══ (a3) Het tweede bedoelde knelpunt is ECHT een kruis-project-conflict ════════════════════════ +{ + const crossPoolItem = pool?.resources.find((r) => r.name === CROSS_ITEM); + const crossRow = occupancy.rows.find((r) => r.libraryItemId === crossPoolItem?.id); + assert(!!crossRow, `"${CROSS_ITEM}" heeft een rij in het bezettingsoverzicht`); + if (crossRow) { + assert(crossRow.conflictDays.length > 0, `"${CROSS_ITEM}" heeft conflictdagen (heeft: 0)`); + const cset = new Set(crossRow.conflictDays); + const docs = crossRow.docs.filter((b) => b.counted && Object.keys(b.dailyLoad).some((iso) => cset.has(iso))); + assert(docs.length >= 2, + `conflict op "${CROSS_ITEM}" komt van ${docs.length} document(en) — verwacht ≥2 (kruis-project)`); + } +} + // ═══ (b) "Verdeel automatisch" lost het op: geen blokkade, geen tekort, en er verschuift iets ═════ const distInputs: DistributionDocInput[] = inputs.map((input, i) => ({ ...input, @@ -142,6 +184,22 @@ if (poolItem) { } } +// ═══ (b2) Ook het tweede knelpunt is verdeelbaar — zij het niet bescheiden ═══════════════════════ +// Gemeten: geen blokkade, geen tekort, maar GROOT schuift ~27 werkdagen op. Dat is geen fout maar +// het gevolg van GROOT's eigen, in zijn spec beloofde interne stukadoors-overallocatie (één ploeg +// van 3 voor drie torens, vraag 9): die moet de verdeling er óók uit werken. De ≤15-werkdagen-eis +// van (b) geldt hier dus bewust NIET — alleen "oplosbaar zonder tekort". +{ + const crossPoolItem = pool?.resources.find((r) => r.name === CROSS_ITEM); + if (crossPoolItem) { + const proposal = computeDistribution(DEMO_COMPANY_ID, pool, crossPoolItem.id, distInputs, { allowSplits: false }); + assert(proposal.blocked === null, + `verdeling "${CROSS_ITEM}" geblokkeerd: ${proposal.blocked ? proposal.blocked.reason : ''}`); + assert(proposal.hasShortfall === false, + `verdeling "${CROSS_ITEM}" laat een tekort achter (${proposal.docs.reduce((a, d) => a + d.shortfalls.length, 0)} niet-geplaatste taak/taken)`); + } +} + // ═══ (c) Geen intrinsieke overvraag: geen showcase-taak vraagt op één dag meer dan de poolcapaciteit ══ // Zou één taak méér vragen dan het bedrijf heeft, dan is het conflict per definitie onoplosbaar en // levert "Verdeel automatisch" altijd een tekort — hoe de documenten ook geschoven worden. From 38c63d42db9b4f2290dfa9a8e3355f6c2f06ab9e Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:46:43 +0200 Subject: [PATCH 042/108] =?UTF-8?q?docs(gids):=20keuzelijst=20achter=20de?= =?UTF-8?q?=20ribbonknop=20Verdelen=E2=80=A6=20en=20de=20'In=20project'-ba?= =?UTF-8?q?dge=20in=20de=20Bibliotheek-weergave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- public/docs/en/gids-resourcebibliotheken.md | 2 +- public/docs/en/gids-verdelen-restcapaciteit.md | 9 ++++++++- public/docs/nl/gids-resourcebibliotheken.md | 2 +- public/docs/nl/gids-verdelen-restcapaciteit.md | 9 ++++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/public/docs/en/gids-resourcebibliotheken.md b/public/docs/en/gids-resourcebibliotheken.md index 533a3d8ce..d2fb59864 100644 --- a/public/docs/en/gids-resourcebibliotheken.md +++ b/public/docs/en/gids-resourcebibliotheken.md @@ -79,7 +79,7 @@ Assign a library resource, and its calendar travels along as a linked copy that ## Three actions that connect the two worlds -- **Assign to project** — from library to project: creates an editable copy with provenance. +- **Assign to project** — from library to project: creates an editable copy with provenance. In the Library view that button only appears on items that aren't in this project yet. If an item is already in there, you'll see the **In project (N×)** badge instead, where N is the number of linked copies in the active project. - **To the library** — from a project-only row into the shared library: links it immediately. If an item with the same name already exists in the library, the app links to that one instead of duplicating it. - **Unlink from library** — provenance disappears, everything becomes fully editable again. A calendar that travelled along unlinks with it, unless another still-linked resource is using that same calendar. diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index daa11d53a..d798aab21 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -16,7 +16,14 @@ single shift that fits within what's actually available. There are two entry points: - From a conflict row in the occupancy overview: the **Distribute…** button. -- From the Resources ribbon, whenever a conflict is open. +- Straight from the Resources ribbon: **Distribute…** sits there too. + +If you haven't picked a library item yet, that ribbon button first opens a list of the items that are +currently double-booked across the open projects, with the number of projects and the number of +conflict days on each row. One click on a row starts the proposal. If nothing is double-booked +anywhere, the dialog simply says so: "No library item is currently double-booked across the open +projects." During a distribution in progress, **Choose another item…** at the bottom takes you back to +that list; note that this also drops the "Applied in … projects" bar. The dialog opens with the title "Distribute over projects" and shows which library item it concerns at the top. You close it with the cross in the top-right corner, with Esc, or with **Discard** at the diff --git a/public/docs/nl/gids-resourcebibliotheken.md b/public/docs/nl/gids-resourcebibliotheken.md index 2b891e0cc..e304ceed5 100644 --- a/public/docs/nl/gids-resourcebibliotheken.md +++ b/public/docs/nl/gids-resourcebibliotheken.md @@ -79,7 +79,7 @@ Wijs je een bibliotheekresource toe, dan reist zijn kalender mee als een gekoppe ## Drie acties die de werelden verbinden -- **Toewijzen aan project** — van bibliotheek naar project: maakt een bewerkbare kopie met herkomst. +- **Toewijzen aan project** — van bibliotheek naar project: maakt een bewerkbare kopie met herkomst. Die knop staat in de Bibliotheek-weergave alleen bij items die nog niet in dit project zitten. Zit een item er al wel in, dan zie je in plaats daarvan de markering **In project (N×)**, met N het aantal gekoppelde kopieën in het actieve project. - **Naar de bibliotheek** — van een projecteigen rij naar de gedeelde bibliotheek: koppelt meteen. Bestaat er al een item met dezelfde naam in de bibliotheek, dan koppelt de app daaraan in plaats van te dupliceren. - **Losmaken van de bibliotheek** — de herkomst verdwijnt, alles wordt weer volledig bewerkbaar. Een meegereisde kalender gaat mee los, tenzij een andere nog-gekoppelde resource diezelfde kalender ook gebruikt. diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index 176f0f1ec..27724641c 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -17,7 +17,14 @@ werkelijk beschikbaar is. Er zijn twee ingangen: - Vanuit een conflictregel in het bezettingsoverzicht: de knop **Verdelen…**. -- Vanuit de Resources-ribbon, wanneer er een conflict openstaat. +- Rechtstreeks via de Resources-ribbon: ook daar staat **Verdelen…**. + +Heb je nog geen bibliotheekitem gekozen, dan opent die ribbonknop eerst een keuzelijst met de items +die op dit moment dubbel geboekt zijn over de geopende projecten, met per regel het aantal projecten +en het aantal conflictdagen. Eén klik op zo'n regel start het voorstel. Is er nergens een dubbele +boeking, dan zegt de dialoog dat gewoon: "Er is nu geen enkel bibliotheekitem dubbel geboekt over de +geopende projecten." In een lopende verdeling brengt **Ander item kiezen…** onderin je terug naar die +lijst; let op: daarmee vervalt ook de strook "Toegepast in … projecten". De dialoog opent met de titel "Verdelen over projecten" en toont bovenaan om welk bibliotheekitem het gaat. Je sluit hem met het kruisje rechtsboven of met Esc, of onderaan met **Verwerpen** — in From a5a1750a7ded479e6ec7a8b6620b48453fbd51b1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:42:17 +0200 Subject: [PATCH 043/108] Gebrek 1: het prijskaartje benoemt zijn stand (uit/aan, of "in beide standen") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naast "Onderbrekingen toestaan" stonden twee kale prijzen achter elkaar ("kost 27 werkdagen uitloop · kost 27 werkdagen uitloop"). Zonder label bij de twee standen leest dat als een weergavefout in plaats van als twee uitkomsten. Nu: "uit: … · aan: …", en wanneer beide standen dezelfde prijs hebben één keer met "in beide standen". Gemeten op de showcase-fixture (drie showcases, poolitem "Plasterers", probe naar het model van tests/library/check-showcase-occupancy.ts): allowSplits=false én allowSplits=true geven allebei prijs 27, zonder blokkade en zonder tekort, met identieke endShiftWorkdays per document (0 / 10 / 27) en in beide standen NUL splitGaps. De aan-stand komt dus echt uit een run met allowSplits: true — de gelijke getallen zijn data, geen kopieerfout: onderbreken lost de interne stukadoors-overallocatie van het grote project (vraag 9 op capaciteit 3) niet op. Nieuwe sleutels (14 locales): resource.distribution.tool.priceOff / .priceOn / .priceSame. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 15 ++++++++++++--- src/i18n/locales/ar/common.json | 3 +++ src/i18n/locales/de/common.json | 3 +++ src/i18n/locales/en/common.json | 3 +++ src/i18n/locales/es/common.json | 3 +++ src/i18n/locales/fa/common.json | 3 +++ src/i18n/locales/fr/common.json | 3 +++ src/i18n/locales/it/common.json | 3 +++ src/i18n/locales/ja/common.json | 3 +++ src/i18n/locales/ko/common.json | 3 +++ src/i18n/locales/nl/common.json | 3 +++ src/i18n/locales/pl/common.json | 3 +++ src/i18n/locales/pt/common.json | 3 +++ src/i18n/locales/tr/common.json | 3 +++ src/i18n/locales/zh/common.json | 3 +++ tests/browser/leveling-distribution.spec.ts | 5 +++++ 16 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 5aa2c3506..54c781acd 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -80,11 +80,20 @@ export function DistributionDialog() { const priceText = (workdays: number): string => (workdays === 0 ? t('resource.distribution.tool.priceNone') : t('resource.distribution.tool.price', { count: workdays })); + // Het prijskaartje benoemt WELKE STAND het getal hoort (gebruikstest 2026-09-12, gebrek 1). Er + // stonden twee kale prijzen naast elkaar ("kost 27 werkdagen uitloop · kost 27 werkdagen + // uitloop"): zonder "uit:"/"aan:" leest dat als een weergavefout in plaats van als twee standen. + // En als beide standen even duur zijn — wat op de showcase-fixture aantoonbaar het geval is, + // want daar lost onderbreken de interne overallocatie van het grote project niet op — is één + // prijs met "in beide standen" eerlijker dan hetzelfde getal twee keer. const toolPriceLabel = (): string => { if (labelsPending) return t('resource.distribution.compute.busy'); - return (!labelsValid || !toolPrice) - ? t('resource.distribution.tool.priceUnknown') - : `${priceText(toolPrice.off)} · ${priceText(toolPrice.on)}`; + if (!labelsValid || !toolPrice) return t('resource.distribution.tool.priceUnknown'); + if (toolPrice.off === toolPrice.on) { + return t('resource.distribution.tool.priceSame', { price: priceText(toolPrice.off) }); + } + return `${t('resource.distribution.tool.priceOff', { price: priceText(toolPrice.off) })}` + + ` · ${t('resource.distribution.tool.priceOn', { price: priceText(toolPrice.on) })}`; }; // Gepind/#63/cannotMove-documenten krijgen GEEN kostenlabel (§4 stap 1: "ze wijken niet") — dat // leest rechtstreeks uit het LAATST BEREKENDE voorstel (`participated`/`cannotMove`), niet uit diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 6787fae9b..255e4c87c 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -720,6 +720,9 @@ "price_many": "تكلف {{count}} يوم عمل تمديدًا", "price_other": "تكلف {{count}} يوم عمل تمديدًا", "priceNone": "لا يكلف أي تمديد", + "priceOff": "إيقاف: {{price}}", + "priceOn": "تشغيل: {{price}}", + "priceSame": "{{price}} — في كلتا الحالتين", "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب" }, "rank": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 85bdcafc6..172f922e9 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -668,6 +668,9 @@ "price_one": "kostet {{count}} Arbeitstag Verzug", "price_other": "kostet {{count}} Arbeitstage Verzug", "priceNone": "kostet keinen Verzug", + "priceOff": "aus: {{price}}", + "priceOn": "ein: {{price}}", + "priceSame": "{{price}} — in beiden Stellungen", "priceUnknown": "Preis unbekannt — Neu berechnen drücken" }, "rank": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 56b344211..0569f58ef 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -668,6 +668,9 @@ "price_one": "costs {{count}} workday of overrun", "price_other": "costs {{count}} workdays of overrun", "priceNone": "costs no overrun", + "priceOff": "off: {{price}}", + "priceOn": "on: {{price}}", + "priceSame": "{{price}} — in both settings", "priceUnknown": "price unknown — press Recalculate" }, "rank": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 932767b8e..418419adc 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -681,6 +681,9 @@ "price_many": "cuesta {{count}} días laborables de retraso", "price_other": "cuesta {{count}} días laborables de retraso", "priceNone": "no cuesta ningún retraso", + "priceOff": "desactivado: {{price}}", + "priceOn": "activado: {{price}}", + "priceSame": "{{price}} — en ambos casos", "priceUnknown": "precio desconocido — pulsa Recalcular" }, "rank": { diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 09840afd3..92bceced0 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -668,6 +668,9 @@ "price_one": "{{count}} روز کاری تمدید هزینه دارد", "price_other": "{{count}} روز کاری تمدید هزینه دارد", "priceNone": "هیچ تمدیدی هزینه ندارد", + "priceOff": "خاموش: {{price}}", + "priceOn": "روشن: {{price}}", + "priceSame": "{{price}} — در هر دو حالت", "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید" }, "rank": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 186b026e1..00ecd0ac6 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -681,6 +681,9 @@ "price_many": "coûte {{count}} jours ouvrables de dépassement", "price_other": "coûte {{count}} jours ouvrables de dépassement", "priceNone": "ne coûte aucun dépassement", + "priceOff": "désactivé : {{price}}", + "priceOn": "activé : {{price}}", + "priceSame": "{{price}} — dans les deux cas", "priceUnknown": "prix inconnu — appuyez sur Recalculer" }, "rank": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 9bf2e5dfd..38f765dd0 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -681,6 +681,9 @@ "price_many": "costa {{count}} giorni lavorativi di ritardo", "price_other": "costa {{count}} giorni lavorativi di ritardo", "priceNone": "non costa alcun ritardo", + "priceOff": "disattivato: {{price}}", + "priceOn": "attivato: {{price}}", + "priceSame": "{{price}} — in entrambi i casi", "priceUnknown": "prezzo sconosciuto — premi Ricalcola" }, "rank": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a056ab120..b26491cce 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -655,6 +655,9 @@ "allowSplitsHint": "作業は一気に移動する代わりに休止日を挟むことができます。MS Project ではこれを「Leveling can create splits in remaining work」と呼びます。", "price_other": "{{count}} 稼働日の延長がかかります", "priceNone": "延長はかかりません", + "priceOff": "オフ: {{price}}", + "priceOn": "オン: {{price}}", + "priceSame": "{{price}} — どちらの設定でも同じ", "priceUnknown": "価格不明 — 再計算を押してください" }, "rank": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index bee240a74..b02409763 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -655,6 +655,9 @@ "allowSplitsHint": "작업이 한 번에 이동하는 대신 중단일을 가질 수 있습니다. MS Project에서는 이를 \"Leveling can create splits in remaining work\"라고 합니다.", "price_other": "{{count}}근무일의 연장 비용이 듭니다", "priceNone": "연장 비용이 들지 않습니다", + "priceOff": "끔: {{price}}", + "priceOn": "켬: {{price}}", + "priceSame": "{{price}} — 두 설정 모두 동일", "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요" }, "rank": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index b546a96b9..7b3cf7e2c 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -668,6 +668,9 @@ "price_one": "kost {{count}} werkdag uitloop", "price_other": "kost {{count}} werkdagen uitloop", "priceNone": "kost geen uitloop", + "priceOff": "uit: {{price}}", + "priceOn": "aan: {{price}}", + "priceSame": "{{price}} — in beide standen", "priceUnknown": "prijs onbekend — druk op Herbereken" }, "rank": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 42d176a0d..d1741f6a5 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -694,6 +694,9 @@ "price_many": "kosztuje {{count}} dni roboczych wydłużenia", "price_other": "kosztuje {{count}} dnia roboczego wydłużenia", "priceNone": "nie kosztuje żadnego wydłużenia", + "priceOff": "wyłączone: {{price}}", + "priceOn": "włączone: {{price}}", + "priceSame": "{{price}} — w obu ustawieniach", "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie" }, "rank": { diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 9769df963..bad6c42c5 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -681,6 +681,9 @@ "price_many": "custa {{count}} dias úteis de atraso", "price_other": "custa {{count}} dias úteis de atraso", "priceNone": "não custa nenhum atraso", + "priceOff": "desativado: {{price}}", + "priceOn": "ativado: {{price}}", + "priceSame": "{{price}} — em ambos os casos", "priceUnknown": "preço desconhecido — prima Recalcular" }, "rank": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 3da836704..3d477e02b 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -668,6 +668,9 @@ "price_one": "{{count}} iş günü kaydırma maliyeti var", "price_other": "{{count}} iş günü kaydırma maliyeti var", "priceNone": "kaydırma maliyeti yok", + "priceOff": "kapalı: {{price}}", + "priceOn": "açık: {{price}}", + "priceSame": "{{price}} — her iki durumda da", "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın" }, "rank": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 2b143972b..6d6b54e3e 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -655,6 +655,9 @@ "allowSplitsHint": "工作可以插入暂停日,而不是整体顺延。在 MS Project 中这称为\"Leveling can create splits in remaining work\"。", "price_other": "需要 {{count}} 个工作日的延误", "priceNone": "不需要延误", + "priceOff": "关闭:{{price}}", + "priceOn": "开启:{{price}}", + "priceSame": "{{price}} — 两种设置下相同", "priceUnknown": "价格未知 — 按下重新计算" }, "rank": { diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 5513888cd..a4a04f54d 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -537,6 +537,11 @@ test('kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel', // onbekend" meer. await expect(page.locator('[data-ops-distribution-rank-row] [data-ops-distribution-cost]')).toHaveCount(2); await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/werkdag|no overrun|geen uitloop|workday/i); + // Gebruikstest 2026-09-12, gebrek 1: de prijs mag nooit als twee kale, naamloze getallen naast + // elkaar staan. Er staat óf "uit: … · aan: …", óf — wanneer beide standen even duur zijn — één + // prijs met "in beide standen". + await expect(page.locator('[data-ops-distribution-tool-price]')) + .toContainText(/uit:|off:|in beide standen|in both settings/i); // Een externe bewerking laat het voorstel vervallen (§6a — buiten de modale dialoog om, zie de // bestaande taak-12-test hierboven). De labels zijn gecachet TOT invalidatie, niet live: zodra het From 7c97b184e85cb7ec1f4e6c90950db62ec01ec581 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:47:44 +0200 Subject: [PATCH 044/108] Gebrek 2: rangorde slepen via pointer-events met een zichtbare greep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Sleep om de volgorde te veranderen" deed in de browser-preview van de eigenaar niets. Oorzaak: de rangordelijst gebruikte native HTML5-drag-and-drop (draggable + dragstart/dragover/drop). Dat protocol wordt door de host geleverd en is buiten een gewoon browsertabblad onbetrouwbaar — in een iframe-preview (de t3-omgeving) en in een Tauri-webview (WebKitGTK) wordt de dragstart afgevangen of nooit uitgezonden, en dan valt het hele mechanisme stil zonder foutmelding. Bovendien was er geen enkele aanwijzing DAT er te slepen viel: de hele rij was draggable, zonder greep en zonder cursor: grab. Nu: pointer-events met setPointerCapture op een eigen greep, hetzelfde patroon als de plafond-handle in PhaseStrip.tsx — een ⋮⋮-icoon met cursor: grab/grabbing, touchAction: none, één actieve pointer, pointercancel als opruimpad, en een zuivere klik zonder beweging commit niets. De gesleepte rij licht op en de doelrij toont een invoegindicator als inset-boxshadow (geen layoutverschuiving tijdens het mikken). De doelrij komt uit een eigen hit-test op de echte rijgeometrie, met de dichtstbijzijnde rand als doel wanneer de sleep boven of onder de lijst uitschiet. reorderTo, move en de pijlknoppen zijn onveranderd; een sleep zet dus nog steeds staleReason = 'rank'. De browsertest gebruikt nu page.mouse met steps en toetst ook de uitlichting en de invoegindicator. Nieuwe sleutels (14 locales): resource.distribution.rank.drag, resource.distribution.help.dragHandle; rank.hint benoemt nu de greep. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 168 +++++++++++++----- src/i18n/locales/ar/common.json | 6 +- src/i18n/locales/de/common.json | 6 +- src/i18n/locales/en/common.json | 6 +- src/i18n/locales/es/common.json | 6 +- src/i18n/locales/fa/common.json | 6 +- src/i18n/locales/fr/common.json | 6 +- src/i18n/locales/it/common.json | 6 +- src/i18n/locales/ja/common.json | 6 +- src/i18n/locales/ko/common.json | 6 +- src/i18n/locales/nl/common.json | 6 +- src/i18n/locales/pl/common.json | 6 +- src/i18n/locales/pt/common.json | 6 +- src/i18n/locales/tr/common.json | 6 +- src/i18n/locales/zh/common.json | 6 +- tests/browser/leveling-distribution.spec.ts | 20 ++- 16 files changed, 213 insertions(+), 59 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 54c781acd..8ee56265f 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -19,17 +19,29 @@ // actieknoppen en ruimt een `info` na 5 s op. De melding die er daarnaast uit gaat is puur // informatief. // -// RANGORDE MET DE MUIS (taak 10, spec §4 stap 1). Native HTML5 drag-and-drop — hetzelfde mechanisme -// als `DataGridHeader`'s kolomherordening (`draggable` + `onDragStart`/`onDragOver`/`onDrop`), niet -// een tweede pointer-events-sleepmechaniek erbij. De lijst is klein en niet virtualized, dus een -// per-rij `onDragOver`/`onDrop` volstaat hier — de window-brede dragover/drop-vangnet-luisteraars die -// de kolomkop nodig heeft (voor een sleep die de headerrij verlaat) zijn voor een rangordelijst met -// eigen scrolgebied niet nodig. De "naar boven/beneden"-knoppen (`move`) blijven de toetsenbordroute -// en het testanker; slepen (`reorderTo`) roept dezelfde `setUI` aan, dus een herordening met de muis -// zet net als de knoppen `staleReason = 'rank'` via `diffReason` in `useDistributionProposal`. -import { useMemo, useState } from 'react'; +// RANGORDE MET DE MUIS (taak 10, spec §4 stap 1) — POINTER-EVENTS, NIET HTML5-DND. +// +// Dit was native HTML5 drag-and-drop (`draggable` + `onDragStart`/`onDragOver`/`onDrop`), naar het +// model van `DataGridHeader`'s kolomherordening. In de GEBRUIKSTEST van de eigenaar (2026-09-12) +// deed "Sleep om de volgorde te veranderen" simpelweg niets. Dat is geen toeval en ook geen bug in +// deze component: HTML5-dnd hangt aan een apart, door de host geleverd drag-protocol +// (`dragstart` → `DataTransfer` → `drop`) dat buiten een gewoon browsertabblad onbetrouwbaar is. +// De app draait juist óók in een iframe-preview (de t3-omgeving) en in een Tauri-webview (WebKitGTK +// op Linux), en daar wordt de dragstart geregeld door de host afgevangen of nooit uitgezonden — +// dan valt het hele mechanisme stil, zónder foutmelding. Pointer-events kennen dat probleem niet: +// `setPointerCapture` houdt de gebeurtenissen binnen dit document, ongeacht de host. +// +// Het patroon is daarom letterlijk dat van de plafond-handle in `PhaseStrip.tsx`: capture op de +// GREEP zelf (het ⋮⋮-icoon, `cursor: grab`), geen document-brede listeners, één actieve pointer, +// en `pointercancel` als opruimpad. Tijdens het slepen licht de gesleepte rij op en toont de +// doelrij een invoegindicator (inset-boxshadow, dus geen layoutverschuiving). +// +// De "naar boven/beneden"-knoppen (`move`) blijven onveranderd de toetsenbordroute en het +// testanker; slepen (`reorderTo`) roept dezelfde `setUI` aan, dus een herordening met de muis zet +// net als de knoppen `staleReason = 'rank'` via `diffReason` in `useDistributionProposal`. +import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown, ChevronUp, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; @@ -154,12 +166,6 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, order } }); }; - // Slepen (zie het moduleblok): `draggedDocId` is de rij die vastgehouden wordt, `dropTarget` de - // rij + plaatsing (boven/onder de rijmidden) waar hij op dit moment op zou landen — puur voor - // visuele feedback tijdens het slepen, `reorderTo` op `onDrop` is het enige commit-moment. - const [draggedDocId, setDraggedDocId] = useState(null); - const [dropTarget, setDropTarget] = useState<{ docId: string; placement: 'before' | 'after' } | null>(null); - // Slepen verplaatst ÉÉN id binnen `orderBase` (bevinding B10): alleen het gesleepte id wordt // eruit gehaald en opnieuw ingevoegd, de rest — inclusief de docId's die dit voorstel niet gezien // heeft — houdt zijn onderlinge volgorde. @@ -176,6 +182,76 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, order } }); }; + // Slepen (zie het moduleblok): `draggedDocId` is de rij die vastgehouden wordt, `dropTarget` de + // rij + plaatsing (boven/onder de rijmidden) waar hij op dit moment op zou landen — puur voor + // visuele feedback tijdens het slepen, het loslaten is het enige commit-moment. + const [draggedDocId, setDraggedDocId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ docId: string; placement: 'before' | 'after' } | null>(null); + // De levende rij-elementen, zodat een pointermove de doelrij uit de ECHTE geometrie kan halen. + // Bij HTML5-dnd deed de browser die hit-test (per-rij `onDragOver`); met pointer capture komen + // alle moves op de greep binnen, dus doen we het zelf. Een `ref`-map en geen `querySelectorAll`: + // die zou ook de rijen van een eventuele tweede lijst vangen. + const rowRefs = useRef(new Map()); + // Eén actieve pointer tegelijk (zoals `PhaseStrip`): `moved` onderscheidt een zuivere klik op de + // greep — die commit niets — van een echte sleep. + const dragRef = useRef<{ pointerId: number; docId: string; moved: boolean } | null>(null); + + /** De rij + plaatsing onder deze y-coördinaat. Buiten de lijst: de dichtstbijzijnde rand, zodat + * een sleep die boven of onder de lijst uitschiet nog steeds een zinnig doel houdt. */ + const dropTargetAt = (clientY: number, dragged: string): { docId: string; placement: 'before' | 'after' } | null => { + let best: { docId: string; placement: 'before' | 'after'; distance: number } | null = null; + for (const row of rankRows) { + if (row.docId === dragged) continue; + const el = rowRefs.current.get(row.docId); + if (!el) continue; + const rect = el.getBoundingClientRect(); + const middle = rect.top + rect.height / 2; + const placement: 'before' | 'after' = clientY < middle ? 'before' : 'after'; + const distance = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0; + if (best === null || distance < best.distance) best = { docId: row.docId, placement, distance }; + } + return best === null ? null : { docId: best.docId, placement: best.placement }; + }; + + const onGripPointerDown = (docId: string) => (event: React.PointerEvent) => { + if (dragRef.current) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointerId: event.pointerId, docId, moved: false }; + setDraggedDocId(docId); + setDropTarget(null); + }; + + const onGripPointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + drag.moved = true; + setDropTarget(dropTargetAt(event.clientY, drag.docId)); + }; + + const endDrag = (commit: boolean) => { + const drag = dragRef.current; + dragRef.current = null; + if (commit && drag !== null && drag.moved && dropTarget !== null) { + reorderTo(drag.docId, dropTarget.docId, dropTarget.placement); + } + setDraggedDocId(null); + setDropTarget(null); + }; + + const onGripPointerUp = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + event.currentTarget.releasePointerCapture(event.pointerId); + endDrag(true); + }; + + const onGripPointerCancel = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + endDrag(false); + }; + const toggleSplits = () => { if (!tune) return; setUI({ levelingDistribution: { ...tune, allowSplits: !tune.allowSplits } }); @@ -462,10 +538,24 @@ export function DistributionDialog() { {rankRows.map((row, index) => (
{ + if (el) rowRefs.current.set(row.docId, el); + else rowRefs.current.delete(row.docId); + }} + className={`flex items-center gap-2 px-2 py-1 border-b border-border-light last:border-b-0 ${ + draggedDocId === row.docId ? 'bg-surface-hover' : ''}`} + // De invoegindicator is een INSET-boxshadow en geen border: een echte rand van + // 2px zou de rij hoger maken en de lijst onder de muis laten verspringen — + // precies tijdens het mikken. + style={{ + boxShadow: dropTarget?.docId === row.docId + ? dropTarget.placement === 'before' + ? 'inset 0 2px 0 0 var(--accent)' + : 'inset 0 -2px 0 0 var(--accent)' + : undefined, + }} data-ops-distribution-rank-row data-ops-doc-id={row.docId} - draggable data-ops-distribution-rank-dragging={draggedDocId === row.docId ? 'true' : undefined} data-ops-distribution-rank-drop-before={ dropTarget?.docId === row.docId && dropTarget.placement === 'before' ? 'true' : undefined @@ -473,29 +563,27 @@ export function DistributionDialog() { data-ops-distribution-rank-drop-after={ dropTarget?.docId === row.docId && dropTarget.placement === 'after' ? 'true' : undefined } - onDragStart={event => { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', row.docId); - setDraggedDocId(row.docId); - }} - onDragOver={event => { - if (draggedDocId === null || draggedDocId === row.docId) return; - event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - const placement: 'before' | 'after' = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; - setDropTarget({ docId: row.docId, placement }); - }} - onDrop={event => { - event.preventDefault(); - if (draggedDocId !== null && dropTarget !== null) reorderTo(draggedDocId, dropTarget.docId, dropTarget.placement); - setDraggedDocId(null); - setDropTarget(null); - }} - onDragEnd={() => { - setDraggedDocId(null); - setDropTarget(null); - }} > + {/* De GREEP (gebruikstest 2026-09-12, gebrek 2). Slepen was onzichtbaar: de hele + rij was `draggable`, zonder greep en zonder `cursor: grab`, dus niets aan de + rij verried dat hij te verslepen was — laat staan dat het mechanisme het in + een webview deed. `touchAction: 'none'` houdt de sleep bij de pointer in + plaats van bij de scroll van de dialoog. */} + + + {index + 1} {row.title} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 255e4c87c..86b34b22b 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -727,7 +727,8 @@ }, "rank": { "title": "من يُراعى أكثر؟", - "hint": "الأعلى ينزاح أقل. اسحب لتغيير الترتيب.", + "hint": "الأعلى هو الأقل تزحزحًا. اسحب المقبض على اليسار، أو استخدم أزرار الأسهم على اليمين.", + "drag": "اسحب لإعادة الترتيب", "float": "الفائض: {{days}}", "cost_zero": "ترك هذا المشروع وحده ينزاح لا يكلف أي يوم عمل", "cost_one": "ترك هذا المشروع وحده ينزاح يكلف يوم عمل واحد", @@ -739,6 +740,9 @@ "moveUp": "نقل لأعلى", "moveDown": "نقل لأسفل" }, + "help": { + "dragHandle": "اسحب هذا الصف إلى موضع آخر في الترتيب." + }, "strip": { "pin": "تثبيت", "pinned": "مثبَّت — هذا المشروع لا ينزاح ويحتفظ أيضًا بأيام عمله", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 172f922e9..425012a95 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -675,7 +675,8 @@ }, "rank": { "title": "Wer wird am meisten geschont?", - "hint": "Oben weicht am wenigsten ab. Ziehen, um die Reihenfolge zu ändern.", + "hint": "Ganz oben weicht am wenigsten. Ziehen Sie am Griff links oder nutzen Sie die Pfeiltasten rechts.", + "drag": "Zum Umsortieren ziehen", "float": "Puffer: {{days}}", "cost_one": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstag", "cost_other": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstage", @@ -683,6 +684,9 @@ "moveUp": "Nach oben", "moveDown": "Nach unten" }, + "help": { + "dragHandle": "Ziehen Sie diese Zeile an eine andere Stelle in der Reihenfolge." + }, "strip": { "pin": "Anheften", "pinned": "Angeheftet — dieses Projekt weicht nicht und behält auch seine Arbeitstage", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0569f58ef..3ee587e08 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -675,7 +675,8 @@ }, "rank": { "title": "Who gets spared the most?", - "hint": "Top of the list wiggles the least. Drag to change the order.", + "hint": "The one on top gives way the least. Drag the handle on the left, or use the arrow buttons on the right.", + "drag": "Drag to reorder", "float": "slack: {{days}}", "cost_one": "letting only this project shift costs {{count}} workday", "cost_other": "letting only this project shift costs {{count}} workdays", @@ -683,6 +684,9 @@ "moveUp": "Move up", "moveDown": "Move down" }, + "help": { + "dragHandle": "Drag this row to another position in the order." + }, "strip": { "pin": "Pin", "pinned": "Pinned — this project does not shift, and keeps its workdays too", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 418419adc..4b80e84a7 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -688,7 +688,8 @@ }, "rank": { "title": "¿A quién se respeta más?", - "hint": "Arriba es lo que menos se desplaza. Arrastra para cambiar el orden.", + "hint": "El de arriba cede lo menos posible. Arrastre el asa de la izquierda o use las flechas de la derecha.", + "drag": "Arrastrar para reordenar", "float": "holgura: {{days}}", "cost_one": "dejar que solo este proyecto se desplace cuesta {{count}} día laborable", "cost_many": "dejar que solo este proyecto se desplace cuesta {{count}} días laborables", @@ -697,6 +698,9 @@ "moveUp": "Subir", "moveDown": "Bajar" }, + "help": { + "dragHandle": "Arrastre esta fila a otra posición del orden." + }, "strip": { "pin": "Fijar", "pinned": "Fijado — este proyecto no se desplaza y también conserva sus días laborables", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 92bceced0..b10351ae0 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -675,7 +675,8 @@ }, "rank": { "title": "چه کسی بیشترین ملاحظه را می‌گیرد؟", - "hint": "بالاترین مورد کمترین جابه‌جایی را دارد. برای تغییر ترتیب بکشید.", + "hint": "بالاترین مورد کمترین جابه‌جایی را دارد. دستگیره سمت چپ را بکشید یا از دکمه‌های پیکان سمت راست استفاده کنید.", + "drag": "برای تغییر ترتیب بکشید", "float": "شناوری: {{days}}", "cost_one": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", "cost_other": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", @@ -683,6 +684,9 @@ "moveUp": "انتقال به بالا", "moveDown": "انتقال به پایین" }, + "help": { + "dragHandle": "این ردیف را به جای دیگری در ترتیب بکشید." + }, "strip": { "pin": "ثابت کردن", "pinned": "ثابت‌شده — این پروژه جابه‌جا نمی‌شود و روزهای کاری خود را نیز حفظ می‌کند", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 00ecd0ac6..6b8419985 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -688,7 +688,8 @@ }, "rank": { "title": "Qui est le plus épargné ?", - "hint": "En haut, on bouge le moins. Faites glisser pour changer l'ordre.", + "hint": "Celui du haut cède le moins. Faites glisser la poignée à gauche, ou utilisez les flèches à droite.", + "drag": "Faire glisser pour réordonner", "float": "marge : {{days}}", "cost_one": "décaler uniquement ce projet coûte {{count}} jour ouvrable", "cost_many": "décaler uniquement ce projet coûte {{count}} jours ouvrables", @@ -697,6 +698,9 @@ "moveUp": "Monter", "moveDown": "Descendre" }, + "help": { + "dragHandle": "Faites glisser cette ligne à une autre place dans l'ordre." + }, "strip": { "pin": "Épingler", "pinned": "Épinglé — ce projet ne se décale pas et conserve aussi ses jours de travail", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 38f765dd0..7cecc05fd 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -688,7 +688,8 @@ }, "rank": { "title": "Chi viene risparmiato di più?", - "hint": "In alto è chi si sposta meno. Trascina per cambiare l'ordine.", + "hint": "Quello in alto cede di meno. Trascina la maniglia a sinistra oppure usa le frecce a destra.", + "drag": "Trascina per riordinare", "float": "margine: {{days}}", "cost_one": "far slittare solo questo progetto costa {{count}} giorno lavorativo", "cost_many": "far slittare solo questo progetto costa {{count}} giorni lavorativi", @@ -697,6 +698,9 @@ "moveUp": "Sposta su", "moveDown": "Sposta giù" }, + "help": { + "dragHandle": "Trascina questa riga in un'altra posizione dell'ordine." + }, "strip": { "pin": "Blocca", "pinned": "Bloccato — questo progetto non slitta e mantiene anche i suoi giorni lavorativi", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index b26491cce..4612e0e44 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -662,13 +662,17 @@ }, "rank": { "title": "誰が最も優先的に守られますか?", - "hint": "上にあるほど動きが少なくなります。ドラッグして順序を変更します。", + "hint": "上にあるものほど動きません。左のハンドルをドラッグするか、右の矢印ボタンを使ってください。", + "drag": "ドラッグして並べ替え", "float": "フロート:{{days}}", "cost_other": "このプロジェクトだけを動かすには {{count}} 稼働日かかります", "costNone": "このプロジェクトだけを動かしても延長はかかりません", "moveUp": "上へ移動", "moveDown": "下へ移動" }, + "help": { + "dragHandle": "この行を順序内の別の位置へドラッグします。" + }, "strip": { "pin": "固定", "pinned": "固定済み — このプロジェクトは動かず、稼働日もそのまま維持されます", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index b02409763..30df805c3 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -662,13 +662,17 @@ }, "rank": { "title": "누가 가장 많이 보호받나요?", - "hint": "위에 있을수록 가장 적게 이동합니다. 드래그하여 순서를 변경하세요.", + "hint": "맨 위에 있는 항목이 가장 적게 밀립니다. 왼쪽 손잡이를 끌거나 오른쪽 화살표 버튼을 사용하세요.", + "drag": "끌어서 순서 변경", "float": "여유: {{days}}", "cost_other": "이 프로젝트만 이동하게 하면 {{count}}근무일이 듭니다", "costNone": "이 프로젝트만 이동하게 해도 연장 비용이 들지 않습니다", "moveUp": "위로 이동", "moveDown": "아래로 이동" }, + "help": { + "dragHandle": "이 행을 순서상의 다른 위치로 끌어 놓습니다." + }, "strip": { "pin": "고정", "pinned": "고정됨 — 이 프로젝트는 이동하지 않으며 근무일도 그대로 유지합니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 7b3cf7e2c..2c4ad6bac 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -675,7 +675,8 @@ }, "rank": { "title": "Wie wordt het meest ontzien?", - "hint": "Bovenaan wijkt het minst. Sleep om de volgorde te veranderen.", + "hint": "Bovenaan wijkt het minst. Sleep aan de greep links, of gebruik de pijlknoppen rechts.", + "drag": "Volgorde verslepen", "float": "speling: {{days}}", "cost_one": "alleen dit project laten opschuiven kost {{count}} werkdag", "cost_other": "alleen dit project laten opschuiven kost {{count}} werkdagen", @@ -683,6 +684,9 @@ "moveUp": "Naar boven", "moveDown": "Naar beneden" }, + "help": { + "dragHandle": "Sleep deze rij naar een andere plek in de volgorde." + }, "strip": { "pin": "Vastzetten", "pinned": "Vastgezet — dit project wijkt niet en houdt ook zijn werkdagen", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index d1741f6a5..73d053fd3 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -701,7 +701,8 @@ }, "rank": { "title": "Kto jest najbardziej oszczędzany?", - "hint": "Na górze przesuwa się najmniej. Przeciągnij, aby zmienić kolejność.", + "hint": "Ten na górze ustępuje najmniej. Przeciągnij uchwyt po lewej albo użyj strzałek po prawej.", + "drag": "Przeciągnij, aby zmienić kolejność", "float": "zapas: {{days}}", "cost_one": "przesunięcie tylko tego projektu kosztuje {{count}} dzień roboczy", "cost_few": "przesunięcie tylko tego projektu kosztuje {{count}} dni robocze", @@ -711,6 +712,9 @@ "moveUp": "Przenieś w górę", "moveDown": "Przenieś w dół" }, + "help": { + "dragHandle": "Przeciągnij ten wiersz w inne miejsce kolejności." + }, "strip": { "pin": "Przypnij", "pinned": "Przypięty — ten projekt się nie przesuwa i zachowuje też swoje dni robocze", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index bad6c42c5..c71eb9799 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -688,7 +688,8 @@ }, "rank": { "title": "Quem é mais poupado?", - "hint": "No topo desloca-se menos. Arraste para mudar a ordem.", + "hint": "O de cima cede menos. Arraste a alça à esquerda ou use as setas à direita.", + "drag": "Arrastar para reordenar", "float": "folga: {{days}}", "cost_one": "deixar apenas este projeto deslocar-se custa {{count}} dia útil", "cost_many": "deixar apenas este projeto deslocar-se custa {{count}} dias úteis", @@ -697,6 +698,9 @@ "moveUp": "Mover para cima", "moveDown": "Mover para baixo" }, + "help": { + "dragHandle": "Arraste esta linha para outra posição na ordem." + }, "strip": { "pin": "Fixar", "pinned": "Fixado — este projeto não se desloca e mantém também os seus dias úteis", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 3d477e02b..983cc6600 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -675,7 +675,8 @@ }, "rank": { "title": "En çok kim korunuyor?", - "hint": "En üstteki en az kayar. Sırayı değiştirmek için sürükleyin.", + "hint": "En üstteki en az kayar. Soldaki tutamacı sürükleyin veya sağdaki ok düğmelerini kullanın.", + "drag": "Sıralamak için sürükleyin", "float": "bolluk: {{days}}", "cost_one": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", "cost_other": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", @@ -683,6 +684,9 @@ "moveUp": "Yukarı taşı", "moveDown": "Aşağı taşı" }, + "help": { + "dragHandle": "Bu satırı sıradaki başka bir konuma sürükleyin." + }, "strip": { "pin": "Sabitle", "pinned": "Sabitlendi — bu proje kaymaz ve iş günlerini de korur", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 6d6b54e3e..958df80cd 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -662,13 +662,17 @@ }, "rank": { "title": "谁得到最大程度的照顾?", - "hint": "排在最上面的移动最少。拖动以更改顺序。", + "hint": "最上面的项目让步最少。拖动左侧的手柄,或使用右侧的箭头按钮。", + "drag": "拖动以调整顺序", "float": "浮动:{{days}}", "cost_other": "仅让此项目顺延需要 {{count}} 个工作日", "costNone": "仅让此项目顺延不需要延误", "moveUp": "上移", "moveDown": "下移" }, + "help": { + "dragHandle": "将此行拖到顺序中的其他位置。" + }, "strip": { "pin": "固定", "pinned": "已固定 — 此项目不顺延,也保留其工作日", diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index a4a04f54d..50b7be6c7 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -374,11 +374,21 @@ test('rangorde: slepen verandert de volgorde en laat het voorstel vervallen', as const firstDocId = await rows.nth(0).getAttribute('data-ops-doc-id'); const secondDocId = await rows.nth(1).getAttribute('data-ops-doc-id'); - // Sleep rij 2 boven rij 1 — native HTML5 drag-and-drop, HETZELFDE mechanisme als - // `DataGridHeader`'s kolomherordening (`draggable` + dragover/drop), geen los pointer-events- - // sleepmechanisme ernaast. `targetPosition` mikt op het BOVENSTE stuk van rij 1, zodat de drop als - // "voor" telt — een drop op het midden zou de rij weer op zijn oude plek laten vallen. - await rows.nth(1).dragTo(rows.nth(0), { targetPosition: { x: 20, y: 2 } }); + // Sleep rij 2 boven rij 1 — POINTER-EVENTS aan de greep (gebruikstest 2026-09-12, gebrek 2: + // HTML5-dnd deed in de iframe-preview en de Tauri-webview niets). Dus echte muisbewegingen met + // `steps`: een pointerdown op de greep, een paar tussenstappen zodat `pointermove` het doel kan + // bepalen (zonder move commit de sleep bewust niets), en loslaten boven de BOVENSTE helft van + // rij 1 — een loslaten op de onderste helft zou de rij weer op zijn oude plek laten vallen. + const grip = rows.nth(1).locator('[data-ops-distribution-rank-grip]'); + const gripBox = (await grip.boundingBox())!; + const targetBox = (await rows.nth(0).boundingBox())!; + await page.mouse.move(gripBox.x + gripBox.width / 2, gripBox.y + gripBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetBox.x + 20, targetBox.y + targetBox.height * 0.2, { steps: 8 }); + // De rij licht op tijdens het slepen en de doelrij toont de invoegindicator. + await expect(rows.nth(1)).toHaveAttribute('data-ops-distribution-rank-dragging', 'true'); + await expect(rows.nth(0)).toHaveAttribute('data-ops-distribution-rank-drop-before', 'true'); + await page.mouse.up(); await expect(page.locator('[data-ops-distribution-rank-row]').first()) .toHaveAttribute('data-ops-doc-id', secondDocId!); From bf4ae8bf9b672c0ed6ca9b9c444af83973b6c434 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:50:58 +0200 Subject: [PATCH 045/108] Gebrek 3: de knop van de schakelaar staat nu in beide standen gecentreerd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De kern is dat de wortel-fontgrootte in deze app 13px is en niet 16px. Tailwinds w-9/h-5/w-4 zijn rem-maten, dus het spoor is 29,25 x 16,25 px en de knop 13 x 13 px — terwijl de plaatsing in VASTE pixels stond (top-0.5, left 2/18) en dus met geen enkele stand klopte. Drie reparaties: (a) verticaal centreren met top-1/2 -translate-y-1/2 in plaats van top-0.5; (b) het spoor heeft in beide standen een rand (in de aan-stand in de accentkleur, dus onzichtbaar), zodat de binnenbreedte niet meer tussen de standen springt; (c) de marge staat in dezelfde rem-basis als de knop: 0.125rem links in de uit-stand en calc(100% - 1rem - 0.125rem) in de aan-stand. Het blijft een `left`, dus de knop glijdt nog steeds. Gemeten in Chromium op 1400x900, ná het uitlopen van de overgang: uit: boven 1,625 / onder 1,625 / links 2,625 / rechts 13,625 aan: boven 1,625 / onder 1,625 / links 13,625 / rechts 2,625 Vóór de fix: links 7,25 / rechts 9 (uit) en links 17 / rechts -0,75 (aan) — in de aan-stand stak de knop dus zelfs buiten het spoor. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 8ee56265f..1f86350ce 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -511,12 +511,25 @@ export function DistributionDialog() { role="switch" aria-checked={tune.allowSplits} onClick={toggleSplits} - className={`relative w-9 h-5 rounded-full transition-colors shrink-0 ${tune.allowSplits ? 'bg-accent' : 'bg-surface-hover border border-border'}`} + // Gebruikstest 2026-09-12, gebrek 3: de knop stond niet gecentreerd. Drie + // oorzaken, alle drie geometrie — en de kern is dat de wortel-fontgrootte in deze + // app 13px is, niet 16px. Tailwind's `w-9`/`h-5`/`w-4` zijn rem-maten, dus het + // spoor is 29,25 × 16,25 px en de knop 13 × 13 px; de `left`-waarden stonden in + // VASTE pixels en klopten dus met geen enkele stand. (a) Verticaal zette + // `top-0.5` (2px) de knop te hoog — nu centreert `top-1/2 -translate-y-1/2` + // exact, ongeacht de fontgrootte. (b) De rand zat alléén in de uit-stand, dus de + // binnenbreedte sprong tussen de standen; nu heeft het spoor in BEIDE standen een + // rand (in de aan-stand in de accentkleur, dus onzichtbaar). (c) De marge staat + // nu in dezelfde rem-basis als de knop zelf: `0.125rem` links in de uit-stand en + // `calc(100% - 1rem - 0.125rem)` in de aan-stand — `100%` is de binnenbreedte van + // het spoor, dus links en rechts blijft per definitie evenveel over. Het blijft + // een `left`, zodat de knop nog steeds glijdt in plaats van te springen. + className={`relative w-9 h-5 rounded-full transition-colors shrink-0 border ${tune.allowSplits ? 'bg-accent border-accent' : 'bg-surface-hover border-border'}`} data-ops-distribution-allow-splits > {t('resource.distribution.tool.allowSplits')} From 2220470772994dd46a3cb125e5bafd4140457141 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:55:33 +0200 Subject: [PATCH 046/108] =?UTF-8?q?Gebrek=204:=20uitleg=20bij=20elke=20bed?= =?UTF-8?q?iening,=20plus=20=C3=A9=C3=A9n=20introregel=20onder=20de=20kop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De dialoog legde nergens uit wat de knoppen doen. Nu heeft elke bediening een title-tooltip in gewone taal: de schakelaar en zijn label (wat "onderbrekingen toestaan" doet), het prijskaartje (wat de twee standen betekenen), de kop en de aanwijzing van de rangordelijst (wat "bovenaan wijkt het minst" concreet inhoudt), het kostenlabel per project, de greep, de pin (bevriest einddatum én werkdagen en telt als vaste last), de plafond-handle (maximale uitloop in werkdagen, met de toetsen en de waarschuwing dat het label het effect toont en niet de sleepafstand), de grafiek (Nu / Na verdelen / rood is boven capaciteit), "Verdeel automatisch/Herbereken", "Toepassen", "Verwerpen" en "Ander item kiezen…". Twee bewuste samenstellingen: de pin en de plafond-handle hielden hun bestaande, korte title als eerste helft — die benoemt de STAND ("Vastgezet", "Maximale uitloop van de einddatum") en dat is specifieker dan de uitleg. Bij "Toepassen" blijft de reden waarom de knop uitstaat voorgaan; de uitleg verschijnt pas als de knop bruikbaar is. Onder de kop staat nu resource.distribution.intro, bewust zonder truncate: een uitleg die halverwege met "…" ophoudt legt niets uit. Nieuwe sleutels (14 locales): resource.distribution.intro en resource.distribution.help.{allowSplits,price,rank,rankCost,pin,ceiling,chart, recompute,apply,discard,pickAnother}. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 41 ++++++++++++++++--- .../dialogs/DistributionDialog/PhaseStrip.tsx | 4 +- src/i18n/locales/ar/common.json | 14 ++++++- src/i18n/locales/de/common.json | 14 ++++++- src/i18n/locales/en/common.json | 14 ++++++- src/i18n/locales/es/common.json | 14 ++++++- src/i18n/locales/fa/common.json | 14 ++++++- src/i18n/locales/fr/common.json | 14 ++++++- src/i18n/locales/it/common.json | 14 ++++++- src/i18n/locales/ja/common.json | 14 ++++++- src/i18n/locales/ko/common.json | 14 ++++++- src/i18n/locales/nl/common.json | 14 ++++++- src/i18n/locales/pl/common.json | 14 ++++++- src/i18n/locales/pt/common.json | 14 ++++++- src/i18n/locales/tr/common.json | 14 ++++++- src/i18n/locales/zh/common.json | 14 ++++++- 16 files changed, 219 insertions(+), 22 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 1f86350ce..4468da003 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -473,6 +473,14 @@ export function DistributionDialog() { {t('resource.distribution.subtitle', { item: itemName })}
)} + {/* Eén regel die de hele handeling uitlegt (gebruikstest 2026-09-12, gebrek 4). Bewust + NIET afgekapt (`truncate`) zoals de ondertitel erboven: een uitleg die halverwege + ophoudt met "…" legt niets uit. */} + {tune && ( +
+ {t('resource.distribution.intro')} +
+ )} - {t('resource.distribution.tool.allowSplits')} - + + {t('resource.distribution.tool.allowSplits')} + + {toolPriceLabel()} @@ -543,10 +558,16 @@ export function DistributionDialog() { {/* (4) Rangorde */}
- + {t('resource.distribution.rank.title')} - {t('resource.distribution.rank.hint')} + + {t('resource.distribution.rank.hint')} +
{rankRows.map((row, index) => (
{isCostCandidate(row.docId) && ( - + {costLabel(row.docId)} )} @@ -673,6 +698,7 @@ export function DistributionDialog() { {/* (6) Voor/na-histogram (taak 11b, spec §7): dezelfde as als de fasestroken hierboven. */}
{poolItem ? ( @@ -789,6 +815,7 @@ export function DistributionDialog() { type="button" className="px-3 py-1.5 rounded-[8px] border border-border hover:bg-surface-hover mr-auto" data-ops-distribution-pick-another + title={t('resource.distribution.help.pickAnother')} onClick={() => setUI({ levelingDistribution: null })} > {applied @@ -800,6 +827,7 @@ export function DistributionDialog() { className="px-3 py-1.5 rounded-[8px] border border-border hover:bg-surface-hover disabled:opacity-40" onClick={recompute} disabled={busy} + title={t('resource.distribution.help.recompute')} > {proposal === null ? t('resource.distribution.compute.auto') @@ -812,7 +840,7 @@ export function DistributionDialog() { type="button" className="px-3 py-1.5 rounded-[8px] bg-accent text-white disabled:opacity-40" disabled={!applyGate.ok} - title={applyGate.reason || undefined} + title={applyGate.reason || t('resource.distribution.help.apply')} onClick={onApply} > {t('resource.distribution.apply')} @@ -820,6 +848,7 @@ export function DistributionDialog() { + + + + + +
+ +
+

Combineerbaar — ze sluiten elkaar niet uit

+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/superpowers/prototypes/README.md b/docs/superpowers/prototypes/README.md new file mode 100644 index 000000000..f648a10d5 --- /dev/null +++ b/docs/superpowers/prototypes/README.md @@ -0,0 +1,7 @@ +# Prototypes + +Deze map bewaart de speelbare prototypes uit ontwerpfasen: één zelfstandig HTML-bestand per lab, dat nooit in de app wordt gebundeld en alleen als referentie bij het bijbehorende ontwerpdocument hoort. + +`2026-08-27-b1c-interface-lab.html` is het Interface-lab van B1c: het zette vier bedieningen voor het verdelen van restcapaciteit naast elkaar zodat ze echt te proberen waren, en variant 4 (de fasestrook-handles) werd op 2026-08-27 het eigenaarsbesluit — zie `docs/superpowers/specs/2026-08-17-b1c-nivelleren-restcapaciteit-design.md` §6. + +Openen doe je door het HTML-bestand gewoon in een browser te laden; er is geen build, server of afhankelijkheid voor nodig. From e319aa42673992aabe2f33116f6719e5a6eb8ae5 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:47:40 +0200 Subject: [PATCH 050/108] =?UTF-8?q?feat(ui):=20=C3=A9=C3=A9n=20gedeelde=20?= =?UTF-8?q?Switch=20voor=20titelbalk=20en=20verdeeldialoog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De schakelaar "Onderbrekingen toestaan" stond nog steeds niet gecentreerd. De eerdere reparatie rekende in rem, en juist dat was de oorzaak: de wortel- fontgrootte is hier 13px, dus `w-9`/`h-5`/`w-4` gaven een spoor van 29,25 x 16,25 px met een knop van 13 x 13 px. Die knop vulde het spoor bijna helemaal — 0,6 px lucht boven en onder, 1,6 px opzij — en plakte in de aan-stand zichtbaar tegen de rand. Rekenen in dezelfde rem-basis maakt dat niet beter; de maat zelf was fout. Nu staat er één gedeelde `Switch` met uitsluitend vaste pixels, getrokken uit de al goed werkende titelbalk-variant: spoor 34 x 18 met `p-[2px]` en `inline-flex items-center`, knop 14 x 14 als flex-kind, aan-stand `translate-x-[16px]`. Verticaal centreren is daarmee een flex-eigenschap en kan niet meer scheef staan; horizontaal is de marge per constructie in beide standen exact 2 px. Gemeten in Chromium: uit 2/2/2/18, aan 2/2/18/2 (boven/onder/links/ rechts). Eén bewuste afwijking van de titelbalk-kleuren: de knop is wit met een schaduwrandje in plaats van `var(--theme-surface)`. Gemeten in het donkere thema was dat #2E3239 op een spoor van #4A515C — twee donkere grijzen die op 300% niet te onderscheiden waren. In de titelbalk viel dat niet op omdat die op een donkerder balk staat; in een dialoog wel. Beide plekken gebruiken nu dit component; de handgeschreven varianten, het uitleggende commentaarblok in DistributionDialog en de losse `.title-bar-autosave-track`-CSS zijn weg. De `data-ops-*`-haken en titels waar de browsertests aan hangen blijven ongewijzigd. Co-Authored-By: Claude Fable 5.1 --- src/components/common/Switch.tsx | 75 +++++++++++++++++++ .../DistributionDialog/DistributionDialog.tsx | 35 ++------- src/components/layout/TitleBar/TitleBar.css | 4 - src/components/layout/TitleBar/TitleBar.tsx | 16 ++-- 4 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 src/components/common/Switch.tsx diff --git a/src/components/common/Switch.tsx b/src/components/common/Switch.tsx new file mode 100644 index 000000000..90c11f3ab --- /dev/null +++ b/src/components/common/Switch.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react'; + +/** + * Eén schakelaar voor de hele app (gebruikstest 2026-09-12, gebrek 3). + * + * De handgeschreven varianten liepen stuk op de wortel-fontgrootte: die is in deze app 13px, dus + * Tailwind's rem-maten (`w-9` = 29,25px, `w-4` = 13px) maakten een knop die het spoor bijna + * helemaal vulde — 0,6px lucht boven en onder, en in de aan-stand een knop die tegen de rand plakt. + * Daarom staat hier ALLES in vaste pixels en doet flex het werk: + * + * spoor 34 × 18 px, `p-[2px]`, `inline-flex items-center` → contentvak 30 × 14 px + * knop 14 × 14 px → 2px lucht boven/onder (flex) + * aan `translate-x-[16px]` → 2 + 16 + 14 = 32, rest 2px rechts + * + * Verticaal centreren is dus een flex-eigenschap (kan niet scheef staan) en de horizontale marge is + * in beide standen per constructie exact 2px. Geen rem, geen `left`-berekening, geen rand die de + * binnenbreedte per stand verandert. + */ +export interface SwitchProps { + checked: boolean; + onChange: () => void; + /** Zichtbaar label naast het spoor. Ontbreekt het, geef dan `ariaLabel` mee. */ + label?: ReactNode; + title?: string; + ariaLabel?: string; + disabled?: boolean; + /** Extra klassen op de knop zelf (bv. de plaatsingsstijl van de titelbalk). */ + className?: string; + /** Doorgeefluik voor `data-ops-*`-haken waar de browsertests aan hangen. */ + [dataAttribute: `data-${string}`]: unknown; +} + +export function Switch({ + checked, + onChange, + label, + title, + ariaLabel, + disabled, + className, + ...rest +}: SwitchProps) { + return ( + + ); +} diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 4468da003..9b5566dfc 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -43,6 +43,7 @@ import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; +import { Switch } from '@/components/common/Switch'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; @@ -514,34 +515,14 @@ export function DistributionDialog() { {t('resource.distribution.tool.title')}
- + className="inline-flex items-center shrink-0" + data-ops-distribution-allow-splits + /> {t('resource.distribution.tool.allowSplits')} diff --git a/src/components/layout/TitleBar/TitleBar.css b/src/components/layout/TitleBar/TitleBar.css index 47c9d5cf2..e4003a49b 100644 --- a/src/components/layout/TitleBar/TitleBar.css +++ b/src/components/layout/TitleBar/TitleBar.css @@ -85,11 +85,7 @@ .title-bar-autosave:hover { background: var(--theme-titlebar-btn-hover); color: var(--theme-text); } .title-bar-autosave:disabled { opacity: .55; cursor: not-allowed; } .title-bar-autosave:disabled:hover { background: transparent; color: var(--theme-text-dim); } -.title-bar-autosave-track { width: 25px; height: 14px; border-radius: 99px; padding: 2px; box-sizing: border-box; background: var(--theme-control-border); transition: background .12s; } -.title-bar-autosave-track > span { display: block; width: 10px; height: 10px; border-radius: 99px; background: var(--theme-surface); transition: transform .12s; } .title-bar-autosave.active { color: var(--theme-text); } -.title-bar-autosave.active .title-bar-autosave-track { background: var(--theme-accent); } -.title-bar-autosave.active .title-bar-autosave-track > span { transform: translateX(11px); } .quick-access-separator { width: 1px; diff --git a/src/components/layout/TitleBar/TitleBar.tsx b/src/components/layout/TitleBar/TitleBar.tsx index 4c080bc21..fc843c521 100644 --- a/src/components/layout/TitleBar/TitleBar.tsx +++ b/src/components/layout/TitleBar/TitleBar.tsx @@ -5,6 +5,7 @@ import { isTauri } from '@/utils/platform'; import { FileText, FolderOpen, Save, Undo2, Redo2, Minus, Square, Copy, X, Settings, } from 'lucide-react'; +import { Switch } from '@/components/common/Switch'; import { SwitcherPill } from '@/components/layout/DocumentChrome/SwitcherPill'; import { buildImportLabels } from '@/i18n/importLabels'; import { canRedo, canUndo } from '@/state/sessionHistory'; @@ -115,19 +116,16 @@ export function TitleBar() { - + />
From 7f9633ca04f674f14bd87ca2a3adaf3f2fd0e662 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:19:04 +0200 Subject: [PATCH 051/108] docs(b1c): ontwerp herontwerp verdeeldialoog naar het Interface-lab (eigenaarsbesluiten 2026-09-12) Co-Authored-By: Claude Fable 5.1 --- ...12-b1c-verdeeldialoog-herontwerp-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md diff --git a/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md new file mode 100644 index 000000000..10264ef26 --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md @@ -0,0 +1,166 @@ +# B1c — Herontwerp van de verdeeldialoog naar het Interface-lab (ontwerp) + +Datum: 2026-09-12 · Status: **ontwerp compleet, door de eigenaar goedgekeurd in gesprek** · +Vervolg op `2026-08-17-b1c-nivelleren-restcapaciteit-design.md` (§6/§7) en het plan +`2026-08-31-b1c-plan3-schrijfpad-paneel.md` (taken 8–13). Referentie voor de bouwer: +`docs/superpowers/prototypes/2026-08-27-b1c-interface-lab.html`, **tabblad 4 (fasestrook-handles)**. + +## 1. Aanleiding + +De gebruikstest van de eigenaar op 2026-09-12 met de drie showcases: "ik vind het hele scherm +nogal vaag, ik snap niet wat alle knoppen doen, er staat overal random tekst zonder uitleg", "je +zou toch ook met balkjes projectjes moeten kunnen slepen", "het is totaal niet duidelijk wat er +allemaal gebeurt en wat wat beïnvloedt". Oorzaak: etappe 3 is tegen de spec-tekst gebouwd, niet +tegen het speelbare prototype dat de eigenaar op 2026-08-27 koos ("we doen optie 4"). Dat +prototype bestond alleen als privé-artifact van de vorige sessie en is op 2026-09-12 teruggehaald +en in de repo gezet. De verschillen met de bouw waren wezenlijk: de strook was geen bediening maar +een dun blokje, pauzes waren onzichtbaar, tijdens het slepen bewoog niets mee, en een prominente +rangordelijst trok de aandacht weg van de balken. + +## 2. Eigenaarsbesluiten (2026-09-12) + +1. **Rangordelijst weg uit beeld** (keuze A). De rijvolgorde is de plaatsingsvolgorde van de + rekenaar (minste totale speling bovenaan); sturen gebeurt uitsluitend via de handle en de pin. + `order` in de tune-state blijft bestaan als afgeleide (spelingsvolgorde), zodat kern en + schrijfpad niet veranderen. +2. **Het prijskaartje van "Onderbrekingen toestaan" is een verschil**, geen twee bedragen: + uit ⇒ "zou N werkdagen besparen", aan ⇒ "bespaart N werkdagen"; bij nul "zou niets besparen" + / "bespaart niets". N = grootste einddatum-verschuiving over de deelnemers in de uit-stand + minus die in de aan-stand. +3. **De dialoog flitst niet.** Geen enkele rekentoestand (bezig, verouderd, tekort, toegepast) + mag de hoogte van de dialoog of van een onderdeel veranderen. +4. De rest van de kern (verdeler, schrijfpad, "alles terugdraaien", invalidatie, keuzelijst bij + lege start, degradatie boven de schaal) blijft zoals gebouwd. + +## 3. Lay-out (van boven naar beneden) + +1. **Kop**: itemnaam; ondertitel; één regel uitleg: "Trek het einde van een balk naar rechts om + dat project te laten uitlopen; zet een project vast als het niet mag bewegen. Toepassen + schrijft de verschuivingen in alle betrokken projecten." +2. **Gereedschap**: de schakelaar **Onderbrekingen toestaan** (gedeelde `Switch`) met het + verschil-prijskaartje (§2.2) en de MS Project-toelichting. +3. **De balken**: per deelnemend project één rij, aaneengesloten onder elkaar, op één tijdas. +4. **De uitkomst**: het histogram Nu/Na op dezelfde tijdas (zelfde `OccupancyAxis`-instantie, + kolom-op-kolom uitgelijnd met de balken), capaciteitslijn, rode arcering boven de capaciteit, + conflictvenster als gestippelde band; daarboven per project een einddatum-badge. +5. **Validatiestrook** (`role="status"`, `aria-live="polite"`, altijd gerenderd, vaste hoogte): + groen "Conflict opgelost — grootste einddatum-verschuiving +N werkdagen ()" of rood + "Nog N ploegdagen tekort op …". Ernaast de reden waarom Toepassen wel of + niet kan. +6. **Knoppenbalk**: links **Ander item kiezen…**, **Reset** (alle plafonds en pins terug, + schakelaar ongemoeid); rechts **Toepassen** (uit-met-reden zolang er tekort of blokkade is) + en **Verwerpen**. Na toepassen de permanente strook "Toegepast in N projecten" met **Alles + terugdraaien** (bestaand gedrag). + +De bestaande **keuzelijst** bij lege start blijft; de bestaande dialoogbreedte (`w-[960px] +max-w-[95vw]`) blijft. + +## 4. De balk (per project) + +Rij = **label** (vaste breedte 150 px: kleurstip in de documentkleur, projectnaam, "speling N +dagen") | **track** (32 px hoog, lichte achtergrond, 1 px-weekscheidingen, donkerdere maandlijn) +| **uitkomstlabel** (vaste breedte 132 px). + +In de track, op de gedeelde as: +- per **werkdag een apart blokje** van 15 px hoog in de projectkleur, met een 1 px witte + scheiding rechts zodat dagen te tellen zijn (= boekingsdag uit `bookingByDay`/`afterLoadByDay`); +- een **pauzedag** (ingevoegde onderbreking) als gearceerd blokje op dezelfde hoogte + (`repeating-linear-gradient(135deg, …)`, dun grijs randje); +- **"toegestaan maar niet benut"** als lege doos met gestippelde rand, van het nieuwe fase-einde + tot de handle-stand; +- onderin een **meetlat** van 3 px: grijs gestippeld = de eigen speling (vanaf de oorspronkelijke + einddatum), massief rood = alles voorbij de speling, dus echte einddatum-verschuiving; +- de **vaste last** van gepinde/#63-documenten als lichte achtergrondband (bestaand). + +Het **uitkomstlabel**: een gekleurde pil met de einddatum-verschuiving ("+0 dagen" groen, "+1"/ +"+2" amber, meer rood), daaronder "max · benut N". Een gepind project toont "vast"; +een #63-document "vast: datums zoals opgeslagen" zonder pin-knop; een `cannotMove`-document +"kan niet wijken". + +Een legenda-regel onder de balken: "Volle blokjes = werkdagen · gearceerd = pauzedag · meetlat: +grijs = speling, rood = einddatum-verschuiving · gestippeld = toegestaan maar niet benut". + +## 5. Het slepen + +- Alleen de **handle** aan het einde van de fase is te pakken: een knop van 15×30 px met drie + grijpstreepjes, `cursor: ew-resize`, `role="slider"`, `aria-valuemin=0`, + `aria-valuemax=CEILING_MAX_WORKDAYS`, `aria-valuetext` met de echte datum ("einde uiterlijk + 28 aug, maximaal 3 werkdagen uitloop, benut 2, einddatum +1 dag"). De balk zelf en het + linkereinde liggen vast. +- Pointer capture (blijft werken buiten het element); elke beweging snapt op hele werkdagen en + zet het **plafond** van dat project (`ceilings[docId]`, bestaande tune-state). Onbegrensd (End) + = handle aan het einde van de as. +- **Live meerekenen tijdens het slepen** onder de ondersteunde schaal: bij elke gesnapte + werkdagverandering één `computeDistribution`-run (gethrottled: maximaal één run in vlucht, de + laatste stand wint), waarna eigen rij, andere rijen (hun pauzepatroon en einddatum, hun start + niet), histogram, badges en validatiestrook meteen hertekenen. Loslaten schrijft niets. +- **Boven de schaal** (bestaande degradatiepoort: >1000 taken per document, >40 boekende taken, + >6 documenten): tijdens het slepen beweegt alleen de handle en de plafondtekst; de run volgt bij + loslaten. De degradatiemelding blijft. +- Toetsenbord op de handle: ←/→ één werkdag, PageUp/PageDown drie, Home = geen uitloop, End = + onbegrensd. Elke toets is een rekenmoment (onder de schaal). +- Spec §3.4 ("nooit per sleep-pixel") wordt hiermee bewust bijgesteld naar "per gesnapte + werkdag, gethrottled, onder de schaal" — de eigenaar wil zien wat er gebeurt terwijl hij sleept, + en het prototype werkte zo. + +## 6. De pin + +Per rij een kleine tekstknop "vastzetten" ↔ "vast — losmaken" (`aria-pressed`). Vastgezet: +grijze rand om de rij, handle `aria-disabled`, het project telt als vaste last en doet niet mee. +Alle deelnemers vast ⇒ validatiestrook: "Alle projecten staan vast — er is niets te +herverdelen. Maak er één los." + +## 7. Stabiliteit: geen flitsen + +- De dialoog staat **aan de bovenkant verankerd** (vaste `top`, geen verticale centrering) met + `max-h-[90vh]` en scroll binnenin, zodat een hoogteverschil nooit de positie verschuift. +- Elk onderdeel dat van toestand wisselt heeft een **gereserveerde hoogte**: de validatiestrook + (één regel, altijd gerenderd, leeg = onzichtbare tekst), het prijskaartje (vaste breedte), + de uitkomstlabels (vaste breedte, "Bezig…" vervangt de tekst, verandert de maat niet), de + "toegepast"-strook (gereserveerd zodra er een record is), de stale-melding (in de + validatiestrook, geen eigen blok). +- Rekentoestand toont zich via opaciteit/kleur, nooit via het toevoegen of weghalen van blokken. +- Browsertest: de `boundingBox` van de dialoog is identiek vóór, tijdens en ná een herberekening + en na een sleep. + +## 8. Wat blijft, wat gaat + +| blijft | gaat | +|---|---| +| `computeDistribution`, `applyDistribution`/`undoDistribution`, invalidatie via vingerafdruk, `resetDocumentScopedUI`-sluiting, keuzelijst (`DistributionPicker`), degradatiepoort, `Switch` | de rangordelijst en zijn sleep-/knopcode (`rank.*`-sleutels blijven tijdelijk voor i18n-poort, worden verwijderd zodra niets ze gebruikt) | +| tune-state (`allowSplits`, `order`, `pinned`, `ceilings`, `applied`) — `order` wordt afgeleid | de kostenlabels per project (waren informatie bij de rangorde) en hun runs | +| `useDistributionProposal` (rekenmomenten, bezig-toestand, labelpas voor het prijskaartje) — uitgebreid met de gethrottelde sleeprun | de huidige `PhaseStrip`-tekening (wordt herbouwd naar §4/§5) en `BeforeAfterChart`s eigen kleurtoewijzing (één `assignDocColors` voor alles) | + +Nieuwe i18n-sleutels onder `resource.distribution` (nl bron, veertien locales): `intro` (nieuwe +tekst), `tool.savesOff`/`tool.savesOn` (met `{{count}}`, meervoudfamilie) en `tool.savesNone*`, +`strip.legend`, `strip.slack`, `strip.pinButton`/`strip.unpinButton`, `strip.maxDate`, +`strip.used`, `status.resolved`, `status.shortfall`, `status.allPinned`, `reset`. Verwijderd +zodra ongebruikt: `rank.*`, `tool.price*`. + +## 9. Tests + +- **Headless** (`tests/planning/check-distribution-strip-geometry.ts`): uit een voorstel de + balkgeometrie afleiden (dagblokjes, pauzedagen, gestippelde rest, meetlat grijs/rood) voor + drie gevallen: binnen de speling, voorbij de speling, met onderbrekingen; plus de + verschil-prijs (uit − aan, nul en positief). +- **Browser** (`tests/browser/leveling-distribution.spec.ts`, herschreven waar de bediening + wijzigt): handle slepen ⇒ badge en histogram wijzigen tijdens het slepen (poll op + `aria-valuenow` én op de badge-tekst vóór `mouse.up`); toetsenbord; pin; schakelaar toont + "zou … besparen"/"bespaart …"; tekort ⇒ rode strook en Toepassen uit-met-reden; Reset; Toepassen + en Alles terugdraaien; de dialoog-`boundingBox` is stabiel over een herberekening en een sleep; + keuzelijst en degradatie blijven zoals ze zijn. +- Bestaande store-/kernchecks blijven ongewijzigd groen. + +## 10. Documentatie + +`public/docs/{nl,en}/gids-verdelen-restcapaciteit.md` herschreven naar de nieuwe bediening (de +balk als bediening, wat de arcering en de meetlat betekenen, het verschil-prijskaartje, Reset), met +één screenshot-loze beschrijving per element. `docs/library.md` alleen waar hij de rangordelijst +noemt. + +## 11. Buiten scope + +- De hele balk versleepbaar maken als harde plaatsing ("dit project begint hier") — een ander + mentaal model dan het gekozen prototype; zo nodig een latere etappe. +- Een expliciete rangorde-bediening terugbrengen (ingeklapt of anders). +- Wijzigingen aan de verdeler-kern of het schrijfpad. From 2e8358ff10a917f081b73f2bee1d143d19188747 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:17:30 +0200 Subject: [PATCH 052/108] =?UTF-8?q?docs(plan):=20B1c-plan=204=20=E2=80=94?= =?UTF-8?q?=20herontwerp=20verdeeldialoog=20naar=20het=20Interface-lab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...-12-b1c-plan4-verdeeldialoog-herontwerp.md | 2998 +++++++++++++++++ 1 file changed, 2998 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-b1c-plan4-verdeeldialoog-herontwerp.md diff --git a/docs/superpowers/plans/2026-09-12-b1c-plan4-verdeeldialoog-herontwerp.md b/docs/superpowers/plans/2026-09-12-b1c-plan4-verdeeldialoog-herontwerp.md new file mode 100644 index 000000000..b339a3894 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-b1c-plan4-verdeeldialoog-herontwerp.md @@ -0,0 +1,2998 @@ +# B1c — Plan 4: het herontwerp van de verdeeldialoog naar het Interface-lab (implementatieplan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** De verdeeldialoog van B1c herbouwen naar het prototype dat de eigenaar op 2026-08-27 koos +(tabblad 4 "Fasestrook-handles" in `docs/superpowers/prototypes/2026-08-27-b1c-interface-lab.html`): +de balk per project wordt de bediening, de rangordelijst verdwijnt, het prijskaartje wordt één +verschil, er wordt live meegerekend tijdens het slepen, en geen enkele rekentoestand mag de dialoog +nog laten flitsen. + +**Architecture:** vijf lagen, van binnen naar buiten. (1) Een **nieuwe pure geometriemodule** +`stripGeometry.ts` die uit een `DistributionProposal`-doorsnede en de gedeelde tijdas de dagblokjes, +pauzedagen, gestippelde rest en de meetlat uitrekent — headless getoetst, geen React. (2) **`PhaseStrip` +herbouwd** op die module: label 150 px | track 32 px | uitkomstlabel 132 px, met een echte +handle-knop van 15×30 px. (3) **`useDistributionProposal`** verliest de per-project-kostenlabels en +krijgt in plaats daarvan één verschil-prijs; zijn bestaande in-flight-coalescing ís de throttle van +de sleeprun. (4) **`DistributionDialog`** verliest de rangordelijst en krijgt een altijd-aanwezige +validatiestrook, een Reset-knop, bovenverankering en een histogram dat kolom-op-kolom onder de +balken uitkomt. (5) **i18n, tests en gidsen** volgen. + +**Tech Stack:** TypeScript strict, React 19, SVG in de DOM (geen Canvas — dat is uitsluitend de Gantt), +`react-i18next` met 14 locales, headless checks als `check-*.ts` onder `tests/planning/` met +registratie in `tests/planning/run.sh`, browsertests als Playwright-specs onder `tests/browser/`. + +--- + +## Waar dit plan vandaan komt + +| bron | wat je eruit haalt | +|---|---| +| `docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md` | **de opdracht.** Alle eigenaarsbesluiten staan daar; dit plan wijkt nergens af. | +| `docs/superpowers/prototypes/2026-08-27-b1c-interface-lab.html`, tabblad 4 | **de maatvoering.** Zoek `.ptrack`, `.pblk`, `.ppause`, `.pfree`, `.pfl`, `.pover`, `.phandle`, `rowBlocks()`, `buildPhaseHandles()`, `dragify()`. Elke pixelmaat in taak 2 komt daar letterlijk vandaan. | +| `docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md` | de huisstijl van een plan in deze repo, en de redenering achter de tune-state (scope-bak "Waar de tune-state woont" — die blijft onverkort gelden). | +| `CLAUDE.md` | de poorten, de i18n-pluralregels, het meldingenkanaal, de store-eigendomstabel. | + +Waar dit plan de spec **concretiseert** staat dat expliciet als **KEUZE VAN DIT PLAN**. Er staat +nergens een **AFWIJKING** in: alles wat hieronder gebouwd wordt valt binnen de eigenaarsbesluiten van +§2 van de spec. + +--- + +## Regels voor iedere uitvoerder van dit plan + +Lees deze acht regels vóór je taak; ze gelden voor élke taak. + +1. **Poorten draaien in de VOORGROND en het oordeel is de EXITCODE.** Nooit `&`, nooit een + achtergrondtaak met een tail. De planningssuite print "alles groen" ook bij exit 1 wanneer het + bundelen faalt; `grep -c '^XX'` is een extraatje, geen poort. +2. **Maximaal ÉÉN zware run tegelijk op deze machine.** Vóór `npm run verify` of + `npm run test:browser` eerst wachten tot er geen andere loopt: + ```bash + while pgrep -f "vite build|tsc --noEmit|playwright test|run.sh" >/dev/null; do sleep 20; done + ``` +3. **`PATH` expliciet zetten vóór élke npm-aanroep** — een subagent-shell heeft nvm niet geladen en + geeft anders exit 127: + ```bash + export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" + ``` +4. **Eén losse browserspec draai je gericht:** + ```bash + npm run test:browser -- tests/browser/leveling-distribution.spec.ts + ``` +5. **Nooit pushen.** Commit op de eigen branch, klaar. Geen merge, geen PR, geen tag. +6. **Geen `alert()`, geen `confirm()`, geen eigen toast.** Gebruikerzichtbare meldingen lopen via het + ene K8a-kanaal (`notify`) of via de dialoog zelf. +7. **Alle zichtbare tekst via `t(...)`.** Nooit een hardgecodeerde string in JSX, ook niet + "tijdelijk". +8. **Geen nieuwe store-velden.** Alles wat deze verbouwing nodig heeft past in de bestaande + `DistributionUiState` (`allowSplits`, `order`, `pinned`, `ceilings`, `applied`). Komt er tóch een + veld bij, dan is dat een ontwerpfout in dit plan — meld het in plaats van het erin te schrijven. + +--- + +## Scope + +**In dit plan:** + +| onderwerp | spec | taak | +|---|---|---| +| pure balkgeometrie (dagblokjes, pauzedagen, gestippelde rest, meetlat) + verschil-prijs | §4, §2.2 | T1 | +| `PhaseStrip` herbouwd naar de prototype-maten, handle-knop, pin als tekstknop, legenda | §4, §5, §6 | T2 | +| hook: live meerekenen tijdens het slepen, `order` afgeleid, kostenlabels weg, verschil-prijs | §2.1, §2.2, §5, §8 | T3 | +| dialooglay-out: rangordelijst weg, validatiestrook, Reset, bovenverankering, kolom-op-kolom | §3, §6, §7 | T4 | +| i18n in veertien locales + opschonen van de dode sleutels | §8 | T5 | +| browsertests herschreven + de boundingBox-stabiliteitstest | §9 | T6 | +| gidsen `nl`/`en` + `docs/library.md` | §10 | T7 | +| de volle poort | — | T8 | + +**NIET in dit plan** (spec §11): + +| onderwerp | waarom niet | +|---|---| +| De hele balk versleepbaar maken als harde plaatsing | ander mentaal model dan het gekozen prototype; zo nodig een latere etappe | +| Een expliciete rangorde-bediening terugbrengen (ingeklapt of anders) | eigenaarsbesluit §2.1: weg uit beeld | +| Wijzigingen aan de verdeler-kern (`distribute.ts`) of het schrijfpad (`applyDistribution`) | §2.4: de kern blijft zoals gebouwd | +| Persistentie van pins/plafonds over een herstart of door het IFC | plan 3, scope-bak "Waar de tune-state woont" — nog steeds geen v1-werk | + +--- + +## Context voor wie hier koud instapt + +Alles hieronder is geverifieerd in de code van 2026-09-12 (HEAD `7f9633ca`, branch +`t3code/b1c-etappe3`). Lees het vóór taak 1 — zonder deze acht punten is de rest raden. + +- **Wat "verdelen" doet.** Eén bibliotheekitem (een poolresource, bv. "Gedeelde kraan") wordt door + meerdere geopende projecten tegelijk geboekt. Samen overschrijden ze de bedrijfscapaciteit. De + verdeler (`src/services/library/distribute.ts`, puur) plaatst de documenten één voor één in + rangorde tegen de restcapaciteit: nummer 1 nivelleert alleen tegen de vaste last, elk volgend + document ziet de échte boekingen van zijn voorgangers. De uitkomst is een `DistributionProposal`. +- **Wat er in een `DistributionProposal` zit** (lees `distribute.ts` regels 90–150): + `docs: DistributionDocResult[]` met per document `delays`, `gaps`, `projectEndBefore`, + `projectEndAfter`, `endShiftWorkdays`, `participated`, `pinnedReason`, `cannotMove`, `shortfalls`; + plus `bookingByDay` (docId → ISO → eenheden VÓÓR), `afterLoadByDay` (idem NÁ), `fixedLoadByDay` + (ISO → vaste last), `residualByDay`, `hasShortfall`, `afterIncomplete`, `blocked`. +- **De tijdas is gedeeld.** `src/components/panels/occupancyAxis.ts` bouwt één `OccupancyAxis` + (segmenten met kalenderdagen, `dayWidth`, `width`, `xOf(iso)`, gatcompressie boven 30 dagen). De + dialoog bouwt hem één keer (`targetWidth: 560`) en geeft dezelfde INSTANTIE aan alle stroken én aan + de voor/na-grafiek. `AXIS.padLeft` is 34 (ruimte voor de y-as-waarden van het histogram), + `AXIS.padRight` is 8. Een strook laat die padLeft-strook gewoon leeg — dat is precies wat de + kolom-op-kolom-uitlijning in taak 4 mogelijk maakt. +- **De tune-state.** `ui.levelingDistribution: DistributionUiState | null` (zie + `src/state/slices/types.ts:255`): `companyId`, `libraryItemId`, `allowSplits`, `order` (docIds), + `pinned` (docId → bool), `ceilings` (docId → werkdagen of `null` = onbegrensd), `applied`. Dit is + app-globale UI-state, géén documentdata: hij zit niet in `DOCUMENT_FIELDS`, overleeft geen + herstart en wordt niet door undo geraakt. +- **Rekenen is discreet.** `useDistributionProposal` abonneert zich bewust NIET op de documentinhoud; + hij leest de store pas binnen een run (`useAppStore.getState()`). Er loopt er precies één tegelijk + (`busyRef`), en een verzoek dat tijdens een run binnenkomt wordt daarna precies ÉÉN keer ingehaald + (`pendingRef`). **Dát mechanisme ís de throttle die spec §5 vraagt** — er komt in taak 3 geen + tweede timer bij. +- **Schaal-degradatie.** Boven 1000 taken per document, 40 boekende taken op dit poolitem of 6 + geopende documenten rekent de dialoog alleen nog op "Herbereken" (`isDistributionDegraded`). In die + stand mag het slepen dus NIET live rekenen. +- **Wat er nu stuk is** (gebruikstest eigenaar 2026-09-12): de strook was een dun blokje van 24 px + zonder dagindeling, pauzes waren onzichtbaar, tijdens het slepen bewoog niets mee, de + rangordelijst trok alle aandacht, en het prijskaartje toonde twee kale bedragen naast elkaar. +- **De poorten.** `npm run verify` (typecheck + lint + alle vijf de suites + de verify-poorten) en + `npm run test:browser`. Oordeel op de exitcode. + +--- + +## Bestandsoverzicht + +| bestand | rol na deze verbouwing | taak | +|---|---|---| +| `src/components/dialogs/DistributionDialog/stripGeometry.ts` | **nieuw** — pure balkgeometrie + verschil-prijs. Geen React, geen domeintypes. | T1 | +| `tests/planning/check-distribution-strip-geometry.ts` | **nieuw** — headless check op T1. | T1 | +| `tests/planning/run.sh` | registratie van die check. | T1 | +| `src/components/dialogs/DistributionDialog/PhaseStrip.tsx` | **herbouwd** — label/track/uitkomstlabel, handle-knop, pin-tekstknop. | T2 | +| `src/components/dialogs/DistributionDialog/useDistributionProposal.ts` | kostenlabels eruit, `savings` erin, sleeprun. | T3 | +| `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` | rangordelijst eruit, validatiestrook/Reset/uitlijning erin. | T4 | +| `src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx` | eigen kleurterugval eruit (één `assignDocColors`). | T4 | +| `src/components/common/Dialog.tsx` | `alignTop`-prop voor de bovenverankering. | T4 | +| `src/i18n/locales/*/common.json` (14×) | nieuwe sleutels, dode sleutels weg. | T5 | +| `tests/browser/leveling-distribution.spec.ts` | herschreven waar de bediening wijzigt + stabiliteitstest. | T6 | +| `public/docs/{nl,en}/gids-verdelen-restcapaciteit.md`, `docs/library.md` | de nieuwe bediening beschreven. | T7 | + +De taken zijn zo geknipt dat T1, T5 en T7 **disjunct in bestanden** zijn en dus parallel gebouwd +kunnen worden; T2 hangt aan T1, T4 aan T2+T3, T6 aan T4, T8 aan alles. + +--- + +## Task 1: de pure balkgeometrie + de verschil-prijs + +**Files:** +- Create: `src/components/dialogs/DistributionDialog/stripGeometry.ts` +- Create: `tests/planning/check-distribution-strip-geometry.ts` +- Modify: `tests/planning/run.sh` (registratie, naast de bestaande `DISTCHARTCHECK`-blok op regel 691–695) + +### Wat deze module beslist (lees dit vóór stap 1) + +De spec (§4) beschrijft de balk in vier lagen. Dit is hoe elke laag uit de proposal-data volgt: + +| laag in §4 | bron | +|---|---| +| volle blokjes = werkdagen | `afterLoadByDay[docId]` — de **NA**-stand. **KEUZE VAN DIT PLAN:** §4 schrijft "`bookingByDay`/`afterLoadByDay`" en laat de keuze open; het moet de NA-stand zijn, anders beweegt de balk niet mee tijdens het slepen en is de hele bediening zinloos. Voor een gepind/#63-document zijn beide gelijk, dus er gaat niets verloren. | +| gearceerd = pauzedag | een dag ín de fasespanne (eerste t/m laatste geboekte dag) zónder boeking die **wél een werkdag is**. Een weekend of feestdag is geen pauzedag en wordt overgeslagen — anders staat elke balk vol arcering. De werkdagvraag komt als `isWorkingDay`-predicaat binnen, zodat de module puur blijft. | +| gestippelde rest | van het fase-einde tot de handle-stand (`ceilingWorkdays - endShiftWorkdays` werkdagen verder). | +| meetlat grijs/rood | grijs = de **beschikbare** speling vanaf het OORSPRONKELIJKE fase-einde (`bookingByDay[docId]`, laatste geboekte dag), `slackWorkdays` werkdagen lang — precies wat het prototype met `.pfl` doet. Rood = alles van het einde van die speling tot het huidige fase-einde (`.pover`). | + +**KEUZE VAN DIT PLAN — waarom de meetlat niet op `projectEndBefore`/`projectEndAfter` splitst.** Die +twee velden beschrijven de PROJECTeinddatum; de balk toont een FASE. Een fase kan binnen haar eigen +speling opschuiven zonder de projecteinddatum te raken, en dat is precies het onderscheid dat de +meetlat moet tonen. Het prototype splitst daarom op de eigen float, en dat is hier navolgbaar omdat +`documentFloatOn` (de kleinste `totalFloat` over de boekende taken, al aanwezig in +`useDistributionProposal.ts`) diezelfde grootheid levert. `endShiftWorkdays` blijft de bron van het +uitkomstlabel — die twee spreken elkaar niet tegen: is `endShiftWorkdays` 0, dan ligt het fase-einde +binnen de speling en is het rode deel per constructie leeg. + +**De verschil-prijs (§2.2).** `savingsWorkdays(offMax, onMax)` = `Math.max(0, offMax - onMax)`, waarbij +`offMax`/`onMax` de grootste `endShiftWorkdays` over de deelnemers zijn in de uit- resp. aan-stand. +De klem op 0 is nodig omdat onderbreken in theorie duurder kan uitvallen; "bespaart niets" is dan het +eerlijke antwoord, een negatief bedrag zou onzin zijn. + +- [ ] **Step 1: Schrijf de falende check** + +Maak `tests/planning/check-distribution-strip-geometry.ts`: + +```ts +// B1c-plan4 taak 1 — de PURE balkgeometrie van de verdeeldialoog (spec §4/§9). +// +// Drie gevallen uit spec §9, plus de verschil-prijs van §2.2. De as is bewust minimaal: één +// aaneengesloten reeks kalenderdagen, zodat elke x-positie met de hand na te rekenen is +// (`AXIS.padLeft + index * dayWidth`). +// +// De werkdagdefinitie komt als predicaat binnen — deze check gebruikt "ma t/m vr", zodat er geen +// kalendermotor aan te pas komt en de pauzedag-logica geïsoleerd getoetst wordt. +import { AXIS, buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; +import { + STRIP, buildStripGeometry, savingsWorkdays, maxEndShiftWorkdays, +} from '@/components/dialogs/DistributionDialog/stripGeometry'; + +let failures = 0; +function ok(cond: boolean, msg: string): void { + if (cond) console.log(`OK ${msg}`); + else { console.log(`XX ${msg}`); failures++; } +} + +// 2026-09-07 is een maandag. Twee volle weken + een dag. +const DAYS = expandDays('2026-09-07', '2026-09-25'); +const axis = buildOccupancyAxis(DAYS, { targetWidth: 560 })!; +const dw = axis.dayWidth; +const xAt = (iso: string): number => axis.xOf(iso)!; +const isWorkingDay = (iso: string): boolean => { + const day = new Date(`${iso}T00:00:00`).getDay(); + return day >= 1 && day <= 5; +}; +const load = (isos: string[]): Record => + Object.fromEntries(isos.map(iso => [iso, 1])); + +ok(axis !== null, 'de as over de fixture-dagen bestaat'); +ok(dw > 0, `de dagbreedte is positief (${dw})`); + +// ── Geval 1: BINNEN DE SPELING ─────────────────────────────────────────────────────────────── +// Oorspronkelijk ma 7 t/m wo 9; na de verdeling di 8 t/m do 10. Speling 3 werkdagen: het nieuwe +// einde valt er ruim binnen, dus er hoort GEEN rood te staan. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-08', '2026-09-09', '2026-09-10']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 3, + ceilingWorkdays: 3, + endShiftWorkdays: 0, + }); + + ok(g.blocks.length === 3, `1 — drie dagblokjes (kreeg ${g.blocks.length})`); + ok(g.blocks.every(b => b.kind === 'work'), '1 — alle drie zijn werkdagblokjes, geen pauzedag'); + ok(g.blocks[0].x === xAt('2026-09-08'), '1 — het eerste blokje staat op de nieuwe startdag'); + ok(g.blocks[0].w === dw, '1 — een blokje is precies één dagbreedte'); + ok(g.phaseEndX === xAt('2026-09-10') + dw, '1 — het fase-einde ligt achter de laatste geboekte dag'); + + // De meetlat: grijs vanaf het OORSPRONKELIJKE einde (wo 9 + 1 = do 10), drie werkdagen lang + // (do 10, vr 11, ma 14) — dus tot en met ma 14, x = xAt(ma 14) + dw. + ok(g.slackBar !== null, '1 — er is een grijze spelingsmeetlat'); + ok(g.slackBar!.x === xAt('2026-09-09') + dw, '1 — de speling begint achter het oorspronkelijke fase-einde'); + ok(g.slackBar!.x + g.slackBar!.w === xAt('2026-09-14') + dw, + '1 — drie WERKdagen speling slaat het weekend over en eindigt na ma 14'); + ok(g.overrunBar === null, '1 — binnen de speling staat er geen rood'); + + // Plafond 3 werkdagen, benut 0 ⇒ de handle staat drie werkdagen voorbij het fase-einde + // (vr 11, ma 14, di 15). + ok(g.handleX === xAt('2026-09-15') + dw, '1 — de handle staat drie werkdagen voorbij het fase-einde'); + ok(g.freeBox !== null && g.freeBox.x === g.phaseEndX, + '1 — de gestippelde rest begint op het fase-einde'); + ok(g.freeBox !== null && g.freeBox.x + g.freeBox.w === g.handleX, + '1 — de gestippelde rest loopt tot de handle'); + ok(g.ceilingEndIso === '2026-09-15', `1 — de plafonddatum is di 15 (kreeg ${g.ceilingEndIso})`); +} + +// ── Geval 2: VOORBIJ DE SPELING ────────────────────────────────────────────────────────────── +// Zelfde oorspronkelijke fase, maar het nieuwe einde ligt op do 17 en de speling is maar 1 werkdag. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-15', '2026-09-16', '2026-09-17']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 1, + ceilingWorkdays: null, + endShiftWorkdays: 5, + }); + + // Eén werkdag speling vanaf do 10 ⇒ de grijze meetlat eindigt na do 10. + ok(g.slackBar !== null && g.slackBar.x + g.slackBar.w === xAt('2026-09-10') + dw, + '2 — de grijze meetlat is precies één werkdag lang'); + ok(g.overrunBar !== null, '2 — voorbij de speling staat een rode meetlat'); + ok(g.overrunBar!.x === xAt('2026-09-10') + dw, '2 — het rood begint waar de speling ophoudt'); + ok(g.overrunBar!.x + g.overrunBar!.w === g.phaseEndX, '2 — het rood loopt tot het fase-einde'); + // Onbegrensd plafond ⇒ de handle staat aan het einde van de as (spec §5). + ok(g.handleX === axis.width - AXIS.padRight, '2 — een onbegrensd plafond zet de handle aan het aseinde'); + ok(g.ceilingEndIso === null, '2 — een onbegrensd plafond heeft geen plafonddatum'); +} + +// ── Geval 3: MET ONDERBREKINGEN ────────────────────────────────────────────────────────────── +// De fase loopt ma 7 → vr 11, met di 8 en do 10 als ingevoegde pauzedagen. Het weekend ertussen +// telt NIET als pauzedag. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-07', '2026-09-09', '2026-09-11']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: load(['2026-09-07', '2026-09-08']), + isWorkingDay, + slackWorkdays: 2, + ceilingWorkdays: 2, + endShiftWorkdays: 1, + }); + + ok(g.blocks.length === 5, `3 — vijf blokjes: drie werkdagen + twee pauzedagen (kreeg ${g.blocks.length})`); + const pauses = g.blocks.filter(b => b.kind === 'pause').map(b => b.iso); + ok(pauses.join(',') === '2026-09-08,2026-09-10', + `3 — precies di 8 en do 10 zijn pauzedagen (kreeg ${pauses.join(',')})`); + ok(g.blocks.every(b => b.iso >= '2026-09-07' && b.iso <= '2026-09-11'), + '3 — er staat niets buiten de fasespanne'); + + // De vaste last is één aaneengesloten band over ma 7 en di 8. + ok(g.fixedBands.length === 1, `3 — de vaste last is één band (kreeg ${g.fixedBands.length})`); + ok(g.fixedBands[0].x === xAt('2026-09-07') && g.fixedBands[0].w === 2 * dw, + '3 — die band beslaat precies twee dagen'); + + // Week- en maandlijnen: maandagen 7, 14 en 21 binnen dit domein; geen 1e van de maand. + ok(g.weekLines.length === 3, `3 — drie weekscheidingen (kreeg ${g.weekLines.length})`); + ok(g.monthLines.length === 0, '3 — geen maandlijn binnen september'); +} + +// ── De verschil-prijs (§2.2) ───────────────────────────────────────────────────────────────── +ok(savingsWorkdays(5, 2) === 3, 'prijs — uit 5, aan 2 ⇒ bespaart 3 werkdagen'); +ok(savingsWorkdays(4, 4) === 0, 'prijs — even duur ⇒ bespaart niets'); +ok(savingsWorkdays(1, 3) === 0, 'prijs — aan is duurder ⇒ geklemd op nul, nooit negatief'); +ok(maxEndShiftWorkdays([{ endShiftWorkdays: 1 }, { endShiftWorkdays: 4 }, { endShiftWorkdays: 0 }]) === 4, + 'prijs — de prijs is de UITSCHIETER over de deelnemers, niet de som'); +ok(maxEndShiftWorkdays([]) === 0, 'prijs — zonder deelnemers is de prijs nul'); + +// ── De maatvoering uit het prototype ───────────────────────────────────────────────────────── +ok(STRIP.labelWidth === 150 && STRIP.endWidth === 132 && STRIP.trackHeight === 32, + 'maten — label 150, uitkomstlabel 132, track 32 (prototype tabblad 4)'); +ok(STRIP.handleWidth === 15 && STRIP.handleHeight === 30, 'maten — de handle is 15×30'); +ok(STRIP.blockHeight === 15 && STRIP.blockTop === 5, 'maten — een dagblokje is 15 hoog op y=5'); + +if (failures > 0) { console.log(`\n${failures} fout(en)`); process.exit(1); } +console.log('\nalles groen'); +``` + +- [ ] **Step 2: Registreer de check in `tests/planning/run.sh`** + +Zoek het bestaande blok (rond regel 691) en zet het nieuwe blok er direct ONDER: + +```bash + # B1c-plan4 taak 1: de pure balkgeometrie van de verdeeldialoog — dagblokjes, pauzedagen (een + # weekend is er GEEN), de gestippelde rest tot het plafond, de meetlat grijs/rood, en de + # verschil-prijs van de onderbrekingsschakelaar. + DISTSTRIPCHECK="$DIR/.distribution-strip-geometry.mjs" + if bundle_check "$DIR/check-distribution-strip-geometry.ts" "$DISTSTRIPCHECK"; then node "$DISTSTRIPCHECK" || STATUS=1; fi +``` + +- [ ] **Step 3: Draai de check en zie hem falen** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run test:planning 2>&1 | grep -A3 "distribution-strip"; echo "exit: ${PIPESTATUS[0]}" +``` + +Verwacht: een bundelfout ("Could not resolve … stripGeometry") en **exit 1**. Ziet de suite er groen +uit, dan kijk je naar de tail in plaats van naar de exitcode — zie regel 1 bovenaan dit plan. + +- [ ] **Step 4: Schrijf `stripGeometry.ts`** + +Maak `src/components/dialogs/DistributionDialog/stripGeometry.ts`: + +```ts +// B1c-plan4 taak 1 — de PURE balkgeometrie van één projectrij in de verdeeldialoog (spec §4). +// +// WAAROM DIT EEN EIGEN MODULE IS. De balk is sinds dit herontwerp de BEDIENING, niet een plaatje: +// hij toont per werkdag een blokje, maakt een ingevoegde pauze zichtbaar, tekent hoeveel ruimte je +// toestond maar niet nodig bleek, en meet met een meetlat af hoeveel van de verschuiving nog binnen +// de eigen speling valt. Dat is genoeg regelwerk om buiten React te horen: hier is het headless +// toetsbaar (`tests/planning/check-distribution-strip-geometry.ts`) en kan `PhaseStrip.tsx` zich tot +// tekenen en gebaren beperken. +// +// PUUR, GEEN DOMEINTYPES. De werkdagvraag komt als predicaat binnen (`isWorkingDay`), niet als +// `WorkCalendar` — zo hoeft de check geen kalendermotor op te tuigen en kan de aanroeper gewoon de +// `CalendarEngine` van het betreffende document doorgeven. Dezelfde keuze als `chartGeometry.ts`, dat +// de capaciteit als `capacityOn`-functie aanneemt. +// +// DE MEETLAT MEET DE FASE, NIET HET PROJECT (KEUZE VAN DIT PLAN, zie het plan bij taak 1). Grijs +// gestippeld = de BESCHIKBARE speling vanaf het oorspronkelijke fase-einde; massief rood = alles +// daarvoorbij. Dat is letterlijk `.pfl`/`.pover` uit het prototype, en het klopt per constructie met +// `endShiftWorkdays`: zolang dat 0 is, blijft het fase-einde binnen de grijze meetlat en is er geen +// rood. +// +// EEN WEEKEND IS GEEN PAUZEDAG. De as loopt op KALENDERdagen, dus zonder de werkdagtoets zou elke +// balk vol arcering staan en zou een echte, ingevoegde onderbreking niet meer opvallen — precies het +// gebrek dat de gebruikstest van 2026-09-12 aanwees ("pauzes waren onzichtbaar"). +import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; +import { parseDate } from '@/utils/dateUtils'; + +/** De maatvoering van één rij, letterlijk uit het prototype (tabblad 4, `.prow`/`.ptrack.tall`). + * Eén definitie, gedeeld door `PhaseStrip.tsx` (de rij zelf) en `DistributionDialog.tsx` (de + * uitlijning van het histogram eronder) — anders lopen balk en histogram alsnog uit elkaar. */ +export const STRIP = { + /** `.plabel` — kleurstip, projectnaam, "speling N dagen". */ + labelWidth: 150, + /** `.pend` — de uitkomstpil met "max · benut N". */ + endWidth: 132, + /** `.prow { gap: 10px }`. */ + gap: 10, + /** `.ptrack.tall { height: 32px }`. */ + trackHeight: 32, + /** `.ptrack.tall .pblk { top: 5px; height: 15px }`. */ + blockHeight: 15, + blockTop: 5, + /** `.pfl`/`.pover { bottom: 3px; height: 3px }`. */ + measureHeight: 3, + measureBottom: 3, + /** `.phandle { top: 1px; height: 30px; width: 15px; margin-left: -7px }`. */ + handleWidth: 15, + handleHeight: 30, + handleTop: 1, +} as const; + +export type StripBlockKind = 'work' | 'pause'; + +/** Eén dagblokje in de track. `w` is altijd één dagbreedte; de 1 px witte scheiding rechts is een + * tekenkeuze van de component, geen geometrie. */ +export interface StripBlock { + iso: string; + x: number; + w: number; + kind: StripBlockKind; +} + +/** Een aaneengesloten horizontale band (vaste last, gestippelde rest, meetlat). */ +export interface StripBand { + x: number; + w: number; +} + +export interface StripGeometry { + blocks: StripBlock[]; + /** De vaste last van gepinde/#63-documenten, samengevoegd tot aaneengesloten banden. */ + fixedBands: StripBand[]; + /** "Toegestaan maar niet benut": van het fase-einde tot de handle. `null` ⇒ niets over. */ + freeBox: StripBand | null; + /** De beschikbare eigen speling, vanaf het OORSPRONKELIJKE fase-einde. */ + slackBar: StripBand | null; + /** Alles voorbij die speling — dus echte einddatum-verschuiving. */ + overrunBar: StripBand | null; + /** x van het MIDDEN van de handle. */ + handleX: number; + /** x van de rechterrand van de laatste geboekte dag. */ + phaseEndX: number; + /** De kalenderdag waarop het plafond uitkomt; `null` bij een onbegrensd plafond. */ + ceilingEndIso: string | null; + /** x-posities van de 1 px weekscheidingen (elke maandag) … */ + weekLines: number[]; + /** … en van de donkerdere maandlijnen (elke eerste van de maand). */ + monthLines: number[]; +} + +export interface StripGeometryInput { + axis: OccupancyAxis; + /** `DistributionProposal.bookingByDay[docId]` — de stand VÓÓR de verdeling. Levert het + * oorspronkelijke fase-einde, het ankerpunt van de meetlat. */ + beforeLoadByDay: Record; + /** `DistributionProposal.afterLoadByDay[docId]` — de stand NÁ de verdeling; dít wordt getekend. */ + loadByDay: Record; + /** `DistributionProposal.fixedLoadByDay`, met de eigen boeking er al afgetrokken wanneer dit + * document zélf gepind is (dat doet de aanroeper — een gepind document zit in de vaste last). */ + fixedLoadByDay: Record; + isWorkingDay: (iso: string) => boolean; + /** De eigen speling in hele werkdagen (`documentFloatOn`, geklemd op ≥ 0). */ + slackWorkdays: number; + /** Het ingestelde plafond in werkdagen; `null` = onbegrensd. */ + ceilingWorkdays: number | null; + /** De benutte uitloop (`DistributionDocResult.endShiftWorkdays`). */ + endShiftWorkdays: number; +} + +/** Alle kalenderdagen van de as met hun x, in tekenvolgorde. */ +function axisDayXs(axis: OccupancyAxis): { iso: string; x: number }[] { + const out: { iso: string; x: number }[] = []; + for (const segment of axis.segments) { + for (let i = 0; i < segment.days.length; i++) { + out.push({ iso: segment.days[i], x: segment.x0 + i * axis.dayWidth }); + } + } + return out; +} + +/** De laatste dag met een boeking, of `null` wanneer er niets geboekt is. */ +function lastBookedDay(loadByDay: Record): string | null { + let last: string | null = null; + for (const [iso, units] of Object.entries(loadByDay)) { + if (units > 0 && (last === null || iso > last)) last = iso; + } + return last; +} + +/** De eerste dag met een boeking, of `null`. */ +function firstBookedDay(loadByDay: Record): string | null { + let first: string | null = null; + for (const [iso, units] of Object.entries(loadByDay)) { + if (units > 0 && (first === null || iso < first)) first = iso; + } + return first; +} + +/** + * Loop vanaf (exclusief) `fromIso` `workdays` WERKdagen vooruit over de as en geef de dag waarop je + * uitkomt plus de x van zijn rechterrand. Loopt de as op vóór het aantal bereikt is, dan levert hij + * het aseinde — de handle mag de as nooit verlaten. + */ +function advanceWorkdays( + axis: OccupancyAxis, + days: { iso: string; x: number }[], + fromIso: string | null, + workdays: number, + isWorkingDay: (iso: string) => boolean, +): { iso: string | null; xEnd: number } { + const startX = fromIso === null + ? AXIS.padLeft + : (axis.xOf(fromIso) ?? AXIS.padLeft) + axis.dayWidth; + if (workdays <= 0) return { iso: fromIso, xEnd: startX }; + let remaining = workdays; + for (const day of days) { + if (fromIso !== null && day.iso <= fromIso) continue; + if (!isWorkingDay(day.iso)) continue; + remaining -= 1; + if (remaining === 0) return { iso: day.iso, xEnd: day.x + axis.dayWidth }; + } + return { iso: null, xEnd: axis.width - AXIS.padRight }; +} + +/** De volledige geometrie van één rij. Zie het moduleblok voor de vier lagen. */ +export function buildStripGeometry(input: StripGeometryInput): StripGeometry { + const { + axis, beforeLoadByDay, loadByDay, fixedLoadByDay, isWorkingDay, + slackWorkdays, ceilingWorkdays, endShiftWorkdays, + } = input; + const dayWidth = axis.dayWidth; + const days = axisDayXs(axis); + const trackRight = axis.width - AXIS.padRight; + + // (1) De dagblokjes van de NA-stand, binnen de fasespanne. + const first = firstBookedDay(loadByDay); + const last = lastBookedDay(loadByDay); + const blocks: StripBlock[] = []; + if (first !== null && last !== null) { + for (const day of days) { + if (day.iso < first || day.iso > last) continue; + const booked = (loadByDay[day.iso] ?? 0) > 0; + if (booked) blocks.push({ iso: day.iso, x: day.x, w: dayWidth, kind: 'work' }); + else if (isWorkingDay(day.iso)) blocks.push({ iso: day.iso, x: day.x, w: dayWidth, kind: 'pause' }); + // Een niet-werkdag zonder boeking is geen pauze maar gewoon weekend: niets tekenen. + } + } + const phaseEndX = last === null ? AXIS.padLeft : (axis.xOf(last) ?? AXIS.padLeft) + dayWidth; + + // (2) De vaste last, samengevoegd tot aaneengesloten banden. + const fixedBands: StripBand[] = []; + let open: StripBand | null = null; + for (const day of days) { + if ((fixedLoadByDay[day.iso] ?? 0) > 0) { + if (open !== null && Math.abs(open.x + open.w - day.x) < 0.001) open.w += dayWidth; + else { open = { x: day.x, w: dayWidth }; fixedBands.push(open); } + } else { + open = null; + } + } + + // (3) De meetlat, verankerd aan het OORSPRONKELIJKE fase-einde. + const origEnd = lastBookedDay(beforeLoadByDay); + const origEndX = origEnd === null ? AXIS.padLeft : (axis.xOf(origEnd) ?? AXIS.padLeft) + dayWidth; + const slack = advanceWorkdays(axis, days, origEnd, Math.max(0, Math.floor(slackWorkdays)), isWorkingDay); + const slackEndX = Math.min(trackRight, slack.xEnd); + const slackBar = slackEndX > origEndX ? { x: origEndX, w: slackEndX - origEndX } : null; + const overrunBar = phaseEndX > slackEndX ? { x: slackEndX, w: phaseEndX - slackEndX } : null; + + // (4) Het plafond: de handle en de gestippelde rest. Onbegrensd ⇒ aan het einde van de as (§5). + let handleX: number; + let ceilingEndIso: string | null; + if (ceilingWorkdays === null) { + handleX = trackRight; + ceilingEndIso = null; + } else { + const ceiling = advanceWorkdays( + axis, days, origEnd, Math.max(0, ceilingWorkdays), isWorkingDay); + handleX = Math.max(AXIS.padLeft, Math.min(trackRight, ceiling.xEnd)); + ceilingEndIso = ceiling.iso; + } + // Het plafond wordt vanaf het OORSPRONKELIJKE einde geteld, de benutte uitloop ook — dus wanneer + // er meer benut is dan toegestaan (een plafond dat krapper is dan de uitkomst) valt de handle ín + // de balk. Dat is geen fout maar precies de bedoelde aflezing; `endShiftWorkdays` staat hier als + // documentatie van die relatie en om de aanroeper niet te laten gissen. + void endShiftWorkdays; + const freeBox = handleX > phaseEndX ? { x: phaseEndX, w: handleX - phaseEndX } : null; + + // (5) Week- en maandlijnen. + const weekLines: number[] = []; + const monthLines: number[] = []; + for (const day of days) { + if (day.iso.slice(8) === '01') monthLines.push(day.x); + else if (parseDate(day.iso).getDay() === 1) weekLines.push(day.x); + } + + return { + blocks, fixedBands, freeBox, slackBar, overrunBar, + handleX, phaseEndX, ceilingEndIso, weekLines, monthLines, + }; +} + +/** De grootste einddatum-verschuiving over de deelnemers — de UITSCHIETER, niet de som (§2.2). */ +export function maxEndShiftWorkdays(docs: { endShiftWorkdays: number }[]): number { + return docs.reduce((max, doc) => Math.max(max, doc.endShiftWorkdays), 0); +} + +/** + * Het verschil-prijskaartje van "Onderbrekingen toestaan" (§2.2): hoeveel werkdagen de aan-stand + * bespaart ten opzichte van de uit-stand. Geklemd op 0 — onderbreken kán in theorie duurder + * uitvallen, en dan is "bespaart niets" het eerlijke antwoord. + */ +export function savingsWorkdays(offMax: number, onMax: number): number { + return Math.max(0, offMax - onMax); +} +``` + +- [ ] **Step 5: Draai de check tot hij groen is** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run test:planning; echo "exit: $?" +``` + +Verwacht: **exit 0**, en in de uitvoer `alles groen` ná de regels van +`check-distribution-strip-geometry`. Faalt een `OK`-regel, lees welke — de x-posities zijn met de +hand na te rekenen als `AXIS.padLeft + index * dayWidth`. + +- [ ] **Step 6: Typecheck + lint** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run typecheck && npm run lint; echo "exit: $?" +``` + +Verwacht: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/dialogs/DistributionDialog/stripGeometry.ts \ + tests/planning/check-distribution-strip-geometry.ts tests/planning/run.sh +git commit -m "$(cat <<'EOF' +feat(b1c): pure balkgeometrie en verschil-prijs voor de verdeeldialoog + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 2: `PhaseStrip` herbouwd — label | track | uitkomstlabel + +**Files:** +- Rewrite: `src/components/dialogs/DistributionDialog/PhaseStrip.tsx` (vervang het HELE bestand) +- Test: taak 6 (browser). Deze taak heeft geen eigen headless test — de geometrie zit in taak 1, wat + hier overblijft is tekenen en gebaren, en dat toets je in een echte browser. + +### Wat er verandert en waarom + +| nu | straks | spec | +|---|---|---| +| één SVG van 24 px hoog met massieve rechthoeken per dag | een rij van drie kolommen: label 150 px, track 32 px hoog, uitkomstlabel 132 px | §4 | +| de boeking als hoogte-gestapelde staaf | per werkdag één blokje van 15 px in de projectkleur met een 1 px witte scheiding rechts | §4 | +| pauzes onzichtbaar | gearceerd blokje (`repeating-linear-gradient(135deg, …)`) met een dun grijs randje | §4 | +| handle = een 5 px streepje | een knop van 15×30 px met drie grijpstreepjes, `cursor: ew-resize` | §5 | +| pin = een icoonknop | een tekstknop "vastzetten" ↔ "vast — losmaken" | §6 | +| effect als kale tekst rechts | een gekleurde pil (+0 groen, +1/+2 amber, meer rood) met daaronder "max · benut N" | §4 | +| geen legenda | één legenda-regel onder alle balken (staat in taak 4, de dialoog) | §4 | + +**KEUZE VAN DIT PLAN — SVG voor de track, DOM voor de handle.** Het prototype tekent alles met +absoluut gepositioneerde ``s. Dit plan tekent de **track in SVG** en zet de **handle als +absoluut gepositioneerde ` + )} +
+ + {/* (b) TRACK. Geforceerd LTR, net als het histogram: een tijdas spiegelt nergens in dit + product. De horizontale scroll zit BEWUST niet hier maar om de hele stapel heen (de + dialoog), zodat balken en histogram kolom-op-kolom blijven staan tijdens het scrollen. */} +
+ + + {/* De arcering van een pauzedag: `repeating-linear-gradient(135deg, …)` uit het + prototype, hier als SVG-pattern zodat hij met het thema meekleurt. */} + + + + + + + {/* De track zelf: lichte achtergrond met een randje (`.ptrack`). */} + + + {/* Vaste last van gepinde/#63-documenten (§4, achtergrondband). */} + {(geometry?.fixedBands ?? []).map((band, i) => ( + + ))} + + {/* Weekscheidingen (1 px) en de donkerdere maandlijn. */} + {(geometry?.weekLines ?? []).map((x, i) => ( + + ))} + {(geometry?.monthLines ?? []).map((x, i) => ( + + ))} + + {/* "Toegestaan maar niet benut" — lege doos met gestippelde rand (§4). */} + {geometry?.freeBox && ( + + )} + + {/* De dagblokjes. Een werkdag in de projectkleur met een 1 px witte scheiding rechts + (`.pwork`), een pauzedag gearceerd met een dun grijs randje (`.ppause`). */} + {(geometry?.blocks ?? []).map(block => (block.kind === 'work' ? ( + + ) : ( + + )))} + + {/* De meetlat: grijs gestippeld = speling, massief rood = einddatum-verschuiving (§4). */} + {geometry?.slackBar && ( + + )} + {geometry?.overrunBar && ( + + )} + + + {/* De HANDLE (§5): een echte knop van 15×30 met drie grijpstreepjes. */} + +
+ + {/* (c) UITKOMSTLABEL — vaste breedte, dus een toestandswissel verandert de maat niet (§7). */} +
+ + {busy ? t('resource.distribution.compute.busy') : shiftText} + + + {`${maxDateText} · ${usedText}`} + +
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run typecheck; echo "exit: $?" +``` + +Verwacht: **fouten in `DistributionDialog.tsx`** — die geeft nog de oude props door +(`dailyLoad`, `scaleMax`, `degraded`, `onTogglePin`) en mist de nieuwe (`beforeLoadByDay`, +`afterLoadByDay`, `isWorkingDay`, `slackWorkdays`, `liveCommit`, `busy`, `formatDay`). Dat is +verwacht: taak 4 bedraadt de aanroeper. **Commit deze taak dus samen met taak 4** wanneer je ze +achter elkaar uitvoert; bouw je ze parallel, dan blijft deze taak op een eigen branch tot taak 4 er +is. De typecheck van de losse component controleer je zolang met: + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npx tsc --noEmit --jsx react-jsx --strict --moduleResolution bundler --module esnext \ + --target es2022 --baseUrl . --paths '{"@/*":["src/*"]}' \ + src/components/dialogs/DistributionDialog/PhaseStrip.tsx 2>&1 | head -20; echo "exit: ${PIPESTATUS[0]}" +``` + +Verwacht: geen fouten die naar `PhaseStrip.tsx` zelf wijzen. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/dialogs/DistributionDialog/PhaseStrip.tsx +git commit -m "$(cat <<'EOF' +feat(b1c): de projectbalk herbouwd naar het Interface-lab-prototype + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 3: de hook — verschil-prijs, sleeprun, kostenlabels weg + +**Files:** +- Modify: `src/components/dialogs/DistributionDialog/useDistributionProposal.ts` +- Test: `tests/planning/check-distribution-chart-scale.ts` blijft ongewijzigd groen (die toetst + `diffReason`, dat hier niet verandert). + +### Wat er verandert + +1. **`costByDoc` verdwijnt.** De kostenlabels ("alleen dit project laten opschuiven kost N + werkdagen") hoorden bij de rangordelijst; die is weg (§2.1/§8). Daarmee verdwijnt ook de duurste + kant van de labelpas: één volledige `computeDistribution`-run **per deelnemer**. Dat is precies + wat het live meerekenen tijdens het slepen betaalbaar maakt — er blijven twee extra runs over in + plaats van N+2. +2. **`toolPrice: { off, on }` wordt `savings`.** Eén verschil (§2.2) in plaats van twee bedragen. + De hook levert het rauwe getal; de dialoog kiest de zin ("zou N besparen" / "bespaart N"). +3. **De sleeprun heeft geen nieuwe code nodig.** De bestaande rekenmoment-2-`useEffect` reageert al + op elke `ceilings`-wijziging via `diffReason`, en `busyRef`/`pendingRef` doen de coalescing die + §5 vraagt ("maximaal één run in vlucht, de laatste stand wint"). Wat er wél bij moet: de + **stale-reden mag tijdens een sleep niet als foutmelding gaan schreeuwen** — zie stap 3. + +**KEUZE VAN DIT PLAN — `order` als "afgeleide" betekent: één keer afgeleid, bij het openen.** +Spec §2.1 zegt dat `order` blijft bestaan "als afgeleide (spelingsvolgorde)". `freshDistributionUi` +zet die spelingsvolgorde al (`floatSortedOrder`) op het moment dat een poolitem gekozen wordt, en +zonder rangordelijst kan niets hem daarna nog veranderen. Er komt dus **geen tweede afleidingsmoment +bij**, en dat is geen luiheid maar een noodzaak: zou de hook `order` bij elke run herberekenen en +terugschrijven naar `ui.levelingDistribution`, dan ziet `diffReason` een `'rank'`-wijziging, plant hij +een nieuwe run, en draait de dialoog in een oneindige lus. De rijvolgorde in de dialoog is en blijft +`tune.order`; documenten die ná het openen bijkomen sluiten achteraan aan (bestaand gedrag in +`buildDistributionInputs`). + +- [ ] **Step 1: Pas het contract aan** + +In `useDistributionProposal.ts`, vervang in `interface DistributionProposalState` de twee laatste +velden: + +```ts + /** docId → "alleen dit document laten opschuiven kost N werkdagen" … */ + costByDoc: Record; + /** Het prijskaartje van de gereedschapsschakelaar … */ + toolPrice: { off: number; on: number } | null; +``` + +door: + +```ts + /** + * Het VERSCHIL-prijskaartje van "Onderbrekingen toestaan" (spec §2.2, eigenaarsbesluit + * 2026-09-12). Twee volledige `computeDistribution`-runs — één met `allowSplits: false`, één met + * `true` — leveren elk de grootste `endShiftWorkdays` over de deelnemers (de UITSCHIETER, niet de + * som); `workdays` is het verschil daartussen, geklemd op ≥ 0. + * + * Waarom één verschil en niet twee bedragen: de gebruikstest van 2026-09-12 las "kost 27 + * werkdagen uitloop · kost 27 werkdagen uitloop" als een weergavefout. Een gebruiker wil weten wat + * de schakelaar hém oplevert, niet wat elke stand los kost. + * + * `null` ⇒ nog niet (her)berekend; de dialoog zegt dan eerlijk "prijs onbekend". + */ + savings: { workdays: number } | null; +``` + +- [ ] **Step 2: Vervang de labelpas door de verschil-pas** + +Vervang de hele functie `scheduleDistributionLabels` (inclusief haar doc-blok) door: + +```ts + /** + * Spec §2.2 — het VERSCHIL-prijskaartje. Twee volledige `computeDistribution`-runs (`allowSplits` + * uit en aan) ná het hoofdvoorstel, elk in een eigen macrotask zodat de browser ertussen kan + * schilderen en een afbreekreden binnen één stap landt. Breekt af zodra `myGeneration` is + * ingehaald door een nieuwere hoofdrun of door een 'edited'-invalidatie + * (`fingerprintsRef.current === null`) — beide zijn precies de gevallen waarin het hoofdvoorstel + * op het scherm zelf ook al niet meer bij de documenten hoort. + * + * DIT WAS VIER KEER ZO DUUR. Tot 2026-09-12 deed deze pas óók per deelnemer een isolatierun voor + * de kostenlabels van de rangordelijst. Die lijst is weg (§2.1) en die runs dus ook: er blijven + * twee runs over in plaats van N+2. Dat is wat het live meerekenen tijdens het slepen (§5) + * betaalbaar maakt. + */ + const scheduleSavingsPass = ( + myGeneration: number, + tuneAtRun: DistributionUiState, + pool: CompanyPool, + built: DistributionDocInput[], + ): void => { + const superseded = () => generationRef.current !== myGeneration || fingerprintsRef.current === null; + // Alleen de teller van DEZE pas mag de bezig-toestand weer uitzetten: een nieuwere pas loopt + // dan al en heeft 'm zojuist zelf aangezet. + const finish = () => { if (generationRef.current === myGeneration) setLabelsBusy(false); }; + + const priceOf = (p: DistributionProposal): number | null => + p.blocked ? null : maxEndShiftWorkdays(p.docs); + + let offPrice: number | null = null; + let onPrice: number | null = null; + const steps: (() => void)[] = [ + () => { + offPrice = priceOf(computeDistribution( + tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: false }, + )); + }, + () => { + onPrice = priceOf(computeDistribution( + tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: true }, + )); + }, + () => { + if (offPrice !== null && onPrice !== null) { + setSavings({ workdays: savingsWorkdays(offPrice, onPrice) }); + } + }, + ]; + + const runStep = (index: number): void => { + if (superseded() || index >= steps.length) { finish(); return; } + try { + steps[index](); + } catch { + // Een mislukte stap laat gewoon geen prijs zien — nooit een gok tonen. De volgende stap + // loopt door; zonder beide prijzen blijft `savings` op `null` en zegt de dialoog eerlijk + // dat de prijs onbekend is. + } + schedule(() => runStep(index + 1)); + }; + + setLabelsBusy(true); + schedule(() => runStep(0)); + }; +``` + +- [ ] **Step 3: Bedraad de nieuwe state** + +Drie kleine wijzigingen in de body van `useDistributionProposal`: + +**(a)** Vervang de twee `useState`-regels + +```ts + const [costByDoc, setCostByDoc] = useState>({}); + const [toolPrice, setToolPrice] = useState<{ off: number; on: number } | null>(null); +``` + +door: + +```ts + const [savings, setSavings] = useState<{ workdays: number } | null>(null); +``` + +**(b)** In `runRef.current` vervang + +```ts + setCostByDoc({}); + setToolPrice(null); +``` + +door + +```ts + // De prijs hoort bij het HUIDIGE voorstel, niet bij het vorige — hij gaat dus meteen leeg zodra + // een nieuwe run start. Een oud getal tijdens "Bezig met verdelen…" is even misleidend als een + // oud getal na een invalidatie. + setSavings(null); +``` + +en vervang de aanroep + +```ts + if (pool && proposalResult && !proposalResult.blocked && !isDistributionDegraded(built, current)) { + scheduleDistributionLabels(myGeneration, current, pool, built, proposalResult); + } +``` + +door + +```ts + if (pool && proposalResult && !proposalResult.blocked && !isDistributionDegraded(built, current)) { + scheduleSavingsPass(myGeneration, current, pool, built); + } +``` + +**(c)** In het `subjectKey`-effect vervang + +```ts + setCostByDoc({}); + setToolPrice(null); +``` + +door + +```ts + setSavings(null); +``` + +en in de `return` van de hook vervang + +```ts + costByDoc, toolPrice, +``` + +door + +```ts + savings, +``` + +- [ ] **Step 4: Werk de imports bij** + +Voeg bovenaan `useDistributionProposal.ts` toe, onder de bestaande `distribute`-import: + +```ts +import { maxEndShiftWorkdays, savingsWorkdays } from './stripGeometry'; +``` + +`DistributionProposal` blijft geïmporteerd (de `priceOf`-signatuur gebruikt hem); `CompanyPool` ook. + +- [ ] **Step 5: Werk het moduleblok bij** + +Vervang in het moduleblok bovenaan de zin over de sleepbeweging + +``` +// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer — een sleepbeweging over de +// plafondhandle in taak 9 levert anders een wachtrij die minutenlang naloopt). +``` + +door + +``` +// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer). DÍT MECHANISME IS DE THROTTLE +// DIE SPEC §5 VRAAGT: onder de ondersteunde schaal commit de balk élke gesnapte werkdag tijdens het +// slepen, en deze in-flight-bewaking zorgt dat er maximaal één run loopt en de laatste stand wint. +// Er hoort hier GEEN tweede timer bij te komen — die zou de laatste stand kunnen inslikken. +``` + +- [ ] **Step 6: Typecheck** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run typecheck 2>&1 | grep -v "DistributionDialog.tsx" ; echo "exit: ${PIPESTATUS[0]}" +``` + +Verwacht: geen fouten in `useDistributionProposal.ts` of `stripGeometry.ts`. Fouten in +`DistributionDialog.tsx` (die leest nog `costByDoc`/`toolPrice`) zijn verwacht en worden in taak 4 +opgeruimd. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/dialogs/DistributionDialog/useDistributionProposal.ts +git commit -m "$(cat <<'EOF' +feat(b1c): verschil-prijskaartje i.p.v. twee bedragen, kostenlabels weg + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 4: de dialooglay-out — rangordelijst weg, validatiestrook, Reset, uitlijning + +**Files:** +- Modify: `src/components/common/Dialog.tsx` (één nieuwe prop) +- Modify: `src/components/dialogs/DistributionDialog/DistributionDialog.tsx` (de grote verbouwing) +- Modify: `src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx` (kleurterugval eruit) + +### De nieuwe opbouw (spec §3) + +``` +┌ kop: itemnaam · ondertitel · één regel uitleg ────────────────────────────┐ +│ gereedschap: [schakelaar] Onderbrekingen toestaan "bespaart 3 werkdagen" │ +│ │ +│ ┌ één horizontale scroll-container ───────────────────────────────────┐ │ +│ │ [label 150] [track ..................] [uitkomst 132] ← per project│ │ +│ │ [label 150] [track ..................] [uitkomst 132] │ │ +│ │ legenda-regel, ingesprongen tot de track │ │ +│ │ [ 150 spatie ] einddatum-badges per project [132 spatie] │ │ +│ │ [ 150 spatie ] histogram Nu / Na verdelen [132 spatie] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ validatiestrook (altijd gerenderd, vaste hoogte) · reden voor Toepassen │ +├ knoppenbalk: [Ander item kiezen…] [Reset] [Herbereken] [Toepassen] [Verwerpen] │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +**KEUZE VAN DIT PLAN — één scroll-container om balken én histogram.** Spec §3.4 eist dat het +histogram "kolom-op-kolom uitgelijnd met de balken" staat. Vandaag heeft élke strook en élk +histogram zijn eigen `overflow-x-auto`; scrollt de gebruiker de ene, dan staan de kolommen scheef. +Eén gedeelde scroll-container om de hele stapel lost dat structureel op. Binnen die container +krijgen de badgerij en het histogram links een spatie van `STRIP.labelWidth + STRIP.gap` en rechts +van `STRIP.endWidth + STRIP.gap`, zodat hun plotgebied exact samenvalt met de tracks. De +`AXIS.padLeft` van 34 px zit ín de as en geldt voor beide — die hoeft niet gecompenseerd te worden. + +**KEUZE VAN DIT PLAN — de stale-melding woont in de validatiestrook, met behoud van haar testanker.** +Spec §7 zegt dat de stale-melding geen eigen blok meer krijgt. Het `data-ops-distribution-stale`-anker +blijft wél bestaan, maar als een `` bínnen de altijd aanwezige strook — dan blijven de +bestaande browserasserties (`toHaveCount(0)` als er niets stale is) geldig én verandert de hoogte +niet. + +**KEUZE VAN DIT PLAN — wat "N ploegdagen tekort" telt.** Het getal is het aantal **unieke ISO-dagen** +over alle `DistributionDocResult.shortfalls[].days` samen. Dat is de grootheid die de gebruiker op de +tijdas terugziet; het aantal (taak, dag)-paren zou hetzelfde tekort dubbel tellen zodra twee taken op +dezelfde dag vastlopen. De "eerste drie data" uit §3.5 zijn de eerste drie van die gesorteerde set. + +- [ ] **Step 1: `Dialog` leert bovenverankeren** + +In `src/components/common/Dialog.tsx`, voeg aan `DialogProps` toe (direct onder `overlayClassName`): + +```ts + /** Verankert het paneel aan de BOVENkant in plaats van verticaal te centreren. Nodig voor + * dialogen waarvan de inhoud van hoogte wisselt: bij verticaal centreren verschuift het hele + * paneel dan onder de muis vandaan (B1c-plan4, spec §7 "de dialoog flitst niet"). */ + alignTop?: boolean; +``` + +Voeg `alignTop = false` toe aan de destructurering, en vervang de overlay-className: + +```tsx + className={`fixed inset-0 flex ${alignTop ? 'items-start pt-[4vh]' : 'items-center'} justify-center ${overlayClassName}`} +``` + +Zo botsen `items-start` en `items-center` nooit in dezelfde class-lijst — dat zou van +CSS-volgorde afhangen in plaats van van deze prop. + +- [ ] **Step 2: Verwijder de rangorde-machinerie uit `DistributionDialog.tsx`** + +Verwijder, in deze volgorde: + +1. In het moduleblok bovenaan: het hele kopje **"RANGORDE MET DE MUIS (taak 10, spec §4 stap 1) — + POINTER-EVENTS, NIET HTML5-DND."** tot en met de laatste regel ervan + (`…zet net als de knoppen \`staleReason = 'rank'\` via \`diffReason\` in \`useDistributionProposal\`.`). + Zet er dit voor in de plaats: + +``` +// DE RANGORDELIJST IS WEG (eigenaarsbesluit 2026-09-12, spec §2.1). Hij trok in de gebruikstest alle +// aandacht weg van de balken, terwijl de balk zelf de bediening hoort te zijn. De rijvolgorde is +// sindsdien puur de plaatsingsvolgorde van de rekenaar — de spelingsvolgorde die +// `freshDistributionUi` bij het openen in `tune.order` zet — en sturen doe je uitsluitend met de +// handle en de pin. `order` blijft in de tune-state bestaan zodat de kern en het schrijfpad +// ONGEWIJZIGD blijven; er is alleen geen bediening meer die hem verandert. Een tweede +// afleidingsmoment (order herberekenen en terugschrijven) zou via `diffReason('rank')` een +// oneindige herberekening geven — zie het plan bij taak 3. +``` + +2. De import van `ChevronDown, ChevronUp, GripVertical` (houd `X`): + +```tsx +import { X } from 'lucide-react'; +``` + +3. De hele `rankRows`-`useMemo`, de functies `move`, `reorderTo`, `dropTargetAt`, + `onGripPointerDown`, `onGripPointerMove`, `endDrag`, `onGripPointerUp`, `onGripPointerCancel`, en + de states `draggedDocId`, `dropTarget`, `rowRefs`, `dragRef`. +4. De helpers `costLabel`, `priceText`, `toolPriceLabel`, `isCostCandidate` en de consts + `labelsValid`, `labelsPending`, `numberFmt`. +5. De hele `{/* (4) Rangorde */}`-`
` in de JSX. +6. De `{/* Tekorten (§4 stap 3) … */}`-`
` (`data-ops-distribution-shortfall`) — die inhoud + verhuist naar de validatiestrook. +7. Het losse `{degraded && …}`-blok en het `{showStale && …}`-blok in de knoppenbalk, plus de const + `showStale`. +8. `orderBase` blijft staan — hij is nog steeds de bron van de rijvolgorde. + +- [ ] **Step 3: Nieuwe afgeleide waarden** + +Vervang de `stripView`-`useMemo` (en alles wat eraan hing) door dit blok. Zet het ná +`bookingByDoc` en vóór `docColors`: + +```tsx + // De boeking NA de verdeling per document — de stand die de balken tekenen (spec §4). + const afterByDoc = useMemo(() => { + if (!proposal || proposal.blocked) return new Map>(); + return new Map(Object.entries(proposal.afterLoadByDay)); + }, [proposal]); + + // De GEDEELDE tijdas van balken én histogram. De as loopt door tot voorbij de laatste geboekte + // dag, zodat een gestippelde rest (toegestaan-maar-niet-benut) er nog binnen past. + const stripView = useMemo(() => { + if (!tune || !proposal || proposal.blocked || !poolItem) return null; + const rankIndex = new Map(orderBase.map((docId, index) => [docId, index])); + const docs = [...proposal.docs].sort((a, b) => + (rankIndex.get(a.docId) ?? orderBase.length) - (rankIndex.get(b.docId) ?? orderBase.length)); + + const days = new Set(Object.keys(proposal.fixedLoadByDay)); + for (const doc of docs) { + for (const iso of Object.keys(bookingByDoc.get(doc.docId) ?? {})) days.add(iso); + for (const iso of Object.keys(afterByDoc.get(doc.docId) ?? {})) days.add(iso); + } + if (days.size === 0) return { axis: null, docs }; + + const outlook = docs.reduce( + (n, doc) => Math.max(n, doc.endShiftWorkdays, tune.ceilings[doc.docId] ?? 0), 0); + if (outlook > 0) { + const last = [...days].sort()[days.size - 1]; + // Werkdagen → kalenderdagen (5/7) plus een marge, en hoe dan ook begrensd: de rest mag de + // as verbreden, niet laten ontsporen. + const extra = Math.min(90, Math.ceil(outlook * 7 / 5) + 2); + for (const iso of expandDays( + formatDate(addCalendarDays(parseDate(last), 1)), + formatDate(addCalendarDays(parseDate(last), extra)), + )) days.add(iso); + } + + return { axis: buildOccupancyAxis([...days], { targetWidth: 560 }), docs }; + }, [tune, proposal, poolItem, orderBase, bookingByDoc, afterByDoc]); + + // Is deze kalenderdag een werkdag in de kalender van dít document? Dat onderscheid bepaalt of een + // boekingsloze dag binnen een fase een PAUZEDAG is (gearceerd) of gewoon weekend (niets). De + // kalender komt uit dezelfde `inputs` waarop het voorstel gerekend is — geen tweede bron. + const workingDayByDoc = useMemo(() => { + const out = new Map boolean>(); + for (const doc of inputs) { + const engine = new CalendarEngine(doc.calendar); + const cache = new Map(); + out.set(doc.docId, (iso: string) => { + const hit = cache.get(iso); + if (hit !== undefined) return hit; + const value = engine.isWorkDay(parseDate(iso)); + cache.set(iso, value); + return value; + }); + } + return out; + }, [inputs]); + + // De eigen speling per document — dezelfde grootheid die de startvolgorde bepaalt, nu als + // labeltekst én als ankerpunt van de grijze meetlat. + const slackByDoc = useMemo(() => { + const out = new Map(); + if (!tune) return out; + for (const doc of inputs) { + const slack = documentFloatOn(doc, tune.companyId, tune.libraryItemId); + out.set(doc.docId, slack === null ? null : Math.max(0, Math.floor(slack))); + } + return out; + }, [inputs, tune]); + + const dayFmt = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), + [i18n.language], + ); + const formatDay = useCallback( + (iso: string) => dayFmt.format(parseDate(iso)), + [dayFmt], + ); + + // Het VERSCHIL-prijskaartje (§2.2). De schakelaar-stand bepaalt de zin, niet het getal: uit ⇒ + // "zou N besparen" (een belofte), aan ⇒ "bespaart N" (een constatering). + const savingsLabel = (): string => { + if (busy || labelsBusy) return t('resource.distribution.compute.busy'); + if (staleReason !== null || degraded || savings === null) { + return t('resource.distribution.tool.priceUnknown'); + } + if (savings.workdays === 0) { + return tune?.allowSplits + ? t('resource.distribution.tool.savesNoneOn') + : t('resource.distribution.tool.savesNoneOff'); + } + return tune?.allowSplits + ? t('resource.distribution.tool.savesOn', { count: savings.workdays }) + : t('resource.distribution.tool.savesOff', { count: savings.workdays }); + }; + + // De unieke tekortdagen over alle documenten (zie het plan bij taak 4 voor waarom UNIEKE dagen). + const shortfallDays = useMemo(() => { + if (!proposal || proposal.blocked) return [] as string[]; + const days = new Set(); + for (const doc of proposal.docs) for (const s of doc.shortfalls) for (const iso of s.days) days.add(iso); + return [...days].sort(); + }, [proposal]); + + // Alle deelnemers vast ⇒ er valt niets te herverdelen (§6). + const allPinned = useMemo(() => { + if (!proposal || proposal.blocked || proposal.docs.length === 0) return false; + return proposal.docs.every(doc => !doc.participated || doc.cannotMove); + }, [proposal]); + + // Reset (§3.6): alle plafonds en pins terug, de schakelaar ONGEMOEID. + const onReset = () => { + if (!tune) return; + setUI({ levelingDistribution: { ...tune, pinned: {}, ceilings: {} } }); + }; +``` + +Werk de imports bovenaan bij: + +```tsx +import { useCallback, useMemo } from 'react'; +``` + +en voeg toe: + +```tsx +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; +import { STRIP } from './stripGeometry'; +``` + +(`useRef`/`useState` mogen weg als niets anders ze nog gebruikt — de typecheck met +`noUnusedLocals` vertelt je dat.) + +- [ ] **Step 4: De validatiestrook** + +Zet dit blok direct ná `applyGate` (hij leest `applyGate.reason`): + +```tsx + /** + * De VALIDATIESTROOK (spec §3.5/§7). Altijd gerenderd, altijd één regel hoog — ook wanneer er + * niets te melden valt: een strook die verschijnt en verdwijnt verschuift alles eronder, en dat + * is precies het flitsen dat §7 verbiedt. De volgorde is die van dringendheid: een voorstel dat + * niet meer bij de documenten hoort gaat vóór alles, dan "alles staat vast", dan het tekort, en + * pas als niets daarvan speelt de groene uitkomst. + */ + const statusLine = useMemo<{ tone: 'ok' | 'bad' | 'neutral'; text: string; stale: boolean }>(() => { + if (busy) return { tone: 'neutral', text: t('resource.distribution.compute.busy'), stale: false }; + if (staleReason !== null) { + return { + tone: 'neutral', + text: `${t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs })}` + + (degraded || staleReason === 'edited' ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''), + stale: true, + }; + } + if (!proposal || proposal.blocked) return { tone: 'neutral', text: '', stale: false }; + if (allPinned) return { tone: 'bad', text: t('resource.distribution.status.allPinned'), stale: false }; + if (proposal.hasShortfall) { + return { + tone: 'bad', + text: t('resource.distribution.status.shortfall', { + count: shortfallDays.length, + days: shortfallDays.slice(0, 3).map(formatDay).join(', '), + }), + stale: false, + }; + } + const worst = proposal.docs.reduce( + (best, doc) => (doc.endShiftWorkdays > best.endShiftWorkdays ? doc : best), + proposal.docs[0] ?? { endShiftWorkdays: 0, title: '' }, + ); + return { + tone: 'ok', + text: t('resource.distribution.status.resolved', { + // Hergebruikt de BESTAANDE `strip.endShift`-meervoudfamilie, die al in alle veertien + // locales met de juiste CLDR-categorieën staat — zo hoeft er voor deze zin geen vijftiende + // meervoudfamilie bij. + shift: worst.endShiftWorkdays === 0 + ? t('resource.distribution.strip.endUnchanged') + : t('resource.distribution.strip.endShift', { count: worst.endShiftWorkdays }), + doc: worst.title, + }), + stale: false, + }; + }, [busy, staleReason, staleDocs, degraded, proposal, allPinned, shortfallDays, formatDay, t]); +``` + +- [ ] **Step 5: De nieuwe JSX-body** + +Vervang de hele ``-aanroep-opening door (let op de nieuwe `alignTop`): + +```tsx + +``` + +Vervang de `{/* (3) Gereedschap … */}`-sectie door: + +```tsx + {/* (2) GEREEDSCHAP — de schakelaar met het VERSCHIL-prijskaartje (§2.2/§3.2). Het + prijsvak heeft een vaste breedte: "Bezig…" mag de schakelaar er niet naast + wegduwen (§7). */} +
+
+ + + {t('resource.distribution.tool.allowSplits')} + + + {savingsLabel()} + +
+ {t('resource.distribution.tool.allowSplitsHint')} +
+``` + +Vervang de `{/* (5) Fasestroken … */}`- en `{/* (6) Voor/na-histogram … */}`-secties samen door één +blok: + +```tsx + {/* (3)(4) DE BALKEN EN DE UITKOMST, in ÉÉN horizontale scroll-container zodat ze + kolom-op-kolom uitgelijnd blijven — ook tijdens het scrollen (spec §3.4). */} +
+
+ {(stripView?.docs ?? []).map(doc => { + const recorded = doc.pinnedReason === 'dates-as-recorded'; + const isPinned = recorded || tune.pinned[doc.docId] === true; + const own = bookingByDoc.get(doc.docId) ?? {}; + // Een gepind document zit ZELF in de vaste last (dat is wat pinnen betekent); + // zonder deze aftrek zou zijn boeking twee keer in dezelfde band staan. + const fixed = isPinned + ? Object.fromEntries(Object.entries(proposal?.fixedLoadByDay ?? {}).map( + ([iso, units]) => [iso, Math.max(0, units - (own[iso] ?? 0))])) + : (proposal?.fixedLoadByDay ?? {}); + return ( + true)} + color={docColors.get(doc.docId) ?? 'var(--theme-accent)'} + slackWorkdays={slackByDoc.get(doc.docId) ?? null} + endShiftWorkdays={doc.endShiftWorkdays} + ceiling={tune.ceilings[doc.docId] ?? null} + pinned={isPinned} + recorded={recorded} + cannotMove={doc.cannotMove} + liveCommit={!degraded} + busy={busy} + formatDay={formatDay} + onTogglePin={() => setPinned(doc.docId, tune.pinned[doc.docId] !== true)} + onCeilingChange={next => setCeiling(doc.docId, next)} + /> + ); + })} + + {/* De legenda-regel (§4), ingesprongen tot waar de tracks beginnen. */} +
+ {t('resource.distribution.strip.legend')} +
+ + {/* De einddatum-badges per project, boven het histogram (§3.4). */} +
+ {(stripView?.docs ?? []).map(doc => ( + + + + {`${doc.title}: ${doc.projectEndAfter ? formatDay(doc.projectEndAfter) : '—'}`} + + + ))} +
+ + {/* Het histogram Nu/Na op DEZELFDE as-instantie, met dezelfde marges als de tracks — + daardoor staan de kolommen per constructie onder de dagblokjes. */} +
+ {poolItem ? ( + ({ docId: doc.docId, title: doc.title }))} + docColors={docColors} + bookingByDay={proposal?.bookingByDay ?? {}} + afterLoadByDay={proposal?.afterLoadByDay ?? {}} + afterIncomplete={proposal?.afterIncomplete ?? false} + shortfallDocs={shortfallDocs} + /> + ) : ( + + {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} + + )} +
+
+
+``` + +- [ ] **Step 6: De validatiestrook + de knoppenbalk** + +Vervang, in het onderste blok (`
`), alles tussen +`<>` en `` van de niet-kiezer-tak door: + +```tsx + {/* (5) VALIDATIESTROOK — `role="status"`, ALTIJD gerenderd, VASTE hoogte (§3.5/§7). Leeg = + onzichtbare tekst, geen weggehaald blok: alleen zó verschuift er nooit iets. */} +
+ {statusLine.stale ? ( + + {statusLine.text} + + ) : ( + {statusLine.text} + )} + + {applyGate.reason} + +
+ + {/* De terugweg na Toepassen (§3.6, bestaand gedrag). Permanent zolang het record geldig is: + het meldingenkanaal kent geen actieknoppen en ruimt een `info` na 5 s op. */} + {applied && ( +
+ + {t('resource.distribution.applied', { count: applied.docs.length })} + + +
+ )} + +
+ + {/* Reset (§3.6): alle plafonds en pins terug naar neutraal, de schakelaar ONGEMOEID — + die is een gereedschapskeuze, geen per-project-bijstelling. */} + + + + + +
+``` + +De degradatiemelding blijft bestaan (§5 "de degradatiemelding blijft"), maar krijgt een +**gereserveerde** plek: zet direct boven de validatiestrook + +```tsx +
+ {degraded ? ( + {t('resource.distribution.compute.degraded')} + ) : null} +
+``` + +- [ ] **Step 7: `BeforeAfterChart` gebruikt één kleurtoewijzing** + +In `BeforeAfterChart.tsx`: verwijder `import { DOC_PALETTE } from '@/utils/documents';` en vervang + +```tsx + fill={docColors.get(b.docId) ?? DOC_PALETTE[0]} +``` + +door + +```tsx + // Eén kleurtoewijzing voor de hele dialoog (spec §8): de map komt van + // `assignDocColors` in `DistributionDialog` en dekt per constructie élk document dat + // hier een staaf krijgt. Een eigen terugval zou een tweede kleurbron zijn en precies + // de mismatch tussen balk en histogram kunnen terugbrengen die B9 opleverde. + fill={docColors.get(b.docId)} +``` + +- [ ] **Step 8: Typecheck + lint** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run typecheck && npm run lint; echo "exit: $?" +``` + +Verwacht: **exit 0**. Krijg je `noUnusedLocals`-fouten, dan staat er nog dode rangorde-code — dat is +de compiler die stap 2 voor je afmaakt. De i18n-sleutels bestaan nog niet; dat geeft geen +typefout (i18next-sleutels zijn strings) maar wél zichtbare Engelse fallback in de app tot taak 5. + +- [ ] **Step 9: Commit** + +```bash +git add src/components/common/Dialog.tsx \ + src/components/dialogs/DistributionDialog/DistributionDialog.tsx \ + src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx +git commit -m "$(cat <<'EOF' +feat(b1c): verdeeldialoog zonder rangordelijst, met validatiestrook en Reset + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 5: i18n — veertien locales, en de dode sleutels eruit + +**Files:** +- Modify: `src/i18n/locales/{nl,en,fr,de,es,zh,it,pt,pl,tr,ar,ja,ko,fa}/common.json` + +### Wat je moet weten over de meervoudsregels van dit project + +Een ontbrekende meervoudsvorm valt in i18next **niet** terug op `_other` maar op het **Engels** — een +gat is dus zichtbare taalvervuiling, geen cosmetiek. `npm run verify:i18n` eist per locale precies de +CLDR-categorieën van die taal: + +| locale | categorieën voor een `{{count}}`-familie | +|---|---| +| `nl`, `en`, `de`, `tr`, `fa` | `_one`, `_other` | +| `fr`, `es`, `it`, `pt` | `_one`, `_many`, `_other` — en `_many` is in dit project gelijk aan `_other` (zie CLAUDE.md: `{{count}}` staat hier altijd in cijfers) | +| `pl` | `_one`, `_few`, `_many`, `_other` | +| `ar` | `_zero`, `_one`, `_two`, `_few`, `_many`, `_other` | +| `zh`, `ja`, `ko` | alleen `_other` | + +Er komen **drie** meervoudsfamilies bij: `tool.savesOff`, `tool.savesOn` en `status.shortfall`. + +**KEUZE VAN DIT PLAN — `status.resolved` en `strip.used` krijgen géén eigen meervoudsfamilie.** +`status.resolved` zet het getal niet zelf neer maar krijgt een al vertaalde `{{shift}}`-zin binnen, +opgebouwd uit de **bestaande** `strip.endShift`-familie (die in alle veertien locales al met de juiste +categorieën staat). `strip.used` gebruikt bewust `{{used}}` in plaats van `{{count}}`, zodat i18next +er geen meervoudsfamilie van maakt voor een getal dat naast een zelfstandig naamwoord staat dat niet +verbuigt ("benut 2"). Zo blijven het er drie in plaats van vijf, zonder dat er ergens een verkeerde +vorm op het scherm komt. `status.shortfall` is wél een familie: daar staat "ploegdag(en)" in de zin, +en spec §8 noemt hem niet expliciet maar §3.5 schrijft de tekst met een telwoord voor. + +- [ ] **Step 1: De Nederlandse bron** + +In `src/i18n/locales/nl/common.json`, onder `resource.distribution`: + +**Vervang** `"intro"` door: + +```json +"intro": "Trek het einde van een balk naar rechts om dat project te laten uitlopen; zet een project vast als het niet mag bewegen. Toepassen schrijft de verschuivingen in alle betrokken projecten.", +``` + +**Verwijder** het hele `"rank"`-object en uit `"tool"` de sleutels `price_one`, `price_other`, +`priceNone`, `priceOff`, `priceOn`, `priceSame` (**`priceUnknown` blijft**). Verwijder uit `"help"` +de sleutels `dragHandle`, `rank`, `rankCost`. Verwijder uit `"strip"` de sleutels `pin`, `pinned`, +`fixedLoad`, `ceiling`, `requestedVsAchievable`. + +**Voeg toe** aan `"tool"`: + +```json +"savesOff_one": "zou {{count}} werkdag besparen", +"savesOff_other": "zou {{count}} werkdagen besparen", +"savesOn_one": "bespaart {{count}} werkdag", +"savesOn_other": "bespaart {{count}} werkdagen", +"savesNoneOff": "zou niets besparen", +"savesNoneOn": "bespaart niets" +``` + +**Voeg toe** aan `"strip"`: + +```json +"legend": "Volle blokjes = werkdagen · gearceerd = pauzedag · meetlat: grijs = speling, rood = einddatum-verschuiving · gestippeld = toegestaan maar niet benut", +"slack": "speling {{days}} dagen", +"pinButton": "vastzetten", +"unpinButton": "vast — losmaken", +"maxDate": "max {{date}}", +"used": "benut {{used}}" +``` + +**Voeg toe** aan `"help"`: + +```json +"reset": "Zet alle plafonds en vastzettingen terug op neutraal. De schakelaar \"Onderbrekingen toestaan\" blijft staan." +``` + +**Voeg toe** als nieuw object onder `resource.distribution`, plus één losse sleutel: + +```json +"status": { + "resolved": "Conflict opgelost — grootste einddatum-verschuiving: {{shift}} ({{doc}})", + "shortfall_one": "Nog {{count}} ploegdag tekort op {{days}}…", + "shortfall_other": "Nog {{count}} ploegdagen tekort op {{days}}…", + "allPinned": "Alle projecten staan vast — er is niets te herverdelen. Maak er één los." +}, +"reset": "Reset" +``` + +- [ ] **Step 2: Engels** + +Dezelfde verwijderingen; toevoegingen: + +```json +"savesOff_one": "would save {{count}} workday", +"savesOff_other": "would save {{count}} workdays", +"savesOn_one": "saves {{count}} workday", +"savesOn_other": "saves {{count}} workdays", +"savesNoneOff": "would save nothing", +"savesNoneOn": "saves nothing" +``` +```json +"legend": "Solid blocks = workdays · hatched = pause day · gauge: grey = slack, red = end-date shift · dotted = allowed but not used", +"slack": "slack {{days}} days", +"pinButton": "pin", +"unpinButton": "pinned — unpin", +"maxDate": "max {{date}}", +"used": "used {{used}}" +``` +```json +"reset": "Puts all ceilings and pins back to neutral. The \"Allow interruptions\" switch stays as it is." +``` +```json +"status": { + "resolved": "Conflict resolved — largest end-date shift: {{shift}} ({{doc}})", + "shortfall_one": "Still {{count}} crew day short on {{days}}…", + "shortfall_other": "Still {{count}} crew days short on {{days}}…", + "allPinned": "Every project is pinned — there is nothing to redistribute. Unpin one." +}, +"reset": "Reset", +"intro": "Drag the end of a bar to the right to let that project run on; pin a project that must not move. Apply writes the shifts into every project involved." +``` + +- [ ] **Step 3: De overige twaalf locales** + +Zelfde verwijderingen, en per locale exact dit. De sleutelnamen zijn overal identiek; alleen de +meervoudssuffixen verschillen volgens de tabel bovenaan deze taak. + +**`de`** (`_one`/`_other`) +```json +"intro": "Ziehen Sie das Ende eines Balkens nach rechts, damit dieses Projekt überlaufen darf; fixieren Sie ein Projekt, das sich nicht bewegen darf. Übernehmen schreibt die Verschiebungen in alle beteiligten Projekte.", +"reset": "Zurücksetzen", +"tool": { "savesOff_one": "würde {{count}} Arbeitstag sparen", "savesOff_other": "würde {{count}} Arbeitstage sparen", "savesOn_one": "spart {{count}} Arbeitstag", "savesOn_other": "spart {{count}} Arbeitstage", "savesNoneOff": "würde nichts sparen", "savesNoneOn": "spart nichts" }, +"strip": { "legend": "Volle Blöcke = Arbeitstage · schraffiert = Pausentag · Messlatte: grau = Puffer, rot = Endterminverschiebung · gepunktet = erlaubt, aber nicht genutzt", "slack": "Puffer {{days}} Tage", "pinButton": "fixieren", "unpinButton": "fixiert — lösen", "maxDate": "max. {{date}}", "used": "genutzt {{used}}" }, +"help": { "reset": "Setzt alle Obergrenzen und Fixierungen auf neutral zurück. Der Schalter „Unterbrechungen zulassen“ bleibt unverändert." }, +"status": { "resolved": "Konflikt gelöst — größte Endterminverschiebung: {{shift}} ({{doc}})", "shortfall_one": "Es fehlt noch {{count}} Schichttag am {{days}}…", "shortfall_other": "Es fehlen noch {{count}} Schichttage am {{days}}…", "allPinned": "Alle Projekte sind fixiert — es gibt nichts umzuverteilen. Lösen Sie eines." } +``` + +**`fr`** (`_one`/`_many`/`_other`) +```json +"intro": "Faites glisser la fin d'une barre vers la droite pour laisser ce projet déborder ; épinglez un projet qui ne doit pas bouger. Appliquer écrit les décalages dans tous les projets concernés.", +"reset": "Réinitialiser", +"tool": { "savesOff_one": "économiserait {{count}} jour ouvré", "savesOff_many": "économiserait {{count}} jours ouvrés", "savesOff_other": "économiserait {{count}} jours ouvrés", "savesOn_one": "économise {{count}} jour ouvré", "savesOn_many": "économise {{count}} jours ouvrés", "savesOn_other": "économise {{count}} jours ouvrés", "savesNoneOff": "n'économiserait rien", "savesNoneOn": "n'économise rien" }, +"strip": { "legend": "Blocs pleins = jours ouvrés · hachuré = jour de pause · règle : gris = marge, rouge = décalage de la date de fin · pointillé = autorisé mais non utilisé", "slack": "marge {{days}} jours", "pinButton": "épingler", "unpinButton": "épinglé — détacher", "maxDate": "max {{date}}", "used": "utilisé {{used}}" }, +"help": { "reset": "Remet tous les plafonds et épinglages à l'état neutre. L'interrupteur « Autoriser les interruptions » reste tel quel." }, +"status": { "resolved": "Conflit résolu — plus grand décalage de fin : {{shift}} ({{doc}})", "shortfall_one": "Il manque encore {{count}} jour-équipe le {{days}}…", "shortfall_many": "Il manque encore {{count}} jours-équipe les {{days}}…", "shortfall_other": "Il manque encore {{count}} jours-équipe les {{days}}…", "allPinned": "Tous les projets sont épinglés — il n'y a rien à redistribuer. Détachez-en un." } +``` + +**`es`** (`_one`/`_many`/`_other`) +```json +"intro": "Arrastra el final de una barra hacia la derecha para dejar que ese proyecto se prolongue; fija un proyecto que no deba moverse. Aplicar escribe los desplazamientos en todos los proyectos implicados.", +"reset": "Restablecer", +"tool": { "savesOff_one": "ahorraría {{count}} día laborable", "savesOff_many": "ahorraría {{count}} días laborables", "savesOff_other": "ahorraría {{count}} días laborables", "savesOn_one": "ahorra {{count}} día laborable", "savesOn_many": "ahorra {{count}} días laborables", "savesOn_other": "ahorra {{count}} días laborables", "savesNoneOff": "no ahorraría nada", "savesNoneOn": "no ahorra nada" }, +"strip": { "legend": "Bloques sólidos = días laborables · rayado = día de pausa · regla: gris = holgura, rojo = desplazamiento de la fecha de fin · punteado = permitido pero no usado", "slack": "holgura {{days}} días", "pinButton": "fijar", "unpinButton": "fijado — soltar", "maxDate": "máx. {{date}}", "used": "usado {{used}}" }, +"help": { "reset": "Devuelve todos los topes y fijaciones al estado neutro. El interruptor «Permitir interrupciones» no cambia." }, +"status": { "resolved": "Conflicto resuelto — mayor desplazamiento de fin: {{shift}} ({{doc}})", "shortfall_one": "Todavía falta {{count}} día-cuadrilla el {{days}}…", "shortfall_many": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", "shortfall_other": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", "allPinned": "Todos los proyectos están fijados: no hay nada que redistribuir. Suelta uno." } +``` + +**`it`** (`_one`/`_many`/`_other`) +```json +"intro": "Trascina la fine di una barra verso destra per lasciare che quel progetto sfori; blocca un progetto che non deve muoversi. Applica scrive gli slittamenti in tutti i progetti coinvolti.", +"reset": "Reimposta", +"tool": { "savesOff_one": "risparmierebbe {{count}} giorno lavorativo", "savesOff_many": "risparmierebbe {{count}} giorni lavorativi", "savesOff_other": "risparmierebbe {{count}} giorni lavorativi", "savesOn_one": "risparmia {{count}} giorno lavorativo", "savesOn_many": "risparmia {{count}} giorni lavorativi", "savesOn_other": "risparmia {{count}} giorni lavorativi", "savesNoneOff": "non risparmierebbe nulla", "savesNoneOn": "non risparmia nulla" }, +"strip": { "legend": "Blocchi pieni = giorni lavorativi · tratteggio = giorno di pausa · righello: grigio = scorrimento, rosso = slittamento della data di fine · punteggiato = consentito ma non usato", "slack": "scorrimento {{days}} giorni", "pinButton": "blocca", "unpinButton": "bloccato — sblocca", "maxDate": "max {{date}}", "used": "usato {{used}}" }, +"help": { "reset": "Riporta tutti i tetti e i blocchi allo stato neutro. L'interruttore «Consenti interruzioni» resta invariato." }, +"status": { "resolved": "Conflitto risolto — slittamento di fine maggiore: {{shift}} ({{doc}})", "shortfall_one": "Manca ancora {{count}} giorno-squadra il {{days}}…", "shortfall_many": "Mancano ancora {{count}} giorni-squadra il {{days}}…", "shortfall_other": "Mancano ancora {{count}} giorni-squadra il {{days}}…", "allPinned": "Tutti i progetti sono bloccati: non c'è nulla da ridistribuire. Sbloccane uno." } +``` + +**`pt`** (`_one`/`_many`/`_other`) +```json +"intro": "Arraste o fim de uma barra para a direita para deixar esse projeto estender-se; fixe um projeto que não pode mover-se. Aplicar escreve os deslocamentos em todos os projetos envolvidos.", +"reset": "Repor", +"tool": { "savesOff_one": "pouparia {{count}} dia útil", "savesOff_many": "pouparia {{count}} dias úteis", "savesOff_other": "pouparia {{count}} dias úteis", "savesOn_one": "poupa {{count}} dia útil", "savesOn_many": "poupa {{count}} dias úteis", "savesOn_other": "poupa {{count}} dias úteis", "savesNoneOff": "não pouparia nada", "savesNoneOn": "não poupa nada" }, +"strip": { "legend": "Blocos cheios = dias úteis · tracejado = dia de pausa · régua: cinzento = folga, vermelho = deslocamento da data de fim · pontilhado = permitido mas não usado", "slack": "folga {{days}} dias", "pinButton": "fixar", "unpinButton": "fixado — soltar", "maxDate": "máx. {{date}}", "used": "usado {{used}}" }, +"help": { "reset": "Repõe todos os limites e fixações no estado neutro. O interruptor «Permitir interrupções» fica como está." }, +"status": { "resolved": "Conflito resolvido — maior deslocamento de fim: {{shift}} ({{doc}})", "shortfall_one": "Ainda falta {{count}} dia-equipa em {{days}}…", "shortfall_many": "Ainda faltam {{count}} dias-equipa em {{days}}…", "shortfall_other": "Ainda faltam {{count}} dias-equipa em {{days}}…", "allPinned": "Todos os projetos estão fixados — não há nada para redistribuir. Solte um." } +``` + +**`pl`** (`_one`/`_few`/`_many`/`_other`) +```json +"intro": "Przeciągnij koniec paska w prawo, aby ten projekt mógł się przedłużyć; przypnij projekt, który nie może się przesuwać. Zastosuj zapisuje przesunięcia we wszystkich powiązanych projektach.", +"reset": "Resetuj", +"tool": { "savesOff_one": "zaoszczędziłoby {{count}} dzień roboczy", "savesOff_few": "zaoszczędziłoby {{count}} dni robocze", "savesOff_many": "zaoszczędziłoby {{count}} dni roboczych", "savesOff_other": "zaoszczędziłoby {{count}} dnia roboczego", "savesOn_one": "oszczędza {{count}} dzień roboczy", "savesOn_few": "oszczędza {{count}} dni robocze", "savesOn_many": "oszczędza {{count}} dni roboczych", "savesOn_other": "oszczędza {{count}} dnia roboczego", "savesNoneOff": "nic by nie zaoszczędziło", "savesNoneOn": "nic nie oszczędza" }, +"strip": { "legend": "Pełne bloki = dni robocze · kreskowanie = dzień przerwy · miarka: szary = zapas, czerwony = przesunięcie daty zakończenia · kropkowany = dozwolone, ale niewykorzystane", "slack": "zapas {{days}} dni", "pinButton": "przypnij", "unpinButton": "przypięty — odepnij", "maxDate": "maks. {{date}}", "used": "wykorzystano {{used}}" }, +"help": { "reset": "Przywraca wszystkie limity i przypięcia do stanu neutralnego. Przełącznik „Zezwalaj na przerwy” pozostaje bez zmian." }, +"status": { "resolved": "Konflikt rozwiązany — największe przesunięcie zakończenia: {{shift}} ({{doc}})", "shortfall_one": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", "shortfall_few": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", "shortfall_many": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", "shortfall_other": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", "allPinned": "Wszystkie projekty są przypięte — nie ma czego rozdzielać. Odepnij jeden." } +``` + +**`tr`** (`_one`/`_other`) +```json +"intro": "Bir projenin taşmasına izin vermek için çubuğun ucunu sağa sürükleyin; hareket etmemesi gereken projeyi sabitleyin. Uygula, kaymaları ilgili tüm projelere yazar.", +"reset": "Sıfırla", +"tool": { "savesOff_one": "{{count}} iş günü kazandırırdı", "savesOff_other": "{{count}} iş günü kazandırırdı", "savesOn_one": "{{count}} iş günü kazandırıyor", "savesOn_other": "{{count}} iş günü kazandırıyor", "savesNoneOff": "hiçbir şey kazandırmazdı", "savesNoneOn": "hiçbir şey kazandırmıyor" }, +"strip": { "legend": "Dolu bloklar = iş günleri · taralı = mola günü · cetvel: gri = bolluk, kırmızı = bitiş tarihi kayması · noktalı = izin verildi ama kullanılmadı", "slack": "bolluk {{days}} gün", "pinButton": "sabitle", "unpinButton": "sabit — çöz", "maxDate": "en geç {{date}}", "used": "kullanılan {{used}}" }, +"help": { "reset": "Tüm tavanları ve sabitlemeleri nötr duruma döndürür. \"Kesintilere izin ver\" anahtarı olduğu gibi kalır." }, +"status": { "resolved": "Çakışma çözüldü — en büyük bitiş kayması: {{shift}} ({{doc}})", "shortfall_one": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", "shortfall_other": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", "allPinned": "Tüm projeler sabit — yeniden dağıtılacak bir şey yok. Birini çözün." } +``` + +**`ar`** (`_zero`/`_one`/`_two`/`_few`/`_many`/`_other`, RTL) +```json +"intro": "اسحب نهاية الشريط إلى اليمين للسماح لهذا المشروع بالتمدد؛ ثبّت المشروع الذي لا يجوز تحريكه. يكتب «تطبيق» الإزاحات في جميع المشاريع المعنية.", +"reset": "إعادة ضبط", +"tool": { "savesOff_zero": "لن يوفّر شيئًا", "savesOff_one": "سيوفّر يوم عمل واحدًا", "savesOff_two": "سيوفّر يومَي عمل", "savesOff_few": "سيوفّر {{count}} أيام عمل", "savesOff_many": "سيوفّر {{count}} يوم عمل", "savesOff_other": "سيوفّر {{count}} يوم عمل", "savesOn_zero": "لا يوفّر شيئًا", "savesOn_one": "يوفّر يوم عمل واحدًا", "savesOn_two": "يوفّر يومَي عمل", "savesOn_few": "يوفّر {{count}} أيام عمل", "savesOn_many": "يوفّر {{count}} يوم عمل", "savesOn_other": "يوفّر {{count}} يوم عمل", "savesNoneOff": "لن يوفّر شيئًا", "savesNoneOn": "لا يوفّر شيئًا" }, +"strip": { "legend": "المربعات الممتلئة = أيام العمل · المظلّلة = يوم توقّف · المسطرة: رمادي = المرونة، أحمر = إزاحة تاريخ الانتهاء · المنقّطة = مسموح لكنه غير مستخدم", "slack": "مرونة {{days}} يوم", "pinButton": "تثبيت", "unpinButton": "مثبّت — فكّ التثبيت", "maxDate": "بحد أقصى {{date}}", "used": "المستخدم {{used}}" }, +"help": { "reset": "يعيد جميع الحدود والتثبيتات إلى الوضع المحايد. يبقى مفتاح «السماح بالانقطاعات» كما هو." }, +"status": { "resolved": "تم حل التعارض — أكبر إزاحة للانتهاء: {{shift}} ({{doc}})", "shortfall_zero": "لا يوجد نقص", "shortfall_one": "ما زال ينقص يوم فريق واحد في {{days}}…", "shortfall_two": "ما زال ينقص يوما فريق في {{days}}…", "shortfall_few": "ما زال ينقص {{count}} أيام فريق في {{days}}…", "shortfall_many": "ما زال ينقص {{count}} يوم فريق في {{days}}…", "shortfall_other": "ما زال ينقص {{count}} يوم فريق في {{days}}…", "allPinned": "جميع المشاريع مثبّتة — لا يوجد ما يُعاد توزيعه. فُكّ تثبيت أحدها." } +``` + +**`fa`** (`_one`/`_other`, RTL) +```json +"intro": "انتهای نوار را به راست بکشید تا آن پروژه تمدید شود؛ پروژه‌ای را که نباید جابه‌جا شود سنجاق کنید. «اعمال» جابه‌جایی‌ها را در همهٔ پروژه‌های مرتبط می‌نویسد.", +"reset": "بازنشانی", +"tool": { "savesOff_one": "{{count}} روز کاری صرفه‌جویی می‌کرد", "savesOff_other": "{{count}} روز کاری صرفه‌جویی می‌کرد", "savesOn_one": "{{count}} روز کاری صرفه‌جویی می‌کند", "savesOn_other": "{{count}} روز کاری صرفه‌جویی می‌کند", "savesNoneOff": "چیزی صرفه‌جویی نمی‌کرد", "savesNoneOn": "چیزی صرفه‌جویی نمی‌کند" }, +"strip": { "legend": "بلوک‌های توپر = روزهای کاری · هاشور = روز توقف · خط‌کش: خاکستری = شناوری، قرمز = جابه‌جایی تاریخ پایان · نقطه‌چین = مجاز اما استفاده‌نشده", "slack": "شناوری {{days}} روز", "pinButton": "سنجاق", "unpinButton": "سنجاق‌شده — باز کردن", "maxDate": "حداکثر {{date}}", "used": "استفاده‌شده {{used}}" }, +"help": { "reset": "همهٔ سقف‌ها و سنجاق‌ها را به حالت خنثی برمی‌گرداند. کلید «اجازهٔ وقفه» بدون تغییر می‌ماند." }, +"status": { "resolved": "تعارض حل شد — بیشترین جابه‌جایی پایان: {{shift}} ({{doc}})", "shortfall_one": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", "shortfall_other": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", "allPinned": "همهٔ پروژه‌ها سنجاق شده‌اند — چیزی برای توزیع مجدد نیست. یکی را باز کنید." } +``` + +**`zh`** (alleen `_other`) +```json +"intro": "向右拖动条形的末端,让该项目延期;将不能移动的项目固定。应用会把偏移写入所有相关项目。", +"reset": "重置", +"tool": { "savesOff_other": "可节省 {{count}} 个工作日", "savesOn_other": "节省 {{count}} 个工作日", "savesNoneOff": "不会节省时间", "savesNoneOn": "没有节省" }, +"strip": { "legend": "实心方块=工作日 · 斜纹=暂停日 · 标尺:灰色=浮时,红色=完工日期偏移 · 虚线=已允许但未使用", "slack": "浮时 {{days}} 天", "pinButton": "固定", "unpinButton": "已固定 — 解除", "maxDate": "最迟 {{date}}", "used": "已用 {{used}}" }, +"help": { "reset": "将所有上限和固定恢复为中立状态。「允许中断」开关保持不变。" }, +"status": { "resolved": "冲突已解决 — 最大完工日期偏移:{{shift}}({{doc}})", "shortfall_other": "{{days}} 仍缺 {{count}} 个班组日…", "allPinned": "所有项目都已固定 — 没有可重新分配的内容。请解除其中一个。" } +``` + +**`ja`** (alleen `_other`) +```json +"intro": "バーの末尾を右へドラッグすると、そのプロジェクトを延長できます。動かしたくないプロジェクトは固定してください。「適用」は関連するすべてのプロジェクトにずれを書き込みます。", +"reset": "リセット", +"tool": { "savesOff_other": "{{count}} 稼働日を節約できます", "savesOn_other": "{{count}} 稼働日を節約しています", "savesNoneOff": "何も節約できません", "savesNoneOn": "節約はありません" }, +"strip": { "legend": "塗りつぶしのブロック=稼働日 · 斜線=休止日 · 目盛り:グレー=余裕、赤=終了日のずれ · 点線=許可したが未使用", "slack": "余裕 {{days}} 日", "pinButton": "固定", "unpinButton": "固定中 — 解除", "maxDate": "最大 {{date}}", "used": "使用 {{used}}" }, +"help": { "reset": "すべての上限と固定を中立に戻します。「中断を許可」スイッチはそのままです。" }, +"status": { "resolved": "競合を解消しました — 最大の終了日のずれ:{{shift}}({{doc}})", "shortfall_other": "{{days}} に {{count}} 班日ぶん不足しています…", "allPinned": "すべてのプロジェクトが固定されています。再配分するものがありません。1 つ解除してください。" } +``` + +**`ko`** (alleen `_other`) +```json +"intro": "막대 끝을 오른쪽으로 끌면 해당 프로젝트를 연장할 수 있습니다. 움직이면 안 되는 프로젝트는 고정하세요. [적용]은 관련된 모든 프로젝트에 이동을 기록합니다.", +"reset": "초기화", +"tool": { "savesOff_other": "작업일 {{count}}일을 절약할 수 있습니다", "savesOn_other": "작업일 {{count}}일을 절약합니다", "savesNoneOff": "절약할 수 있는 시간이 없습니다", "savesNoneOn": "절약되지 않습니다" }, +"strip": { "legend": "채워진 블록 = 작업일 · 빗금 = 휴지일 · 눈금: 회색 = 여유, 빨강 = 종료일 이동 · 점선 = 허용했지만 사용하지 않음", "slack": "여유 {{days}}일", "pinButton": "고정", "unpinButton": "고정됨 — 해제", "maxDate": "최대 {{date}}", "used": "사용 {{used}}" }, +"help": { "reset": "모든 상한과 고정을 중립 상태로 되돌립니다. '중단 허용' 스위치는 그대로 유지됩니다." }, +"status": { "resolved": "충돌 해결 — 최대 종료일 이동: {{shift}} ({{doc}})", "shortfall_other": "{{days}}에 작업조 {{count}}일이 부족합니다…", "allPinned": "모든 프로젝트가 고정되어 재분배할 것이 없습니다. 하나를 해제하세요." } +``` + +De blokken hierboven zijn **fragmenten**: voeg de sleutels toe aan de al bestaande objecten +`tool`, `strip` en `help` van die locale, en zet `status`, `intro` en `reset` rechtstreeks onder +`resource.distribution`. Overschrijf de bestaande objecten niet in hun geheel. + +- [ ] **Step 4: Controleer dat er geen dode sleutel meer gebruikt wordt** + +```bash +grep -rn "distribution.rank\.\|tool\.price_\|tool\.priceNone\|tool\.priceOff\|tool\.priceOn\|tool\.priceSame\|help\.dragHandle\|help\.rankCost\|strip\.requestedVsAchievable\|strip\.fixedLoad" src/ tests/ ; echo "treffers: $?" +``` + +Verwacht: **geen enkele treffer** (`grep` exit 1). Is er wel één, dan is taak 4 niet af. + +- [ ] **Step 5: De i18n-poort** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run verify:i18n; echo "exit: $?" +``` + +Verwacht: **exit 0**. Meldt hij een ontbrekende categorie, kijk in de tabel bovenaan deze taak welke +suffixen die locale eist — een `_many` vergeten in `fr`/`es`/`it`/`pt` is de meest voorkomende fout. + +- [ ] **Step 6: Commit** + +```bash +git add src/i18n/locales +git commit -m "$(cat <<'EOF' +i18n(b1c): verschil-prijs, legenda en validatiestrook in veertien locales + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 6: de browsertests herschrijven + +**Files:** +- Modify: `tests/browser/leveling-distribution.spec.ts` + +De fixtures blijven ongewijzigd: `createLibrary`, `bookOnPoolItem`, `seedTwoConflictingDocuments`, +`seedUncountedDocument`, `seedTwoSingleDayDocuments`, `seedUnsolvableShortfall`, `makeScheduled`, +`openOccupancyWithRealClick`, `openDistributionFromConflictRow`, `openDistributionFromRibbon`, +`seedLargeDegradedProject`, `seedManySmallDocuments`, `delayedTaskCount`. Alleen de tests die de +**bediening** aanraken gaan op de schop. + +- [ ] **Step 1: Verwijder de vervallen test** + +Verwijder de hele test `'rangorde: slepen verandert de volgorde en laat het voorstel vervallen'` +inclusief zijn commentaarblok — de rangordelijst bestaat niet meer (spec §2.1). + +- [ ] **Step 2: Pas de pin-/toetsenbordtest aan** + +Vervang in `'fasestroken: pin en plafond zijn met het toetsenbord te bedienen'` de pin-locator + +```ts + const pin = strip.getByRole('button', { name: /Vastzetten|Pin/ }); +``` + +door + +```ts + // De pin is sinds het herontwerp een TEKSTknop ("vastzetten" ↔ "vast — losmaken", spec §6), geen + // icoon meer. Het testanker blijft `data-ops-distribution-pin`; de toestand blijft `aria-pressed`. + const pin = strip.locator('[data-ops-distribution-pin]'); +``` + +en vervang de laatste twee regels van die test + +```ts + await handle.press('End'); + await expect(handle).toHaveAttribute('aria-valuetext', /onbegrensd|unlimited/i); +``` + +door + +```ts + await handle.press('End'); + await expect(handle).toHaveAttribute('aria-valuetext', /onbegrensd|unlimited/i); + // Onbegrensd zet de handle aan het einde van de as (spec §5) — meetbaar: hij staat rechts van + // het laatste dagblokje van dezelfde rij. + const handleBox = (await handle.boundingBox())!; + const lastDay = (await strip.locator('[data-ops-distribution-day="work"]').last().boundingBox())!; + expect(handleBox.x).toBeGreaterThan(lastDay.x + lastDay.width); +``` + +- [ ] **Step 3: Vervang de "rekent pas bij loslaten"-test door de LIVE-sleeptest** + +Vervang de hele test `'plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten'` +door: + +```ts +test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // De rijen staan in plaatsingsvolgorde; nr. 2 is degene die moet wijken. Home = plafond 0, een + // deterministisch startpunt ongeacht de benutte uitloop. + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + await handle.focus(); + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) + .not.toContain('…'); + + // Geen magisch getal: de dagbreedte komt uit de gedeelde tijdas, zichtbaar via hetzelfde + // attribuut dat de sleeppositie zelf ook gebruikt. + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + expect(dayWidth).toBeGreaterThan(0); + + const effectBefore = await strip.locator('[data-ops-distribution-effect]').textContent(); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + + // Spec §5, het eigenaarsbesluit dat §3.4 van de oude spec bijstelt: tijdens het slepen wordt er + // ECHT gerekend. Zowel de waarde als het uitkomstlabel moeten VÓÓR `mouse.up()` meebewegen. + await page.mouse.move(box.x + box.width / 2 + 3 * dayWidth, box.y + box.height / 2, { steps: 6 }); + await expect(handle).toHaveAttribute('aria-valuenow', '3'); + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) + .not.toBe(effectBefore); + + // De muis verlaat het element en het slepen loopt door — `setPointerCapture` op de handle, geen + // document-brede listener nodig. + await page.mouse.move(box.x + box.width / 2 + 5 * dayWidth, box.y - 200, { steps: 4 }); + await expect(handle).toHaveAttribute('aria-valuenow', '5'); + await page.mouse.up(); + + const shiftedDocId = await strip.getAttribute('data-ops-doc-id') ?? ''; + await expect.poll(() => page.evaluate(docId => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return ui.ceilings[docId]; + }, shiftedDocId)).toBe(5); +}); +``` + +- [ ] **Step 4: De verschil-prijs** + +Vervang de test `'kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel'` door: + +```ts +test('het prijskaartje is één VERSCHIL en volgt de stand van de schakelaar', async ({ page, ops: _ops }) => { + await seedTwoConflictingDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // Eigenaarsbesluit 2026-09-12 (spec §2.2): geen twee bedragen naast elkaar meer, maar één + // verschil. Schakelaar UIT ⇒ een belofte ("zou … besparen"); AAN ⇒ een constatering ("bespaart + // …"). Nul is expliciet, niet leeg. + const price = page.locator('[data-ops-distribution-tool-price]'); + await expect.poll(() => price.textContent()) + .toMatch(/zou .*besparen|would save|zou niets besparen|would save nothing/i); + + await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ }).click(); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect.poll(() => price.textContent()) + .toMatch(/^(?!.*zou)(?=.*(bespaart|saves))/i); + + // Een externe bewerking laat het voorstel vervallen (§6a); een prijs bij een vervallen voorstel + // zou liegen, dus die gaat terug naar "prijs onbekend". + await page.evaluate(() => { + const s = window.__OPS__!.store.getState(); + const task = s.tasks[0]; + s.updateTask(task.id, { time: { ...task.time, scheduleDuration: 4 } }); + }); + await expect(price).toContainText(/prijs onbekend|price unknown/i); +}); +``` + +- [ ] **Step 5: Nieuw — de validatiestrook, Reset en de vaste-hoogte-belofte** + +Voeg deze drie tests toe, onder de bestaande taak-12-tests: + +```ts +test('de validatiestrook staat er altijd en meldt de uitkomst of het tekort', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + + // Spec §3.5/§7: ALTIJD gerenderd, ook wanneer alles goed is — en dan groen met de grootste + // einddatum-verschuiving erin. + const status = page.locator('[data-ops-distribution-status]'); + await expect(status).toBeVisible(); + await expect(status).toContainText(/Conflict opgelost|Conflict resolved/i); + + // Alle deelnemers vastzetten ⇒ er valt niets te herverdelen, en de strook zegt dat met een uitweg. + for (const pin of await page.locator('[data-ops-distribution-pin]').all()) await pin.click(); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(status).toContainText(/staan vast|is pinned/i); +}); + +test('een tekort kleurt de validatiestrook rood en houdt Toepassen tegen', async ({ page, ops: _ops }) => { + await seedUnsolvableShortfall(page); + await openDistributionFromConflictRow(page); + + const status = page.locator('[data-ops-distribution-status]'); + await expect(status).toContainText(/tekort|short/i); + await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); + await expect(page.locator('[data-ops-distribution-apply-reason]')).toContainText(/tekort|shortfall/i); +}); + +test('Reset zet plafonds en vastzettingen terug, en laat de schakelaar staan', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + + await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ }).click(); + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + await strip.locator('[data-ops-distribution-handle]').focus(); + await strip.locator('[data-ops-distribution-handle]').press('ArrowRight'); + await page.locator('[data-ops-distribution-pin]').first().click(); + await expect.poll(() => page.evaluate(() => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return Object.keys(ui.ceilings).length + Object.values(ui.pinned).filter(Boolean).length; + })).toBeGreaterThan(1); + + await page.locator('[data-ops-distribution-reset]').click(); + + // Spec §3.6: alle plafonds en pins terug, de SCHAKELAAR ongemoeid. + await expect.poll(() => page.evaluate(() => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return { + ceilings: Object.values(ui.ceilings).filter(v => v !== null).length, + pins: Object.values(ui.pinned).filter(Boolean).length, + splits: ui.allowSplits, + }; + })).toEqual({ ceilings: 0, pins: 0, splits: true }); +}); + +test('de dialoog flitst niet: dezelfde boundingBox over een herberekening en een sleep', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const dialog = page.locator('[data-ops-distribution-dialog]'); + const before = (await dialog.boundingBox())!; + + // (1) Een herberekening. De bezig-toestand mag zich alleen via kleur/opaciteit tonen — nooit door + // een blok toe te voegen of weg te halen (spec §7). + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + const during = (await dialog.boundingBox())!; + await expect(page.locator('[data-ops-distribution-status]')).toBeVisible(); + const after = (await dialog.boundingBox())!; + + // (2) Een sleep, inclusief de live herberekening die eronder loopt. + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 4 * dayWidth, box.y + box.height / 2, { steps: 8 }); + const dragging = (await dialog.boundingBox())!; + await page.mouse.up(); + const released = (await dialog.boundingBox())!; + + for (const [name, box2] of [['tijdens', during], ['erna', after], ['slepend', dragging], ['losgelaten', released]] as const) { + expect(box2.x, `x ${name}`).toBeCloseTo(before.x, 0); + expect(box2.y, `y ${name}`).toBeCloseTo(before.y, 0); + expect(box2.width, `breedte ${name}`).toBeCloseTo(before.width, 0); + expect(box2.height, `hoogte ${name}`).toBeCloseTo(before.height, 0); + } +}); +``` + +- [ ] **Step 6: De twee degradatietests** + +De kostenlabels bestaan niet meer, dus `[data-ops-distribution-cost]` bestaat niet meer. Vervang in +`'boven het documentenplafond rekent de dialoog alleen op de knop'`: + +```ts + await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); +``` + +door + +```ts + // Gedegradeerd ⇒ geen verschil-pas, dus geen prijs maar de expliciete route. + await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/prijs onbekend|price unknown/i); +``` + +En in `'boven de ondersteunde schaal rekent de dialoog alleen op de knop'` vervang de twee +`[data-ops-distribution-cost]`/`[data-ops-distribution-effect]`-asserties door: + +```ts + await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/prijs onbekend|price unknown/i); + + const handle = page.locator('[data-ops-distribution-strip]').first().locator('[data-ops-distribution-handle]'); + await handle.focus(); + await handle.press('ArrowRight'); + // Boven de schaal rekent een plafondwijziging NIET door (spec §5): de reden staat in de + // validatiestrook, met de expliciete route erbij. + await expect(page.locator('[data-ops-distribution-status]')).toContainText(/Herbereken|Recalculate/); +``` + +- [ ] **Step 7: Draai de spec** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +while pgrep -f "vite build|tsc --noEmit|playwright test|run.sh" >/dev/null; do sleep 20; done +npm run test:browser -- tests/browser/leveling-distribution.spec.ts; echo "exit: $?" +``` + +Verwacht: **exit 0**. Bij een val: `test-results/` heeft screenshots en traces, +`playwright-report/` het HTML-rapport. + +- [ ] **Step 8: Commit** + +```bash +git add tests/browser/leveling-distribution.spec.ts +git commit -m "$(cat <<'EOF' +test(b1c): browsertests voor de herontworpen verdeeldialoog + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 7: de gidsen en `docs/library.md` + +**Files:** +- Modify: `public/docs/nl/gids-verdelen-restcapaciteit.md` +- Modify: `public/docs/en/gids-verdelen-restcapaciteit.md` +- Modify: `docs/library.md` (alleen waar hij de rangordelijst noemt) + +De `manifest.json`-entry bestaat al (het artikel-id verandert niet). Blijf binnen de +**miniMarkdown-subset**: koppen `#`/`##`/`###`, paragrafen, enkelvoudige lijsten, `**vet**`, +`*cursief*`, `` `code` ``, codeblokken, afbeeldingen, en alleen `docs://`- en `examples://`-links. +Geen tabellen, geen blockquotes, geen h4, geen rauwe HTML. + +- [ ] **Step 1: Vervang in de NL-gids de sectie "Wie wordt het meest ontzien?"** + +Verwijder die hele sectie (kop + twee alinea's) en zet er dit voor in de plaats: + +```markdown +## De balk is de bediening + +Onder de schakelaar staat per project één balk op één gedeelde tijdas. Links de projectnaam met een +kleurstip en de speling die dat project nog heeft, in het midden de balk zelf, rechts de uitkomst. + +In de balk zie je per werkdag een apart blokje in de kleur van het project, met een dun wit lijntje +ertussen — zo kun je de dagen letterlijk tellen. Een **gearceerd** blokje is een pauzedag: een +werkdag waarop de verdeler het werk even stillegt om ruimte te maken voor een ander project. Die +verschijnen alleen als je "Onderbrekingen toestaan" aan hebt staan. + +Onderin de balk loopt een dunne meetlat mee. Het **grijs gestippelde** deel is de speling die dit +project nog had: zoveel mag het opschuiven zonder dat de einddatum meegaat. Het **massief rode** +deel is alles daarvoorbij — dát is echte einddatum-verschuiving. Een balk zonder rood kost het +project dus niets. + +Rechts van het laatste blokje staat een **greep** met drie streepjes. Trek die naar rechts om dit +project meer uitloop toe te staan; de verdeler bepaalt vervolgens zelf op welke dagen hij pauzeert +en hoeveel van die ruimte hij echt nodig heeft. Wat je toestond maar niet nodig bleek, staat als een +**gestippeld kadertje** achter de balk. Terwijl je sleept rekent de app mee: de andere balken, de +grafiek en de uitkomsten veranderen onder je hand. Op een heel groot overzicht doet hij dat niet — +daar volgt de berekening zodra je loslaat. + +Met het toetsenbord werkt dezelfde greep: pijltjes verzetten één werkdag, PageUp en PageDown drie, +Home zet het plafond op nul en End maakt het onbegrensd. + +Rechts van de balk staat de uitkomst: een gekleurde pil met wat er met de einddatum gebeurt (groen +bij nul, amber bij een dag of twee, rood daarboven), en daaronder de uiterste datum die je toestond +plus hoeveel dagen daarvan echt benut zijn. +``` + +- [ ] **Step 2: Vervang in de NL-gids de sectie "Vastzetten of een plafond"** + +Vervang de laatste alinea van die sectie (die begint met "Het plafond is een sleepbare handle op de +fasestrook") door: + +```markdown +Vastzetten doe je met de tekstknop **vastzetten** links bij de projectnaam; hij verandert dan in +**vast — losmaken**. Het plafond zet je met de greep in de balk, zoals hierboven beschreven. Met de +knop **Reset** onderin zet je in één keer alle plafonds en vastzettingen terug op neutraal; de +schakelaar "Onderbrekingen toestaan" blijft daarbij staan, want dat is een keuze over het +gereedschap en niet over één project. +``` + +- [ ] **Step 3: Vervang in de NL-gids de sectie "Onderbrekingen toestaan", laatste alinea** + +Vervang + +```markdown +Werk dat al begonnen is, wordt nooit onderbroken, met of zonder deze schakelaar aan: dat deel kan +alleen nog via uitloop wijken. Bij elke instelling van de schakelaar toont de dialoog het prijskaartje +in werkdagen uitloop, zodat je het effect kunt afwegen vóór je toepast. +``` + +door + +```markdown +Werk dat al begonnen is, wordt nooit onderbroken, met of zonder deze schakelaar aan: dat deel kan +alleen nog via uitloop wijken. Naast de schakelaar staat wat hij oplevert, als één verschil: staat +hij uit, dan lees je "zou 3 werkdagen besparen"; staat hij aan, dan "bespaart 3 werkdagen". Levert +onderbreken hier niets op, dan staat dat er ook gewoon. +``` + +- [ ] **Step 4: Voeg in de NL-gids een sectie toe vóór "Toepassen en terugdraaien"** + +```markdown +## De regel onder de grafiek + +Onder de grafiek staat altijd één regel met het oordeel. Groen betekent dat het conflict opgelost is, +met daarbij de grootste einddatum-verschuiving en het project dat hem draagt. Rood betekent dat er +nog een tekort staat, met de eerste dagen waarop het misgaat. Staan alle projecten vast, dan zegt die +regel dat er niets te herverdelen valt en dat je er één moet losmaken. Is het voorstel niet meer +actueel — omdat je iets veranderde of omdat er in een van de projecten gewerkt is — dan staat dat +daar ook. + +Die regel is er altijd, ook als er niets te melden valt. Dat is met opzet: zo verspringt de rest van +het scherm niet zodra er iets verandert. +``` + +- [ ] **Step 5: Dezelfde vier wijzigingen in de EN-gids** + +`public/docs/en/gids-verdelen-restcapaciteit.md`, met de EN-koppen: + +```markdown +## The bar is the control + +Below the switch, each project gets one bar on one shared timeline. On the left the project name with +a colour dot and the slack it still has, in the middle the bar itself, on the right the outcome. + +Inside the bar you see one block per workday in the project's colour, separated by a thin white line — +so you can literally count the days. A **hatched** block is a pause day: a workday on which the +distributor holds the work still to make room for another project. Those only appear when "Allow +interruptions" is on. + +A thin gauge runs along the bottom of the bar. The **grey dotted** part is the slack this project +still had: it may shift that far without moving its end date. The **solid red** part is everything +beyond that — real end-date shift. A bar without red costs the project nothing. + +To the right of the last block sits a **grip** with three strokes. Drag it to the right to allow this +project more overrun; the distributor then decides for itself which days to pause on and how much of +that room it actually needs. Whatever you allowed but turned out not to be needed shows up as a +**dotted box** behind the bar. While you drag, the app recalculates: the other bars, the chart and the +outcomes move under your hand. On a very large overview it does not — there the calculation follows +when you release. + +The same grip works from the keyboard: the arrow keys move one workday, PageUp and PageDown three, +Home sets the ceiling to zero and End makes it unlimited. + +To the right of the bar sits the outcome: a coloured pill with what happens to the end date (green at +zero, amber at a day or two, red above that), and below it the latest date you allowed plus how many +of those days are actually used. +``` + +```markdown +Pinning is the **pin** text button next to the project name; it turns into **pinned — unpin**. The +ceiling is the grip in the bar, as described above. The **Reset** button at the bottom puts every +ceiling and pin back to neutral in one go; the "Allow interruptions" switch stays as it is, because +that is a choice about the tool and not about one project. +``` + +```markdown +Work that has already started is never interrupted, with or without this switch: that part can only +give way through overrun. Next to the switch you see what it buys you, as a single difference: with +the switch off it reads "would save 3 workdays", with it on "saves 3 workdays". If interrupting buys +nothing here, it says so plainly. +``` + +```markdown +## The line below the chart + +Below the chart there is always one line with the verdict. Green means the conflict is resolved, with +the largest end-date shift and the project carrying it. Red means a shortfall remains, with the first +days on which it goes wrong. If every project is pinned, that line says there is nothing to +redistribute and that you should unpin one. If the proposal is no longer current — because you +changed something, or because someone worked in one of the projects — that shows up there too. + +That line is always there, even when there is nothing to report. That is deliberate: it stops the +rest of the screen from jumping whenever something changes. +``` + +- [ ] **Step 6: `docs/library.md`** + +Vervang in punt 5 de zin + +``` +De gebruiker stuurt het + voorstel bij met een rangorde ("wie wordt het meest ontzien"), een schakelaar voor onderbrekingen, + en per project twee begrenzingen: +``` + +door + +``` +De gebruiker stuurt het + voorstel bij vanuit de balken zelf (herontwerp 2026-09-12: de rangordelijst is uit beeld + verdwenen — de rijvolgorde ís de spelingsvolgorde van de rekenaar, en sturen doe je met de greep + en de pin), met daarnaast een schakelaar voor onderbrekingen, en per project twee begrenzingen: +``` + +en vervang in dezelfde alinea "De rangorde, pins en plafonds horen bij de verdeelsessie zelf" door +"De pins en plafonds horen bij de verdeelsessie zelf". + +- [ ] **Step 7: De docs-poort** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run verify:docs; echo "exit: $?" +``` + +Verwacht: **exit 0**. Klaagt hij over de parser-subset, dan staat er een tabel, een blockquote, een +h4 of rauwe HTML in je tekst. + +- [ ] **Step 8: Commit** + +```bash +git add public/docs/nl/gids-verdelen-restcapaciteit.md \ + public/docs/en/gids-verdelen-restcapaciteit.md docs/library.md +git commit -m "$(cat <<'EOF' +docs(b1c): gidsen en library.md volgen de nieuwe bediening van de verdeeldialoog + +Co-Authored-By: Claude Opus 5 (1M context) +EOF +)" +``` + +--- + +## Task 8: de volle poort + +**Files:** geen — dit is de poort, geen wijziging. Blijkt er tóch iets stuk, dan repareer je dat in +de taak waar het hoort en draai je deze taak opnieuw. + +- [ ] **Step 1: Wacht tot er niets zwaars draait, en draai `verify`** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +while pgrep -f "vite build|tsc --noEmit|playwright test|run.sh" >/dev/null; do sleep 20; done +npm run verify; echo "exit: $?" +``` + +Verwacht: **exit 0**. Let specifiek op deze vier poorten: + +- **`verify:i18n`** — de drie nieuwe meervoudsfamilies in veertien locales (taak 5). +- **`verify:docs`** — de gidsen blijven binnen de miniMarkdown-subset (taak 7). +- **`verify:cycles`** — `stripGeometry.ts` importeert alleen `occupancyAxis` en `dateUtils`, beide + bladmodules; `useDistributionProposal` → `stripGeometry` is een nieuwe rand en mag geen kring + sluiten. +- **`verify:gantt-boundaries`** — deze dialoog tekent SVG in de DOM en hoort de Canvas-renderer-, + viewport- en pointergrenzen van de Gantt niet te raken. + +Kijk **niet** naar de tekst "alles groen" in de tail — de planningssuite print die ook bij exit 1 +wanneer het bundelen faalt. + +- [ ] **Step 2: De volle browserpoort** + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +while pgrep -f "vite build|tsc --noEmit|playwright test|run.sh" >/dev/null; do sleep 20; done +npm run test:browser; echo "exit: $?" +``` + +Verwacht: **exit 0**. Deze dekking moet er staan in +`tests/browser/leveling-distribution.spec.ts`: + +| flow | taak | +|---|---| +| dialoog openen vanuit de conflictregel, focus-trap, twee balken, sluiten (Esc) | bestaand | +| kiezer-stand: lijst, geen conflicten, "Ander item kiezen…" | bestaand | +| geblokkeerd voorstel: uitleg + Toepassen uit | bestaand | +| pin als tekstknop met `aria-pressed`; plafond met pijltjes/Home/End; End zet de handle aan het aseinde | 6 | +| slepen: snappen op werkdagen, LIVE meerekenen vóór `mouse.up`, doorlopen buiten het element | 6 | +| het prijskaartje is één verschil en volgt de schakelaarstand | 6 | +| validatiestrook: groen bij opgelost, "alles vast", rood bij tekort | 6 | +| Reset zet plafonds en pins terug en laat de schakelaar staan | 6 | +| de `boundingBox` van de dialoog is stabiel over herberekening én sleep | 6 | +| voor/na-preview: conflictdagen weg in de na-stand | bestaand | +| Toepassen in twee projecten + "Alles terugdraaien" | bestaand | +| voorstel vervalt bij een bewerking; documentwissel sluit de dialoog | bestaand | +| de twee degradatiestanden | 6 | + +Ontbreekt er een rij, dan is die taak niet af. + +- [ ] **Step 3: Een echte blik op het scherm** + +De poorten meten gedrag, niet vorm. Deze verbouwing gaat over vorm, dus kijk er één keer naar: + +```bash +export PATH="/home/nozzit/.nvm/versions/node/v22.23.2/bin:$PATH" +npm run dev +``` + +Lees de poort uit de uitvoer van de dev-server (niet 3007 aannemen — die is per worktree), open de +app, maak twee projecten die op één bibliotheekitem boeken, open Bezetting → Verdelen…, en +controleer met eigen ogen: + +1. de dagblokjes zijn te tellen en de kleur per project komt overeen met het histogram eronder; +2. de kolommen van het histogram staan recht onder de dagblokjes, ook na horizontaal scrollen; +3. slepen aan de greep laat de andere balken en de grafiek meebewegen; +4. de dialoog verspringt geen pixel tijdens het rekenen; +5. in het donkere thema is de arcering van een pauzedag nog zichtbaar en de meetlat nog leesbaar. + +- [ ] **Step 4: `git status --short` hoort schoon te zijn** + +```bash +git status --short; git log --oneline -8 +``` + +--- + +## Zelfreview van dit plan (uitgevoerd) + +### 1. Spec-dekking + +| spec | eis | taak | +|---|---|---| +| §2.1 | rangordelijst weg, `order` blijft bestaan als afgeleide | T3 (afleiding), T4 (lijst weg), T6 (test weg) | +| §2.2 | prijskaartje is één verschil, met de vier zinsvormen | T1 (`savingsWorkdays`), T3 (`savings`), T4 (`savingsLabel`), T5 (teksten), T6 (test) | +| §2.3 | geen rekentoestand verandert een hoogte | T2 (vaste breedtes), T4 (validatiestrook + degradatiesleuf), T6 (boundingBox-test) | +| §2.4 | kern, schrijfpad, terugdraaien, invalidatie, kiezer, degradatie blijven | expliciet buiten scope; T4 laat `applyGate`, `onApply`, `onUndoAll`, `DistributionPicker` ongemoeid | +| §3.1 | kop met itemnaam, ondertitel, één regel uitleg | T4 (kop blijft), T5 (`intro` herschreven) | +| §3.2 | gereedschap: schakelaar + verschil-prijs + MSP-toelichting | T4 stap 5, T5 | +| §3.3 | per deelnemend project één rij, aaneengesloten, één tijdas | T4 stap 5 | +| §3.4 | histogram op dezelfde as-instantie, kolom-op-kolom, capaciteitslijn, rode arcering, einddatum-badges | T4 stap 5 (gedeelde scroll-container + badgerij) | +| §3.5 | validatiestrook `role="status"`, `aria-live`, altijd gerenderd, vaste hoogte, groen/rood + reden | T4 stap 4 en 6, T5 (`status.*`), T6 | +| §3.6 | knoppenbalk: Ander item kiezen…, Reset, Toepassen, Verwerpen, "toegepast"-strook | T4 stap 6, T5 (`reset`, `help.reset`), T6 (Reset-test) | +| §4 | label 150 / track 32 / uitkomst 132, dagblokjes, pauzedag, gestippelde rest, meetlat, vaste last, pil + "max · benut", "vast"/"vast: datums zoals opgeslagen"/"kan niet wijken", legenda | T1 (geometrie), T2 (tekening + label), T4 (legenda-regel), T5 (`strip.*`) | +| §5 | alleen de handle pakbaar, 15×30, `role="slider"`, `aria-valuetext`, pointer capture, snappen, live meerekenen gethrottled, degradatie, toetsenbord ←/→/PgUp/PgDn/Home/End | T2 (alles), T3 (throttle-motivering), T6 (twee tests) | +| §6 | pin als tekstknop met `aria-pressed`, grijze rand, `aria-disabled`, "alles vast"-melding | T2 (knop + `aria-disabled` + gedempte kleuren), T4 (`allPinned`), T5 | +| §7 | bovenverankering, gereserveerde hoogtes, toestand via opaciteit/kleur, boundingBox-test | T4 stap 1/4/6, T2 (vaste breedte uitkomstlabel), T6 | +| §8 | de nieuwe sleutels, en `rank.*`/`tool.price*` weg zodra ongebruikt; één `assignDocColors` | T5, T4 stap 7 | +| §9 | headless check met drie gevallen + verschil-prijs; browsertests herschreven | T1, T6 | +| §10 | gidsen nl/en herschreven, `docs/library.md` waar hij de rangorde noemt | T7 | +| §11 | buiten scope | scope-tabel bovenaan | + +Geen enkele spec-sectie zonder taak. + +### 2. Plaatshouder-scan + +Doorzocht op "TBD", "TODO", "later", "vergelijkbaar met taak N", "voeg foutafhandeling toe", +"schrijf tests voor het bovenstaande" en op stappen die wél zeggen wát maar niet hóé. Gevonden en +opgelost: + +- Taak 4 stap 2 stond eerst als "verwijder de rangorde-code". Dat is nu een genummerde lijst van + acht concrete verwijderingen mét de vervangende commentaartekst. +- Taak 5 stond eerst als "vertaal de nieuwe sleutels naar de overige twaalf talen". Dat zijn nu + twaalf volledig uitgeschreven JSON-fragmenten mét de juiste CLDR-suffixen per taal. +- Taak 7 stond eerst als "herschrijf de gids". Dat is nu per sectie de letterlijke vervangende + tekst, in beide talen. + +Eén bewuste uitzondering die géén plaatshouder is: taak 4 stap 5 en 6 vervangen benoemde JSX-secties +in een bestand van 843 regels in plaats van het hele bestand over te typen. De ankers zijn de +bestaande commentaarkoppen (`{/* (4) Rangorde */}`, `{/* (5) Fasestroken … */}`, +`{/* (6) Voor/na-histogram … */}`) — die staan er letterlijk zo in. + +### 3. Type-consistentie tussen de taken + +| naam | gedefinieerd in | gebruikt in | klopt | +|---|---|---|---| +| `STRIP` (`labelWidth` 150, `endWidth` 132, `gap` 10, `trackHeight` 32, `blockHeight` 15, `blockTop` 5, `measureHeight` 3, `measureBottom` 3, `handleWidth` 15, `handleHeight` 30, `handleTop` 1) | T1 | T1 (check), T2 (tekening), T4 (uitlijning) | ✔ | +| `buildStripGeometry(input: StripGeometryInput): StripGeometry` | T1 | T2 | ✔ — T2 geeft exact de acht velden mee die `StripGeometryInput` eist | +| `StripGeometry.ceilingEndIso` | T1 | T2 (`maxDate`-label) | ✔ | +| `savingsWorkdays(off, on)` / `maxEndShiftWorkdays(docs)` | T1 | T3 (`scheduleSavingsPass`) | ✔ | +| `DistributionProposalState.savings: { workdays: number } \| null` | T3 | T4 (`savingsLabel`) | ✔ — `costByDoc`/`toolPrice` bestaan nergens meer | +| `PhaseStripProps` (18 velden) | T2 | T4 (de aanroep) | ✔ — de aanroep in T4 stap 5 geeft alle 18, incl. `beforeLoadByDay`/`afterLoadByDay`/`isWorkingDay`/`slackWorkdays`/`liveCommit`/`busy`/`formatDay` | +| `Dialog.alignTop?: boolean` | T4 stap 1 | T4 stap 5 | ✔ | +| i18n-sleutels `tool.savesOff/savesOn/savesNoneOff/savesNoneOn`, `strip.legend/slack/pinButton/unpinButton/maxDate/used`, `status.resolved/shortfall/allPinned`, `reset`, `help.reset` | T5 | T2 en T4 | ✔ — één-op-één dezelfde namen; `strip.used` gebruikt `{{used}}` (geen `count`), `status.resolved` gebruikt `{{shift}}` + `{{doc}}`, `status.shortfall` gebruikt `{{count}}` + `{{days}}` | +| testankers `data-ops-distribution-{status,reset,legend,end-badges,track,handle,pin,effect,tool-price,day}` | T2 en T4 | T6 | ✔ | + +Eén punt dat de uitvoerder van T4 moet bewaken: `shortfallDocs` (de bestaande `useMemo` die +`BeforeAfterChart` voedt) blijft bestaan — hij wordt níét vervangen door `shortfallDays`. Die twee +hebben verschillende vormen (per document met een taakteller, versus de unieke dagen over alles) en +verschillende afnemers. + +### 4. Waar dit plan concretiseert (alle **KEUZE VAN DIT PLAN**-plekken op een rij) + +1. **T1** — de balk tekent de **NA**-stand (`afterLoadByDay`), niet de voor-stand. §4 laat beide toe; + alleen de NA-stand beweegt mee tijdens het slepen. +2. **T1** — de meetlat splitst op de **eigen speling vanaf het oorspronkelijke fase-einde**, niet op + `projectEndBefore`/`projectEndAfter`. Dat is letterlijk `.pfl`/`.pover` uit het prototype, en het + is per constructie consistent met `endShiftWorkdays`. +3. **T2** — **SVG voor de track, DOM voor de handle**, met de maatvoering van het prototype. Drie + redenen in de taak; het volgt bovendien wat `PhaseStrip` en `ContourPhaseStrip` vandaag al doen. +4. **T2/T3** — **live commit per gesnapte werkdag** onder de schaal; de bestaande + in-flight-coalescing van de hook ís de throttle, er komt geen tweede timer bij. +5. **T3** — `order` is **één keer afgeleid, bij het openen**. Een tweede afleidingsmoment zou via + `diffReason('rank')` een oneindige herberekening geven. +6. **T4** — **één gedeelde horizontale scroll-container** om balken, badges en histogram, zodat de + kolom-op-kolom-uitlijning ook tijdens het scrollen klopt. +7. **T4** — de **stale-melding verhuist naar de validatiestrook** maar behoudt haar + `data-ops-distribution-stale`-anker als `` erbinnen, zodat de bestaande browserasserties + geldig blijven én de hoogte vast is. +8. **T4** — "N ploegdagen tekort" telt **unieke ISO-dagen** over alle tekorten samen. +9. **T5** — `status.resolved` en `strip.used` krijgen **geen eigen meervoudsfamilie**: de eerste + hergebruikt de bestaande `strip.endShift`-familie via een `{{shift}}`-parameter, de tweede + vermijdt `count` bewust. `status.shortfall` wordt wél een familie (spec §8 noemt hem niet, §3.5 + schrijft de tekst met een telwoord voor). + +### 5. Wat de uitvoerder onderweg moet verifiëren + +Deze drie aannames zijn gelezen maar niet gedraaid; ze staan ook in de betreffende stap. + +1. **`CalendarEngine`'s constructor neemt één `WorkCalendar`** (`CalendarEngine.ts:64`) en + `isWorkDay(date: Date)` bestaat (`:171`). `DistributionDocInput` draagt `calendar` én `calendars`; + dit plan gebruikt alleen `calendar` — controleer of dat voor een document met een afwijkende + taakkalender de juiste werkdagen oplevert, en meld het als een taakkalender hier zichtbaar + afwijkt (T4 stap 3). +2. **Tailwind's `items-start` versus `items-center`** in dezelfde class-lijst is afhankelijk van + CSS-volgorde; daarom vervangt `alignTop` de klasse in plaats van er één naast te zetten + (T4 stap 1). Controleer in stap 3 van taak 8 met eigen ogen dat het paneel écht bovenaan hangt. +3. **`--success` (#16A34A) en `--warning` (#F59E0B)** bestaan in `src/styles/globals.css` + (regels 1170 en 1173) — geverifieerd op 2026-09-12. Wat nog wél te controleren valt: of het + DONKERE thema ze overschrijft met een variant die op `color-mix(… 10% …)`-achtergrond genoeg + contrast houdt. Blijkt de amberkleur daar onleesbaar, meld dat dan in plaats van er zelf een + vierde kleur bij te verzinnen (T2 stap 1, T4 stap 6). From d97ef4cb6a2893aaf5da7a52406e76e34dd380e2 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:21:40 +0200 Subject: [PATCH 053/108] docs(b1c): gidsen en library.md volgen de nieuwe bediening van de verdeeldialoog Co-Authored-By: Claude Opus 5 (1M context) --- docs/library.md | 7 +- .../docs/en/gids-verdelen-restcapaciteit.md | 75 ++++++++++++------ .../docs/nl/gids-verdelen-restcapaciteit.md | 79 +++++++++++++------ 3 files changed, 111 insertions(+), 50 deletions(-) diff --git a/docs/library.md b/docs/library.md index 74e4a8dc1..8fe03ed67 100644 --- a/docs/library.md +++ b/docs/library.md @@ -419,8 +419,9 @@ Alle drie komen voort uit dezelfde wortel — **er is geen gedeelde opslag tusse Sinds B1c-etappe 3 heeft dit voorstel ook een schrijfpad en een bediening: een dialoog ("Verdelen over projecten", geen drill-down — besluit eigenaar 2026-08-31), te openen vanuit de conflictregel in het bezettingsoverzicht of vanuit de Resources-ribbon. De gebruiker stuurt het - voorstel bij met een rangorde ("wie wordt het meest ontzien"), een schakelaar voor onderbrekingen, - en per project twee begrenzingen: **vastzetten** (pin) bevriest een project volledig — einddatum + voorstel bij vanuit de balken zelf (herontwerp 2026-09-12: de rangordelijst is uit beeld + verdwenen — de rijvolgorde ís de spelingsvolgorde van de rekenaar, en sturen doe je met de greep + en de pin), met daarnaast een schakelaar voor onderbrekingen, en per project twee begrenzingen: **vastzetten** (pin) bevriest een project volledig — einddatum én werkdagen blijven ongewijzigd, het telt mee als vaste last — en een **plafond** begrenst alleen hóéveel een project mag opschuiven; een plafond van 0 laat de bestaande speling nog wel toe, in tegenstelling tot vastzetten. Toepassen schrijft de verschuiving in álle deelnemende documenten @@ -430,7 +431,7 @@ Alle drie komen voort uit dezelfde wortel — **er is geen gedeelde opslag tusse in een document waar intussen zelf verder gewerkt is; dat wordt met naam gemeld en blijft op zijn nieuwe stand staan. - De rangorde, pins en plafonds horen bij de verdeelsessie zelf, niet bij het project: het zijn geen + De pins en plafonds horen bij de verdeelsessie zelf, niet bij het project: het zijn geen `DOCUMENT_FIELDS` en ze round-trippen dus niet door het IFC, overleven geen undo in het actieve document en beginnen na een herstart weer neutraal (zie de scope-bak "Waar de tune-state woont" in `docs/superpowers/plans/2026-08-31-b1c-plan3-schrijfpad-paneel.md` voor de volledige afweging). Zie diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index d798aab21..0faba8da0 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -48,8 +48,8 @@ complex alone already wants on three towers at once. That one is resolvable too, apartment complex a sizeable delay — instructive to compare with the first case. Click **Masonry crew**, then **Distribute…** on that row and let -**Distribute automatically** build the proposal; then change the ranking, or put a ceiling on one of -the projects, and watch what happens to the proposed shift. +**Distribute automatically** build the proposal; then drag a project's grip to the right, or pin a +project, and watch what happens to the proposed shift. A few situations block the distribution right away, with a clear reason: @@ -66,23 +66,41 @@ without deployment in between the days it is deployed — instead of shifting in exactly the same switch as "Leveling can create splits in remaining work" in Microsoft Project. Work that has already started is never interrupted, whether this toggle is on or off: that part can -only give way through overrun. Whatever the toggle is set to, the dialog shows the price tag in -workdays of overrun, so you can weigh the effect before you apply. +only give way through overrun. Next to the switch you see what it buys you, as a single difference: +with the switch off it reads "would save 3 workdays", with it on "saves 3 workdays". If interrupting +buys nothing here, it says so plainly. -## Who gets spared the most? +## The bar is the control -Below that sits the ranking list **"Who gets spared the most?"**. This is the order in which -projects get priority: the project at the top wiggles the least, and each project below it gives way -first whenever a choice has to be made. Drag a project to change the order, or use the arrow buttons -to move it up or down one place. +Below the switch, each project gets one bar on one shared timeline. On the left the project name with +a colour dot and the slack it still has, in the middle the bar itself, on the right the outcome. -Next to each project you'll see the slack it still has, and what it would cost to let only this -project shift — in workdays of overrun. That way you can see straight away which project is the -cheapest place to absorb the shift, instead of having to guess. +Inside the bar you see one block per workday in the project's colour, separated by a thin white line +— so you can literally count the days. A **hatched** block is a pause day: a workday on which the +distributor holds the work still to make room for another project. Those only appear when "Allow +interruptions" is on. + +A thin gauge runs along the bottom of the bar. The **grey dotted** part is the slack this project +still had: it may shift that far without moving its end date. The **solid red** part is everything +beyond that — real end-date shift. A bar without red costs the project nothing. + +To the right of the last block sits a **grip** with three strokes. Drag it to the right to allow this +project more overrun; the distributor then decides for itself which days to pause on and how much of +that room it actually needs. Whatever you allowed but turned out not to be needed shows up as a +**dotted box** behind the bar. While you drag, the app recalculates: the other bars, the chart and +the outcomes move under your hand. On a very large overview it does not — there the calculation +follows when you release. + +The same grip works from the keyboard: the arrow keys move one workday, PageUp and PageDown three, +Home sets the ceiling to zero and End makes it unlimited. + +To the right of the bar sits the outcome: a coloured pill with what happens to the end date (green at +zero, amber at a day or two, red above that), and below it the latest date you allowed plus how many +of those days are actually used. ## Pinning or a ceiling -Below that, on a strip per project, you have two ways to limit the room it gives up: +For each project you have two ways to limit the room it gives up: - **Pin** freezes a project completely: both its end date and its workdays stay exactly as they are now. A pinned project never gives up room — it counts in the calculation as a fixed load that the @@ -92,14 +110,14 @@ Below that, on a strip per project, you have two ways to limit the room it gives already present within the existing schedule may still be used — that's different from pinning, where even the workdays within the task no longer change. -The ceiling is a draggable handle on each project's phase strip: drag it, or use the arrow keys to -move it one workday at a time, Home for a ceiling of 0, and End for unlimited. The label next to the -handle shows what that means for the project's end date, and if less overrun turned out to be needed -than you had allowed, the label says so explicitly: "requested X, closest achievable Y". +Pinning is the **pin** text button next to the project name; it turns into **pinned — unpin**. The +ceiling is the grip in the bar, as described above. The **Reset** button at the bottom puts every +ceiling and pin back to neutral in one go; the "Allow interruptions" switch stays as it is, because +that is a choice about the tool and not about one project. ## Before and after -Below the phase strips sits a chart with two states, "Now" and "After distributing": how the load +Below the bars sits a chart with two states, "Now" and "After distributing": how the load currently runs against the library's capacity line, and how that changes once you apply the proposal. If a shortfall remains despite every setting, the dialog shows next to it which tasks don't fit per project, and **Apply** stays disabled with the reason next to it. @@ -121,8 +139,8 @@ A task that can't be shifted gets a plain-language reason instead of just a red - **No free window was found within the searched period.** Further out in time it's unknown whether there's room — this isn't a definitive "no", it just means the search period wasn't long enough. - **The library's residual capacity is used up.** This project's own demand still had room, but other - projects already occupy the resource up to the company capacity. Give that other project a lower - spot in the ranking, or pin it so the rest can plan around it. + projects already occupy the resource up to the company capacity. Allow that other project more + overrun with its grip, or pin it so the rest can plan around it. A project with **["Dates as recorded"](docs://datums-zoals-opgeslagen)** turned on never takes part in a distribution — leave that mode in that project first before adding it to a distribution proposal. @@ -131,7 +149,7 @@ a distribution — leave that mode in that project first before adding it to a d There is no separate mode for automatic calculation: at the bottom of the dialog sits a single button, which reads **"Distribute automatically"** as long as there's no proposal yet, and -**"Recalculate"** afterwards. Changing the ranking, a ceiling, a pin, or the "Allow interruptions" +**"Recalculate"** afterwards. Changing a ceiling, a pin, or the "Allow interruptions" toggle makes the dialog recalculate the proposal right away on its own — you don't have to press the button for that yourself. Only on a very large overview (many tasks in one of the involved projects, or many tasks that book against this item) does the dialog switch that automatism off; it then reports @@ -142,6 +160,17 @@ AI assistant, by another edit, or because you press **Apply** yourself — the d proposal as no longer current. That is never recalculated automatically: press **Recalculate** yourself when that happens. +## The line below the chart + +Below the chart there is always one line with the verdict. Green means the conflict is resolved, with +the largest end-date shift and the project carrying it. Red means a shortfall remains, with the first +days on which it goes wrong. If every project is pinned, that line says there is nothing to +redistribute and that you should unpin one. If the proposal is no longer current — because you +changed something, or because someone worked in one of the projects — that shows up there too. + +That line is always there, even when there is nothing to report. That is deliberate: it stops the +rest of the screen from jumping whenever something changes. + ## Applying and undoing Once the proposal is valid and everything fits, **Apply** writes the shift into all the involved @@ -162,9 +191,9 @@ reverted normally. Right after applying, the dialog also reports the proposal it current" — that isn't a glitch: the projects have, after all, just changed. Press Recalculate if you want to distribute again from the same dialog. -The choices you make in this dialog — ranking, pins, ceilings — belong to this one session, just like +The choices you make in this dialog — pins and ceilings — belong to this one session, just like the "applied" strip: they stay in place for as long as you keep working in the app, even across a -document switch, and only reset to a neutral ranking once you go distribute a different library item +document switch, and only reset to neutral once you go distribute a different library item or restart the app. Nothing about them is stored in the project itself. ## The boundary diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index 27724641c..2949fd277 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -49,8 +49,8 @@ appartementencomplex al aan drie torens tegelijk gevraagd wordt. Die is ook oplo appartementencomplex een flink stuk uitloop — leerzaam om naast het eerste geval te leggen. Klik op **Masonry crew** en dan op **Verdelen…**, en laat **Verdeel -automatisch** het voorstel maken; verander daarna eens de rangorde of zet een plafond op een project, -en kijk wat er met de voorgestelde verschuiving gebeurt. +automatisch** het voorstel maken; trek daarna eens de greep van een project naar rechts of zet een +project vast, en kijk wat er met de voorgestelde verschuiving gebeurt. Een paar situaties blokkeren de verdeling meteen, met een duidelijke reden: @@ -69,23 +69,42 @@ verschuiven. Dit is precies dezelfde knop als "Leveling can create splits in rem Microsoft Project. Werk dat al begonnen is, wordt nooit onderbroken, met of zonder deze schakelaar aan: dat deel kan -alleen nog via uitloop wijken. Bij elke instelling van de schakelaar toont de dialoog het prijskaartje -in werkdagen uitloop, zodat je het effect kunt afwegen vóór je toepast. +alleen nog via uitloop wijken. Naast de schakelaar staat wat hij oplevert, als één verschil: staat +hij uit, dan lees je "zou 3 werkdagen besparen"; staat hij aan, dan "bespaart 3 werkdagen". Levert +onderbreken hier niets op, dan staat dat er ook gewoon. -## Wie wordt het meest ontzien? +## De balk is de bediening -Daaronder staat de rangordelijst **"Wie wordt het meest ontzien?"**. Dit is de volgorde waarin de -projecten voorrang krijgen: het project bovenaan wijkt het minst, elk project daaronder levert eerder -in als er een keuze gemaakt moet worden. Versleep een project om de volgorde te wijzigen, of gebruik -de pijltjes om het een plek omhoog of omlaag te zetten. +Onder de schakelaar staat per project één balk op één gedeelde tijdas. Links de projectnaam met een +kleurstip en de speling die dat project nog heeft, in het midden de balk zelf, rechts de uitkomst. -Bij elk project staat de speling die het nog heeft, en wat het zou kosten om alléén dít project te -laten opschuiven — in werkdagen uitloop. Zo zie je meteen welk project de goedkoopste plek is om de -verschuiving te laten landen, in plaats van dat te moeten gissen. +In de balk zie je per werkdag een apart blokje in de kleur van het project, met een dun wit lijntje +ertussen — zo kun je de dagen letterlijk tellen. Een **gearceerd** blokje is een pauzedag: een +werkdag waarop de verdeler het werk even stillegt om ruimte te maken voor een ander project. Die +verschijnen alleen als je "Onderbrekingen toestaan" aan hebt staan. + +Onderin de balk loopt een dunne meetlat mee. Het **grijs gestippelde** deel is de speling die dit +project nog had: zoveel mag het opschuiven zonder dat de einddatum meegaat. Het **massief rode** +deel is alles daarvoorbij — dát is echte einddatum-verschuiving. Een balk zonder rood kost het +project dus niets. + +Rechts van het laatste blokje staat een **greep** met drie streepjes. Trek die naar rechts om dit +project meer uitloop toe te staan; de verdeler bepaalt vervolgens zelf op welke dagen hij pauzeert +en hoeveel van die ruimte hij echt nodig heeft. Wat je toestond maar niet nodig bleek, staat als een +**gestippeld kadertje** achter de balk. Terwijl je sleept rekent de app mee: de andere balken, de +grafiek en de uitkomsten veranderen onder je hand. Op een heel groot overzicht doet hij dat niet — +daar volgt de berekening zodra je loslaat. + +Met het toetsenbord werkt dezelfde greep: pijltjes verzetten één werkdag, PageUp en PageDown drie, +Home zet het plafond op nul en End maakt het onbegrensd. + +Rechts van de balk staat de uitkomst: een gekleurde pil met wat er met de einddatum gebeurt (groen +bij nul, amber bij een dag of twee, rood daarboven), en daaronder de uiterste datum die je toestond +plus hoeveel dagen daarvan echt benut zijn. ## Vastzetten of een plafond -Per project heb je daaronder, op een strook per project, twee manieren om de ruimte te begrenzen: +Per project heb je twee manieren om de ruimte te begrenzen: - **Vastzetten** (de pin) bevriest een project volledig: zowel de einddatum als de werkdagen blijven precies zoals ze nu zijn. Een vastgezet project levert dus nooit ruimte in — het telt in de @@ -95,15 +114,15 @@ Per project heb je daaronder, op een strook per project, twee manieren om de rui mag verschuiven, maar de speling binnen de bestaande planning nog wel benut mag worden — dat is iets anders dan vastzetten, waar zelfs de werkdagen binnen de taak niet meer wijzigen. -Het plafond is een sleepbare handle op de fasestrook van elk project: sleep hem, of gebruik de -pijltjestoetsen om per werkdag te verschuiven, Home voor een plafond van 0, en End voor onbegrensd. -Het label bij de handle toont wat dat betekent voor de einddatum van het project, en als er minder -uitloop nodig bleek dan je had toegestaan, meldt het label dat expliciet: "gevraagd X, dichtst -haalbare Y". +Vastzetten doe je met de tekstknop **vastzetten** links bij de projectnaam; hij verandert dan in +**vast — losmaken**. Het plafond zet je met de greep in de balk, zoals hierboven beschreven. Met de +knop **Reset** onderin zet je in één keer alle plafonds en vastzettingen terug op neutraal; de +schakelaar "Onderbrekingen toestaan" blijft daarbij staan, want dat is een keuze over het +gereedschap en niet over één project. ## Vóór en na -Onder de fasestroken staat een grafiek met twee standen, "Nu" en "Na verdelen": hoe de belasting op +Onder de balken staat een grafiek met twee standen, "Nu" en "Na verdelen": hoe de belasting op dit moment tegen de capaciteitslijn van de bibliotheek aanloopt, en hoe dat verandert zodra je het voorstel toepast. Blijft er, ondanks alle instellingen, een tekort over, dan toont de dialoog daarbij per project welke taken niet passen, en blijft **Toepassen** uitgeschakeld met de reden erbij. @@ -126,8 +145,8 @@ vinkje: - **Binnen de doorzochte periode is geen vrij venster gevonden.** Verderop in de tijd is het onbekend of er wel ruimte is — dit is geen definitief "nee", maar de zoekperiode was niet lang genoeg. - **De restcapaciteit van de bibliotheek is op.** De eigen inzet van dit project had nog ruimte, maar - andere projecten bezetten de resource al tot aan de bedrijfscapaciteit. Geef zo'n ander project een - lagere plek in de rangorde, of zet het vast zodat de rest eromheen plant. + andere projecten bezetten de resource al tot aan de bedrijfscapaciteit. Sta zo'n ander project met + zijn greep meer uitloop toe, of zet het vast zodat de rest eromheen plant. Een project met **["Datums zoals opgeslagen"](docs://datums-zoals-opgeslagen)** aan doet nooit mee in een verdeling — verlaat die modus eerst in dat project voordat je het aan een verdeelvoorstel @@ -137,7 +156,7 @@ toevoegt. Er is geen aparte modus voor automatisch rekenen: onderaan de dialoog staat één knop, die **"Verdeel automatisch"** heet zolang er nog geen voorstel is, en daarna **"Herbereken"**. Wijzig je -de rangorde, een plafond, een vastzetting, of de schakelaar "Onderbrekingen toestaan", dan rekent de +een plafond, een vastzetting, of de schakelaar "Onderbrekingen toestaan", dan rekent de dialoog het voorstel vanzelf meteen opnieuw door — je hoeft daarvoor niet zelf op de knop te drukken. Alleen bij een heel groot overzicht (veel taken in een van de betrokken projecten, of veel taken die op dit item boeken) schakelt de dialoog dat automatisme uit; ze meldt dan dat ze pas rekent zodra je @@ -148,6 +167,18 @@ door een AI-assistent, door een andere bewerking, of doordat je zelf op **Toepas meldt de dialoog het voorstel als niet meer actueel. Dat wordt nooit automatisch opnieuw doorgerekend: druk dan zelf op **Herbereken**. +## De regel onder de grafiek + +Onder de grafiek staat altijd één regel met het oordeel. Groen betekent dat het conflict opgelost is, +met daarbij de grootste einddatum-verschuiving en het project dat hem draagt. Rood betekent dat er +nog een tekort staat, met de eerste dagen waarop het misgaat. Staan alle projecten vast, dan zegt die +regel dat er niets te herverdelen valt en dat je er één moet losmaken. Is het voorstel niet meer +actueel — omdat je iets veranderde of omdat er in een van de projecten gewerkt is — dan staat dat +daar ook. + +Die regel is er altijd, ook als er niets te melden valt. Dat is met opzet: zo verspringt de rest van +het scherm niet zodra er iets verandert. + ## Toepassen en terugdraaien Is het voorstel geldig en past alles, dan schrijft **Toepassen** de verschuiving in álle betrokken @@ -169,9 +200,9 @@ stand staan, terwijl de rest gewoon teruggaat. Meteen na Toepassen meldt de dial ook als "niet meer actueel" — dat is geen storing: de projecten zijn immers net gewijzigd. Druk op Herbereken als je vanuit dezelfde dialoog nog een keer wilt verdelen. -De keuzes die je in deze dialoog maakt — rangorde, vastzettingen, plafonds — horen net als de +De keuzes die je in deze dialoog maakt — vastzettingen en plafonds — horen net als de "toegepast"-strook bij deze ene sessie: ze blijven staan zolang je met de app bezig bent, ook over een -documentwissel heen, en gaan pas naar een neutrale rangorde zodra je een ander bibliotheekitem gaat +documentwissel heen, en gaan pas terug naar neutraal zodra je een ander bibliotheekitem gaat verdelen of de app herstart. In het project zelf worden ze nergens opgeslagen. ## De grens From 8f58eb92d88c3150c6911ee4c3d839157315d7b4 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:21:40 +0200 Subject: [PATCH 054/108] docs: artikeltelling in README bijwerken naar 35 (docs-poort) Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7bed101be..79e653747 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ src/ extensions/ # Extensiesysteem (types, api, loader, service) i18n/ # Vertalingen, 14 talen × 4 namespaces hooks/ types/ utils/ styles/ -public/docs/ # In-app handleiding: 34 artikelen × 14 talen (voedt ook de wiki) +public/docs/ # In-app handleiding: 35 artikelen × 14 talen (voedt ook de wiki) examples/ # Voorbeeldplanningen in IFC tests/ # planning · library · mcp · dev-server · browser src-tauri/ # De Rust-schil (dun: precies één native command) From 262baebd404b5efba6e07c1961c95b19c15956f0 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:23:48 +0200 Subject: [PATCH 055/108] feat(b1c): verschil-prijskaartje i.p.v. twee bedragen, kostenlabels weg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De per-deelnemer-isolatieruns van de labelpas zijn vervallen met de rangordelijst (plan 4 §2.1): er blijven twee prijsruns over in plaats van N+2, wat het live meerekenen tijdens het slepen betaalbaar maakt. De hook levert nu `savings` (één verschil, geklemd op nul). `costByDoc`/`toolPrice` blijven tijdelijk als DEPRECATED compat-exports staan tot taak 4 de dialoog herbouwt. Co-Authored-By: Claude Fable 5.1 --- .../useDistributionProposal.ts | 182 ++++++++++-------- 1 file changed, 102 insertions(+), 80 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts index d70c04701..792892c34 100644 --- a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -15,8 +15,10 @@ // DE BEZIG-TOESTAND IS ECHT, GEEN COSMETICA. `setBusy(true)` gebeurt synchroon, het rekenen pas in // een `setTimeout(…, 0)` daarna — anders blokkeert de synchrone solve de paint en ziet de gebruiker // nooit dat er iets gebeurt. Er loopt er precies één tegelijk; een verzoek dat tijdens een run -// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer — een sleepbeweging over de -// plafondhandle in taak 9 levert anders een wachtrij die minutenlang naloopt). +// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer). DÍT MECHANISME IS DE THROTTLE +// DIE SPEC §5 VRAAGT: onder de ondersteunde schaal commit de balk élke gesnapte werkdag tijdens het +// slepen, en deze in-flight-bewaking zorgt dat er maximaal één run loopt en de laatste stand wint. +// Er hoort hier GEEN tweede timer bij te komen — die zou de laatste stand kunnen inslikken. // // SCHAAL-DEGRADATIE (§3.4). Boven `MAX_TASKS_AUTO` taken in één deelnemend document of // `MAX_BOOKING_TASKS_AUTO` boekende taken op dít poolitem is automatisch doorrekenen bij elke @@ -42,6 +44,9 @@ import { type DistributionProposal, } from '@/services/library/distribute'; import { documentFingerprint } from '@/services/library/proposalFingerprint'; +// TODO(plan4-T1): vervang deze twee lokale helpers door +// `import { maxEndShiftWorkdays, savingsWorkdays } from './stripGeometry';` zodra taak 1 die module +// heeft toegevoegd — de signaturen en het gedrag hieronder zijn letterlijk die van het plan. import { documentTitle, untitledOrdinals, displayDocumentTitle } from '@/utils/documents'; /** Meer taken dan dit in één deelnemend document ⇒ handmatig herberekenen (§3.4). */ @@ -62,6 +67,24 @@ export const MAX_BOOKING_TASKS_AUTO = 40; */ export const MAX_DOCS_AUTO = 6; +// TODO(plan4-T1): deze twee helpers horen in `./stripGeometry.ts` (taak 1 van plan 4) en staan hier +// tijdelijk lokaal omdat die module in deze worktree nog niet bestaat. Bij het samenvoegen: dit blok +// schrappen en de import bovenaan activeren — signatuur en gedrag zijn identiek aan de plantekst. + +/** De grootste einddatum-verschuiving over de deelnemers — de UITSCHIETER, niet de som (§2.2). */ +export function maxEndShiftWorkdays(docs: { endShiftWorkdays: number }[]): number { + return docs.reduce((max, doc) => Math.max(max, doc.endShiftWorkdays), 0); +} + +/** + * Het verschil-prijskaartje van "Onderbrekingen toestaan" (§2.2): hoeveel werkdagen de aan-stand + * bespaart ten opzichte van de uit-stand. Geklemd op 0 — onderbreken kán in theorie duurder + * uitvallen, en dan is "bespaart niets" het eerlijke antwoord. + */ +export function savingsWorkdays(offMax: number, onMax: number): number { + return Math.max(0, offMax - onMax); +} + /** Waarom het huidige voorstel niet meer actueel is — 1-op-1 de `resource.distribution.stale.*` * sleutels. De vier tune-assen komen uit `diffReason`; `'edited'` komt uit de * vingerafdruk-bewaking (taak 12, spec §6a). @@ -125,15 +148,31 @@ export interface DistributionProposalState { /** De invoer waarop het HUIDIGE voorstel gerekend is — de dialoog leest hier de documenttitels * en de float per document uit, zodat rangordelijst en strook nooit uit een andere bron komen. */ inputs: DistributionDocInput[]; - /** docId → "alleen dit document laten opschuiven kost N werkdagen" (taak 13, spec §4 stap 1): een - * volledige `computeDistribution`-run met dít document alleen op rang 1 en alle andere deelnemers - * gepind. Ontbreekt een docId ⇒ geen label — óf nog niet (her)berekend (stale/gedegradeerd), óf - * het document is zelf gepind/#63/cannotMove en wijkt sowieso niet (de dialoog beslist dat via - * `proposal.docs[].participated`/`cannotMove`, niet via deze map). */ + /** + * Het VERSCHIL-prijskaartje van "Onderbrekingen toestaan" (spec §2.2, eigenaarsbesluit + * 2026-09-12). Twee volledige `computeDistribution`-runs — één met `allowSplits: false`, één met + * `true` — leveren elk de grootste `endShiftWorkdays` over de deelnemers (de UITSCHIETER, niet de + * som); `workdays` is het verschil daartussen, geklemd op ≥ 0. + * + * Waarom één verschil en niet twee bedragen: de gebruikstest van 2026-09-12 las "kost 27 + * werkdagen uitloop · kost 27 werkdagen uitloop" als een weergavefout. Een gebruiker wil weten wat + * de schakelaar hém oplevert, niet wat elke stand los kost. + * + * `null` ⇒ nog niet (her)berekend; de dialoog zegt dan eerlijk "prijs onbekend". + */ + savings: { workdays: number } | null; + /** + * @deprecated COMPAT — TAAK 4 RUIMT DIT OP. De rangordelijst met haar kostenlabels is met deze + * taak vervallen (§2.1/§8) en de per-deelnemer-isolatieruns die deze map vulden zijn verdwenen; + * hij staat hier alleen nog omdat `DistributionDialog.tsx` pas in taak 4 herbouwd wordt en + * anders niet compileert. Altijd leeg. Verwijder dit veld zodra de dialoog het niet meer leest. + */ costByDoc: Record; - /** Het prijskaartje van de gereedschapsschakelaar (taak 13, spec §3.4/§6): de grootste - * `endShiftWorkdays` over de deelnemers (de uitschieter, niet de som) voor elke stand van - * "Onderbrekingen toestaan". `null` ⇒ nog niet (her)berekend. */ + /** + * @deprecated COMPAT — TAAK 4 RUIMT DIT OP. Vervangen door `savings` (één verschil in plaats van + * twee bedragen, §2.2). Wordt gevuld uit dezelfde twee prijsruns als `savings`, dus hij kost niets + * extra; alleen de dialoog van vóór taak 4 leest hem nog. + */ toolPrice: { off: number; on: number } | null; } @@ -359,7 +398,11 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const [staleReason, setStaleReason] = useState(null); const [lastStaleReason, setLastStaleReason] = useState(null); const [staleDocs, setStaleDocs] = useState(''); - const [costByDoc, setCostByDoc] = useState>({}); + const [savings, setSavings] = useState<{ workdays: number } | null>(null); + // COMPAT — TAAK 4 RUIMT DEZE TWEE OP. `costByDoc` blijft per definitie leeg (de isolatieruns die + // hem vulden zijn met de rangordelijst verdwenen); `toolPrice` wordt uit dezelfde twee prijsruns + // als `savings` gevuld. Beide bestaan alleen zolang `DistributionDialog.tsx` ze nog leest. + const [costByDoc] = useState>({}); const [toolPrice, setToolPrice] = useState<{ off: number; on: number } | null>(null); // Bewaakt de labelpas (taak 13) tegen twee soorten inhaalslag: (1) een NIEUWE hoofdrun start — @@ -421,27 +464,23 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr }, []); /** - * Taak 13 (spec §4 stap 1 / §6). Per document een VOLLEDIGE `computeDistribution`-run met dat - * document alleen op rang 1 en alle andere deelnemers gepind ("alleen dit project laten - * opschuiven"), plus twee runs voor het prijskaartje van de gereedschapsschakelaar (`allowSplits` - * uit/aan). Draait ná het hoofdvoorstel (dat schildert dan al), en breekt af zodra `myGeneration` - * is ingehaald door een nieuwere hoofdrun of door een 'edited'-invalidatie - * (`fingerprintsRef.current === null`) — beide zijn precies de gevallen waarin het hoofdvoorstel op - * het scherm zelf ook al niet meer bij de documenten hoort. + * Spec §2.2 — het VERSCHIL-prijskaartje. Twee volledige `computeDistribution`-runs (`allowSplits` + * uit en aan) ná het hoofdvoorstel, elk in een eigen macrotask zodat de browser ertussen kan + * schilderen en een afbreekreden binnen één stap landt. Breekt af zodra `myGeneration` is + * ingehaald door een nieuwere hoofdrun of door een 'edited'-invalidatie + * (`fingerprintsRef.current === null`) — beide zijn precies de gevallen waarin het hoofdvoorstel + * op het scherm zelf ook al niet meer bij de documenten hoort. * - * ÉÉN MACROTASK PER STAP, NIET ÉÉN VOOR ALLES (fixronde-2 bevinding B6). Dit stond in één - * synchrone `setTimeout`-callback: N volledige solves plus twee prijsruns achter elkaar, zonder - * dat de browser ertussen kon schilderen of een klik kon verwerken. Gemeten op vijf documenten - * van 990 taken: 3,8 seconden bevroren UI, bij élke tune-wijziging — en `superseded()` kón - * daartussen niet ingrijpen, want er was geen "daartussen". Elke stap heeft nu zijn eigen - * macrotask, dus de UI blijft responsief en een afbreekreden landt binnen één stap. + * DIT WAS VIER KEER ZO DUUR. Tot 2026-09-12 deed deze pas óók per deelnemer een isolatierun voor + * de kostenlabels van de rangordelijst. Die lijst is weg (§2.1) en die runs dus ook: er blijven + * twee runs over in plaats van N+2. Dat is wat het live meerekenen tijdens het slepen (§5) + * betaalbaar maakt. */ - const scheduleDistributionLabels = ( + const scheduleSavingsPass = ( myGeneration: number, tuneAtRun: DistributionUiState, pool: CompanyPool, built: DistributionDocInput[], - proposalResult: DistributionProposal, ): void => { const superseded = () => generationRef.current !== myGeneration || fingerprintsRef.current === null; // Alleen de teller van DEZE pas mag de bezig-toestand weer uitzetten: een nieuwere pas loopt @@ -449,58 +488,39 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const finish = () => { if (generationRef.current === myGeneration) setLabelsBusy(false); }; const priceOf = (p: DistributionProposal): number | null => - p.blocked ? null : p.docs.reduce((max, d) => Math.max(max, d.endShiftWorkdays), 0); + p.blocked ? null : maxEndShiftWorkdays(p.docs); - const costs: Record = {}; let offPrice: number | null = null; let onPrice: number | null = null; - const steps: (() => void)[] = []; - - for (const doc of built) { - const docResult = proposalResult.docs.find(d => d.docId === doc.docId); - // Gepind/#63/cannotMove ⇒ geen label (§4 stap 1: "ze wijken niet"), en dus ook geen stap. - if (!docResult || !docResult.participated || docResult.cannotMove) continue; - steps.push(() => { - const isolated = built.map(other => ( - other.docId === doc.docId - ? { ...other, rank: 1, pinned: false } - : { ...other, pinned: true } + const steps: (() => void)[] = [ + () => { + offPrice = priceOf(computeDistribution( + tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: false }, )); - const isolatedResult = computeDistribution( - tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, isolated, - { allowSplits: tuneAtRun.allowSplits }, - ); - if (isolatedResult.blocked) return; - const own = isolatedResult.docs.find(d => d.docId === doc.docId); - if (own) costs[doc.docId] = own.endShiftWorkdays; - }); - } - // De labels gaan in ÉÉN keer naar de state, ná de laatste isolatierun: half gevulde - // rangorderijen zijn misleidender dan een zichtbare bezig-toestand. - steps.push(() => { setCostByDoc({ ...costs }); }); - steps.push(() => { - offPrice = priceOf(computeDistribution( - tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: false }, - )); - }); - steps.push(() => { - onPrice = priceOf(computeDistribution( - tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: true }, - )); - }); - steps.push(() => { - if (offPrice !== null && onPrice !== null) setToolPrice({ off: offPrice, on: onPrice }); - }); + }, + () => { + onPrice = priceOf(computeDistribution( + tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: true }, + )); + }, + () => { + if (offPrice !== null && onPrice !== null) { + setSavings({ workdays: savingsWorkdays(offPrice, onPrice) }); + // COMPAT — TAAK 4 RUIMT DIT OP: dezelfde twee getallen nog even in de oude vorm, zodat + // de nog niet herbouwde dialoog blijft compileren en draaien. Kost niets extra. + setToolPrice({ off: offPrice, on: onPrice }); + } + }, + ]; const runStep = (index: number): void => { if (superseded() || index >= steps.length) { finish(); return; } try { steps[index](); } catch { - // Een mislukte stap (bv. een solverfout in een isolatiescenario) laat gewoon geen label - // zien — nooit een gok tonen (zie het moduleblok over "stille uitsluiting" in distribute.ts). - // De rest van de pas loopt door: één onberekenbaar project hoort de andere labels niet mee - // te slepen. + // Een mislukte stap laat gewoon geen prijs zien — nooit een gok tonen. De volgende stap + // loopt door; zonder beide prijzen blijft `savings` op `null` en zegt de dialoog eerlijk + // dat de prijs onbekend is. } schedule(() => runStep(index + 1)); }; @@ -519,11 +539,11 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr // hieraan als ingehaald en breekt af (§4 stap 1: "een label van een vervallen voorstel is // misleidender dan geen label"). const myGeneration = ++generationRef.current; - // De labels horen bij het HUIDIGE voorstel, niet bij het vorige — ze gaan dus meteen leeg zodra - // een nieuwe run start (net als de bezig-toestand: een oud getal tijdens "Bezig met verdelen…" - // is even misleidend als een oud getal na een invalidatie). - setCostByDoc({}); - setToolPrice(null); + // De prijs hoort bij het HUIDIGE voorstel, niet bij het vorige — hij gaat dus meteen leeg zodra + // een nieuwe run start. Een oud getal tijdens "Bezig met verdelen…" is even misleidend als een + // oud getal na een invalidatie. + setSavings(null); + setToolPrice(null); // COMPAT — TAAK 4 RUIMT DIT OP. // De labelpas van de vorige generatie is hiermee ingehaald; zijn bezig-toestand hoort dus ook // weg. Wordt er straks weer een pas gepland, dan zet die 'm zelf terug aan. setLabelsBusy(false); @@ -547,12 +567,12 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr setStaleReason(null); setStaleDocs(''); - // Taak 13 (spec §4 stap 1 / §6): de kostenlabels en het prijskaartje NÁ het hoofdvoorstel, - // in een tweede macrotask zodat het hoofdvoorstel eerst schildert. Boven de ondersteunde - // schaal (§3.4) is elk label ZELF een volledige run erbij — dat is precies de kost die de - // schaal-degradatie voorkomt, dus daar blijft het bij "druk op Herbereken". + // Spec §2.2: de verschil-prijs NÁ het hoofdvoorstel, in een tweede macrotask zodat het + // hoofdvoorstel eerst schildert. Boven de ondersteunde schaal (§3.4) is elke prijsrun ZELF + // een volledige run erbij — dat is precies de kost die de schaal-degradatie voorkomt, dus + // daar blijft het bij "druk op Herbereken". if (pool && proposalResult && !proposalResult.blocked && !isDistributionDegraded(built, current)) { - scheduleDistributionLabels(myGeneration, current, pool, built, proposalResult); + scheduleSavingsPass(myGeneration, current, pool, built); } } finally { busyRef.current = false; @@ -589,8 +609,8 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr setStaleReason(null); setLastStaleReason(null); setStaleDocs(''); - setCostByDoc({}); - setToolPrice(null); + setSavings(null); + setToolPrice(null); // COMPAT — TAAK 4 RUIMT DIT OP. if (subjectKey !== null) runRef.current(); // `tune` bewust buiten de deps: alleen het ONDERWERP is hier de trigger, niet elke tune-tik. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -647,6 +667,8 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr return { proposal, busy, labelsBusy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs, + savings, + // COMPAT — TAAK 4 RUIMT DEZE TWEE OP. costByDoc, toolPrice, }; } From bf05c60934f5b3700eceba2ff4b459a306d919bf Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:58:00 +0200 Subject: [PATCH 056/108] feat(i18n): sleutels voor de herbouwde verdeeldialoog in veertien talen (plan 4, taak 5) Co-Authored-By: Claude Opus 5 (1M context) --- src/i18n/locales/ar/common.json | 42 +++++++++++++++++++++++++++++---- src/i18n/locales/de/common.json | 30 +++++++++++++++++++---- src/i18n/locales/en/common.json | 30 +++++++++++++++++++---- src/i18n/locales/es/common.json | 33 ++++++++++++++++++++++---- src/i18n/locales/fa/common.json | 30 +++++++++++++++++++---- src/i18n/locales/fr/common.json | 33 ++++++++++++++++++++++---- src/i18n/locales/it/common.json | 33 ++++++++++++++++++++++---- src/i18n/locales/ja/common.json | 27 +++++++++++++++++---- src/i18n/locales/ko/common.json | 27 +++++++++++++++++---- src/i18n/locales/nl/common.json | 30 +++++++++++++++++++---- src/i18n/locales/pl/common.json | 36 ++++++++++++++++++++++++---- src/i18n/locales/pt/common.json | 33 ++++++++++++++++++++++---- src/i18n/locales/tr/common.json | 30 +++++++++++++++++++---- src/i18n/locales/zh/common.json | 27 +++++++++++++++++---- 14 files changed, 371 insertions(+), 70 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 86a4a61ba..691d84ae8 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -704,7 +704,7 @@ "title": "التوزيع بين المشاريع", "back": "العودة إلى الإشغال", "subtitle": "{{item}} — توزيع السعة المتبقية على المشاريع المفتوحة", - "intro": "اختر من يُعفى أكثر من غيره، وثبّت مشروعًا أو امنحه سقفًا عند الحاجة، وانظر في الرسم البياني ما يتبقى من الحجز المزدوج. \"تطبيق\" يكتب الإزاحات في كل المشاريع المعنية.", + "intro": "اسحب نهاية الشريط إلى اليمين للسماح لهذا المشروع بالتمدد؛ ثبّت المشروع الذي لا يجوز تحريكه. يكتب «تطبيق» الإزاحات في جميع المشاريع المعنية.", "blocked": { "uncounted": "احسب أولاً: {{docs}} لم يُحسب بعد، لذا لا يوجد ما يمكن توزيعه.", "material": "هذا عنصر مادي؛ التوزيع يعمل فقط للأشخاص والمعدات.", @@ -724,7 +724,21 @@ "priceOff": "إيقاف: {{price}}", "priceOn": "تشغيل: {{price}}", "priceSame": "{{price}} — في كلتا الحالتين", - "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب" + "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب", + "savesOff_zero": "لن يوفّر شيئًا", + "savesOff_one": "سيوفّر يوم عمل واحدًا", + "savesOff_two": "سيوفّر يومَي عمل", + "savesOff_few": "سيوفّر {{count}} أيام عمل", + "savesOff_many": "سيوفّر {{count}} يوم عمل", + "savesOff_other": "سيوفّر {{count}} يوم عمل", + "savesOn_zero": "لا يوفّر شيئًا", + "savesOn_one": "يوفّر يوم عمل واحدًا", + "savesOn_two": "يوفّر يومَي عمل", + "savesOn_few": "يوفّر {{count}} أيام عمل", + "savesOn_many": "يوفّر {{count}} يوم عمل", + "savesOn_other": "يوفّر {{count}} يوم عمل", + "savesNoneOff": "لن يوفّر شيئًا", + "savesNoneOn": "لا يوفّر شيئًا" }, "rank": { "title": "من يُراعى أكثر؟", @@ -753,7 +767,8 @@ "recompute": "يعيد حساب الاقتراح بالترتيب والتثبيتات والسقوف الحالية.", "apply": "يكتب الإزاحات في كل المشاريع المعنية؛ ويحصل كل مشروع على خطوة تراجع خاصة به.", "discard": "يغلق مربع الحوار دون تغيير أي شيء.", - "pickAnother": "العودة إلى قائمة عناصر المكتبة لتوزيع عنصر آخر." + "pickAnother": "العودة إلى قائمة عناصر المكتبة لتوزيع عنصر آخر.", + "reset": "يعيد جميع الحدود والتثبيتات إلى الوضع المحايد. يبقى مفتاح «السماح بالانقطاعات» كما هو." }, "strip": { "pin": "تثبيت", @@ -777,7 +792,13 @@ "endShift_many": "النهاية +{{count}} يومًا", "endShift_other": "النهاية +{{count}} يوم", "requestedVsAchievable": "المطلوب {{requested}}، الأقرب القابل للتحقيق {{achievable}}", - "handleLabel": "سقف التمديد لـ {{doc}}" + "handleLabel": "سقف التمديد لـ {{doc}}", + "legend": "المربعات الممتلئة = أيام العمل · المظلّلة = يوم توقّف · المسطرة: رمادي = المرونة، أحمر = إزاحة تاريخ الانتهاء · المنقّطة = مسموح لكنه غير مستخدم", + "slack": "مرونة {{days}} يوم", + "pinButton": "تثبيت", + "unpinButton": "مثبّت — فكّ التثبيت", + "maxDate": "بحد أقصى {{date}}", + "used": "المستخدم {{used}}" }, "compute": { "recalculate": "إعادة الحساب", @@ -823,7 +844,18 @@ "selectHint": "اختر عنصر مكتبة به تعارض لبدء التوزيع.", "noConflicts": "لا يوجد حاليًا أي عنصر مكتبة محجوز مرتين عبر المشاريع المفتوحة.", "pickAnother": "اختيار عنصر آخر…", - "pickAnotherApplied": "اختيار عنصر آخر (سيختفي شريط التراجع)…" + "pickAnotherApplied": "اختيار عنصر آخر (سيختفي شريط التراجع)…", + "reset": "إعادة ضبط", + "status": { + "resolved": "تم حل التعارض — أكبر إزاحة للانتهاء: {{shift}} ({{doc}})", + "shortfall_zero": "لا يوجد نقص", + "shortfall_one": "ما زال ينقص يوم فريق واحد في {{days}}…", + "shortfall_two": "ما زال ينقص يوما فريق في {{days}}…", + "shortfall_few": "ما زال ينقص {{count}} أيام فريق في {{days}}…", + "shortfall_many": "ما زال ينقص {{count}} يوم فريق في {{days}}…", + "shortfall_other": "ما زال ينقص {{count}} يوم فريق في {{days}}…", + "allPinned": "جميع المشاريع مثبّتة — لا يوجد ما يُعاد توزيعه. فُكّ تثبيت أحدها." + } }, "color": "اللون" }, diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index e1c9ffeea..c187805a6 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -656,7 +656,7 @@ "title": "Über Projekte verteilen", "back": "Zurück zur Belegung", "subtitle": "{{item}} — Restkapazität über die geöffneten Projekte verteilen", - "intro": "Wählen Sie, wer am meisten geschont wird, fixieren oder begrenzen Sie bei Bedarf ein Projekt, und sehen Sie im Diagramm, was von der Doppelbuchung übrig bleibt. Übernehmen schreibt die Verschiebungen in alle beteiligten Projekte.", + "intro": "Ziehen Sie das Ende eines Balkens nach rechts, damit dieses Projekt überlaufen darf; fixieren Sie ein Projekt, das sich nicht bewegen darf. Übernehmen schreibt die Verschiebungen in alle beteiligten Projekte.", "blocked": { "uncounted": "Zuerst berechnen: {{docs}} ist noch nicht berechnet, daher gibt es nichts zu verteilen.", "material": "Dies ist ein Materialartikel; Verteilen funktioniert nur für Personen und Geräte.", @@ -672,7 +672,13 @@ "priceOff": "aus: {{price}}", "priceOn": "ein: {{price}}", "priceSame": "{{price}} — in beiden Stellungen", - "priceUnknown": "Preis unbekannt — Neu berechnen drücken" + "priceUnknown": "Preis unbekannt — Neu berechnen drücken", + "savesOff_one": "würde {{count}} Arbeitstag sparen", + "savesOff_other": "würde {{count}} Arbeitstage sparen", + "savesOn_one": "spart {{count}} Arbeitstag", + "savesOn_other": "spart {{count}} Arbeitstage", + "savesNoneOff": "würde nichts sparen", + "savesNoneOn": "spart nichts" }, "rank": { "title": "Wer wird am meisten geschont?", @@ -697,7 +703,8 @@ "recompute": "Berechnet den Vorschlag mit der aktuellen Reihenfolge, den Fixierungen und Obergrenzen neu.", "apply": "Schreibt die Verschiebungen in alle beteiligten Projekte; jedes Projekt erhält einen eigenen Rückgängig-Schritt.", "discard": "Schließt den Dialog, ohne etwas zu ändern.", - "pickAnother": "Zurück zur Liste der Bibliothekselemente, um ein anderes Element zu verteilen." + "pickAnother": "Zurück zur Liste der Bibliothekselemente, um ein anderes Element zu verteilen.", + "reset": "Setzt alle Obergrenzen und Fixierungen auf neutral zurück. Der Schalter „Unterbrechungen zulassen“ bleibt unverändert." }, "strip": { "pin": "Anheften", @@ -713,7 +720,13 @@ "endShift_one": "Ende +{{count}} Tag", "endShift_other": "Ende +{{count}} Tage", "requestedVsAchievable": "angefragt {{requested}}, am ehesten erreichbar {{achievable}}", - "handleLabel": "Verzugsobergrenze für {{doc}}" + "handleLabel": "Verzugsobergrenze für {{doc}}", + "legend": "Volle Blöcke = Arbeitstage · schraffiert = Pausentag · Messlatte: grau = Puffer, rot = Endterminverschiebung · gepunktet = erlaubt, aber nicht genutzt", + "slack": "Puffer {{days}} Tage", + "pinButton": "fixieren", + "unpinButton": "fixiert — lösen", + "maxDate": "max. {{date}}", + "used": "genutzt {{used}}" }, "compute": { "recalculate": "Neu berechnen", @@ -751,7 +764,14 @@ "selectHint": "Wählen Sie einen Bibliotheksartikel mit einem Konflikt, um mit dem Verteilen zu beginnen.", "noConflicts": "Zurzeit ist kein Bibliothekselement über die geöffneten Projekte hinweg doppelt belegt.", "pickAnother": "Anderes Element wählen…", - "pickAnotherApplied": "Anderes Element wählen (die Rückgängig-Leiste entfällt)…" + "pickAnotherApplied": "Anderes Element wählen (die Rückgängig-Leiste entfällt)…", + "reset": "Zurücksetzen", + "status": { + "resolved": "Konflikt gelöst — größte Endterminverschiebung: {{shift}} ({{doc}})", + "shortfall_one": "Es fehlt noch {{count}} Schichttag am {{days}}…", + "shortfall_other": "Es fehlen noch {{count}} Schichttage am {{days}}…", + "allPinned": "Alle Projekte sind fixiert — es gibt nichts umzuverteilen. Lösen Sie eines." + } }, "color": "Farbe" }, diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 233fe3832..0b113f926 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -656,7 +656,7 @@ "title": "Distribute over projects", "back": "Back to occupancy", "subtitle": "{{item}} — distribute residual capacity over the open projects", - "intro": "Choose who is spared the most, pin or cap a project where needed, and look at the chart to see what remains of the double booking. Apply writes the shifts into every project involved.", + "intro": "Drag the end of a bar to the right to let that project run on; pin a project that must not move. Apply writes the shifts into every project involved.", "blocked": { "uncounted": "Calculate first: {{docs}} has not been calculated yet, so there is nothing to distribute.", "material": "This is a material item; distributing only works for people and equipment.", @@ -672,7 +672,13 @@ "priceOff": "off: {{price}}", "priceOn": "on: {{price}}", "priceSame": "{{price}} — in both settings", - "priceUnknown": "price unknown — press Recalculate" + "priceUnknown": "price unknown — press Recalculate", + "savesOff_one": "would save {{count}} workday", + "savesOff_other": "would save {{count}} workdays", + "savesOn_one": "saves {{count}} workday", + "savesOn_other": "saves {{count}} workdays", + "savesNoneOff": "would save nothing", + "savesNoneOn": "saves nothing" }, "rank": { "title": "Who gets spared the most?", @@ -697,7 +703,8 @@ "recompute": "Recalculates the proposal with the current order, pins and caps.", "apply": "Writes the shifts into every project involved; each project gets its own undo step.", "discard": "Closes the dialog without changing anything.", - "pickAnother": "Back to the list of library items, to distribute a different item." + "pickAnother": "Back to the list of library items, to distribute a different item.", + "reset": "Puts all ceilings and pins back to neutral. The \"Allow interruptions\" switch stays as it is." }, "strip": { "pin": "Pin", @@ -713,7 +720,13 @@ "endShift_one": "end +{{count}} day", "endShift_other": "end +{{count}} days", "requestedVsAchievable": "requested {{requested}}, closest achievable {{achievable}}", - "handleLabel": "Overrun ceiling for {{doc}}" + "handleLabel": "Overrun ceiling for {{doc}}", + "legend": "Solid blocks = workdays · hatched = pause day · gauge: grey = slack, red = end-date shift · dotted = allowed but not used", + "slack": "slack {{days}} days", + "pinButton": "pin", + "unpinButton": "pinned — unpin", + "maxDate": "max {{date}}", + "used": "used {{used}}" }, "compute": { "recalculate": "Recalculate", @@ -751,7 +764,14 @@ "selectHint": "Choose a library item with a conflict to start distributing.", "noConflicts": "No library item is currently double-booked across the open projects.", "pickAnother": "Choose another item…", - "pickAnotherApplied": "Choose another item (the undo bar goes away)…" + "pickAnotherApplied": "Choose another item (the undo bar goes away)…", + "reset": "Reset", + "status": { + "resolved": "Conflict resolved — largest end-date shift: {{shift}} ({{doc}})", + "shortfall_one": "Still {{count}} crew day short on {{days}}…", + "shortfall_other": "Still {{count}} crew days short on {{days}}…", + "allPinned": "Every project is pinned — there is nothing to redistribute. Unpin one." + } }, "color": "Color" }, diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 1b258f77e..7b4d44de9 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -668,7 +668,7 @@ "title": "Repartir entre proyectos", "back": "Volver a la ocupación", "subtitle": "{{item}} — repartir la capacidad residual entre los proyectos abiertos", - "intro": "Elija a quién se protege más, fije o limite un proyecto si hace falta y mire en el gráfico qué queda de la doble reserva. Aplicar escribe los desplazamientos en todos los proyectos implicados.", + "intro": "Arrastra el final de una barra hacia la derecha para dejar que ese proyecto se prolongue; fija un proyecto que no deba moverse. Aplicar escribe los desplazamientos en todos los proyectos implicados.", "blocked": { "uncounted": "Calcula primero: {{docs}} aún no está calculado, así que no hay nada que repartir.", "material": "Esto es un artículo de material; el reparto solo funciona para personas y equipos.", @@ -685,7 +685,15 @@ "priceOff": "desactivado: {{price}}", "priceOn": "activado: {{price}}", "priceSame": "{{price}} — en ambos casos", - "priceUnknown": "precio desconocido — pulsa Recalcular" + "priceUnknown": "precio desconocido — pulsa Recalcular", + "savesOff_one": "ahorraría {{count}} día laborable", + "savesOff_many": "ahorraría {{count}} días laborables", + "savesOff_other": "ahorraría {{count}} días laborables", + "savesOn_one": "ahorra {{count}} día laborable", + "savesOn_many": "ahorra {{count}} días laborables", + "savesOn_other": "ahorra {{count}} días laborables", + "savesNoneOff": "no ahorraría nada", + "savesNoneOn": "no ahorra nada" }, "rank": { "title": "¿A quién se respeta más?", @@ -711,7 +719,8 @@ "recompute": "Vuelve a calcular la propuesta con el orden, las fijaciones y los límites actuales.", "apply": "Escribe los desplazamientos en todos los proyectos implicados; cada proyecto recibe su propio paso de deshacer.", "discard": "Cierra el diálogo sin cambiar nada.", - "pickAnother": "Volver a la lista de elementos de la biblioteca para repartir otro elemento." + "pickAnother": "Volver a la lista de elementos de la biblioteca para repartir otro elemento.", + "reset": "Devuelve todos los topes y fijaciones al estado neutro. El interruptor «Permitir interrupciones» no cambia." }, "strip": { "pin": "Fijar", @@ -729,7 +738,13 @@ "endShift_many": "fin +{{count}} días", "endShift_other": "fin +{{count}} días", "requestedVsAchievable": "solicitado {{requested}}, más cercano alcanzable {{achievable}}", - "handleLabel": "Techo de retraso para {{doc}}" + "handleLabel": "Techo de retraso para {{doc}}", + "legend": "Bloques sólidos = días laborables · rayado = día de pausa · regla: gris = holgura, rojo = desplazamiento de la fecha de fin · punteado = permitido pero no usado", + "slack": "holgura {{days}} días", + "pinButton": "fijar", + "unpinButton": "fijado — soltar", + "maxDate": "máx. {{date}}", + "used": "usado {{used}}" }, "compute": { "recalculate": "Recalcular", @@ -769,7 +784,15 @@ "selectHint": "Elige un artículo de biblioteca con un conflicto para empezar a repartir.", "noConflicts": "Ningún elemento de la biblioteca tiene actualmente doble reserva en los proyectos abiertos.", "pickAnother": "Elegir otro elemento…", - "pickAnotherApplied": "Elegir otro elemento (la barra de deshacer desaparece)…" + "pickAnotherApplied": "Elegir otro elemento (la barra de deshacer desaparece)…", + "reset": "Restablecer", + "status": { + "resolved": "Conflicto resuelto — mayor desplazamiento de fin: {{shift}} ({{doc}})", + "shortfall_one": "Todavía falta {{count}} día-cuadrilla el {{days}}…", + "shortfall_many": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", + "shortfall_other": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", + "allPinned": "Todos los proyectos están fijados: no hay nada que redistribuir. Suelta uno." + } }, "color": "Color" }, diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 70317ddfa..31ebf25f2 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -656,7 +656,7 @@ "title": "توزیع بین پروژه‌ها", "back": "بازگشت به اشغال", "subtitle": "{{item}} — توزیع ظرفیت باقی‌مانده بین پروژه‌های باز", - "intro": "انتخاب کنید کدام پروژه بیشتر در امان بماند، در صورت نیاز پروژه‌ای را پین کنید یا برایش سقف بگذارید، و در نمودار ببینید از رزرو دوگانه چه می‌ماند. «اعمال» جابه‌جایی‌ها را در همهٔ پروژه‌های درگیر می‌نویسد.", + "intro": "انتهای نوار را به راست بکشید تا آن پروژه تمدید شود؛ پروژه‌ای را که نباید جابه‌جا شود سنجاق کنید. «اعمال» جابه‌جایی‌ها را در همهٔ پروژه‌های مرتبط می‌نویسد.", "blocked": { "uncounted": "ابتدا محاسبه کنید: {{docs}} هنوز محاسبه نشده است، بنابراین چیزی برای توزیع وجود ندارد.", "material": "این یک قلم مصالح است؛ توزیع فقط برای افراد و تجهیزات کار می‌کند.", @@ -672,7 +672,13 @@ "priceOff": "خاموش: {{price}}", "priceOn": "روشن: {{price}}", "priceSame": "{{price}} — در هر دو حالت", - "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید" + "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید", + "savesOff_one": "{{count}} روز کاری صرفه‌جویی می‌کرد", + "savesOff_other": "{{count}} روز کاری صرفه‌جویی می‌کرد", + "savesOn_one": "{{count}} روز کاری صرفه‌جویی می‌کند", + "savesOn_other": "{{count}} روز کاری صرفه‌جویی می‌کند", + "savesNoneOff": "چیزی صرفه‌جویی نمی‌کرد", + "savesNoneOn": "چیزی صرفه‌جویی نمی‌کند" }, "rank": { "title": "چه کسی بیشترین ملاحظه را می‌گیرد؟", @@ -697,7 +703,8 @@ "recompute": "پیشنهاد را با ترتیب، پین‌ها و سقف‌های فعلی دوباره محاسبه می‌کند.", "apply": "جابه‌جایی‌ها را در همهٔ پروژه‌های درگیر می‌نویسد؛ هر پروژه گام واگرد خودش را می‌گیرد.", "discard": "پنجره را بدون هیچ تغییری می‌بندد.", - "pickAnother": "بازگشت به فهرست موارد کتابخانه، برای توزیع مورد دیگری." + "pickAnother": "بازگشت به فهرست موارد کتابخانه، برای توزیع مورد دیگری.", + "reset": "همهٔ سقف‌ها و سنجاق‌ها را به حالت خنثی برمی‌گرداند. کلید «اجازهٔ وقفه» بدون تغییر می‌ماند." }, "strip": { "pin": "ثابت کردن", @@ -713,7 +720,13 @@ "endShift_one": "پایان +{{count}} روز", "endShift_other": "پایان +{{count}} روز", "requestedVsAchievable": "درخواست‌شده {{requested}}، نزدیک‌ترین قابل‌دستیابی {{achievable}}", - "handleLabel": "سقف تمدید برای {{doc}}" + "handleLabel": "سقف تمدید برای {{doc}}", + "legend": "بلوک‌های توپر = روزهای کاری · هاشور = روز توقف · خط‌کش: خاکستری = شناوری، قرمز = جابه‌جایی تاریخ پایان · نقطه‌چین = مجاز اما استفاده‌نشده", + "slack": "شناوری {{days}} روز", + "pinButton": "سنجاق", + "unpinButton": "سنجاق‌شده — باز کردن", + "maxDate": "حداکثر {{date}}", + "used": "استفاده‌شده {{used}}" }, "compute": { "recalculate": "محاسبه مجدد", @@ -751,7 +764,14 @@ "selectHint": "برای شروع توزیع، یک قلم کتابخانه دارای تعارض انتخاب کنید.", "noConflicts": "در حال حاضر هیچ عنصر کتابخانه‌ای در پروژه‌های باز به‌صورت دوگانه رزرو نشده است.", "pickAnother": "انتخاب عنصر دیگر…", - "pickAnotherApplied": "انتخاب عنصر دیگر (نوار بازگردانی حذف می‌شود)…" + "pickAnotherApplied": "انتخاب عنصر دیگر (نوار بازگردانی حذف می‌شود)…", + "reset": "بازنشانی", + "status": { + "resolved": "تعارض حل شد — بیشترین جابه‌جایی پایان: {{shift}} ({{doc}})", + "shortfall_one": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", + "shortfall_other": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", + "allPinned": "همهٔ پروژه‌ها سنجاق شده‌اند — چیزی برای توزیع مجدد نیست. یکی را باز کنید." + } }, "color": "رنگ" }, diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index bcc202d5d..b8dc6c562 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -668,7 +668,7 @@ "title": "Répartir entre les projets", "back": "Retour à l'occupation", "subtitle": "{{item}} — répartir la capacité résiduelle entre les projets ouverts", - "intro": "Choisissez qui est le plus épargné, épinglez un projet ou donnez-lui un plafond si nécessaire, et regardez dans le graphique ce qu'il reste de la double réservation. Appliquer écrit les décalages dans tous les projets concernés.", + "intro": "Faites glisser la fin d'une barre vers la droite pour laisser ce projet déborder ; épinglez un projet qui ne doit pas bouger. Appliquer écrit les décalages dans tous les projets concernés.", "blocked": { "uncounted": "Calculez d'abord : {{docs}} n'est pas encore calculé, il n'y a donc rien à répartir.", "material": "Il s'agit d'un article matériel ; la répartition ne fonctionne que pour les personnes et le matériel.", @@ -685,7 +685,15 @@ "priceOff": "désactivé : {{price}}", "priceOn": "activé : {{price}}", "priceSame": "{{price}} — dans les deux cas", - "priceUnknown": "prix inconnu — appuyez sur Recalculer" + "priceUnknown": "prix inconnu — appuyez sur Recalculer", + "savesOff_one": "économiserait {{count}} jour ouvré", + "savesOff_many": "économiserait {{count}} jours ouvrés", + "savesOff_other": "économiserait {{count}} jours ouvrés", + "savesOn_one": "économise {{count}} jour ouvré", + "savesOn_many": "économise {{count}} jours ouvrés", + "savesOn_other": "économise {{count}} jours ouvrés", + "savesNoneOff": "n'économiserait rien", + "savesNoneOn": "n'économise rien" }, "rank": { "title": "Qui est le plus épargné ?", @@ -711,7 +719,8 @@ "recompute": "Recalcule la proposition avec l'ordre, les épingles et les plafonds actuels.", "apply": "Écrit les décalages dans tous les projets concernés ; chaque projet reçoit sa propre étape d'annulation.", "discard": "Ferme la boîte de dialogue sans rien modifier.", - "pickAnother": "Retour à la liste des éléments de bibliothèque, pour répartir un autre élément." + "pickAnother": "Retour à la liste des éléments de bibliothèque, pour répartir un autre élément.", + "reset": "Remet tous les plafonds et épinglages à l'état neutre. L'interrupteur « Autoriser les interruptions » reste tel quel." }, "strip": { "pin": "Épingler", @@ -729,7 +738,13 @@ "endShift_many": "fin +{{count}} jours", "endShift_other": "fin +{{count}} jours", "requestedVsAchievable": "demandé {{requested}}, le plus proche réalisable {{achievable}}", - "handleLabel": "Plafond de dépassement pour {{doc}}" + "handleLabel": "Plafond de dépassement pour {{doc}}", + "legend": "Blocs pleins = jours ouvrés · hachuré = jour de pause · règle : gris = marge, rouge = décalage de la date de fin · pointillé = autorisé mais non utilisé", + "slack": "marge {{days}} jours", + "pinButton": "épingler", + "unpinButton": "épinglé — détacher", + "maxDate": "max {{date}}", + "used": "utilisé {{used}}" }, "compute": { "recalculate": "Recalculer", @@ -769,7 +784,15 @@ "selectHint": "Choisissez un article de bibliothèque en conflit pour commencer à répartir.", "noConflicts": "Aucun élément de bibliothèque n'est actuellement réservé en double sur les projets ouverts.", "pickAnother": "Choisir un autre élément…", - "pickAnotherApplied": "Choisir un autre élément (la barre d'annulation disparaît)…" + "pickAnotherApplied": "Choisir un autre élément (la barre d'annulation disparaît)…", + "reset": "Réinitialiser", + "status": { + "resolved": "Conflit résolu — plus grand décalage de fin : {{shift}} ({{doc}})", + "shortfall_one": "Il manque encore {{count}} jour-équipe le {{days}}…", + "shortfall_many": "Il manque encore {{count}} jours-équipe les {{days}}…", + "shortfall_other": "Il manque encore {{count}} jours-équipe les {{days}}…", + "allPinned": "Tous les projets sont épinglés — il n'y a rien à redistribuer. Détachez-en un." + } }, "color": "Couleur" }, diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 34dbd74c7..d19ddf82f 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -668,7 +668,7 @@ "title": "Distribuisci tra i progetti", "back": "Torna all'occupazione", "subtitle": "{{item}} — distribuisci la capacità residua tra i progetti aperti", - "intro": "Scegli chi viene risparmiato di più, fissa o limita un progetto se serve e guarda nel grafico che cosa resta della doppia prenotazione. Applica scrive gli spostamenti in tutti i progetti coinvolti.", + "intro": "Trascina la fine di una barra verso destra per lasciare che quel progetto sfori; blocca un progetto che non deve muoversi. Applica scrive gli slittamenti in tutti i progetti coinvolti.", "blocked": { "uncounted": "Calcola prima: {{docs}} non è ancora stato calcolato, quindi non c'è nulla da distribuire.", "material": "Questo è un articolo di materiale; la distribuzione funziona solo per persone e attrezzature.", @@ -685,7 +685,15 @@ "priceOff": "disattivato: {{price}}", "priceOn": "attivato: {{price}}", "priceSame": "{{price}} — in entrambi i casi", - "priceUnknown": "prezzo sconosciuto — premi Ricalcola" + "priceUnknown": "prezzo sconosciuto — premi Ricalcola", + "savesOff_one": "risparmierebbe {{count}} giorno lavorativo", + "savesOff_many": "risparmierebbe {{count}} giorni lavorativi", + "savesOff_other": "risparmierebbe {{count}} giorni lavorativi", + "savesOn_one": "risparmia {{count}} giorno lavorativo", + "savesOn_many": "risparmia {{count}} giorni lavorativi", + "savesOn_other": "risparmia {{count}} giorni lavorativi", + "savesNoneOff": "non risparmierebbe nulla", + "savesNoneOn": "non risparmia nulla" }, "rank": { "title": "Chi viene risparmiato di più?", @@ -711,7 +719,8 @@ "recompute": "Ricalcola la proposta con l'ordine, i fissaggi e i limiti attuali.", "apply": "Scrive gli spostamenti in tutti i progetti coinvolti; ogni progetto riceve il proprio passo di annullamento.", "discard": "Chiude la finestra senza modificare nulla.", - "pickAnother": "Torna all'elenco degli elementi della libreria per distribuire un altro elemento." + "pickAnother": "Torna all'elenco degli elementi della libreria per distribuire un altro elemento.", + "reset": "Riporta tutti i tetti e i blocchi allo stato neutro. L'interruttore «Consenti interruzioni» resta invariato." }, "strip": { "pin": "Blocca", @@ -729,7 +738,13 @@ "endShift_many": "fine +{{count}} giorni", "endShift_other": "fine +{{count}} giorni", "requestedVsAchievable": "richiesto {{requested}}, il più vicino raggiungibile {{achievable}}", - "handleLabel": "Tetto di ritardo per {{doc}}" + "handleLabel": "Tetto di ritardo per {{doc}}", + "legend": "Blocchi pieni = giorni lavorativi · tratteggio = giorno di pausa · righello: grigio = scorrimento, rosso = slittamento della data di fine · punteggiato = consentito ma non usato", + "slack": "scorrimento {{days}} giorni", + "pinButton": "blocca", + "unpinButton": "bloccato — sblocca", + "maxDate": "max {{date}}", + "used": "usato {{used}}" }, "compute": { "recalculate": "Ricalcola", @@ -769,7 +784,15 @@ "selectHint": "Scegli un articolo di libreria con un conflitto per iniziare a distribuire.", "noConflicts": "Nessun elemento della libreria è attualmente prenotato due volte nei progetti aperti.", "pickAnother": "Scegli un altro elemento…", - "pickAnotherApplied": "Scegli un altro elemento (la barra di annullamento scompare)…" + "pickAnotherApplied": "Scegli un altro elemento (la barra di annullamento scompare)…", + "reset": "Reimposta", + "status": { + "resolved": "Conflitto risolto — slittamento di fine maggiore: {{shift}} ({{doc}})", + "shortfall_one": "Manca ancora {{count}} giorno-squadra il {{days}}…", + "shortfall_many": "Mancano ancora {{count}} giorni-squadra il {{days}}…", + "shortfall_other": "Mancano ancora {{count}} giorni-squadra il {{days}}…", + "allPinned": "Tutti i progetti sono bloccati: non c'è nulla da ridistribuire. Sbloccane uno." + } }, "color": "Colore" }, diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index bb36a9ef6..f2f63d05b 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -644,7 +644,7 @@ "title": "プロジェクト間で配分", "back": "稼働状況に戻る", "subtitle": "{{item}} — 残キャパシティを開いているプロジェクト間で配分", - "intro": "どのプロジェクトを最も優先して守るかを選び、必要なら固定や上限を設定し、二重予約がどれだけ残るかをグラフで確認します。適用すると、関係するすべてのプロジェクトにずれが書き込まれます。", + "intro": "バーの末尾を右へドラッグすると、そのプロジェクトを延長できます。動かしたくないプロジェクトは固定してください。「適用」は関連するすべてのプロジェクトにずれを書き込みます。", "blocked": { "uncounted": "先に計算してください:{{docs}} はまだ計算されていないため、配分するものがありません。", "material": "これは資材項目です。配分は人員と機材にのみ機能します。", @@ -659,7 +659,11 @@ "priceOff": "オフ: {{price}}", "priceOn": "オン: {{price}}", "priceSame": "{{price}} — どちらの設定でも同じ", - "priceUnknown": "価格不明 — 再計算を押してください" + "priceUnknown": "価格不明 — 再計算を押してください", + "savesOff_other": "{{count}} 稼働日を節約できます", + "savesOn_other": "{{count}} 稼働日を節約しています", + "savesNoneOff": "何も節約できません", + "savesNoneOn": "節約はありません" }, "rank": { "title": "誰が最も優先的に守られますか?", @@ -683,7 +687,8 @@ "recompute": "現在の順序、固定、上限で提案を計算し直します。", "apply": "関係するすべてのプロジェクトにずれを書き込みます。各プロジェクトには個別の元に戻す操作が用意されます。", "discard": "何も変更せずにダイアログを閉じます。", - "pickAnother": "ライブラリ項目の一覧に戻り、別の項目を配分します。" + "pickAnother": "ライブラリ項目の一覧に戻り、別の項目を配分します。", + "reset": "すべての上限と固定を中立に戻します。「中断を許可」スイッチはそのままです。" }, "strip": { "pin": "固定", @@ -697,7 +702,13 @@ "endUnchanged": "終了日は変更なし", "endShift_other": "終了日 +{{count}} 日", "requestedVsAchievable": "要求 {{requested}}、達成可能な最も近い値 {{achievable}}", - "handleLabel": "{{doc}} の延長上限" + "handleLabel": "{{doc}} の延長上限", + "legend": "塗りつぶしのブロック=稼働日 · 斜線=休止日 · 目盛り:グレー=余裕、赤=終了日のずれ · 点線=許可したが未使用", + "slack": "余裕 {{days}} 日", + "pinButton": "固定", + "unpinButton": "固定中 — 解除", + "maxDate": "最大 {{date}}", + "used": "使用 {{used}}" }, "compute": { "recalculate": "再計算", @@ -733,7 +744,13 @@ "selectHint": "配分を始めるには、競合のあるライブラリ項目を選択してください。", "noConflicts": "現在、開いているプロジェクト間で二重予約されているライブラリ項目はありません。", "pickAnother": "別の項目を選ぶ…", - "pickAnotherApplied": "別の項目を選ぶ(元に戻すバーは消えます)…" + "pickAnotherApplied": "別の項目を選ぶ(元に戻すバーは消えます)…", + "reset": "リセット", + "status": { + "resolved": "競合を解消しました — 最大の終了日のずれ:{{shift}}({{doc}})", + "shortfall_other": "{{days}} に {{count}} 班日ぶん不足しています…", + "allPinned": "すべてのプロジェクトが固定されています。再配分するものがありません。1 つ解除してください。" + } }, "color": "色" }, diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index bacc25140..f43e66f6b 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -644,7 +644,7 @@ "title": "프로젝트 간 분배", "back": "점유 현황으로 돌아가기", "subtitle": "{{item}} — 열린 프로젝트 간에 잔여 용량 분배", - "intro": "가장 많이 보호할 프로젝트를 고르고, 필요하면 프로젝트를 고정하거나 상한을 주고, 이중 예약이 얼마나 남는지 차트에서 확인하세요. 적용하면 관련된 모든 프로젝트에 이동이 기록됩니다.", + "intro": "막대 끝을 오른쪽으로 끌면 해당 프로젝트를 연장할 수 있습니다. 움직이면 안 되는 프로젝트는 고정하세요. [적용]은 관련된 모든 프로젝트에 이동을 기록합니다.", "blocked": { "uncounted": "먼저 계산하세요: {{docs}} 이(가) 아직 계산되지 않아 분배할 것이 없습니다.", "material": "이것은 자재 항목입니다. 분배는 인력과 장비에만 작동합니다.", @@ -659,7 +659,11 @@ "priceOff": "끔: {{price}}", "priceOn": "켬: {{price}}", "priceSame": "{{price}} — 두 설정 모두 동일", - "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요" + "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요", + "savesOff_other": "작업일 {{count}}일을 절약할 수 있습니다", + "savesOn_other": "작업일 {{count}}일을 절약합니다", + "savesNoneOff": "절약할 수 있는 시간이 없습니다", + "savesNoneOn": "절약되지 않습니다" }, "rank": { "title": "누가 가장 많이 보호받나요?", @@ -683,7 +687,8 @@ "recompute": "현재 순서와 고정, 상한으로 제안을 다시 계산합니다.", "apply": "관련된 모든 프로젝트에 이동을 기록합니다. 프로젝트마다 별도의 실행 취소 단계가 생깁니다.", "discard": "아무것도 바꾸지 않고 대화 상자를 닫습니다.", - "pickAnother": "라이브러리 항목 목록으로 돌아가 다른 항목을 분배합니다." + "pickAnother": "라이브러리 항목 목록으로 돌아가 다른 항목을 분배합니다.", + "reset": "모든 상한과 고정을 중립 상태로 되돌립니다. '중단 허용' 스위치는 그대로 유지됩니다." }, "strip": { "pin": "고정", @@ -697,7 +702,13 @@ "endUnchanged": "종료일 변경 없음", "endShift_other": "종료일 +{{count}}일", "requestedVsAchievable": "요청 {{requested}}, 가장 가까운 달성 가능값 {{achievable}}", - "handleLabel": "{{doc}}의 연장 상한" + "handleLabel": "{{doc}}의 연장 상한", + "legend": "채워진 블록 = 작업일 · 빗금 = 휴지일 · 눈금: 회색 = 여유, 빨강 = 종료일 이동 · 점선 = 허용했지만 사용하지 않음", + "slack": "여유 {{days}}일", + "pinButton": "고정", + "unpinButton": "고정됨 — 해제", + "maxDate": "최대 {{date}}", + "used": "사용 {{used}}" }, "compute": { "recalculate": "다시 계산", @@ -733,7 +744,13 @@ "selectHint": "분배를 시작하려면 충돌이 있는 라이브러리 항목을 선택하세요.", "noConflicts": "현재 열려 있는 프로젝트에서 이중 예약된 라이브러리 항목이 없습니다.", "pickAnother": "다른 항목 선택…", - "pickAnotherApplied": "다른 항목 선택(되돌리기 막대가 사라집니다)…" + "pickAnotherApplied": "다른 항목 선택(되돌리기 막대가 사라집니다)…", + "reset": "초기화", + "status": { + "resolved": "충돌 해결 — 최대 종료일 이동: {{shift}} ({{doc}})", + "shortfall_other": "{{days}}에 작업조 {{count}}일이 부족합니다…", + "allPinned": "모든 프로젝트가 고정되어 재분배할 것이 없습니다. 하나를 해제하세요." + } }, "color": "색상" }, diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 6c829e473..652ff6282 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -656,7 +656,7 @@ "title": "Verdelen over projecten", "back": "Terug naar bezetting", "subtitle": "{{item}} — restcapaciteit verdelen over de geopende projecten", - "intro": "Kies wie het meest ontzien wordt, zet zo nodig een project vast of geef het een plafond, en kijk in de grafiek wat er van de dubbele boeking overblijft. Toepassen schrijft de verschuivingen in alle betrokken projecten.", + "intro": "Trek het einde van een balk naar rechts om dat project te laten uitlopen; zet een project vast als het niet mag bewegen. Toepassen schrijft de verschuivingen in alle betrokken projecten.", "blocked": { "uncounted": "Eerst doorrekenen: {{docs}} is nog niet berekend, dus er valt niets tegen te verdelen.", "material": "Dit is een materiaal-item; verdelen werkt alleen voor mensen en materieel.", @@ -672,7 +672,13 @@ "priceOff": "uit: {{price}}", "priceOn": "aan: {{price}}", "priceSame": "{{price}} — in beide standen", - "priceUnknown": "prijs onbekend — druk op Herbereken" + "priceUnknown": "prijs onbekend — druk op Herbereken", + "savesOff_one": "zou {{count}} werkdag besparen", + "savesOff_other": "zou {{count}} werkdagen besparen", + "savesOn_one": "bespaart {{count}} werkdag", + "savesOn_other": "bespaart {{count}} werkdagen", + "savesNoneOff": "zou niets besparen", + "savesNoneOn": "bespaart niets" }, "rank": { "title": "Wie wordt het meest ontzien?", @@ -697,7 +703,8 @@ "recompute": "Rekent het voorstel opnieuw door met de huidige volgorde, vastzettingen en plafonds.", "apply": "Schrijft de verschuivingen weg in alle betrokken projecten; elk project krijgt een eigen ongedaan-maken-stap.", "discard": "Sluit de dialoog zonder iets te wijzigen.", - "pickAnother": "Terug naar de lijst met bibliotheekitems, om een ander item te verdelen." + "pickAnother": "Terug naar de lijst met bibliotheekitems, om een ander item te verdelen.", + "reset": "Zet alle plafonds en vastzettingen terug op neutraal. De schakelaar \"Onderbrekingen toestaan\" blijft staan." }, "strip": { "pin": "Vastzetten", @@ -713,7 +720,13 @@ "endShift_one": "eind +{{count}} dag", "endShift_other": "eind +{{count}} dagen", "requestedVsAchievable": "gevraagd {{requested}}, dichtst haalbare {{achievable}}", - "handleLabel": "Uitloop-plafond voor {{doc}}" + "handleLabel": "Uitloop-plafond voor {{doc}}", + "legend": "Volle blokjes = werkdagen · gearceerd = pauzedag · meetlat: grijs = speling, rood = einddatum-verschuiving · gestippeld = toegestaan maar niet benut", + "slack": "speling {{days}} dagen", + "pinButton": "vastzetten", + "unpinButton": "vast — losmaken", + "maxDate": "max {{date}}", + "used": "benut {{used}}" }, "compute": { "recalculate": "Herbereken", @@ -751,7 +764,14 @@ "selectHint": "Kies een bibliotheekitem met een conflict om te gaan verdelen.", "noConflicts": "Er is nu geen enkel bibliotheekitem dubbel geboekt over de geopende projecten.", "pickAnother": "Ander item kiezen…", - "pickAnotherApplied": "Ander item kiezen (de terugdraai-strook vervalt)…" + "pickAnotherApplied": "Ander item kiezen (de terugdraai-strook vervalt)…", + "reset": "Reset", + "status": { + "resolved": "Conflict opgelost — grootste einddatum-verschuiving: {{shift}} ({{doc}})", + "shortfall_one": "Nog {{count}} ploegdag tekort op {{days}}…", + "shortfall_other": "Nog {{count}} ploegdagen tekort op {{days}}…", + "allPinned": "Alle projecten staan vast — er is niets te herverdelen. Maak er één los." + } }, "color": "Kleur" }, diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 21f33a68f..1b723e7fe 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -680,7 +680,7 @@ "title": "Rozdziel między projekty", "back": "Powrót do obłożenia", "subtitle": "{{item}} — rozdziel zdolność rezydualną między otwarte projekty", - "intro": "Wybierz, kto ma być najbardziej oszczędzony, w razie potrzeby przypnij projekt albo nadaj mu limit i sprawdź na wykresie, co zostaje z podwójnej rezerwacji. Zastosuj zapisuje przesunięcia we wszystkich objętych projektach.", + "intro": "Przeciągnij koniec paska w prawo, aby ten projekt mógł się przedłużyć; przypnij projekt, który nie może się przesuwać. Zastosuj zapisuje przesunięcia we wszystkich powiązanych projektach.", "blocked": { "uncounted": "Najpierw przelicz: {{docs}} nie jest jeszcze obliczony, więc nie ma czego rozdzielać.", "material": "To jest pozycja materiałowa; rozdzielanie działa tylko dla osób i sprzętu.", @@ -698,7 +698,17 @@ "priceOff": "wyłączone: {{price}}", "priceOn": "włączone: {{price}}", "priceSame": "{{price}} — w obu ustawieniach", - "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie" + "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie", + "savesOff_one": "zaoszczędziłoby {{count}} dzień roboczy", + "savesOff_few": "zaoszczędziłoby {{count}} dni robocze", + "savesOff_many": "zaoszczędziłoby {{count}} dni roboczych", + "savesOff_other": "zaoszczędziłoby {{count}} dnia roboczego", + "savesOn_one": "oszczędza {{count}} dzień roboczy", + "savesOn_few": "oszczędza {{count}} dni robocze", + "savesOn_many": "oszczędza {{count}} dni roboczych", + "savesOn_other": "oszczędza {{count}} dnia roboczego", + "savesNoneOff": "nic by nie zaoszczędziło", + "savesNoneOn": "nic nie oszczędza" }, "rank": { "title": "Kto jest najbardziej oszczędzany?", @@ -725,7 +735,8 @@ "recompute": "Przelicza propozycję z bieżącą kolejnością, przypięciami i limitami.", "apply": "Zapisuje przesunięcia we wszystkich objętych projektach; każdy projekt otrzymuje własny krok cofnięcia.", "discard": "Zamyka okno bez żadnych zmian.", - "pickAnother": "Powrót do listy elementów biblioteki, aby rozdzielić inny element." + "pickAnother": "Powrót do listy elementów biblioteki, aby rozdzielić inny element.", + "reset": "Przywraca wszystkie limity i przypięcia do stanu neutralnego. Przełącznik „Zezwalaj na przerwy” pozostaje bez zmian." }, "strip": { "pin": "Przypnij", @@ -745,7 +756,13 @@ "endShift_many": "koniec +{{count}} dni", "endShift_other": "koniec +{{count}} dnia", "requestedVsAchievable": "żądano {{requested}}, najbliższe osiągalne {{achievable}}", - "handleLabel": "Pułap wydłużenia dla {{doc}}" + "handleLabel": "Pułap wydłużenia dla {{doc}}", + "legend": "Pełne bloki = dni robocze · kreskowanie = dzień przerwy · miarka: szary = zapas, czerwony = przesunięcie daty zakończenia · kropkowany = dozwolone, ale niewykorzystane", + "slack": "zapas {{days}} dni", + "pinButton": "przypnij", + "unpinButton": "przypięty — odepnij", + "maxDate": "maks. {{date}}", + "used": "wykorzystano {{used}}" }, "compute": { "recalculate": "Przelicz ponownie", @@ -787,7 +804,16 @@ "selectHint": "Wybierz pozycję biblioteki z konfliktem, aby rozpocząć rozdzielanie.", "noConflicts": "Żaden element biblioteki nie jest obecnie podwójnie zarezerwowany w otwartych projektach.", "pickAnother": "Wybierz inny element…", - "pickAnotherApplied": "Wybierz inny element (pasek cofania zniknie)…" + "pickAnotherApplied": "Wybierz inny element (pasek cofania zniknie)…", + "reset": "Resetuj", + "status": { + "resolved": "Konflikt rozwiązany — największe przesunięcie zakończenia: {{shift}} ({{doc}})", + "shortfall_one": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", + "shortfall_few": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", + "shortfall_many": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", + "shortfall_other": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", + "allPinned": "Wszystkie projekty są przypięte — nie ma czego rozdzielać. Odepnij jeden." + } }, "color": "Kolor" }, diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index c520e8b22..d817df4ba 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -668,7 +668,7 @@ "title": "Distribuir entre projetos", "back": "Voltar à ocupação", "subtitle": "{{item}} — distribuir a capacidade residual entre os projetos abertos", - "intro": "Escolha quem é mais poupado, fixe ou limite um projeto quando necessário e veja no gráfico o que resta da reserva dupla. Aplicar escreve os deslocamentos em todos os projetos envolvidos.", + "intro": "Arraste o fim de uma barra para a direita para deixar esse projeto estender-se; fixe um projeto que não pode mover-se. Aplicar escreve os deslocamentos em todos os projetos envolvidos.", "blocked": { "uncounted": "Calcule primeiro: {{docs}} ainda não foi calculado, por isso não há nada para distribuir.", "material": "Este é um item de material; a distribuição só funciona para pessoas e equipamentos.", @@ -685,7 +685,15 @@ "priceOff": "desativado: {{price}}", "priceOn": "ativado: {{price}}", "priceSame": "{{price}} — em ambos os casos", - "priceUnknown": "preço desconhecido — prima Recalcular" + "priceUnknown": "preço desconhecido — prima Recalcular", + "savesOff_one": "pouparia {{count}} dia útil", + "savesOff_many": "pouparia {{count}} dias úteis", + "savesOff_other": "pouparia {{count}} dias úteis", + "savesOn_one": "poupa {{count}} dia útil", + "savesOn_many": "poupa {{count}} dias úteis", + "savesOn_other": "poupa {{count}} dias úteis", + "savesNoneOff": "não pouparia nada", + "savesNoneOn": "não poupa nada" }, "rank": { "title": "Quem é mais poupado?", @@ -711,7 +719,8 @@ "recompute": "Recalcula a proposta com a ordem, as fixações e os limites atuais.", "apply": "Escreve os deslocamentos em todos os projetos envolvidos; cada projeto recebe o seu próprio passo de anulação.", "discard": "Fecha a caixa de diálogo sem alterar nada.", - "pickAnother": "Voltar à lista de itens da biblioteca, para distribuir outro item." + "pickAnother": "Voltar à lista de itens da biblioteca, para distribuir outro item.", + "reset": "Repõe todos os limites e fixações no estado neutro. O interruptor «Permitir interrupções» fica como está." }, "strip": { "pin": "Fixar", @@ -729,7 +738,13 @@ "endShift_many": "fim +{{count}} dias", "endShift_other": "fim +{{count}} dias", "requestedVsAchievable": "pedido {{requested}}, mais próximo alcançável {{achievable}}", - "handleLabel": "Teto de atraso para {{doc}}" + "handleLabel": "Teto de atraso para {{doc}}", + "legend": "Blocos cheios = dias úteis · tracejado = dia de pausa · régua: cinzento = folga, vermelho = deslocamento da data de fim · pontilhado = permitido mas não usado", + "slack": "folga {{days}} dias", + "pinButton": "fixar", + "unpinButton": "fixado — soltar", + "maxDate": "máx. {{date}}", + "used": "usado {{used}}" }, "compute": { "recalculate": "Recalcular", @@ -769,7 +784,15 @@ "selectHint": "Escolha um item de biblioteca com um conflito para começar a distribuir.", "noConflicts": "Nenhum item da biblioteca está atualmente com reserva dupla nos projetos abertos.", "pickAnother": "Escolher outro item…", - "pickAnotherApplied": "Escolher outro item (a barra de desfazer desaparece)…" + "pickAnotherApplied": "Escolher outro item (a barra de desfazer desaparece)…", + "reset": "Repor", + "status": { + "resolved": "Conflito resolvido — maior deslocamento de fim: {{shift}} ({{doc}})", + "shortfall_one": "Ainda falta {{count}} dia-equipa em {{days}}…", + "shortfall_many": "Ainda faltam {{count}} dias-equipa em {{days}}…", + "shortfall_other": "Ainda faltam {{count}} dias-equipa em {{days}}…", + "allPinned": "Todos os projetos estão fixados — não há nada para redistribuir. Solte um." + } }, "color": "Cor" }, diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index a4adf96a7..c117d13d7 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -656,7 +656,7 @@ "title": "Projeler arasında dağıt", "back": "Doluluğa dön", "subtitle": "{{item}} — kalan kapasiteyi açık projeler arasında dağıt", - "intro": "En çok kimin korunacağını seçin, gerekirse bir projeyi sabitleyin veya ona bir tavan verin ve çift rezervasyondan geriye ne kaldığını grafikte görün. Uygula, kaymaları ilgili tüm projelere yazar.", + "intro": "Bir projenin taşmasına izin vermek için çubuğun ucunu sağa sürükleyin; hareket etmemesi gereken projeyi sabitleyin. Uygula, kaymaları ilgili tüm projelere yazar.", "blocked": { "uncounted": "Önce hesapla: {{docs}} henüz hesaplanmadı, bu yüzden dağıtılacak bir şey yok.", "material": "Bu bir malzeme kalemi; dağıtım yalnızca kişiler ve ekipman için çalışır.", @@ -672,7 +672,13 @@ "priceOff": "kapalı: {{price}}", "priceOn": "açık: {{price}}", "priceSame": "{{price}} — her iki durumda da", - "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın" + "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın", + "savesOff_one": "{{count}} iş günü kazandırırdı", + "savesOff_other": "{{count}} iş günü kazandırırdı", + "savesOn_one": "{{count}} iş günü kazandırıyor", + "savesOn_other": "{{count}} iş günü kazandırıyor", + "savesNoneOff": "hiçbir şey kazandırmazdı", + "savesNoneOn": "hiçbir şey kazandırmıyor" }, "rank": { "title": "En çok kim korunuyor?", @@ -697,7 +703,8 @@ "recompute": "Öneriyi mevcut sıra, sabitlemeler ve tavanlarla yeniden hesaplar.", "apply": "Kaymaları ilgili tüm projelere yazar; her proje kendi geri alma adımını alır.", "discard": "İletişim kutusunu hiçbir şeyi değiştirmeden kapatır.", - "pickAnother": "Başka bir öğeyi dağıtmak için kitaplık öğeleri listesine dönün." + "pickAnother": "Başka bir öğeyi dağıtmak için kitaplık öğeleri listesine dönün.", + "reset": "Tüm tavanları ve sabitlemeleri nötr duruma döndürür. \"Kesintilere izin ver\" anahtarı olduğu gibi kalır." }, "strip": { "pin": "Sabitle", @@ -713,7 +720,13 @@ "endShift_one": "bitiş +{{count}} gün", "endShift_other": "bitiş +{{count}} gün", "requestedVsAchievable": "istenen {{requested}}, en yakın ulaşılabilir {{achievable}}", - "handleLabel": "{{doc}} için kaydırma tavanı" + "handleLabel": "{{doc}} için kaydırma tavanı", + "legend": "Dolu bloklar = iş günleri · taralı = mola günü · cetvel: gri = bolluk, kırmızı = bitiş tarihi kayması · noktalı = izin verildi ama kullanılmadı", + "slack": "bolluk {{days}} gün", + "pinButton": "sabitle", + "unpinButton": "sabit — çöz", + "maxDate": "en geç {{date}}", + "used": "kullanılan {{used}}" }, "compute": { "recalculate": "Yeniden hesapla", @@ -751,7 +764,14 @@ "selectHint": "Dağıtmaya başlamak için çakışan bir kitaplık öğesi seçin.", "noConflicts": "Şu anda açık projeler arasında çift rezerve edilmiş bir kitaplık öğesi yok.", "pickAnother": "Başka bir öğe seç…", - "pickAnotherApplied": "Başka bir öğe seç (geri alma çubuğu kaybolur)…" + "pickAnotherApplied": "Başka bir öğe seç (geri alma çubuğu kaybolur)…", + "reset": "Sıfırla", + "status": { + "resolved": "Çakışma çözüldü — en büyük bitiş kayması: {{shift}} ({{doc}})", + "shortfall_one": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", + "shortfall_other": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", + "allPinned": "Tüm projeler sabit — yeniden dağıtılacak bir şey yok. Birini çözün." + } }, "color": "Renk" }, diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 915e23ccd..cbb40a27b 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -644,7 +644,7 @@ "title": "在项目间分配", "back": "返回占用情况", "subtitle": "{{item}} — 在打开的项目之间分配剩余产能", - "intro": "选择最应被保全的项目,必要时固定某个项目或为其设置上限,然后在图表中查看重复占用还剩下多少。应用会把位移写入所有相关项目。", + "intro": "向右拖动条形的末端,让该项目延期;将不能移动的项目固定。应用会把偏移写入所有相关项目。", "blocked": { "uncounted": "请先计算:{{docs}} 尚未计算,因此没有可分配的内容。", "material": "这是一个材料项;分配仅适用于人员和设备。", @@ -659,7 +659,11 @@ "priceOff": "关闭:{{price}}", "priceOn": "开启:{{price}}", "priceSame": "{{price}} — 两种设置下相同", - "priceUnknown": "价格未知 — 按下重新计算" + "priceUnknown": "价格未知 — 按下重新计算", + "savesOff_other": "可节省 {{count}} 个工作日", + "savesOn_other": "节省 {{count}} 个工作日", + "savesNoneOff": "不会节省时间", + "savesNoneOn": "没有节省" }, "rank": { "title": "谁得到最大程度的照顾?", @@ -683,7 +687,8 @@ "recompute": "按当前的顺序、固定项和上限重新计算方案。", "apply": "把位移写入所有相关项目;每个项目都会获得自己的撤销步骤。", "discard": "关闭对话框,不做任何更改。", - "pickAnother": "返回资源库项目列表,以分配其他项目。" + "pickAnother": "返回资源库项目列表,以分配其他项目。", + "reset": "将所有上限和固定恢复为中立状态。「允许中断」开关保持不变。" }, "strip": { "pin": "固定", @@ -697,7 +702,13 @@ "endUnchanged": "结束日期不变", "endShift_other": "结束日期 +{{count}} 天", "requestedVsAchievable": "请求 {{requested}},最接近可实现 {{achievable}}", - "handleLabel": "{{doc}} 的延误上限" + "handleLabel": "{{doc}} 的延误上限", + "legend": "实心方块=工作日 · 斜纹=暂停日 · 标尺:灰色=浮时,红色=完工日期偏移 · 虚线=已允许但未使用", + "slack": "浮时 {{days}} 天", + "pinButton": "固定", + "unpinButton": "已固定 — 解除", + "maxDate": "最迟 {{date}}", + "used": "已用 {{used}}" }, "compute": { "recalculate": "重新计算", @@ -733,7 +744,13 @@ "selectHint": "选择一个存在冲突的资源库项目开始分配。", "noConflicts": "当前没有任何资源库条目在已打开的项目之间被重复预订。", "pickAnother": "选择其他条目…", - "pickAnotherApplied": "选择其他条目(撤销条将消失)…" + "pickAnotherApplied": "选择其他条目(撤销条将消失)…", + "reset": "重置", + "status": { + "resolved": "冲突已解决 — 最大完工日期偏移:{{shift}}({{doc}})", + "shortfall_other": "{{days}} 仍缺 {{count}} 个班组日…", + "allPinned": "所有项目都已固定 — 没有可重新分配的内容。请解除其中一个。" + } }, "color": "颜色" }, From eeb9fc892df3d9da6d8550a4530df35d15df6017 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:58:14 +0200 Subject: [PATCH 057/108] feat(b1c): pure balkgeometrie en verschil-prijs voor de verdeeldialoog (plan 4, taak 1) Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/stripGeometry.ts | 258 ++++++++++++++++++ .../check-distribution-strip-geometry.ts | 152 +++++++++++ tests/planning/run.sh | 6 + 3 files changed, 416 insertions(+) create mode 100644 src/components/dialogs/DistributionDialog/stripGeometry.ts create mode 100644 tests/planning/check-distribution-strip-geometry.ts diff --git a/src/components/dialogs/DistributionDialog/stripGeometry.ts b/src/components/dialogs/DistributionDialog/stripGeometry.ts new file mode 100644 index 000000000..250e63c8a --- /dev/null +++ b/src/components/dialogs/DistributionDialog/stripGeometry.ts @@ -0,0 +1,258 @@ +// B1c-plan4 taak 1 — de PURE balkgeometrie van één projectrij in de verdeeldialoog (spec §4). +// +// WAAROM DIT EEN EIGEN MODULE IS. De balk is sinds dit herontwerp de BEDIENING, niet een plaatje: +// hij toont per werkdag een blokje, maakt een ingevoegde pauze zichtbaar, tekent hoeveel ruimte je +// toestond maar niet nodig bleek, en meet met een meetlat af hoeveel van de verschuiving nog binnen +// de eigen speling valt. Dat is genoeg regelwerk om buiten React te horen: hier is het headless +// toetsbaar (`tests/planning/check-distribution-strip-geometry.ts`) en kan `PhaseStrip.tsx` zich tot +// tekenen en gebaren beperken. +// +// PUUR, GEEN DOMEINTYPES. De werkdagvraag komt als predicaat binnen (`isWorkingDay`), niet als +// `WorkCalendar` — zo hoeft de check geen kalendermotor op te tuigen en kan de aanroeper gewoon de +// `CalendarEngine` van het betreffende document doorgeven. Dezelfde keuze als `chartGeometry.ts`, dat +// de capaciteit als `capacityOn`-functie aanneemt. +// +// DE MEETLAT MEET DE FASE, NIET HET PROJECT (KEUZE VAN DIT PLAN, zie het plan bij taak 1). Grijs +// gestippeld = de BESCHIKBARE speling vanaf het oorspronkelijke fase-einde; massief rood = alles +// daarvoorbij. Dat is letterlijk `.pfl`/`.pover` uit het prototype, en het klopt per constructie met +// `endShiftWorkdays`: zolang dat 0 is, blijft het fase-einde binnen de grijze meetlat en is er geen +// rood. +// +// EEN WEEKEND IS GEEN PAUZEDAG. De as loopt op KALENDERdagen, dus zonder de werkdagtoets zou elke +// balk vol arcering staan en zou een echte, ingevoegde onderbreking niet meer opvallen — precies het +// gebrek dat de gebruikstest van 2026-09-12 aanwees ("pauzes waren onzichtbaar"). +import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; +import { parseDate } from '@/utils/dateUtils'; + +/** De maatvoering van één rij, letterlijk uit het prototype (tabblad 4, `.prow`/`.ptrack.tall`). + * Eén definitie, gedeeld door `PhaseStrip.tsx` (de rij zelf) en `DistributionDialog.tsx` (de + * uitlijning van het histogram eronder) — anders lopen balk en histogram alsnog uit elkaar. */ +export const STRIP = { + /** `.plabel` — kleurstip, projectnaam, "speling N dagen". */ + labelWidth: 150, + /** `.pend` — de uitkomstpil met "max · benut N". */ + endWidth: 132, + /** `.prow { gap: 10px }`. */ + gap: 10, + /** `.ptrack.tall { height: 32px }`. */ + trackHeight: 32, + /** `.ptrack.tall .pblk { top: 5px; height: 15px }`. */ + blockHeight: 15, + blockTop: 5, + /** `.pfl`/`.pover { bottom: 3px; height: 3px }`. */ + measureHeight: 3, + measureBottom: 3, + /** `.phandle { top: 1px; height: 30px; width: 15px; margin-left: -7px }`. */ + handleWidth: 15, + handleHeight: 30, + handleTop: 1, +} as const; + +export type StripBlockKind = 'work' | 'pause'; + +/** Eén dagblokje in de track. `w` is altijd één dagbreedte; de 1 px witte scheiding rechts is een + * tekenkeuze van de component, geen geometrie. */ +export interface StripBlock { + iso: string; + x: number; + w: number; + kind: StripBlockKind; +} + +/** Een aaneengesloten horizontale band (vaste last, gestippelde rest, meetlat). */ +export interface StripBand { + x: number; + w: number; +} + +export interface StripGeometry { + blocks: StripBlock[]; + /** De vaste last van gepinde/#63-documenten, samengevoegd tot aaneengesloten banden. */ + fixedBands: StripBand[]; + /** "Toegestaan maar niet benut": van het fase-einde tot de handle. `null` ⇒ niets over. */ + freeBox: StripBand | null; + /** De beschikbare eigen speling, vanaf het OORSPRONKELIJKE fase-einde. */ + slackBar: StripBand | null; + /** Alles voorbij die speling — dus echte einddatum-verschuiving. */ + overrunBar: StripBand | null; + /** x van het MIDDEN van de handle. */ + handleX: number; + /** x van de rechterrand van de laatste geboekte dag. */ + phaseEndX: number; + /** De kalenderdag waarop het plafond uitkomt; `null` bij een onbegrensd plafond. */ + ceilingEndIso: string | null; + /** x-posities van de 1 px weekscheidingen (elke maandag) … */ + weekLines: number[]; + /** … en van de donkerdere maandlijnen (elke eerste van de maand). */ + monthLines: number[]; +} + +export interface StripGeometryInput { + axis: OccupancyAxis; + /** `DistributionProposal.bookingByDay[docId]` — de stand VÓÓR de verdeling. Levert het + * oorspronkelijke fase-einde, het ankerpunt van de meetlat. */ + beforeLoadByDay: Record; + /** `DistributionProposal.afterLoadByDay[docId]` — de stand NÁ de verdeling; dít wordt getekend. */ + loadByDay: Record; + /** `DistributionProposal.fixedLoadByDay`, met de eigen boeking er al afgetrokken wanneer dit + * document zélf gepind is (dat doet de aanroeper — een gepind document zit in de vaste last). */ + fixedLoadByDay: Record; + isWorkingDay: (iso: string) => boolean; + /** De eigen speling in hele werkdagen (`documentFloatOn`, geklemd op ≥ 0). */ + slackWorkdays: number; + /** Het ingestelde plafond in werkdagen; `null` = onbegrensd. */ + ceilingWorkdays: number | null; + /** De benutte uitloop (`DistributionDocResult.endShiftWorkdays`). */ + endShiftWorkdays: number; +} + +/** Alle kalenderdagen van de as met hun x, in tekenvolgorde. */ +function axisDayXs(axis: OccupancyAxis): { iso: string; x: number }[] { + const out: { iso: string; x: number }[] = []; + for (const segment of axis.segments) { + for (let i = 0; i < segment.days.length; i++) { + out.push({ iso: segment.days[i], x: segment.x0 + i * axis.dayWidth }); + } + } + return out; +} + +/** De laatste dag met een boeking, of `null` wanneer er niets geboekt is. */ +function lastBookedDay(loadByDay: Record): string | null { + let last: string | null = null; + for (const [iso, units] of Object.entries(loadByDay)) { + if (units > 0 && (last === null || iso > last)) last = iso; + } + return last; +} + +/** De eerste dag met een boeking, of `null`. */ +function firstBookedDay(loadByDay: Record): string | null { + let first: string | null = null; + for (const [iso, units] of Object.entries(loadByDay)) { + if (units > 0 && (first === null || iso < first)) first = iso; + } + return first; +} + +/** + * Loop vanaf (exclusief) `fromIso` `workdays` WERKdagen vooruit over de as en geef de dag waarop je + * uitkomt plus de x van zijn rechterrand. Loopt de as op vóór het aantal bereikt is, dan levert hij + * het aseinde — de handle mag de as nooit verlaten. + */ +function advanceWorkdays( + axis: OccupancyAxis, + days: { iso: string; x: number }[], + fromIso: string | null, + workdays: number, + isWorkingDay: (iso: string) => boolean, +): { iso: string | null; xEnd: number } { + const startX = fromIso === null + ? AXIS.padLeft + : (axis.xOf(fromIso) ?? AXIS.padLeft) + axis.dayWidth; + if (workdays <= 0) return { iso: fromIso, xEnd: startX }; + let remaining = workdays; + for (const day of days) { + if (fromIso !== null && day.iso <= fromIso) continue; + if (!isWorkingDay(day.iso)) continue; + remaining -= 1; + if (remaining === 0) return { iso: day.iso, xEnd: day.x + axis.dayWidth }; + } + return { iso: null, xEnd: axis.width - AXIS.padRight }; +} + +/** De volledige geometrie van één rij. Zie het moduleblok voor de vier lagen. */ +export function buildStripGeometry(input: StripGeometryInput): StripGeometry { + const { + axis, beforeLoadByDay, loadByDay, fixedLoadByDay, isWorkingDay, + slackWorkdays, ceilingWorkdays, endShiftWorkdays, + } = input; + const dayWidth = axis.dayWidth; + const days = axisDayXs(axis); + const trackRight = axis.width - AXIS.padRight; + + // (1) De dagblokjes van de NA-stand, binnen de fasespanne. + const first = firstBookedDay(loadByDay); + const last = lastBookedDay(loadByDay); + const blocks: StripBlock[] = []; + if (first !== null && last !== null) { + for (const day of days) { + if (day.iso < first || day.iso > last) continue; + const booked = (loadByDay[day.iso] ?? 0) > 0; + if (booked) blocks.push({ iso: day.iso, x: day.x, w: dayWidth, kind: 'work' }); + else if (isWorkingDay(day.iso)) blocks.push({ iso: day.iso, x: day.x, w: dayWidth, kind: 'pause' }); + // Een niet-werkdag zonder boeking is geen pauze maar gewoon weekend: niets tekenen. + } + } + const phaseEndX = last === null ? AXIS.padLeft : (axis.xOf(last) ?? AXIS.padLeft) + dayWidth; + + // (2) De vaste last, samengevoegd tot aaneengesloten banden. + const fixedBands: StripBand[] = []; + let open: StripBand | null = null; + for (const day of days) { + if ((fixedLoadByDay[day.iso] ?? 0) > 0) { + if (open !== null && Math.abs(open.x + open.w - day.x) < 0.001) open.w += dayWidth; + else { open = { x: day.x, w: dayWidth }; fixedBands.push(open); } + } else { + open = null; + } + } + + // (3) De meetlat, verankerd aan het OORSPRONKELIJKE fase-einde. + const origEnd = lastBookedDay(beforeLoadByDay); + const origEndX = origEnd === null ? AXIS.padLeft : (axis.xOf(origEnd) ?? AXIS.padLeft) + dayWidth; + const slack = advanceWorkdays(axis, days, origEnd, Math.max(0, Math.floor(slackWorkdays)), isWorkingDay); + const slackEndX = Math.min(trackRight, slack.xEnd); + const slackBar = slackEndX > origEndX ? { x: origEndX, w: slackEndX - origEndX } : null; + const overrunBar = phaseEndX > slackEndX ? { x: slackEndX, w: phaseEndX - slackEndX } : null; + + // (4) Het plafond: de handle en de gestippelde rest. Onbegrensd ⇒ aan het einde van de as (§5). + let handleX: number; + let ceilingEndIso: string | null; + if (ceilingWorkdays === null) { + handleX = trackRight; + ceilingEndIso = null; + } else { + // Het plafond is een uitloop van de EINDDATUM, en `endShiftWorkdays` zegt hoeveel daarvan al + // benut is. Wat er dan nog openstaat — `ceilingWorkdays - endShiftWorkdays` werkdagen — ligt + // per definitie ACHTER het huidige fase-einde: dat is precies de "toegestaan maar niet + // benut"-doos van spec §4 ("van het nieuwe fase-einde tot de handle-stand") en het maakt de + // aflezing van het uitkomstlabel ("max · benut N") kloppend. Het oorspronkelijke einde + // is hier bewust NIET het anker: een fase kan binnen haar eigen speling opschuiven zonder de + // einddatum te raken (`endShiftWorkdays` blijft dan 0), en met een anker op het oude einde zou + // de vrije ruimte dan stilzwijgend met die verschuiving krimpen terwijl er niets van de uitloop + // verbruikt is. + const remaining = Math.max(0, Math.floor(ceilingWorkdays) - Math.max(0, Math.floor(endShiftWorkdays))); + const ceiling = advanceWorkdays(axis, days, last, remaining, isWorkingDay); + handleX = Math.max(AXIS.padLeft, Math.min(trackRight, ceiling.xEnd)); + ceilingEndIso = ceiling.iso; + } + const freeBox = handleX > phaseEndX ? { x: phaseEndX, w: handleX - phaseEndX } : null; + + // (5) Week- en maandlijnen. + const weekLines: number[] = []; + const monthLines: number[] = []; + for (const day of days) { + if (day.iso.slice(8) === '01') monthLines.push(day.x); + else if (parseDate(day.iso).getDay() === 1) weekLines.push(day.x); + } + + return { + blocks, fixedBands, freeBox, slackBar, overrunBar, + handleX, phaseEndX, ceilingEndIso, weekLines, monthLines, + }; +} + +/** De grootste einddatum-verschuiving over de deelnemers — de UITSCHIETER, niet de som (§2.2). */ +export function maxEndShiftWorkdays(docs: { endShiftWorkdays: number }[]): number { + return docs.reduce((max, doc) => Math.max(max, doc.endShiftWorkdays), 0); +} + +/** + * Het verschil-prijskaartje van "Onderbrekingen toestaan" (§2.2): hoeveel werkdagen de aan-stand + * bespaart ten opzichte van de uit-stand. Geklemd op 0 — onderbreken kán in theorie duurder + * uitvallen, en dan is "bespaart niets" het eerlijke antwoord. + */ +export function savingsWorkdays(offMax: number, onMax: number): number { + return Math.max(0, offMax - onMax); +} diff --git a/tests/planning/check-distribution-strip-geometry.ts b/tests/planning/check-distribution-strip-geometry.ts new file mode 100644 index 000000000..566ec764f --- /dev/null +++ b/tests/planning/check-distribution-strip-geometry.ts @@ -0,0 +1,152 @@ +// B1c-plan4 taak 1 — de PURE balkgeometrie van de verdeeldialoog (spec §4/§9). +// +// Drie gevallen uit spec §9, plus de verschil-prijs van §2.2. De as is bewust minimaal: één +// aaneengesloten reeks kalenderdagen, zodat elke x-positie met de hand na te rekenen is +// (`AXIS.padLeft + index * dayWidth`). +// +// De werkdagdefinitie komt als predicaat binnen — deze check gebruikt "ma t/m vr", zodat er geen +// kalendermotor aan te pas komt en de pauzedag-logica geïsoleerd getoetst wordt. +import { AXIS, buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; +import { + STRIP, buildStripGeometry, savingsWorkdays, maxEndShiftWorkdays, +} from '@/components/dialogs/DistributionDialog/stripGeometry'; + +let failures = 0; +function ok(cond: boolean, msg: string): void { + if (cond) console.log(`OK ${msg}`); + else { console.log(`XX ${msg}`); failures++; } +} + +// 2026-09-07 is een maandag. Twee volle weken + een dag. +const DAYS = expandDays('2026-09-07', '2026-09-25'); +const axis = buildOccupancyAxis(DAYS, { targetWidth: 560 })!; +const dw = axis.dayWidth; +const xAt = (iso: string): number => axis.xOf(iso)!; +const isWorkingDay = (iso: string): boolean => { + const day = new Date(`${iso}T00:00:00`).getDay(); + return day >= 1 && day <= 5; +}; +const load = (isos: string[]): Record => + Object.fromEntries(isos.map(iso => [iso, 1])); + +ok(axis !== null, 'de as over de fixture-dagen bestaat'); +ok(dw > 0, `de dagbreedte is positief (${dw})`); + +// ── Geval 1: BINNEN DE SPELING ─────────────────────────────────────────────────────────────── +// Oorspronkelijk ma 7 t/m wo 9; na de verdeling di 8 t/m do 10. Speling 3 werkdagen: het nieuwe +// einde valt er ruim binnen, dus er hoort GEEN rood te staan. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-08', '2026-09-09', '2026-09-10']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 3, + ceilingWorkdays: 3, + endShiftWorkdays: 0, + }); + + ok(g.blocks.length === 3, `1 — drie dagblokjes (kreeg ${g.blocks.length})`); + ok(g.blocks.every(b => b.kind === 'work'), '1 — alle drie zijn werkdagblokjes, geen pauzedag'); + ok(g.blocks[0].x === xAt('2026-09-08'), '1 — het eerste blokje staat op de nieuwe startdag'); + ok(g.blocks[0].w === dw, '1 — een blokje is precies één dagbreedte'); + ok(g.phaseEndX === xAt('2026-09-10') + dw, '1 — het fase-einde ligt achter de laatste geboekte dag'); + + // De meetlat: grijs vanaf het OORSPRONKELIJKE einde (wo 9 + 1 = do 10), drie werkdagen lang + // (do 10, vr 11, ma 14) — dus tot en met ma 14, x = xAt(ma 14) + dw. + ok(g.slackBar !== null, '1 — er is een grijze spelingsmeetlat'); + ok(g.slackBar!.x === xAt('2026-09-09') + dw, '1 — de speling begint achter het oorspronkelijke fase-einde'); + ok(g.slackBar!.x + g.slackBar!.w === xAt('2026-09-14') + dw, + '1 — drie WERKdagen speling slaat het weekend over en eindigt na ma 14'); + ok(g.overrunBar === null, '1 — binnen de speling staat er geen rood'); + + // Plafond 3 werkdagen, benut 0 ⇒ de handle staat drie werkdagen voorbij het fase-einde + // (vr 11, ma 14, di 15). + ok(g.handleX === xAt('2026-09-15') + dw, '1 — de handle staat drie werkdagen voorbij het fase-einde'); + ok(g.freeBox !== null && g.freeBox.x === g.phaseEndX, + '1 — de gestippelde rest begint op het fase-einde'); + ok(g.freeBox !== null && g.freeBox.x + g.freeBox.w === g.handleX, + '1 — de gestippelde rest loopt tot de handle'); + ok(g.ceilingEndIso === '2026-09-15', `1 — de plafonddatum is di 15 (kreeg ${g.ceilingEndIso})`); +} + +// ── Geval 2: VOORBIJ DE SPELING ────────────────────────────────────────────────────────────── +// Zelfde oorspronkelijke fase, maar het nieuwe einde ligt op do 17 en de speling is maar 1 werkdag. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-15', '2026-09-16', '2026-09-17']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 1, + ceilingWorkdays: null, + endShiftWorkdays: 5, + }); + + // Eén werkdag speling vanaf do 10 ⇒ de grijze meetlat eindigt na do 10. + ok(g.slackBar !== null && g.slackBar.x + g.slackBar.w === xAt('2026-09-10') + dw, + '2 — de grijze meetlat is precies één werkdag lang'); + ok(g.overrunBar !== null, '2 — voorbij de speling staat een rode meetlat'); + ok(g.overrunBar!.x === xAt('2026-09-10') + dw, '2 — het rood begint waar de speling ophoudt'); + ok(g.overrunBar!.x + g.overrunBar!.w === g.phaseEndX, '2 — het rood loopt tot het fase-einde'); + // Onbegrensd plafond ⇒ de handle staat aan het einde van de as (spec §5). + ok(g.handleX === axis.width - AXIS.padRight, '2 — een onbegrensd plafond zet de handle aan het aseinde'); + ok(g.ceilingEndIso === null, '2 — een onbegrensd plafond heeft geen plafonddatum'); +} + +// ── Geval 3: MET ONDERBREKINGEN ────────────────────────────────────────────────────────────── +// De fase loopt ma 7 → vr 11, met di 8 en do 10 als ingevoegde pauzedagen. Het weekend ertussen +// telt NIET als pauzedag. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const after = load(['2026-09-07', '2026-09-09', '2026-09-11']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: load(['2026-09-07', '2026-09-08']), + isWorkingDay, + slackWorkdays: 2, + ceilingWorkdays: 2, + endShiftWorkdays: 1, + }); + + ok(g.blocks.length === 5, `3 — vijf blokjes: drie werkdagen + twee pauzedagen (kreeg ${g.blocks.length})`); + const pauses = g.blocks.filter(b => b.kind === 'pause').map(b => b.iso); + ok(pauses.join(',') === '2026-09-08,2026-09-10', + `3 — precies di 8 en do 10 zijn pauzedagen (kreeg ${pauses.join(',')})`); + ok(g.blocks.every(b => b.iso >= '2026-09-07' && b.iso <= '2026-09-11'), + '3 — er staat niets buiten de fasespanne'); + + // De vaste last is één aaneengesloten band over ma 7 en di 8. + ok(g.fixedBands.length === 1, `3 — de vaste last is één band (kreeg ${g.fixedBands.length})`); + ok(g.fixedBands[0].x === xAt('2026-09-07') && g.fixedBands[0].w === 2 * dw, + '3 — die band beslaat precies twee dagen'); + + // Week- en maandlijnen: maandagen 7, 14 en 21 binnen dit domein; geen 1e van de maand. + ok(g.weekLines.length === 3, `3 — drie weekscheidingen (kreeg ${g.weekLines.length})`); + ok(g.monthLines.length === 0, '3 — geen maandlijn binnen september'); +} + +// ── De verschil-prijs (§2.2) ───────────────────────────────────────────────────────────────── +ok(savingsWorkdays(5, 2) === 3, 'prijs — uit 5, aan 2 ⇒ bespaart 3 werkdagen'); +ok(savingsWorkdays(4, 4) === 0, 'prijs — even duur ⇒ bespaart niets'); +ok(savingsWorkdays(1, 3) === 0, 'prijs — aan is duurder ⇒ geklemd op nul, nooit negatief'); +ok(maxEndShiftWorkdays([{ endShiftWorkdays: 1 }, { endShiftWorkdays: 4 }, { endShiftWorkdays: 0 }]) === 4, + 'prijs — de prijs is de UITSCHIETER over de deelnemers, niet de som'); +ok(maxEndShiftWorkdays([]) === 0, 'prijs — zonder deelnemers is de prijs nul'); + +// ── De maatvoering uit het prototype ───────────────────────────────────────────────────────── +ok(STRIP.labelWidth === 150 && STRIP.endWidth === 132 && STRIP.trackHeight === 32, + 'maten — label 150, uitkomstlabel 132, track 32 (prototype tabblad 4)'); +ok(STRIP.handleWidth === 15 && STRIP.handleHeight === 30, 'maten — de handle is 15×30'); +ok(STRIP.blockHeight === 15 && STRIP.blockTop === 5, 'maten — een dagblokje is 15 hoog op y=5'); + +if (failures > 0) { console.log(`\n${failures} fout(en)`); process.exit(1); } +console.log('\nalles groen'); diff --git a/tests/planning/run.sh b/tests/planning/run.sh index 9dcd9da57..f7ac52abc 100755 --- a/tests/planning/run.sh +++ b/tests/planning/run.sh @@ -711,6 +711,12 @@ if [ "$RUN_HOLIDAYS" -eq 1 ]; then DISTCHARTCHECK="$DIR/.distribution-chart-scale.mjs" if bundle_check "$DIR/check-distribution-chart-scale.ts" "$DISTCHARTCHECK"; then node "$DISTCHARTCHECK" || STATUS=1; fi + # B1c-plan4 taak 1: de pure balkgeometrie van de verdeeldialoog — dagblokjes, pauzedagen (een + # weekend is er GEEN), de gestippelde rest tot het plafond, de meetlat grijs/rood, en de + # verschil-prijs van de onderbrekingsschakelaar. + DISTSTRIPCHECK="$DIR/.distribution-strip-geometry.mjs" + if bundle_check "$DIR/check-distribution-strip-geometry.ts" "$DISTSTRIPCHECK"; then node "$DISTSTRIPCHECK" || STATUS=1; fi + # Ribbon Baselines & Progress: drie overlays links en twee kleurcontrols rechts horen ieder in # een verticale stack; losse groepsitems worden horizontaal gerenderd en maken de rij te breed. OVERLAYRIBBONCHECK="$DIR/.ribbon-overlays.mjs" From 6f1c85719227432410c700a8e71cb89d4a192ae4 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:05:08 +0200 Subject: [PATCH 058/108] feat(b1c): de projectbalk herbouwd naar het Interface-lab-prototype Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 74 ++- .../dialogs/DistributionDialog/PhaseStrip.tsx | 529 ++++++++++-------- 2 files changed, 373 insertions(+), 230 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 9b5566dfc..614dbb2b5 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -39,7 +39,7 @@ // De "naar boven/beneden"-knoppen (`move`) blijven onveranderd de toetsenbordroute en het // testanker; slepen (`reorderTo`) roept dezelfde `setUI` aan, dus een herordening met de muis zet // net als de knoppen `staleReason = 'rank'` via `diffReason` in `useDistributionProposal`. -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; @@ -49,6 +49,7 @@ import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; import { scopeTaskIdsFor } from '@/services/library/distribute'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; +import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; import { documentFloatOn, useDistributionProposal } from './useDistributionProposal'; @@ -128,6 +129,62 @@ export function DistributionDialog() { [i18n.language], ); + // B1c-plan4 taak 2 — ÉÉN datumnotatie voor alle balken (de plafonddatum in het uitkomstlabel), + // net als `numberFmt` hierboven: per rij een eigen `Intl.DateTimeFormat` bouwen is duur en zou + // per rij anders kunnen uitpakken. + const dayFmt = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), + [i18n.language], + ); + const formatDay = useCallback((iso: string) => dayFmt.format(parseDate(iso)), [dayFmt]); + + // B1c-plan4 taak 2 — de WERKDAGVRAAG per document, uit de PROJECTkalender van dat document + // (`CalendarEngine.isWorkDay`): dezelfde eenheid als `endShiftWorkdays` in `distribute.ts`, dus + // een balk arceert alleen dagen die voor de verdeler ook echt werkdagen zijn. KANTTEKENING (taak + // 1): een individuele taak kan een afwijkende taakkalender hebben — de balk leest die bewust niet, + // want hij toont de fase van een PROJECT, niet van één taak. Eén `CalendarEngine` per document, + // gememoïseerd: die constructor materialiseert feestdagen en uitzonderingen, en de balken vragen + // per kalenderdag opnieuw. + const isWorkingDayByDoc = useMemo(() => { + const byDoc = new Map boolean>(); + for (const doc of inputs) { + const engine = new CalendarEngine(doc.calendar); + byDoc.set(doc.docId, (iso: string) => engine.isWorkDay(parseDate(iso))); + } + return byDoc; + }, [inputs]); + + /** Terugval voor een document dat de laatste run niet gezien heeft: ma t/m vr. */ + const isWorkingDayFallback = useCallback( + (iso: string) => { const day = parseDate(iso).getDay(); return day >= 1 && day <= 5; }, + [], + ); + + // B1c-plan4 taak 2 — de vaste last ZOALS ÉÉN BALK HEM ZIET. Een gepind document zit ZELF in de + // vaste last (dat is wat pinnen betekent); zonder deze aftrek zou zijn eigen boeking twee keer in + // dezelfde rij staan — één keer als achtergrondband, één keer als dagblokje. De oude `PhaseStrip` + // deed die aftrek binnenin; nu de component alleen nog tekent, hoort hij hier. + const fixedLoadFor = useCallback((docId: string, pinned: boolean): Record => { + const fixed = proposal?.fixedLoadByDay ?? {}; + if (!pinned) return fixed; + const own = proposal?.bookingByDay[docId] ?? {}; + const out: Record = {}; + for (const [iso, units] of Object.entries(fixed)) { + out[iso] = Math.max(0, units - (own[iso] ?? 0)); + } + return out; + }, [proposal]); + + // De eigen speling per document (`documentFloatOn`) — het ankerpunt van de grijze meetlat. + const slackByDoc = useMemo(() => { + const byDoc = new Map(); + if (!tune) return byDoc; + for (const doc of inputs) { + byDoc.set(doc.docId, documentFloatOn(doc, tune.companyId, tune.libraryItemId)); + } + return byDoc; + }, [inputs, tune]); + // De VOLLEDIGE rangorde, inclusief docId's die de LAATSTE run niet gezien heeft (fixronde-2 // bevinding B10). `rankRows` hieronder toont alleen wat er in `inputs` zit — dat is juist voor de // weergave, maar een herordening mag daar niet uit gebouwd worden: `order = rankRows.map(...)` @@ -653,22 +710,27 @@ export function DistributionDialog() { {(stripView?.docs ?? []).map(doc => { const recorded = doc.pinnedReason === 'dates-as-recorded'; + const pinnedNow = recorded || tune.pinned[doc.docId] === true; return ( setPinned(doc.docId, tune.pinned[doc.docId] !== true)} onCeilingChange={next => setCeiling(doc.docId, next)} /> diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 501fe9d5f..418fc2dc6 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -1,53 +1,42 @@ -// B1c-plan3 taak 9/10 — de FASESTROOK van één document in de verdeeldialoog (spec §6). +// B1c-plan4 taak 2 — de BALK van één project in de verdeeldialoog (spec §4/§5/§6). // -// WAT DE STROOK TOONT. Eén SVG-rij op de tijdas die de dialoog één keer bouwt (`occupancyAxis.ts`) -// en aan alle stroken én aan de voor/na-grafiek doorgeeft — binnen déze dialoog komen die dus per -// constructie op dezelfde x-posities uit. Met het histogram van het bezettingsoverzicht deelt hij -// alleen de as-CODE, niet de as-instantie: dat overzicht rekent op een andere richtbreedte -// (`targetWidth` 760 tegen 560 hier) en heeft dus een eigen dagbreedte (bevinding B9). -// - de VASTE LAST als achtergrondband: wat gepinde/#63-documenten al van het poolitem opeisen; -// - de BOEKING van dít document als gevulde blokken over de dagen waarop het echt boekt. Dagen -// zonder boeking binnen de spanne blijven leeg — dat is precies wat een bestaande split of een -// ingevoegde pauze zichtbaar maakt; -// - een GESTIPPELDE STAART van het einde van de benutte uitloop tot het plafond: toegestaan, maar -// niet benut. +// DE BALK IS DE BEDIENING. Vóór dit herontwerp was de strook een dun blokje van 24 px waarin je +// dagen niet kon tellen, pauzes niet zag en tijdens het slepen niets bewoog; de eigenaar vatte dat +// op 2026-09-12 samen als "ik snap niet wat alle knoppen doen". De vorm hier is die van het +// gekozen prototype (`docs/superpowers/prototypes/2026-08-27-b1c-interface-lab.html`, tabblad 4): +// een rij van drie kolommen — label 150 px | track 32 px | uitkomstlabel 132 px — met per werkdag +// een apart blokje, zodat je de dagen letterlijk kunt tellen. // -// HET LABEL BIJ DE HANDLE TOONT HET EINDDATUM-EFFECT, NIET DE SLEEPAFSTAND (§6). Een gebruiker -// verplaatst een plafond om te zien wat er met zijn EINDDATUM gebeurt; "handle 3 werkdagen naar -// rechts" is een handeling, geen uitkomst. Staat er meer toe dan er benut wordt, dan zegt de strook -// dat er apart bij ("gevraagd 3, dichtst haalbare 1") — anders leest een ongebruikte ruimte als een -// mislukking. +// DE GEOMETRIE ZIT NIET HIER. `stripGeometry.ts` rekent uit een voorstel-doorsnede de dagblokjes, +// pauzedagen, de gestippelde rest en de meetlat uit; deze component tekent ze en vangt de gebaren +// op. Dat is dezelfde knip als tussen `chartGeometry.ts` en `BeforeAfterChart.tsx`. // -// TOETSENBORD IS EEN VOLWAARDIGE ROUTE, GEEN NAZORG. De handle is een `role="slider"` met -// `aria-valuetext` (plafond, benutting én einddatum-effect); pijltjes verzetten één werkdag, -// PageUp/PageDown vijf, Home zet het plafond op 0 en End maakt het onbegrensd. Snappen op hele -// werkdagen is inherent: het plafond ís een geheel aantal werkdagen. +// SVG VOOR DE TRACK, DOM VOOR DE HANDLE. Zie het plan bij taak 2: de tekenlaag neemt de x/w van de +// pure module 1-op-1 over, en de handle is een echte ` )} - {pinned && !recorded && ( - {t('resource.distribution.strip.pinned')} - )} - {cannotMove && ( - {t('resource.distribution.strip.cannotMove')} - )} - - {achievableText !== null && ( - - {achievableText} - - )} - - {effectText} -
- {/* Geforceerd LTR, net als het histogram: een tijdas spiegelt nergens in dit product. */} -
-
- + {/* (b) TRACK. Geforceerd LTR, net als het histogram: een tijdas spiegelt nergens in dit + product. De horizontale scroll zit BEWUST niet hier maar om de hele stapel heen (de + dialoog), zodat balken en histogram kolom-op-kolom blijven staan tijdens het scrollen. */} +
+ + + {/* De arcering van een pauzedag: `repeating-linear-gradient(135deg, …)` uit het + prototype, hier als SVG-pattern zodat hij met het thema meekleurt. */} + + + + + + + {/* De track zelf: lichte achtergrond met een randje (`.ptrack`). */} + + + {/* Vaste last van gepinde/#63-documenten (§4, achtergrondband). */} + {(geometry?.fixedBands ?? []).map((band, i) => ( + + ))} + + {/* Weekscheidingen (1 px) en de donkerdere maandlijn. */} + {(geometry?.weekLines ?? []).map((x, i) => ( + + ))} + {(geometry?.monthLines ?? []).map((x, i) => ( + + ))} + + {/* "Toegestaan maar niet benut" — lege doos met gestippelde rand (§4). */} + {geometry?.freeBox && ( - {/* Vaste last: wat gepinde/#63-documenten al opeisen (§6 achtergrondband). */} - {fixedRects.map(r => ( - - ))} - {/* De boeking van dít document, in de DOCUMENTKLEUR (bevinding B9) — dezelfde die de - legenda en de staven van de voor/na-grafiek eronder gebruiken; boekingsloze dagen - binnen de spanne blijven leeg. */} - {bookedRects.map(r => ( - - ))} - {/* Toegestaan maar niet benut. */} - {tailWorkdays > 0 && dayWidth > 0 && ( - - )} - -
-
+ )} + + {/* De dagblokjes. Een werkdag in de projectkleur met een 1 px witte scheiding rechts + (`.pwork`), een pauzedag gearceerd met een dun grijs randje (`.ppause`). */} + {(geometry?.blocks ?? []).map(block => (block.kind === 'work' ? ( + + ) : ( + + )))} + + {/* De meetlat: grijs gestippeld = speling, massief rood = einddatum-verschuiving (§4). */} + {geometry?.slackBar && ( + + )} + {geometry?.overrunBar && ( + + )} + + + {/* De HANDLE (§5): een echte knop van 15×30 met drie grijpstreepjes. */} + +
+ + {/* (c) UITKOMSTLABEL — vaste breedte, dus een toestandswissel verandert de maat niet (§7). */} +
+ + {busy ? t('resource.distribution.compute.busy') : shiftText} + + + {`${maxDateText} · ${usedText}`} +
); From 0d82eedba5bbdb0e15e3ed26feea6c321e57e6fe Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:14:11 +0200 Subject: [PATCH 059/108] feat(b1c): Dialog kan bovenverankeren (alignTop) voor dialogen die van hoogte wisselen Co-Authored-By: Claude Fable 5.1 --- src/components/common/Dialog.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/common/Dialog.tsx b/src/components/common/Dialog.tsx index 29da735db..af08f2bb9 100644 --- a/src/components/common/Dialog.tsx +++ b/src/components/common/Dialog.tsx @@ -28,6 +28,11 @@ export interface DialogProps { onConfirm?: () => void; /** Overschrijft de standaard-overlaytint + z-laag (`bg-black/60 z-50`). */ overlayClassName?: string; + /** Verankert het paneel aan de BOVENkant in plaats van verticaal te centreren. Nodig voor + * dialogen waarvan de inhoud van hoogte wisselt: bij verticaal centreren verschuift het hele + * paneel dan onder de muis vandaan (B1c-plan4, spec §7 "de dialoog flitst niet"). Opt-in, dus + * alle bestaande dialogen blijven letterlijk gecentreerd. */ + alignTop?: boolean; /** `stopPropagation` op de backdrop-klik (nodig bij stapeling boven een andere dialoog). */ stopBackdropPropagation?: boolean; /** Extra attributen op de overlay (bv. `data-ops-task-dialog` voor de self-test-harness). */ @@ -39,7 +44,7 @@ export interface DialogProps { export function Dialog({ panelClassName, onBackdropClick, onCancel, onConfirm, - overlayClassName = 'bg-black/60 z-50', stopBackdropPropagation = false, + overlayClassName = 'bg-black/60 z-50', stopBackdropPropagation = false, alignTop = false, overlayProps, panelProps, children, }: DialogProps) { useDialogKeys({ onConfirm, onCancel }); @@ -56,9 +61,11 @@ export function Dialog({ } : undefined; + // `items-start` en `items-center` komen bewust nooit samen in de class-lijst: welke van de twee + // zou gelden hangt dan van de CSS-volgorde af in plaats van van `alignTop`. return (
From 2f9ed5d528382ae6e9142ca79045757f3c0507d7 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:16:16 +0200 Subject: [PATCH 060/108] docs(b1c): de "lege na-balk" is een as-fout, niet een verdelerfout De verdeler plaatst een wijkend document correct: zijn boeking staat volledig in `afterLoadByDay`, zonder tekort. Die na-dagen kunnen echter GEHEEL buiten de voor-dagen vallen, en `endShiftWorkdays` is geen maat voor hoe ver de boeking opschoof (een `manuallyScheduled`-taak houdt haar eigen datums in de CPM-solve, dus dat getal blijft 0 terwijl de boeking tien werkdagen verderop ligt). Een afnemer die zijn tijdas uit de voor-stand opbouwt tekent zo'n document leeg. Vastgelegd in het moduleblok van distribute.ts plus case 18 in check-distribute.ts, die beide punten pint voor manuallyScheduled true en false. Geen gedragswijziging: een geslaagde plaatsing tot tekort bombarderen zou liegen. Co-Authored-By: Claude Fable 5.1 --- src/services/library/distribute.ts | 16 +++++++++ tests/library/check-distribute.ts | 58 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/services/library/distribute.ts b/src/services/library/distribute.ts index de2b212a4..53bc1e860 100644 --- a/src/services/library/distribute.ts +++ b/src/services/library/distribute.ts @@ -30,6 +30,22 @@ // is per taak minimaal; de rangorde bepaalt wie de vroege ruimte krijgt. Er is dus geen aparte // "float-pass" nodig — dat zou het bestaande, al-geteste SGS-gedrag dupliceren. // +// `afterLoadByDay` KAN BUITEN DE VOOR-DAGEN VALLEN — EN `endShiftWorkdays` IS GEEN MAAT DAARVOOR +// (onderzoek 2026-09-14, naar aanleiding van een "lege na-balk" in de verdeeldialoog). Een deelnemer +// die wijkt boekt per definitie op ANDERE dagen dan in `bookingByDay`, en die dagen kunnen volledig +// buiten de vereniging van `bookingByDay` + `fixedLoadByDay` liggen: twee documenten met elk tien +// werkdagen op hetzelfde poolitem van capaciteit 1 geven na verdeling een B-boeking die pas ná de +// laatste voor-dag begint. Een afnemer die zijn tijdas uit de VOOR-stand opbouwt tekent zo'n +// document als een LEGE balk — terwijl het voorstel eerlijk is: de boeking staat gewoon in +// `afterLoadByDay`, er is geen tekort, en niets is stil verdwenen. Dat is dus een presentatiefout, +// geen verdelerfout; hier een tekort of blokkade van maken zou liegen over een geslaagde plaatsing. +// +// `endShiftWorkdays` mag daarbij NIET als staartmarge voor zo'n as gebruikt worden. Dat getal meet +// de PROJECTeinddatum, en een `manuallyScheduled`-taak houdt haar eigen opgeslagen datums in de +// CPM-solve (`CPMSolver.ts`): haar boeking schuift dan tien werkdagen op terwijl `endShiftWorkdays` +// gewoon 0 blijft. De enige betrouwbare bron voor "welke dagen moet ik tonen" is de vereniging van +// `fixedLoadByDay`, `bookingByDay` ÉN `afterLoadByDay`. `check-distribute.ts` (case 18) pint dit. +// // DE KOSTENLABELS EN DE GEREEDSCHAPSSCHAKELAAR ZIJN GEEN APARTE API (spec §4 stap 1 / §6). "Alleen // dit project laten opschuiven kost +N werkdagen" en het prijskaartje van "onderbrekingen toestaan" // zijn `computeDistribution` opnieuw draaien met een andere rangorde resp. `allowSplits` — de kern diff --git a/tests/library/check-distribute.ts b/tests/library/check-distribute.ts index 5782c0eae..cde41e82e 100644 --- a/tests/library/check-distribute.ts +++ b/tests/library/check-distribute.ts @@ -768,6 +768,64 @@ console.log('-- distribute: bookingByDay bevat elk boekend document, gelijk aan } } +// ═══════════════════════════════════════════════════════════════════════════ +// Case 18 (onderzoek 2026-09-14, "lege na-balk"): een deelnemer die wijkt mag zijn boeking NOOIT +// stilzwijgend kwijtraken. Repro van de browserfixture `seedTwoConflictingDocuments`: twee +// documenten met elk tien werkdagen werk (ma 2026-09-07 t/m vr 2026-09-18) op één poolitem met +// capaciteit 1. B kan er niet bij en schuift er volledig achter — zijn na-boeking valt daarmee +// GEHEEL buiten de voor-dagen. +// +// De twee dingen die deze case vastlegt (zie het moduleblok van distribute.ts): +// (a) B's boeking staat volledig en ongeschonden in `afterLoadByDay`, zonder tekort — een lege +// na-balk in de UI is dus een presentatiefout (as uit de VOOR-stand), geen verdelerfout; +// (b) `endShiftWorkdays` is GEEN maat voor hoe ver de boeking opschoof: met +// `manuallyScheduled: true` houdt de taak haar eigen datums in de CPM-solve, dus dat getal +// blijft 0 terwijl de boeking tien werkdagen verderop ligt. Met `false` is het wél 10. +// ═══════════════════════════════════════════════════════════════════════════ +console.log('-- distribute: na-boeking buiten de voor-dagen, endShiftWorkdays is geen maat (case 18) --'); +for (const manual of [true, false]) { + const p = pool([poolRes('lib-1', 'Gedeelde kraan', 1)]); + const mk = (docId: string, rank: number): DistributionDocInput => { + const t = task(`${docId}-t1`, '2026-09-07', '2026-09-18', 10, { manuallyScheduled: manual }); + return distDoc(docId, { + rank, + resources: [stamped(`${docId}-r1`, 'lib-1')], + tasks: [t], + assignments: [assign(`${docId}-a1`, t.id, `${docId}-r1`, 1)], + }); + }; + const label = `case 18 (manuallyScheduled=${manual})`; + const result = computeDistribution('c1', p, 'lib-1', [mk('A', 1), mk('B', 2)], OPTS_OFF); + assert(result.blocked === null, `${label}: geen blokkade`); + + const docB = result.docs.find(d => d.docId === 'B')!; + assert(docB !== undefined && docB.participated, `${label}: B doet mee als deelnemer`); + assert(docB.shortfalls.length === 0, `${label}: B is geplaatst, dus geen tekort (kreeg ${JSON.stringify(docB.shortfalls)})`); + assert(result.hasShortfall === false, `${label}: geen tekort in het voorstel`); + + // (a) De kern van de bevinding: geplaatst ⇒ de boeking staat er, en wel volledig. + const before = result.bookingByDay['B'] ?? {}; + const after = result.afterLoadByDay['B'] ?? {}; + const sum = (m: Record): number => Object.values(m).reduce((n, v) => n + v, 0); + assert(Object.keys(after).length > 0, `${label}: afterLoadByDay['B'] is NIET leeg — een geplaatste boeking mag niet stil verdwijnen`); + assert(sum(after) === sum(before), `${label}: B houdt zonder tekort exact evenveel werk (voor ${sum(before)}, na ${sum(after)})`); + + // De na-dagen liggen GEHEEL buiten de voor-dagen: precies de stand waarin een as uit de + // voor-stand een lege balk tekent. + const beforeDays = new Set([...Object.keys(result.fixedLoadByDay), ...Object.values(result.bookingByDay).flatMap(m => Object.keys(m))]); + assert( + Object.keys(after).every(iso => !beforeDays.has(iso)), + `${label}: B's na-dagen vallen buiten de voor-dagen (na ${JSON.stringify(Object.keys(after))})`, + ); + + // (b) endShiftWorkdays volgt de PROJECTeinddatum, niet de boeking. + assert( + docB.endShiftWorkdays === (manual ? 0 : 10), + `${label}: endShiftWorkdays is ${manual ? 0 : 10} (kreeg ${docB.endShiftWorkdays}) — een handmatig geplande taak schuift haar einddatum niet op`, + ); + assert(docB.delays['B-t1'] === 10, `${label}: de boeking schuift wél tien werkdagen (kreeg ${JSON.stringify(docB.delays)})`); +} + // ── Uitslag ────────────────────────────────────────────────────────────────── if (fails === 0) { console.log(`OK distribute: alle checks groen (${checks})`); From 14b01d890f31a7274c8594ffdc07c9135fb73ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:18:42 +0000 Subject: [PATCH 061/108] Rapport: resourcediagram groeperen op resourcetype in twee lagen (#113, manuvarkey punt 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optie "Groeperen op resourcetype": eerst een band per type — arbeid, ploeg, onderaannemer, materieel, materiaal, in die vaste volgorde (niet op vertaald label, zodat een uitgedeeld vel in elke taal dezelfde blokvolgorde heeft) — daarbinnen de resourcebanden zoals voorheen, de taken op diepte 2. "(geen)" blijft achteraan op diepte 0: taken zonder resource hebben geen type. Zonder de optie zijn de rijen byte-identiek. Bij "elke resource op een nieuwe pagina" krijgt een band direct onder een band geen eigen gedwongen breekpositie, zodat een typekop bij zijn eerste resource blijft in plaats van alleen op een leeg vel te staan. Instelling in reportSettings.resourceGantt (tolerante parser), vinkje in het paneel, sleutel in 14 locales, gidsen nl+en. Regressie: check-reports (volgorde, dieptes, tellingen, unieke sleutels, byte-identiek zonder optie, gelijknamigen over twee typen), check-print-report (breekposities bij een typelaag), settings-check, browserspec (typekop blijft bij eerste resource: nog steeds drie pagina's). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- public/docs/en/gids-rapporten-printen.md | 6 +- public/docs/nl/gids-rapporten-printen.md | 6 +- src/components/panels/ReportPanel.tsx | 20 ++++++- src/engine/reports/resourceGantt.ts | 56 +++++++++++++++++-- src/i18n/locales/ar/report.json | 1 + src/i18n/locales/de/report.json | 1 + src/i18n/locales/en/report.json | 1 + src/i18n/locales/es/report.json | 1 + src/i18n/locales/fa/report.json | 1 + src/i18n/locales/fr/report.json | 1 + src/i18n/locales/it/report.json | 1 + src/i18n/locales/ja/report.json | 1 + src/i18n/locales/ko/report.json | 1 + src/i18n/locales/nl/report.json | 1 + src/i18n/locales/pl/report.json | 1 + src/i18n/locales/pt/report.json | 1 + src/i18n/locales/tr/report.json | 1 + src/i18n/locales/zh/report.json | 1 + src/services/print/printPreview.ts | 4 +- src/utils/reportSettings.ts | 5 ++ tests/browser/resource-gantt-report.spec.ts | 5 ++ tests/planning/check-print-report.ts | 12 ++++ .../check-report-resource-gantt-setting.ts | 12 ++-- tests/planning/check-reports.ts | 25 +++++++++ 24 files changed, 150 insertions(+), 15 deletions(-) diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index 781dda606..18221506b 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -101,7 +101,11 @@ with the tasks assigned to it underneath, in order of start. That is the "who do overview for the site meeting, or — with the option **Each resource on a new page** — a separate sheet per person to hand out. A task with two resources appears under both bands; summary tasks are left out — an assignment on a summary task (which an import can produce) is not shown here. **Include tasks without a resource** adds a *(none)* band -at the bottom, so the meeting can see at a glance what nobody owns yet. +at the bottom, so the meeting can see at a glance what nobody owns yet. **Group by resource type** +adds a layer above: first a band per type — labour, crew, subcontractor, equipment, material, in +that fixed order — with the resources inside it; useful when a meeting discusses the people first +and the equipment after, or when you only want to print the equipment block. With *Each resource on +a new page* a type band starts a new sheet together with its first resource. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 35b4bebeb..317de9cf2 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -104,7 +104,11 @@ een nieuwe pagina** — een los vel per persoon om uit te delen. Een taak met tw onder beide banden; verzameltaken doen niet mee — een toewijzing op een verzameltaak (die uit een import kan komen) wordt hier niet getoond. Met **Taken zonder resource meenemen** komt er onderaan een band *(geen)* bij, zodat je in het overleg meteen ziet wat -nog niemand heeft. +nog niemand heeft. Met **Groeperen op resourcetype** komt er een laag boven: eerst een band per type +— arbeid, ploeg, onderaannemer, materieel, materiaal, in die vaste volgorde — en daarbinnen de +resources; handig als je in het overleg eerst de mensen en dan het materieel bespreekt, of alleen het +materieelblok wilt afdrukken. Bij *Elke resource op een nieuwe pagina* begint een typeband op een +nieuw vel samen met zijn eerste resource. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index d7689fd69..b2521f22c 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -447,12 +447,20 @@ export function ReportPanel() { const isGanttLike = isGanttReportType(reportType); const noneLabel = tTask('structure.none'); // De bandvolgorde volgt de app-taal (nooit de OS-taal van de afdrukker: zelfde vel, zelfde nummering). + // Typelabels voor de optionele typelaag (punt 2): dezelfde sleutels als het resourcepaneel. + const resourceTypeLabels = useMemo(() => ({ + LABOR: tCommon('resource.type.labor'), CREW: tCommon('resource.type.crew'), + SUBCONTRACTOR: tCommon('resource.type.subcontractor'), EQUIPMENT: tCommon('resource.type.equipment'), + MATERIAL: tCommon('resource.type.material'), + }), [tCommon]); const resourceGantt = useMemo(() => (reportType === 'resourceGantt' ? computeResourceGanttRows({ tasks, resources, assignments }, { includeUnassigned: resourceGanttOptions.includeUnassigned, noneLabel, locale: i18n.language, + groupByType: resourceGanttOptions.groupByType, typeLabels: resourceTypeLabels, }) : null), - [reportType, tasks, resources, assignments, noneLabel, resourceGanttOptions.includeUnassigned, i18n.language]); + [reportType, tasks, resources, assignments, noneLabel, resourceGanttOptions.includeUnassigned, + resourceGanttOptions.groupByType, resourceTypeLabels, i18n.language]); // Rijenbron van de Gantt-render: resourcediagram ⇒ de resourcebanden; Gantt-afdruk ⇒ de schermrijen // bij Volg weergave (#54), anders `undefined` = de volledige takenboom (oud gedrag, geen verrassingen). const reportRows = resourceGantt ? resourceGantt.rows : followView ? viewRows : undefined; @@ -1400,6 +1408,16 @@ export function ReportPanel() { /> {t('resourceGantt.includeUnassigned')} + )} diff --git a/src/engine/reports/resourceGantt.ts b/src/engine/reports/resourceGantt.ts index 3ab0814b0..8d242295c 100644 --- a/src/engine/reports/resourceGantt.ts +++ b/src/engine/reports/resourceGantt.ts @@ -1,5 +1,5 @@ import type { Task } from '@/types/task'; -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceType } from '@/types/resource'; import { encodeBandKey, encodeGroupedTaskRowKey, NONE_RAWKEY, type ViewRow } from '@/engine/view/visibleRows'; import { taskStart } from './reportCommon'; @@ -30,6 +30,12 @@ import { taskStart } from './reportCommon'; * eenduidig anker en zou op een blad per persoon de bladrand af lopen — het paneel zet `showDeps` * voor dit type uit (bevinding 2). * + * Optioneel in TWEE LAGEN (manuvarkey op #113, punt 2): eerst een band per resourceTYPE (mensen + * eerst — arbeid, ploeg, onderaannemer — dan materieel, dan materiaal; een vaste volgorde, niet + * de vertaalde labelvolgorde, zodat een uitgedeeld vel in elke taal dezelfde blokvolgorde heeft), + * daarbinnen de resourcebanden zoals hierboven, en de taken op diepte 2. De "(geen)"-band blijft + * op diepte 0 als laatste: taken zonder resource hebben geen type. + * * Invoer is structureel een `ReportContext`-subset, zoals de rest van `src/engine/reports/`. Puur: * geen React-/store-imports, headless getest in `tests/planning/check-reports.ts`. */ @@ -46,6 +52,11 @@ export interface ResourceGanttRowsOptions { noneLabel: string; /** BCP-47-taal voor de bandvolgorde en de gelijknaamdetectie (de app-taal, `i18n.language`). */ locale: string; + /** Twee lagen: een band per resourcetype, daarbinnen per resource (default uit). */ + groupByType?: boolean; + /** Labels per resourcetype — `t('common:resource.type.')`; een ontbrekend label valt + * terug op de enum-naam. Alleen gelezen bij `groupByType`. */ + typeLabels?: Partial>; } export interface ResourceGanttRowsResult { @@ -66,6 +77,18 @@ export interface ResourceGanttRowsResult { /** Bandsleutel van de "(geen)"-band — dezelfde codering als de schermgroepering. */ const NONE_BAND_KEY = encodeBandKey([NONE_RAWKEY]); +/** + * Bandvolgorde van de typen bij `groupByType`: wie het werk doet eerst, dan waarmee, dan waarvan. + * Bewust vast en niet op vertaald label gesorteerd — zie de moduledoc. Een type dat hier zou + * ontbreken (kan niet met het huidige enum) komt achteraan. + */ +export const RESOURCE_TYPE_BAND_ORDER: readonly ResourceType[] = ['LABOR', 'CREW', 'SUBCONTRACTOR', 'EQUIPMENT', 'MATERIAL']; + +/** Ruwe bandsleutel van een typeband — met prefix, zodat hij nooit botst met een resource-id. */ +function typeRawKey(type: ResourceType): string { + return `type:${type}`; +} + /** Op start (`taskStart`, dezelfde definitie als de tabelrapporten), lege datums achteraan, * gelijke starts in invoervolgorde (stabiel). */ function sortByStart(tasks: Task[]): Task[] { @@ -144,15 +167,38 @@ export function computeResourceGanttRows( .sort((a, b) => collator.compare(a.label, b.label) || a.index - b.index); const rows: ViewRow[] = []; - for (const band of bands) { - const key = encodeBandKey([band.id]); + // Eén resourceband met zijn taakrijen, onder een optioneel typepad (diepte +1). + const pushBand = (band: (typeof bands)[number], path: string[]) => { + const groupPath = [...path, band.id]; + const key = encodeBandKey(groupPath); rows.push({ kind: 'group', rowKey: key, key, label: band.label, count: band.tasks.size, - depth: 0, levelIndex: 0, collapsed: false, + depth: path.length, levelIndex: path.length, collapsed: false, }); for (const task of sortByStart([...band.tasks.values()])) { - rows.push({ kind: 'task', rowKey: encodeGroupedTaskRowKey([band.id], task.id), task, depth: 1, dimmed: false }); + rows.push({ kind: 'task', rowKey: encodeGroupedTaskRowKey(groupPath, task.id), task, depth: path.length + 1, dimmed: false }); + } + }; + if (opts.groupByType) { + const typeOf = new Map(ctx.resources.map(r => [r.id, r.type])); + const rank = (type: ResourceType) => { + const i = RESOURCE_TYPE_BAND_ORDER.indexOf(type); + return i < 0 ? RESOURCE_TYPE_BAND_ORDER.length : i; + }; + // Typen in de vaste volgorde; binnen een type blijft de collator-volgorde van `bands`. + const types = [...new Set(bands.map(b => typeOf.get(b.id) as ResourceType))].sort((a, b) => rank(a) - rank(b)); + for (const type of types) { + const members = bands.filter(b => typeOf.get(b.id) === type); + const raw = typeRawKey(type); + const key = encodeBandKey([raw]); + rows.push({ + kind: 'group', rowKey: key, key, label: opts.typeLabels?.[type] ?? type, + count: members.reduce((n, b) => n + b.tasks.size, 0), depth: 0, levelIndex: 0, collapsed: false, + }); + for (const band of members) pushBand(band, [raw]); } + } else { + for (const band of bands) pushBand(band, []); } const unassigned = sortByStart(leaves.filter(t => !assignedTaskIds.has(t.id))); diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index f092c4dc0..8967183c2 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -146,6 +146,7 @@ "resourceGantt": { "pageBreakPerResource": "كل مورد في صفحة جديدة", "includeUnassigned": "تضمين المهام بدون مورد", + "groupByType": "تجميع حسب نوع المورد", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", "resources": "الموارد:", "assignments": "التعيينات:", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index 8915a09ef..32fb4e1f7 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -134,6 +134,7 @@ "resourceGantt": { "pageBreakPerResource": "Jede Ressource auf einer neuen Seite", "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", + "groupByType": "Nach Ressourcentyp gruppieren", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", "resources": "Ressourcen:", "assignments": "Zuweisungen:", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index b7623ac18..b6e6e95b2 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -134,6 +134,7 @@ "resourceGantt": { "pageBreakPerResource": "Each resource on a new page", "includeUnassigned": "Include tasks without a resource", + "groupByType": "Group by resource type", "empty": "No resource assignments — assign resources to tasks first.", "resources": "Resources:", "assignments": "Assignments:", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index 348a71c20..f22487037 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -137,6 +137,7 @@ "resourceGantt": { "pageBreakPerResource": "Cada recurso en una página nueva", "includeUnassigned": "Incluir tareas sin recurso", + "groupByType": "Agrupar por tipo de recurso", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", "resources": "Recursos:", "assignments": "Asignaciones:", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 1859b27c1..94bec2d65 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -135,6 +135,7 @@ "resourceGantt": { "pageBreakPerResource": "هر منبع در صفحه‌ای جدید", "includeUnassigned": "شامل کردن کارهای بدون منبع", + "groupByType": "گروه‌بندی بر اساس نوع منبع", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", "resources": "منابع:", "assignments": "تخصیص‌ها:", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index bb6f6a62a..586e3de55 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -137,6 +137,7 @@ "resourceGantt": { "pageBreakPerResource": "Chaque ressource sur une nouvelle page", "includeUnassigned": "Inclure les tâches sans ressource", + "groupByType": "Grouper par type de ressource", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", "resources": "Ressources :", "assignments": "Affectations :", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index c02838467..0e2c916da 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -137,6 +137,7 @@ "resourceGantt": { "pageBreakPerResource": "Ogni risorsa su una nuova pagina", "includeUnassigned": "Includi le attività senza risorsa", + "groupByType": "Raggruppa per tipo di risorsa", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", "resources": "Risorse:", "assignments": "Assegnazioni:", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index 9fac187bb..761168867 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -131,6 +131,7 @@ "resourceGantt": { "pageBreakPerResource": "リソースごとに改ページ", "includeUnassigned": "リソース未割当のタスクを含める", + "groupByType": "リソース種別でグループ化", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", "resources": "リソース:", "assignments": "割当:", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 45118c025..01f4df6a0 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -131,6 +131,7 @@ "resourceGantt": { "pageBreakPerResource": "자원마다 새 페이지", "includeUnassigned": "자원이 없는 작업 포함", + "groupByType": "자원 유형별로 그룹화", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", "resources": "자원:", "assignments": "배정:", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index d4d2c2312..21415b417 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -134,6 +134,7 @@ "resourceGantt": { "pageBreakPerResource": "Elke resource op een nieuwe pagina", "includeUnassigned": "Taken zonder resource meenemen", + "groupByType": "Groeperen op resourcetype", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", "resources": "Resources:", "assignments": "Toewijzingen:", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 44f07b15f..99b1453ce 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -140,6 +140,7 @@ "resourceGantt": { "pageBreakPerResource": "Każdy zasób na nowej stronie", "includeUnassigned": "Uwzględnij zadania bez zasobu", + "groupByType": "Grupuj według typu zasobu", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", "resources": "Zasoby:", "assignments": "Przypisania:", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index b7bd207fe..59d35cf3a 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -137,6 +137,7 @@ "resourceGantt": { "pageBreakPerResource": "Cada recurso numa nova página", "includeUnassigned": "Incluir tarefas sem recurso", + "groupByType": "Agrupar por tipo de recurso", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", "resources": "Recursos:", "assignments": "Atribuições:", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index cf25531a9..0543a94a3 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -134,6 +134,7 @@ "resourceGantt": { "pageBreakPerResource": "Her kaynak yeni bir sayfada", "includeUnassigned": "Kaynaksız görevleri dahil et", + "groupByType": "Kaynak türüne göre grupla", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", "resources": "Kaynaklar:", "assignments": "Atamalar:", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 7e8c7eae9..f5836e1ba 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -131,6 +131,7 @@ "resourceGantt": { "pageBreakPerResource": "每个资源另起一页", "includeUnassigned": "包含未分配资源的任务", + "groupByType": "按资源类型分组", "empty": "没有资源分配——请先为任务分配资源。", "resources": "资源:", "assignments": "分配:", diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 7c9026e74..bd78414cd 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -1206,7 +1206,9 @@ export function renderReport( // Resourcediagram (issue #113): een gedwongen overgang vóór elke bandrij ná de eerste — de // bovenrand van rij i is de onderrand van rij i-1, dus exact een bestaande breekpositie. const forcedBreakOffsets = options.pageBreakBeforeGroups - ? printRows.flatMap((row, i) => (row.kind === 'group' && i > 0 ? [m.totalHeaderHeight + i * m.rowHeight] : [])) + // Een band direct ónder een band (typelaag, punt 2) blijft bij zijn ouder: anders zou de typekop + // alleen op een verder leeg vel staan. + ? printRows.flatMap((row, i) => (row.kind === 'group' && i > 0 && printRows[i - 1].kind !== 'group' ? [m.totalHeaderHeight + i * m.rowHeight] : [])) : undefined; return { width: canvasWidth, height: canvasHeight, tableWidth: m.tableWidth, headerHeight: m.totalHeaderHeight, diff --git a/src/utils/reportSettings.ts b/src/utils/reportSettings.ts index 3df813e61..42b3f0bfa 100644 --- a/src/utils/reportSettings.ts +++ b/src/utils/reportSettings.ts @@ -137,15 +137,19 @@ export type TableReportPeriodKey = (typeof TABLE_REPORT_PERIOD_KEYS)[number]; * elke resource begint op een nieuwe pagina, zodat je per ploeg of medewerker één vel kunt * uitdelen; uit = één doorlopend overlegdocument. `includeUnassigned` neemt de taken zonder * resource als laatste band mee — handig om in een overleg te zien wat nog niemand heeft. + * `groupByType` (manuvarkey, punt 2) zet er een laag boven: eerst een band per resourcetype + * (arbeid, ploeg, onderaannemer, materieel, materiaal), daarbinnen per resource. */ export interface ResourceGanttReportOptions { pageBreakPerResource: boolean; includeUnassigned: boolean; + groupByType: boolean; } export const DEFAULT_RESOURCE_GANTT_OPTIONS: ResourceGanttReportOptions = { pageBreakPerResource: false, includeUnassigned: false, + groupByType: false, }; /** Grenzen van de numerieke opties (de UI en de loader delen ze). */ @@ -330,6 +334,7 @@ function parseResourceGanttOptions(raw: unknown): ResourceGanttReportOptions { return { pageBreakPerResource: parseBoolean(s.pageBreakPerResource) ?? d.pageBreakPerResource, includeUnassigned: parseBoolean(s.includeUnassigned) ?? d.includeUnassigned, + groupByType: parseBoolean(s.groupByType) ?? d.groupByType, }; } diff --git a/tests/browser/resource-gantt-report.spec.ts b/tests/browser/resource-gantt-report.spec.ts index 0a97a5556..115f20e6c 100644 --- a/tests/browser/resource-gantt-report.spec.ts +++ b/tests/browser/resource-gantt-report.spec.ts @@ -57,6 +57,11 @@ test('resourcediagram: rapporttype rendert per resource, opties sturen samenvatt await expect(pages).toHaveCount(3); await expect(count('unassigned')).toHaveText('1'); + // Typelaag (manuvarkey punt 2): Ploeg A (ploeg) en Kraan (materieel) krijgen elk een typekop, en die + // blijft bij zijn eerste resource op hetzelfde vel — nog steeds drie pagina's, geen leeg vel per type. + await page.locator('[data-ops-report-option="groupByType"]').check(); + await expect(pages).toHaveCount(3); + // De échte export (vector-tak, zie paginateVector) moet dezelfde drie pagina's opleveren als de // preview: dat is het pad dat de gebruiker in handen krijgt, en de enige plek waar de gedwongen // breekposities de PDF in gaan. diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 70334fbac..dad026193 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -486,6 +486,18 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ // Eén band ⇒ niets te breken: geen gedwongen posities, dus ook geen lege eerste pagina. const single = measurePrintReport(bandTasks.slice(0, 2), [], cal, 'Eén resource', baseOptions({ rows: bands.slice(0, 3), pageBreakBeforeGroups: true })); ok(single.forcedBreakOffsets === undefined, 'één band ⇒ geen gedwongen posities'); + // Typelaag (manuvarkey punt 2): een band direct ónder een band krijgt géén eigen gedwongen + // positie — de typekop blijft bij zijn eerste resource; de tweede resource onder hetzelfde type + // en de volgende typekop breken wél. + const typed: ViewRow[] = [ + { kind: 'group', rowKey: 'type0', key: 'type0', label: 'Ploeg', count: 4, depth: 0, levelIndex: 0, collapsed: false }, + ...bands.slice(0, 6).map(r => ({ ...r, depth: r.depth + 1 })), + { kind: 'group', rowKey: 'type1', key: 'type1', label: 'Materieel', count: 2, depth: 0, levelIndex: 0, collapsed: false }, + ...bands.slice(6).map(r => ({ ...r, depth: r.depth + 1 })), + ]; + const typedForced = measurePrintReport(bandTasks, [], cal, 'Resourcediagram', baseOptions({ rows: typed, pageBreakBeforeGroups: true })); + ok(JSON.stringify(typedForced.forcedBreakOffsets) === JSON.stringify([typedForced.headerHeight + 4 * rowH, typedForced.headerHeight + 7 * rowH]), + `typelaag: gedwongen posities vóór resource 2 (rij 4) en typeband 2 (rij 7), niet vóór een band direct onder een typekop (got ${JSON.stringify(typedForced.forcedBreakOffsets)})`); // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee // paginabreedtes — steeds drie body-rijen die exact op de bandgrenzen eindigen. diff --git a/tests/planning/check-report-resource-gantt-setting.ts b/tests/planning/check-report-resource-gantt-setting.ts index c81f1c033..832b2e94f 100644 --- a/tests/planning/check-report-resource-gantt-setting.ts +++ b/tests/planning/check-report-resource-gantt-setting.ts @@ -14,7 +14,7 @@ const expect = (label: string, got: unknown, want: unknown) => { if (JSON.stringify(got) !== JSON.stringify(want)) failures.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); }; -expect('defaults: blad per resource uit, taken zonder resource uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false }); +expect('defaults: blad per resource uit, taken zonder resource uit, typelaag uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false, groupByType: false }); expect('defaults: één bron', DEFAULT_REPORT_SETTINGS.resourceGantt, DEFAULT_RESOURCE_GANTT_OPTIONS); expect('isGanttReportType: gantt én resourceGantt', [isGanttReportType('gantt'), isGanttReportType('resourceGantt'), isGanttReportType('milestones'), isGanttReportType('resourceAssignments')], [true, true, false, false]); // Review N6: één predicaat voor "dit type tekent geen relaties" — de forcering van showDeps én het @@ -25,10 +25,10 @@ expect('showDeps staat standaard aan (de forcering doet dus echt iets)', DEFAULT // barColorSelection), dus zonder lijnen is het misleidend — verborgen op hetzelfde predicaat als de relaties. expect('reportTypeShowsCriticalToggle: verborgen waar geen relaties getekend worden', [reportTypeShowsCriticalToggle('gantt'), reportTypeShowsCriticalToggle('resourceGantt'), reportTypeShowsCriticalToggle('milestones')], [true, false, true]); -localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true } })); +localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true, groupByType: true } })); const loaded = await loadReportSettings(); expect('opgeslagen rapporttype resourceGantt wordt geladen', loaded.reportType, 'resourceGantt'); -expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true }); +expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true, groupByType: true }); localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'gantt', showLegend: false })); const zonderBlok = await loadReportSettings(); @@ -37,16 +37,16 @@ expect('… en de overige voorkeuren blijven staan', zonderBlok.showLegend, fals localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { pageBreakPerResource: 'ja', includeUnassigned: true } })); const rommel = await loadReportSettings(); -expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true }); +expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true, groupByType: false }); localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: 'aan' })); expect('rommel in het hele blok ⇒ defaults', (await loadReportSettings()).resourceGantt, DEFAULT_RESOURCE_GANTT_OPTIONS); localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceDiagram' })); expect('onbekend rapporttype ⇒ default rapporttype', (await loadReportSettings()).reportType, DEFAULT_REPORT_SETTINGS.reportType); -await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false } }); +await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false, groupByType: true } }); const saved = await loadReportSettings(); expect('rapporttype overleeft een round-trip', saved.reportType, 'resourceGantt'); -expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false }); +expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false, groupByType: true }); if (failures.length > 0) { console.log(`XX report-resource-gantt-setting: ${failures.length} afwijking(en)`); diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 04c1a5fd6..d1950390e 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -529,6 +529,31 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); eq('resourceGantt: naamloze resource ⇒ band "#2" (positie in de projectlijst)', naamloos.rows.filter(x => x.kind === 'group').map(label), ['#2']); eq('resourceGantt: naamloze resource — A telt als toegewezen', naamloos.counts, { resources: 1, assignments: 1, unassignedTasks: 6 }); + // manuvarkey op #113, punt 2: twee lagen — typeband (vaste volgorde: mensen, dan materieel, dan + // materiaal), daarin de resourcebanden op diepte 1, de taken op diepte 2; "(geen)" blijft achteraan + // op diepte 0 (taken zonder resource hebben geen type). Zonder de optie byte-identiek. + const typeLabels = { LABOR: 'Arbeid', CREW: 'Ploeg', SUBCONTRACTOR: 'Onderaannemer', EQUIPMENT: 'Materieel', MATERIAL: 'Materiaal' }; + const typed = computeResourceGanttRows(base, { ...opts, groupByType: true, typeLabels }); + const typedGroups = typed.rows.filter(x => x.kind === 'group'); + eq('resourceGantt/type: typebanden vóór hun resources, mensen eerst', typedGroups.map(x => `${x.depth}:${label(x)}`), ['0:Ploeg', '1:Ploeg 1', '0:Materieel', '1:Kraan']); + ok('resourceGantt/type: taakrijen op diepte 2', typed.rows.every(x => x.kind !== 'task' || x.depth === 2)); + ok('resourceGantt/type: rijsleutels uniek', new Set(typed.rows.map(x => x.rowKey)).size === typed.rows.length); + eq('resourceGantt/type: typeband telt de taakrijen eronder', typedGroups.filter(x => x.depth === 0).map(x => x.count), [3, 1]); + eq('resourceGantt/type: tellingen ongewijzigd', typed.counts, r.counts); + eq('resourceGantt/type: dezelfde taken onder Ploeg 1', [...bandTasks(typed.rows, 'Ploeg 1')].sort(), [A, B, D].sort()); + const typedNone = computeResourceGanttRows(base, { ...opts, groupByType: true, typeLabels, includeUnassigned: true }); + const typedNoneGroups = typedNone.rows.filter(x => x.kind === 'group'); + const lastGroup = typedNoneGroups[typedNoneGroups.length - 1]; + ok('resourceGantt/type: "(geen)" blijft als laatste band op diepte 0', label(lastGroup) === '(geen)' && lastGroup.depth === 0); + ok('resourceGantt/type: zonder de optie byte-identiek', JSON.stringify(computeResourceGanttRows(base, { ...opts, typeLabels }).rows) === JSON.stringify(r.rows)); + eq('resourceGantt/type: ontbrekend label ⇒ enum-naam', computeResourceGanttRows(base, { ...opts, groupByType: true }).rows.filter(x => x.kind === 'group' && x.depth === 0).map(label), ['CREW', 'EQUIPMENT']); + // Gelijknamigen binnen één type houden hun volgnummer; de nummering loopt over de hele lijst. + const typedTwins = computeResourceGanttRows({ + tasks: ctx.tasks, resources: [jan1, { ...jan2, type: 'LABOR' }], + assignments: [asg('a1', A, jan1.id), asg('a2', B, jan2.id)], + }, { ...opts, groupByType: true, typeLabels }); + eq('resourceGantt/type: gelijknamigen over twee typen houden hun volgnummer', typedTwins.rows.filter(x => x.kind === 'group').map(x => `${x.depth}:${label(x)}`), ['0:Arbeid', '1:Jan #2', '0:Materieel', '1:Jan #1']); + // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). const dubbel = computeResourceGanttRows({ From 3efe4e5918ade8d2f35a4fb90382022f6dcc4369 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:19:05 +0200 Subject: [PATCH 062/108] feat(b1c): verdeeldialoog zonder rangordelijst, met validatiestrook en Reset De rangordelijst en haar sleep-/knopcode zijn weg: de balk is de bediening. De validatiestrook is altijd gerenderd met een vaste hoogte (role=status, aria-live=polite) en draagt de stale-melding, het tekort met projectnaam en de opgeloste uitkomst; de degradatiemelding krijgt een gereserveerde regel. Balken, legenda, einddatum-badges en histogram staan in EEN horizontale scroll-container, zodat de kolommen uitgelijnd blijven. Reset zet plafonds en pins terug. De dialoog verankert via alignTop aan de bovenkant. Het prijskaartje is een verschil ("zou N besparen" / "bespaart N") in een vak met vaste breedte, en het histogram leest dezelfde kleurtoewijzing als de balken. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/BeforeAfterChart.tsx | 20 +- .../DistributionDialog/DistributionDialog.tsx | 832 ++++++++---------- 2 files changed, 383 insertions(+), 469 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx b/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx index 5a0d6261d..e88dde46a 100644 --- a/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx +++ b/src/components/dialogs/DistributionDialog/BeforeAfterChart.tsx @@ -12,7 +12,8 @@ // DE VERTICALE SCHAAL IS EEN EIGEN SCHAAL (fixronde-2 bevinding B1). Deze grafiek STAPELT alle // documenten; de fasestroken tekenen er één per rij. Leende de grafiek de strookschaal, dan viel de // stapelsom boven de schaal en werd het conflict onzichtbaar. `chartScaleMax` rekent daarom over de -// stapelsom van VOOR én NA plus de capaciteit; `PhaseStrip` houdt zijn eigen `scaleMax`. +// stapelsom van VOOR én NA plus de capaciteit. De balken erboven kennen sinds B1c-plan4 taak 2 +// helemaal geen verticale schaal meer: die tekenen per werkdag een blokje van vaste hoogte. // // CONFLICTDEFINITIE (ongewijzigd t.o.v. het bezettingsoverzicht): som van de boeking over alle // documenten op een dag STRIKT GROTER dan `maxUnitsOn(poolItem, dag)` — geen tweede definitie. Voor @@ -30,7 +31,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { Resource } from '@/types/resource'; import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; -import { DOC_PALETTE } from '@/utils/documents'; import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; import { CHART_PLOT, buildStackedChart, chartScaleMax, type StackedChartGeometry, @@ -92,9 +92,13 @@ function MiniHistogram({ {...(kind === 'before' ? { 'data-ops-distribution-chart-before': '' } : { 'data-ops-distribution-chart-after': '' })} > {label} - {/* Geforceerd LTR, net als het bezettingshistogram en de fasestroken: een tijdas spiegelt - nergens in dit product. */} -
+ {/* Geforceerd LTR, net als het bezettingshistogram en de balken: een tijdas spiegelt nergens + in dit product. GEEN eigen `overflow-x-auto` meer (B1c-plan4 taak 4): de horizontale + scroll zit sinds dit herontwerp om de HELE stapel balken+histogram heen, in + `DistributionDialog`. Had dit blok zijn eigen scroller, dan schoven de histogramkolommen + weg onder de dagblokjes zodra je er één van verschoof — precies de kolom-op-kolom- + uitlijning die spec §3.4 eist. */} +
))} diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 614dbb2b5..142c4b3e3 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -19,41 +19,43 @@ // actieknoppen en ruimt een `info` na 5 s op. De melding die er daarnaast uit gaat is puur // informatief. // -// RANGORDE MET DE MUIS (taak 10, spec §4 stap 1) — POINTER-EVENTS, NIET HTML5-DND. +// DE RANGORDELIJST IS WEG (eigenaarsbesluit 2026-09-12, spec §2.1). Hij trok in de gebruikstest alle +// aandacht weg van de balken, terwijl de balk zelf de bediening hoort te zijn. De rijvolgorde is +// sindsdien puur de plaatsingsvolgorde van de rekenaar — de spelingsvolgorde die +// `freshDistributionUi` bij het openen in `tune.order` zet — en sturen doe je uitsluitend met de +// handle en de pin. `order` blijft in de tune-state bestaan zodat de kern en het schrijfpad +// ONGEWIJZIGD blijven; er is alleen geen bediening meer die hem verandert. Een tweede +// afleidingsmoment (order herberekenen en terugschrijven) zou via `diffReason('rank')` een +// oneindige herberekening geven — zie het plan bij taak 3. // -// Dit was native HTML5 drag-and-drop (`draggable` + `onDragStart`/`onDragOver`/`onDrop`), naar het -// model van `DataGridHeader`'s kolomherordening. In de GEBRUIKSTEST van de eigenaar (2026-09-12) -// deed "Sleep om de volgorde te veranderen" simpelweg niets. Dat is geen toeval en ook geen bug in -// deze component: HTML5-dnd hangt aan een apart, door de host geleverd drag-protocol -// (`dragstart` → `DataTransfer` → `drop`) dat buiten een gewoon browsertabblad onbetrouwbaar is. -// De app draait juist óók in een iframe-preview (de t3-omgeving) en in een Tauri-webview (WebKitGTK -// op Linux), en daar wordt de dragstart geregeld door de host afgevangen of nooit uitgezonden — -// dan valt het hele mechanisme stil, zónder foutmelding. Pointer-events kennen dat probleem niet: -// `setPointerCapture` houdt de gebeurtenissen binnen dit document, ongeacht de host. +// DE HOOGTE LIGT VAST (B1c-plan4 taak 4, spec §7). Geen enkele REKENtoestand — bezig, stale, +// gedegradeerd, tekort — mag de dialoog van maat laten veranderen; anders verspringt de balk onder +// de muis precies tijdens het slepen. Daarom: de validatiestrook is ALTIJD gerenderd met een vaste +// hoogte (leeg = onzichtbare tekst, geen weggehaald blok), de degradatiemelding heeft een +// gereserveerde regel, het prijsvak een vaste breedte, en de dialoog hangt via `alignTop` aan de +// BOVENkant — bij verticaal centreren zou elke hoogtewijziging het hele paneel verschuiven. // -// Het patroon is daarom letterlijk dat van de plafond-handle in `PhaseStrip.tsx`: capture op de -// GREEP zelf (het ⋮⋮-icoon, `cursor: grab`), geen document-brede listeners, één actieve pointer, -// en `pointercancel` als opruimpad. Tijdens het slepen licht de gesleepte rij op en toont de -// doelrij een invoegindicator (inset-boxshadow, dus geen layoutverschuiving). -// -// De "naar boven/beneden"-knoppen (`move`) blijven onveranderd de toetsenbordroute en het -// testanker; slepen (`reorderTo`) roept dezelfde `setUI` aan, dus een herordening met de muis zet -// net als de knoppen `staleReason = 'rank'` via `diffReason` in `useDistributionProposal`. -import { useCallback, useMemo, useRef, useState } from 'react'; +// ÉÉN HORIZONTALE SCROLL-CONTAINER (spec §3.4). Balken, legenda, einddatum-badges en het histogram +// staan samen in één `overflow-x-auto`. Had elk zijn eigen scroller — zoals vóór deze taak — dan +// stonden de kolommen scheef zodra je er één van verschoof. De badgerij en het histogram krijgen +// links `STRIP.labelWidth + STRIP.gap` en rechts `STRIP.endWidth + STRIP.gap` marge, zodat hun +// plotgebied exact samenvalt met de tracks; de `AXIS.padLeft` van 34 px zit ín de as en geldt dus +// voor beide. +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react'; +import { X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; import { Switch } from '@/components/common/Switch'; import { Dialog } from '@/components/common/Dialog'; import { DISTRIBUTION_BLOCK_KEY } from '@/utils/levelingReasonKey'; import { planDistributionWrites } from '@/services/library/applyDistribution'; import { scopeTaskIdsFor } from '@/services/library/distribute'; -import { maxUnitsOn } from '@/engine/scheduler/ResourceLoad'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; import { documentFloatOn, useDistributionProposal } from './useDistributionProposal'; import { assignDocColors } from './chartGeometry'; +import { STRIP } from './stripGeometry'; import { PhaseStrip } from './PhaseStrip'; import { DistributionPicker } from './DistributionPicker'; import { BeforeAfterChart } from './BeforeAfterChart'; @@ -69,69 +71,17 @@ export function DistributionDialog() { const { proposal, busy, labelsBusy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs, - costByDoc, toolPrice, + savings, } = useDistributionProposal(tune); - // Taak 13 (spec §4 stap 1 / §6): de labels zijn gecachet (zie de hook), maar mogen NOOIT getoond - // worden zolang het voorstel zelf niet meer bij de documenten hoort — een gecachet getal bij een - // vervallen voorstel is misleidender dan geen getal. Dus: staleReason of degraded ⇒ altijd de - // "druk op Herbereken"-tekst, ongeacht wat er nog in de cache staat. - const labelsValid = staleReason === null && !degraded; - // De labelpas loopt in eigen macrotasks ná het hoofdvoorstel (fixronde-2 bevinding B6), dus er is - // een echt venster waarin het voorstel al staat en de getallen nog niet. Dat venster hoort - // ZICHTBAAR te zijn: "Bezig…" bij de labels, terwijl de hoofdknoppen gewoon bruikbaar blijven — - // deze pas raakt het voorstel zelf niet. Zonder deze toestand las hetzelfde venster als "druk op - // Herbereken", terwijl er juist gerekend werd. - const labelsPending = busy || labelsBusy; - const costLabel = (docId: string): string => { - if (labelsPending) return t('resource.distribution.compute.busy'); - const cost = labelsValid ? costByDoc[docId] : undefined; - if (cost === undefined) return t('resource.distribution.compute.pressRecompute'); - return cost === 0 - ? t('resource.distribution.rank.costNone') - : t('resource.distribution.rank.cost', { count: cost }); - }; - const priceText = (workdays: number): string => (workdays === 0 - ? t('resource.distribution.tool.priceNone') - : t('resource.distribution.tool.price', { count: workdays })); - // Het prijskaartje benoemt WELKE STAND het getal hoort (gebruikstest 2026-09-12, gebrek 1). Er - // stonden twee kale prijzen naast elkaar ("kost 27 werkdagen uitloop · kost 27 werkdagen - // uitloop"): zonder "uit:"/"aan:" leest dat als een weergavefout in plaats van als twee standen. - // En als beide standen even duur zijn — wat op de showcase-fixture aantoonbaar het geval is, - // want daar lost onderbreken de interne overallocatie van het grote project niet op — is één - // prijs met "in beide standen" eerlijker dan hetzelfde getal twee keer. - const toolPriceLabel = (): string => { - if (labelsPending) return t('resource.distribution.compute.busy'); - if (!labelsValid || !toolPrice) return t('resource.distribution.tool.priceUnknown'); - if (toolPrice.off === toolPrice.on) { - return t('resource.distribution.tool.priceSame', { price: priceText(toolPrice.off) }); - } - return `${t('resource.distribution.tool.priceOff', { price: priceText(toolPrice.off) })}` - + ` · ${t('resource.distribution.tool.priceOn', { price: priceText(toolPrice.on) })}`; - }; - // Gepind/#63/cannotMove-documenten krijgen GEEN kostenlabel (§4 stap 1: "ze wijken niet") — dat - // leest rechtstreeks uit het LAATST BEREKENDE voorstel (`participated`/`cannotMove`), niet uit - // `costByDoc`: die twee vragen zijn onafhankelijk van elkaar (een document kan best deelnemen - // terwijl zijn label nog niet — of niet meer — gecached is). - const isCostCandidate = (docId: string): boolean => { - if (!proposal || proposal.blocked) return false; - const docResult = proposal.docs.find(d => d.docId === docId); - return docResult !== undefined && docResult.participated && !docResult.cannotMove; - }; - const close = () => setUI({ showDistributionDialog: false }); const poolItem = tune ? pools[tune.companyId]?.resources.find(r => r.id === tune.libraryItemId) : undefined; const itemName = poolItem?.name || tune?.libraryItemId || ''; - const numberFmt = useMemo( - () => new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }), - [i18n.language], - ); - - // B1c-plan4 taak 2 — ÉÉN datumnotatie voor alle balken (de plafonddatum in het uitkomstlabel), - // net als `numberFmt` hierboven: per rij een eigen `Intl.DateTimeFormat` bouwen is duur en zou - // per rij anders kunnen uitpakken. + // B1c-plan4 taak 2 — ÉÉN datumnotatie voor alle balken (de plafonddatum in het uitkomstlabel en + // de einddatum-badges): per rij een eigen `Intl.DateTimeFormat` bouwen is duur en zou per rij + // anders kunnen uitpakken. const dayFmt = useMemo( () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), [i18n.language], @@ -143,13 +93,20 @@ export function DistributionDialog() { // een balk arceert alleen dagen die voor de verdeler ook echt werkdagen zijn. KANTTEKENING (taak // 1): een individuele taak kan een afwijkende taakkalender hebben — de balk leest die bewust niet, // want hij toont de fase van een PROJECT, niet van één taak. Eén `CalendarEngine` per document, - // gememoïseerd: die constructor materialiseert feestdagen en uitzonderingen, en de balken vragen - // per kalenderdag opnieuw. + // gememoïseerd en met een eigen dagcache: die constructor materialiseert feestdagen en + // uitzonderingen, en de balken vragen per kalenderdag opnieuw — bij elke sleepstap. const isWorkingDayByDoc = useMemo(() => { const byDoc = new Map boolean>(); for (const doc of inputs) { const engine = new CalendarEngine(doc.calendar); - byDoc.set(doc.docId, (iso: string) => engine.isWorkDay(parseDate(iso))); + const cache = new Map(); + byDoc.set(doc.docId, (iso: string) => { + const hit = cache.get(iso); + if (hit !== undefined) return hit; + const value = engine.isWorkDay(parseDate(iso)); + cache.set(iso, value); + return value; + }); } return byDoc; }, [inputs]); @@ -175,141 +132,29 @@ export function DistributionDialog() { return out; }, [proposal]); - // De eigen speling per document (`documentFloatOn`) — het ankerpunt van de grijze meetlat. + // De eigen speling per document (`documentFloatOn`) — het ankerpunt van de grijze meetlat. Naar + // beneden geklemd en afgerond op hele werkdagen: de meetlat rekent in werkdagen, en een negatieve + // float (een al te krappe planning) zou daar een meetlat mét negatieve breedte van maken. const slackByDoc = useMemo(() => { const byDoc = new Map(); if (!tune) return byDoc; for (const doc of inputs) { - byDoc.set(doc.docId, documentFloatOn(doc, tune.companyId, tune.libraryItemId)); + const slack = documentFloatOn(doc, tune.companyId, tune.libraryItemId); + byDoc.set(doc.docId, slack === null ? null : Math.max(0, Math.floor(slack))); } return byDoc; }, [inputs, tune]); // De VOLLEDIGE rangorde, inclusief docId's die de LAATSTE run niet gezien heeft (fixronde-2 - // bevinding B10). `rankRows` hieronder toont alleen wat er in `inputs` zit — dat is juist voor de - // weergave, maar een herordening mag daar niet uit gebouwd worden: `order = rankRows.map(...)` - // gooide elk id weg dat toevallig niet in het laatste voorstel zat (een document dat nog niet in - // `inputs` stond omdat er sinds het openen nog niet gerekend is). Die rangorde was dan stilletjes - // weg. Dus: `tune.order` is de basis, en alleen nieuw gezien docId's sluiten achteraan aan. + // bevinding B10). Sinds de rangordelijst weg is, is dit uitsluitend nog de RIJVOLGORDE van de + // balken — de plaatsingsvolgorde waarin de rekenaar de documenten zag. `tune.order` is de basis, + // en alleen nieuw gezien docId's sluiten achteraan aan. const orderBase = useMemo(() => { if (!tune) return []; const known = new Set(tune.order); return [...tune.order, ...inputs.map(doc => doc.docId).filter(id => !known.has(id))]; }, [tune, inputs]); - // De rangordelijst leest UITSLUITEND uit `tune.order` — de bron van waarheid voor de rangorde. - // Documenten die na het openen zijn bijgekomen sluiten achteraan aan (zie `buildDistributionInputs`). - const rankRows = useMemo(() => { - if (!tune) return []; - const byId = new Map(inputs.map(doc => [doc.docId, doc])); - return orderBase.filter(id => byId.has(id)).map(docId => { - const doc = byId.get(docId)!; - return { docId, title: doc.title, float: documentFloatOn(doc, tune.companyId, tune.libraryItemId) }; - }); - }, [tune, inputs, orderBase]); - - // Eén plaats omhoog/omlaag = een VERWISSELING met de dichtstbijzijnde ZICHTBARE buur binnen - // `orderBase`. Onbekende id's ertussen worden overgeslagen en blijven op hun eigen index staan — - // een verwisseling raakt per definitie alleen de twee betrokken plekken (bevinding B10). - const move = (docId: string, delta: -1 | 1) => { - if (!tune) return; - const order = [...orderBase]; - const visible = new Set(rankRows.map(row => row.docId)); - const from = order.indexOf(docId); - if (from < 0) return; - let to = from + delta; - while (to >= 0 && to < order.length && !visible.has(order[to])) to += delta; - if (to < 0 || to >= order.length) return; - [order[from], order[to]] = [order[to], order[from]]; - setUI({ levelingDistribution: { ...tune, order } }); - }; - - // Slepen verplaatst ÉÉN id binnen `orderBase` (bevinding B10): alleen het gesleepte id wordt - // eruit gehaald en opnieuw ingevoegd, de rest — inclusief de docId's die dit voorstel niet gezien - // heeft — houdt zijn onderlinge volgorde. - const reorderTo = (docId: string, targetDocId: string, placement: 'before' | 'after') => { - if (!tune || docId === targetDocId) return; - const order = [...orderBase]; - const from = order.indexOf(docId); - if (from < 0) return; - order.splice(from, 1); - let to = order.indexOf(targetDocId); - if (to < 0) return; - if (placement === 'after') to += 1; - order.splice(to, 0, docId); - setUI({ levelingDistribution: { ...tune, order } }); - }; - - // Slepen (zie het moduleblok): `draggedDocId` is de rij die vastgehouden wordt, `dropTarget` de - // rij + plaatsing (boven/onder de rijmidden) waar hij op dit moment op zou landen — puur voor - // visuele feedback tijdens het slepen, het loslaten is het enige commit-moment. - const [draggedDocId, setDraggedDocId] = useState(null); - const [dropTarget, setDropTarget] = useState<{ docId: string; placement: 'before' | 'after' } | null>(null); - // De levende rij-elementen, zodat een pointermove de doelrij uit de ECHTE geometrie kan halen. - // Bij HTML5-dnd deed de browser die hit-test (per-rij `onDragOver`); met pointer capture komen - // alle moves op de greep binnen, dus doen we het zelf. Een `ref`-map en geen `querySelectorAll`: - // die zou ook de rijen van een eventuele tweede lijst vangen. - const rowRefs = useRef(new Map()); - // Eén actieve pointer tegelijk (zoals `PhaseStrip`): `moved` onderscheidt een zuivere klik op de - // greep — die commit niets — van een echte sleep. - const dragRef = useRef<{ pointerId: number; docId: string; moved: boolean } | null>(null); - - /** De rij + plaatsing onder deze y-coördinaat. Buiten de lijst: de dichtstbijzijnde rand, zodat - * een sleep die boven of onder de lijst uitschiet nog steeds een zinnig doel houdt. */ - const dropTargetAt = (clientY: number, dragged: string): { docId: string; placement: 'before' | 'after' } | null => { - let best: { docId: string; placement: 'before' | 'after'; distance: number } | null = null; - for (const row of rankRows) { - if (row.docId === dragged) continue; - const el = rowRefs.current.get(row.docId); - if (!el) continue; - const rect = el.getBoundingClientRect(); - const middle = rect.top + rect.height / 2; - const placement: 'before' | 'after' = clientY < middle ? 'before' : 'after'; - const distance = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0; - if (best === null || distance < best.distance) best = { docId: row.docId, placement, distance }; - } - return best === null ? null : { docId: best.docId, placement: best.placement }; - }; - - const onGripPointerDown = (docId: string) => (event: React.PointerEvent) => { - if (dragRef.current) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - dragRef.current = { pointerId: event.pointerId, docId, moved: false }; - setDraggedDocId(docId); - setDropTarget(null); - }; - - const onGripPointerMove = (event: React.PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - drag.moved = true; - setDropTarget(dropTargetAt(event.clientY, drag.docId)); - }; - - const endDrag = (commit: boolean) => { - const drag = dragRef.current; - dragRef.current = null; - if (commit && drag !== null && drag.moved && dropTarget !== null) { - reorderTo(drag.docId, dropTarget.docId, dropTarget.placement); - } - setDraggedDocId(null); - setDropTarget(null); - }; - - const onGripPointerUp = (event: React.PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - event.currentTarget.releasePointerCapture(event.pointerId); - endDrag(true); - }; - - const onGripPointerCancel = (event: React.PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - endDrag(false); - }; - const toggleSplits = () => { if (!tune) return; setUI({ levelingDistribution: { ...tune, allowSplits: !tune.allowSplits } }); @@ -324,41 +169,55 @@ export function DistributionDialog() { setUI({ levelingDistribution: { ...tune, ceilings: { ...tune.ceilings, [docId]: value } } }); }; - // De BOEKING per document per dag (§6, de gevulde blokken van een fasestrook + taak 11b de - // VOOR-stand van de voor/na-grafiek). `computeDistribution` levert dat zelf terug als - // `proposal.bookingByDay` (letterlijk het grootboek dat de kern al opbouwt) — een tweede aanroep - // van `computeLibraryOccupancy` hier zou stilzwijgend van diezelfde bron kunnen afwijken, dus die - // is verwijderd; dit leest uitsluitend uit het voorstel dat toch al berekend wordt. + // Reset (§3.6): alle plafonds en pins terug naar neutraal, de schakelaar ONGEMOEID — die is een + // gereedschapskeuze en geen per-project-bijstelling. + const onReset = () => { + if (!tune) return; + setUI({ levelingDistribution: { ...tune, pinned: {}, ceilings: {} } }); + }; + + // De BOEKING per document per dag (§6, het ankerpunt van de meetlat + taak 11b de VOOR-stand van + // de voor/na-grafiek). `computeDistribution` levert dat zelf terug als `proposal.bookingByDay` + // (letterlijk het grootboek dat de kern al opbouwt) — een tweede aanroep van + // `computeLibraryOccupancy` hier zou stilzwijgend van diezelfde bron kunnen afwijken. const bookingByDoc = useMemo(() => { const empty = new Map>(); if (!proposal || proposal.blocked) return empty; return new Map(Object.entries(proposal.bookingByDay)); }, [proposal]); - // De GEDEELDE tijdas van alle stroken plus de verticale schaal VAN DE STROKEN. De as loopt door - // tot voorbij de laatste geboekte dag, zodat een gestippelde staart (toegestaan-maar-niet-benut) - // er nog binnen past; werkdagen worden daarbij als kolommen van één dagbreedte getekend. - // - // `scaleMax` hier is bewust de PER-DOCUMENT-schaal: een fasestrook toont de vaste last plus de - // boeking van ÉÉN document, dus het hoogste van die per-document-stapelingen is precies de juiste - // bovengrens. De voor/na-grafiek stapelt ALLE documenten en heeft daarom een eigen, hogere schaal - // (`chartScaleMax` in `chartGeometry.ts`) — deze waarde daar hergebruiken liet het conflict - // letterlijk van de grafiek af vallen (fixronde-2 bevinding B1). + // De boeking NA de verdeling per document — de stand die de balken als dagblokjes tekenen (§4). + // Dít moet de NA-stand zijn en niet de VOOR-stand: anders beweegt de balk niet mee tijdens het + // slepen en is de hele bediening zinloos. + const afterByDoc = useMemo(() => { + const empty = new Map>(); + if (!proposal || proposal.blocked) return empty; + return new Map(Object.entries(proposal.afterLoadByDay)); + }, [proposal]); + + // De GEDEELDE tijdas van balken én histogram. De as loopt door tot voorbij de laatste geboekte + // dag, zodat een gestippelde rest (toegestaan-maar-niet-benut) er nog binnen past. De VERTICALE + // schaal van de stroken is hier geen onderwerp meer: sinds de balk per werkdag een blokje van + // vaste hoogte tekent (taak 2) bestaat er geen strookschaal — `stripGeometry.ts` rekent alleen + // nog in x. De grafiek houdt zijn eigen `chartScaleMax` (fixronde-2 bevinding B1). const stripView = useMemo(() => { if (!tune || !proposal || proposal.blocked || !poolItem) return null; - const rankIndex = new Map(rankRows.map((row, index) => [row.docId, index])); + const rankIndex = new Map(orderBase.map((docId, index) => [docId, index])); const docs = [...proposal.docs].sort((a, b) => - (rankIndex.get(a.docId) ?? rankRows.length) - (rankIndex.get(b.docId) ?? rankRows.length)); + (rankIndex.get(a.docId) ?? orderBase.length) - (rankIndex.get(b.docId) ?? orderBase.length)); const days = new Set(Object.keys(proposal.fixedLoadByDay)); - for (const doc of docs) for (const iso of Object.keys(bookingByDoc.get(doc.docId) ?? {})) days.add(iso); - if (days.size === 0) return { axis: null, docs, scaleMax: 1 }; + for (const doc of docs) { + for (const iso of Object.keys(bookingByDoc.get(doc.docId) ?? {})) days.add(iso); + for (const iso of Object.keys(afterByDoc.get(doc.docId) ?? {})) days.add(iso); + } + if (days.size === 0) return { axis: null, docs }; const outlook = docs.reduce( (n, doc) => Math.max(n, doc.endShiftWorkdays, tune.ceilings[doc.docId] ?? 0), 0); if (outlook > 0) { const last = [...days].sort()[days.size - 1]; - // Werkdagen → kalenderdagen (5/7) plus een marge, en hoe dan ook begrensd: de staart mag de + // Werkdagen → kalenderdagen (5/7) plus een marge, en hoe dan ook begrensd: de rest mag de // as verbreden, niet laten ontsporen. const extra = Math.min(90, Math.ceil(outlook * 7 / 5) + 2); for (const iso of expandDays( @@ -367,28 +226,13 @@ export function DistributionDialog() { )) days.add(iso); } - const axis = buildOccupancyAxis([...days], { targetWidth: 560 }); - let scaleMax = 1; - if (axis) { - for (const segment of axis.segments) { - for (const iso of segment.days) { - const capacity = maxUnitsOn(poolItem, iso); - if (capacity > scaleMax) scaleMax = capacity; - const fixed = proposal.fixedLoadByDay[iso] ?? 0; - for (const doc of docs) { - const stacked = fixed + (bookingByDoc.get(doc.docId)?.[iso] ?? 0); - if (stacked > scaleMax) scaleMax = stacked; - } - } - } - } - return { axis, docs, scaleMax }; - }, [tune, proposal, poolItem, rankRows, bookingByDoc]); + return { axis: buildOccupancyAxis([...days], { targetWidth: 560 }), docs }; + }, [tune, proposal, poolItem, orderBase, bookingByDoc, afterByDoc]); - // Eén kleurtoewijzing voor de hele dialoog (fixronde-2 bevinding B9): de fasestroken EN de + // Eén kleurtoewijzing voor de hele dialoog (fixronde-2 bevinding B9): de balken EN de // legenda/staven van de voor/na-grafiek lezen dezelfde map, zodat "project B" boven en onder - // dezelfde kleur heeft. Stond dit in de grafiek, dan tekende de strook zijn boeking in het - // accentkleur en klopte de belofte "strook en grafiek matchen" alleen op de x-as. + // dezelfde kleur heeft. Stond dit in de grafiek, dan tekende de balk zijn boeking in het + // accentkleur en klopte de belofte "balk en grafiek matchen" alleen op de x-as. const docColors = useMemo( () => assignDocColors((stripView?.docs ?? []).map(doc => doc.docId)), [stripView], @@ -411,6 +255,52 @@ export function DistributionDialog() { return out; }, [inputs, tune]); + // Het VERSCHIL-prijskaartje (§2.2). De schakelaar-stand bepaalt de ZIN, niet het getal: uit ⇒ + // "zou N besparen" (een belofte), aan ⇒ "bespaart N" (een constatering). De labelpas loopt in + // eigen macrotasks ná het hoofdvoorstel (fixronde-2 bevinding B6), dus er is een echt venster + // waarin het voorstel al staat en het getal nog niet; dat venster hoort ZICHTBAAR te zijn als + // "Bezig…" en niet als "druk op Herbereken". + const savingsLabel = (): string => { + if (busy || labelsBusy) return t('resource.distribution.compute.busy'); + if (staleReason !== null || degraded || savings === null) { + return t('resource.distribution.tool.priceUnknown'); + } + if (savings.workdays === 0) { + return tune?.allowSplits + ? t('resource.distribution.tool.savesNoneOn') + : t('resource.distribution.tool.savesNoneOff'); + } + return tune?.allowSplits + ? t('resource.distribution.tool.savesOn', { count: savings.workdays }) + : t('resource.distribution.tool.savesOff', { count: savings.workdays }); + }; + + // Welke documenten een tekort houden, met hun teller — dezelfde `DistributionDocResult.shortfalls` + // die de voor/na-grafiek gebruikt om de NA-onvolledigheid te melden, en die de validatiestrook + // gebruikt om het tekort BIJ NAAM te noemen. + const shortfallDocs = useMemo(() => { + if (!proposal || proposal.blocked) return []; + return proposal.docs + .filter(doc => doc.shortfalls.length > 0) + .map(doc => ({ docId: doc.docId, title: doc.title, count: doc.shortfalls.length })); + }, [proposal]); + + // De UNIEKE tekortdagen over alle documenten. Uniek en niet het aantal (taak, dag)-paren: dat zou + // hetzelfde tekort dubbel tellen zodra twee taken op dezelfde dag vastlopen, terwijl de gebruiker + // op de tijdas juist die dagen terugziet. + const shortfallDays = useMemo(() => { + if (!proposal || proposal.blocked) return [] as string[]; + const days = new Set(); + for (const doc of proposal.docs) for (const s of doc.shortfalls) for (const iso of s.days) days.add(iso); + return [...days].sort(); + }, [proposal]); + + // Alle deelnemers vast ⇒ er valt niets te herverdelen (§6). + const allPinned = useMemo(() => { + if (!proposal || proposal.blocked || proposal.docs.length === 0) return false; + return proposal.docs.every(doc => !doc.participated || doc.cannotMove); + }, [proposal]); + // Waarom Toepassen wel of niet mag — en zo niet, met welke tekst (spec §4 stap 3: validatie wijst // altijd een uitweg aan). De volgorde is die van de taakomschrijving: eerst "er is nog niets", // dan de blokkade, dan het tekort, dan een vervallen voorstel, en pas daarna het triviale @@ -437,6 +327,64 @@ export function DistributionDialog() { return { ok: true, reason: '' }; }, [busy, proposal, staleReason, staleDocs, blockedDocTitles, scopeTaskIdsByDoc, t]); + /** + * De VALIDATIESTROOK (spec §3.5/§7). Altijd gerenderd, altijd één regel hoog — ook wanneer er + * niets te melden valt: een strook die verschijnt en verdwijnt verschuift alles eronder, en dat + * is precies het flitsen dat §7 verbiedt. De volgorde is die van dringendheid: een voorstel dat + * niet meer bij de documenten hoort gaat vóór alles, dan "alles staat vast", dan het tekort, en + * pas als niets daarvan speelt de groene uitkomst. + * + * IN DE GEDEGRADEERDE STAND IS DIT DE ENIGE PLEK DIE HET ZEGT. De uitkomstpil van een balk toont + * daar sinds taak 2 geen "druk op Herbereken" meer — hij toont het effect van de LAATSTE + * berekening, terwijl de handle al ergens anders staat. Zonder deze zin leest dat als "mijn + * sleep deed niets". Daarom plakt `compute.pressRecompute` hier achter elke stale-reden zodra er + * gedegradeerd gerekend wordt. + */ + const statusLine = useMemo<{ tone: 'ok' | 'bad' | 'neutral'; text: string; stale: boolean }>(() => { + if (busy) return { tone: 'neutral', text: t('resource.distribution.compute.busy'), stale: false }; + if (staleReason !== null) { + return { + tone: 'neutral', + text: t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs }) + + (degraded || staleReason === 'edited' ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''), + stale: true, + }; + } + if (!proposal || proposal.blocked) return { tone: 'neutral', text: '', stale: false }; + if (allPinned) return { tone: 'bad', text: t('resource.distribution.status.allPinned'), stale: false }; + if (proposal.hasShortfall) { + // Het tekort MET PROJECTNAAM. Een document waarvan geen enkele taak geplaatst kon worden + // heeft een LEGE na-boeking en dus een lege balk; zonder de naam erbij is er niets op het + // scherm dat uitlegt waaróm die rij leeg is. `shortfall.doc` is de bestaande zin die het + // tekortblok hiervóór per document toonde — die inhoud verhuist hiernaartoe. + const named = shortfallDocs + .map(doc => t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })) + .join(' · '); + const head = t('resource.distribution.status.shortfall', { + count: shortfallDays.length, + days: shortfallDays.slice(0, 3).map(formatDay).join(', '), + }); + return { tone: 'bad', text: named ? `${head} ${named}` : head, stale: false }; + } + const worst = proposal.docs.reduce( + (best, doc) => (doc.endShiftWorkdays > best.endShiftWorkdays ? doc : best), + proposal.docs[0] ?? { endShiftWorkdays: 0, title: '' }, + ); + return { + tone: 'ok', + text: t('resource.distribution.status.resolved', { + // Hergebruikt de BESTAANDE `strip.endShift`-meervoudfamilie, die al in alle veertien + // locales met de juiste CLDR-categorieën staat — zo hoeft er voor deze zin geen vijftiende + // meervoudfamilie bij. + shift: worst.endShiftWorkdays === 0 + ? t('resource.distribution.strip.endUnchanged') + : t('resource.distribution.strip.endShift', { count: worst.endShiftWorkdays }), + doc: worst.title, + }), + stale: false, + }; + }, [busy, staleReason, staleDocs, degraded, proposal, allPinned, shortfallDocs, shortfallDays, formatDay, t]); + /** Het laatst toegepaste record — leeft in de tune-state en overleeft dus een sluiting van de * dialoog (§7) én een documentwissel (fixronde B1c-etappe-3, bevinding B3), zodat opnieuw openen * op hetzelfde poolitem de terugweg nog toont. Dat laatste is essentieel: een verdeling schrijft @@ -495,20 +443,11 @@ export function DistributionDialog() { } }; - // Taak 11b (voor/na-grafiek): welke documenten een tekort houden, met hun teller — dezelfde - // `DistributionDocResult.shortfalls` als het bestaande tekortblok (7), hier omgezet naar de vorm - // die `BeforeAfterChart` nodig heeft om de NA-onvolledigheid bij de na-grafiek zelf te melden. - const shortfallDocs = useMemo(() => { - if (!proposal || proposal.blocked) return []; - return proposal.docs - .filter(doc => doc.shortfalls.length > 0) - .map(doc => ({ docId: doc.docId, title: doc.title, count: doc.shortfalls.length })); - }, [proposal]); - - const showStale = staleReason !== null || busy; + const shortfallTitleByDoc = new Map(shortfallDocs.map(doc => [doc.docId, doc])); return ( ) : ( <> - {/* (3) Gereedschap — schakelaar met prijskaartje (taak 13, spec §6): `computeDistribution` - één keer met `allowSplits: false` en één keer met `true` — de prijs is de grootste - `endShiftWorkdays` over de deelnemers. Gecachet in de hook tot invalidatie; zolang het - voorstel niet actueel is staat er eerlijk "prijs onbekend". */} + {/* (2) GEREEDSCHAP — de schakelaar met het VERSCHIL-prijskaartje (§2.2/§3.2). Het + prijsvak heeft een VASTE breedte: "Bezig…" mag de schakelaar er niet naast + wegduwen (§7). */}
- - {t('resource.distribution.tool.title')} -
- {toolPriceLabel()} + {savingsLabel()}
{t('resource.distribution.tool.allowSplitsHint')}
- {/* (4) Rangorde */} -
- - {t('resource.distribution.rank.title')} - - - {t('resource.distribution.rank.hint')} - -
- {rankRows.map((row, index) => ( -
{ - if (el) rowRefs.current.set(row.docId, el); - else rowRefs.current.delete(row.docId); - }} - className={`flex items-center gap-2 px-2 py-1 border-b border-border-light last:border-b-0 ${ - draggedDocId === row.docId ? 'bg-surface-hover' : ''}`} - // De invoegindicator is een INSET-boxshadow en geen border: een echte rand van - // 2px zou de rij hoger maken en de lijst onder de muis laten verspringen — - // precies tijdens het mikken. - style={{ - boxShadow: dropTarget?.docId === row.docId - ? dropTarget.placement === 'before' - ? 'inset 0 2px 0 0 var(--accent)' - : 'inset 0 -2px 0 0 var(--accent)' - : undefined, - }} - data-ops-distribution-rank-row - data-ops-doc-id={row.docId} - data-ops-distribution-rank-dragging={draggedDocId === row.docId ? 'true' : undefined} - data-ops-distribution-rank-drop-before={ - dropTarget?.docId === row.docId && dropTarget.placement === 'before' ? 'true' : undefined - } - data-ops-distribution-rank-drop-after={ - dropTarget?.docId === row.docId && dropTarget.placement === 'after' ? 'true' : undefined - } - > - {/* De GREEP (gebruikstest 2026-09-12, gebrek 2). Slepen was onzichtbaar: de hele - rij was `draggable`, zonder greep en zonder `cursor: grab`, dus niets aan de - rij verried dat hij te verslepen was — laat staan dat het mechanisme het in - een webview deed. `touchAction: 'none'` houdt de sleep bij de pointer in - plaats van bij de scroll van de dialoog. */} - - - - {index + 1} - {row.title} - - {t('resource.distribution.rank.float', { - days: row.float === null ? '—' : numberFmt.format(row.float), - })} - - {isCostCandidate(row.docId) && ( + {/* (3)(4) DE BALKEN EN DE UITKOMST, in ÉÉN horizontale scroll-container zodat ze + kolom-op-kolom uitgelijnd blijven — ook tijdens het scrollen (spec §3.4). */} +
+
+ {(stripView?.docs ?? []).map(doc => { + const recorded = doc.pinnedReason === 'dates-as-recorded'; + const pinnedNow = recorded || tune.pinned[doc.docId] === true; + return ( + setPinned(doc.docId, tune.pinned[doc.docId] !== true)} + onCeilingChange={next => setCeiling(doc.docId, next)} + /> + ); + })} + + {/* De legenda-regel (§4), ingesprongen tot waar de tracks beginnen. */} +
+ {t('resource.distribution.strip.legend')} +
+ + {/* De einddatum-badges per project, boven het histogram (§3.4). Een project met een + tekort krijgt hier zijn markering: zijn balk is dan (deels) LEEG omdat de motor + zijn werk nergens kwijt kon, en dat moet bij die kleur staan en niet alleen + onderin de validatiestrook. */} +
+ {(stripView?.docs ?? []).map(doc => { + const short = shortfallTitleByDoc.get(doc.docId); + const shortText = short + ? t('resource.distribution.shortfall.doc', { doc: short.title, count: short.count }) + : undefined; + return ( - {costLabel(row.docId)} + + + {`${doc.title}: ${doc.projectEndAfter ? formatDay(doc.projectEndAfter) : '—'}`} + - )} - - -
- ))} + ); + })} +
+ + {/* Het histogram Nu/Na op DEZELFDE as-instantie, met dezelfde marges als de tracks — + daardoor staan de kolommen per constructie onder de dagblokjes. */} +
+ {poolItem ? ( + ({ docId: doc.docId, title: doc.title }))} + docColors={docColors} + bookingByDay={proposal?.bookingByDay ?? {}} + afterLoadByDay={proposal?.afterLoadByDay ?? {}} + afterIncomplete={proposal?.afterIncomplete ?? false} + shortfallDocs={shortfallDocs} + /> + ) : ( + + {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} + + )} +
-
- - {/* (5) Fasestroken (§6). Eén rij per document, in RANGORDE — dezelfde volgorde als de - lijst hierboven, zodat een pin of plafond de stroken niet onder de muis vandaan - herschikt. De legenda benoemt de achtergrondband; de blokken zijn de eigen boeking. */} -
- - - {t('resource.distribution.strip.fixedLoad')} - - {(stripView?.docs ?? []).map(doc => { - const recorded = doc.pinnedReason === 'dates-as-recorded'; - const pinnedNow = recorded || tune.pinned[doc.docId] === true; - return ( - setPinned(doc.docId, tune.pinned[doc.docId] !== true)} - onCeilingChange={next => setCeiling(doc.docId, next)} - /> - ); - })} -
- - {/* (6) Voor/na-histogram (taak 11b, spec §7): dezelfde as als de fasestroken hierboven. */} -
- {poolItem ? ( - ({ docId: doc.docId, title: doc.title }))} - docColors={docColors} - bookingByDay={proposal?.bookingByDay ?? {}} - afterLoadByDay={proposal?.afterLoadByDay ?? {}} - afterIncomplete={proposal?.afterIncomplete ?? false} - shortfallDocs={shortfallDocs} - /> - ) : ( - - {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} - - )} -
- - {/* Tekorten (§4 stap 3): een geldige preview, maar Toepassen blijft uit. */} - {proposal?.hasShortfall && ( -
- - {t('resource.distribution.shortfall.title')} - - {proposal.docs.filter(doc => doc.shortfalls.length > 0).map(doc => ( - - {t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.shortfalls.length })} - - ))} -
- )} +
)}
- {/* (7) Stale-strook + knoppenbalk. + {/* (5) Validatiestrook + knoppenbalk. IN DE KIEZER-STAND STAAT HIER NIETS BEHALVE "SLUITEN" (bedieningsreparatie 2026-09-11). "Verdeel automatisch", "Toepassen" en "Verwerpen" slaan zonder gekozen poolitem nergens @@ -799,31 +660,66 @@ export function DistributionDialog() {
) : ( <> - {degraded && ( -
- {t('resource.distribution.compute.degraded')} -
- )} - {showStale && ( -
+ {degraded ? ( + {t('resource.distribution.compute.degraded')} + ) : null} +
+ + {/* VALIDATIESTROOK — `role="status"`, ALTIJD gerenderd, VASTE hoogte (§3.5/§7). Leeg = + onzichtbare tekst, geen weggehaald blok: alleen zó verschuift er nooit iets. Het anker + `data-ops-distribution-stale` blijft bestaan, maar als een SPAN bínnen deze strook — + zo blijven de bestaande asserties ("geen stale ⇒ count 0") geldig zonder dat de strook + zelf van hoogte wisselt. */} +
+ {statusLine.stale ? ( + + {statusLine.text} + + ) : ( + {statusLine.text} + )} + - {staleReason - ? `${t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs })}${busy ? ` ${t('resource.distribution.compute.busy')}` : degraded || staleReason === 'edited' ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''}` - : t('resource.distribution.compute.busy')} -
- )} + {applyGate.reason} + +
{/* Spec §5: de terugweg woont HIER, niet in het meldingenkanaal (dat kent geen actieknoppen en ruimt info na 5 s op). Permanent zolang het record geldig is — hij verdwijnt alleen - door "Alles terugdraaien" of doordat een NIEUW Toepassen hem vervangt. */} + door "Alles terugdraaien" of doordat een NIEUW Toepassen hem vervangt. Dit blok mág van + hoogte wisselen: het verschijnt door een expliciete HANDELING van de gebruiker, niet + door een rekentoestand — en dat is precies het onderscheid dat §7 maakt. */} {applied && (
)} -
+ +
{/* Terug naar de kiezer. Geen bevestigingsdialoog (die zou over een dialoog heen moeten, en `hasBlockingDialogOpen` weert dat terecht) — in plaats daarvan ZEGT de knop wat er verdwijnt zodra er een `applied`-record staat: twee losse sleutels, want de waarschuwende @@ -856,7 +753,7 @@ export function DistributionDialog() { documenten blijft staan (die draai je terug met de gewone undo per document). */} + {/* Reset (§3.6): alle plafonds en pins terug naar neutraal, de schakelaar ONGEMOEID — + die is een gereedschapskeuze, geen per-project-bijstelling. */} + +
{/* NA-onvolledigheid (moduleblok hierboven): de na-balken missen de vraag van niet-geplaatste - taken — dat moet bij de na-grafiek zelf staan, niet alleen in het aparte tekortblok - onderaan de dialoog, anders leest een lezer de na-stand als "opgelost". */} - {afterIncomplete && ( -
- - {t('resource.distribution.shortfall.title')} + taken — dat moet bij de na-grafiek zelf staan, anders leest een lezer de na-stand als + "opgelost". Sinds B1c-plan4 taak 4 is dit een GERESERVEERDE regel van vaste hoogte en geen + blok dat verschijnt en verdwijnt: een tekort is een REKENtoestand, en spec §7 verbiedt dat + een rekentoestand de dialoog van maat laat veranderen. Gemeten vóór deze reservering: één + sleep die een tekort opleverde maakte de dialoog 34 px hoger, precies onder de muis. De + volledige opsomming per document staat in de validatiestrook onderin. */} +
+ t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })).join(' · ') : undefined} + > + {afterIncomplete ? ( + + {`${t('resource.distribution.shortfall.title')}: ${shortfallDocs.map(doc => + t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })).join(' · ')}`} - {shortfallDocs.map(doc => ( - - {t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })} - - ))} -
- )} + ) : null} +
); } diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index b022a850e..2f9ea2b1b 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -620,9 +620,16 @@ export function DistributionDialog() { {/* Het histogram Nu/Na op DEZELFDE as-instantie, met dezelfde marges als de tracks — daardoor staan de kolommen per constructie onder de dagblokjes. */} + {/* GEEN horizontale padding, en de marge compenseert de 1 px rand: het plotgebied + van het histogram moet exact op de tracks vallen. Met `px-2` stond elke + histogramkolom 8 px rechts van zijn dagblokje — gemeten 2026-09-14, blokje op + x=428 tegen staaf op x=436. */}
@@ -714,13 +721,18 @@ export function DistributionDialog() { ) : ( {statusLine.text} )} + {/* De reden waarom Toepassen uit staat, rechts in dezelfde strook. Bij een VERVALLEN + voorstel is die reden woordelijk de stale-zin die links al staat; dan blijft dit vak + leeg in plaats van dezelfde zin twee keer op één regel te zetten (gezien in de + gebruiksfoto van 2026-09-14). Het anker blijft altijd bestaan, zodat een test op + "welke reden staat er" niet van de toestand afhangt. */} - {applyGate.reason} + {statusLine.stale ? '' : applyGate.reason}
From 8fdeb52d6c3cc81e72dc7f358f14a00c71c3cd93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:38:52 +0000 Subject: [PATCH 066/108] Rapport: eenheden/dag en curve per rij in het resourcediagram (#113, manuvarkey punt 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per taakrij onder een resourceband levert `computeResourceGanttRows` nu de toewijzing van die band op die taak (`assignmentByRowKey`, per `rowKey`): eenheden per dag opgeteld over records van dezelfde resource, de curve alleen als alle records dezelfde hebben (afwezig telt als UNIFORM), anders null. Rijen onder "(geen)" hebben geen entry. De render tekent dat als twee tabelkolommen direct achter de naam (`PrintOptions.assignmentColumns` + `rowAssignments` + `curveLabels`): eenheden rechts uitgelijnd (geheel zonder decimalen, anders tot twee), de vertaalde curve links en afgekort met `fitText`, een streepje bij verschillende curves; kolomkoppen via `labels.tableHeaders.unitsPerDay`/`curve`. De tabel wordt precies de twee kolombreedtes (45 + 75) breder; `buildPrintRows` hangt de toewijzing aan `PrintRow.assignment`. Zonder de optie byte-identiek, ook mét meegegeven `rowAssignments`. De preview-signatuur serialiseert de Map expliciet (anders `{}`, dus geen herrender bij een gewijzigde inzet). Optie `showAssignmentColumns` standaard aan (dit is de informatie waarvoor het rapport wordt uitgedeeld), tolerante parser; drie sleutels in 14 locales (twee kolomkoppen, één optielabel); curvenamen uit `common:resource.curve.*`; gidsen nl+en; CLAUDE.md-rapportparagraaf voor de drie nieuwe opties. Regressie: check-reports (optelling, UNIFORM-terugval, null bij verschillende curves, entries exact voor de taakrijen onder resourcebanden), check-print-report (tabelbreedte, celteksten per kolom, koppen, zonder optie byte-identiek), settings-check, browserspec (standaard aan; uitzetten rastert opnieuw). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- CLAUDE.md | 9 +- public/docs/en/gids-rapporten-printen.md | 7 +- public/docs/nl/gids-rapporten-printen.md | 6 ++ src/components/panels/ReportPanel.tsx | 30 ++++++- src/engine/reports/resourceGantt.ts | 38 ++++++++- src/i18n/locales/ar/report.json | 3 + src/i18n/locales/de/report.json | 3 + src/i18n/locales/en/report.json | 3 + src/i18n/locales/es/report.json | 3 + src/i18n/locales/fa/report.json | 3 + src/i18n/locales/fr/report.json | 3 + src/i18n/locales/it/report.json | 3 + src/i18n/locales/ja/report.json | 3 + src/i18n/locales/ko/report.json | 3 + src/i18n/locales/nl/report.json | 3 + src/i18n/locales/pl/report.json | 3 + src/i18n/locales/pt/report.json | 3 + src/i18n/locales/tr/report.json | 3 + src/i18n/locales/zh/report.json | 3 + src/services/print/printPreview.ts | 82 ++++++++++++++++--- src/utils/reportSettings.ts | 7 +- tests/browser/resource-gantt-report.spec.ts | 10 +++ tests/planning/check-print-report.ts | 33 ++++++++ .../check-report-resource-gantt-setting.ts | 12 +-- tests/planning/check-reports.ts | 25 +++++- 25 files changed, 274 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f3315339..4198c83b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,14 @@ start; relaties staan bij dit type uit omdat een taak onder meerdere banden kan relatielijnen en legendaregel, de balken volgen `barColorSelection` via `criticalFill`); optie "blad per resource" = `PrintOptions.pageBreakBeforeGroups` → `RenderReportResult.forcedBreakOffsets` → `forcedBreakOffsetsPx` in `tileLayout`, waar een gedwongen -positie zonder vulgraaddrempel wint; `isGanttReportType()` bundelt beide Gantt-achtige typen; de voet +positie zonder vulgraaddrempel wint — een band direct onder een band (de optionele typelaag +`groupByType`: eerst een band per resourcetype in de vaste volgorde `RESOURCE_TYPE_BAND_ORDER`) krijgt +geen eigen gedwongen positie; optie *Rapportageperiode* = de gedeelde `ReportingPeriod` als +`PrintOptions.timeWindow` (tijdas exact op het venster, geometrie geklemd op het chartgebied want +`Draw2D` kent geen clip; de rijenbron filtert op overlap en telt `counts.outsidePeriod`); optie +*Eenheden/dag en curve tonen* = `PrintOptions.assignmentColumns` + `rowAssignments` (per `rowKey` +uit `assignmentByRowKey`: eenheden opgeteld, curve alleen bij eensluidende records) als twee +tabelkolommen achter de naam; `isGanttReportType()` bundelt beide Gantt-achtige typen; de voet met legenda is sinds #113 net als de kop een herhaalbaar blok — `RenderReportResult.footerHeight` → `repeatFooterHeightPx`/`repeatFooter`, instelling `repeatFooter` standaard aan; let op: de preview rendert per pagina één volledige `renderReport`-pass extra voor die strook, net als voor de kop), het diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index fe3c7bcda..1f62bf699 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -111,7 +111,12 @@ only tasks that touch the window take part, the time axis runs exactly from the date, and a bar that continues beyond it is cut off at the edge. That lets a meeting put the look-ahead report and the resource diagram side by side over exactly the same weeks. *Project duration* (the default) is the usual behaviour; with a window the summary block also counts how -many tasks fall outside it. +many tasks fall outside it. After the task name two columns **Units/d** and **Curve** are shown by +default: how many units per day the band's resource is assigned to the task, and with which +distribution curve — "you are on Foundations half days, front loaded" says more than "you are on +Foundations". A task under two bands shows each band's own load; two assignments of the same +resource on one task are added up, and a dash in the curve column means those assignments carry +different curves. Turn **Show units/day and curve** off for a narrower table. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 702b50745..3fb74d211 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -114,6 +114,12 @@ venster raken doen mee, de tijdas loopt precies van de begin- tot de einddatum, verder doorloopt wordt aan de rand afgekapt. Zo leg je in dezelfde vergadering het look-ahead-rapport en het resourcediagram over exact dezelfde weken naast elkaar. *Hele project* (standaard) is het gewone gedrag; bij een venster telt het overzichtsblok ook hoeveel taken erbuiten vallen. +Achter de taaknaam staan standaard twee kolommen **Eenh./d** en **Curve**: hoeveel eenheden per +dag de resource van die band op de taak staat en met welke verdeelcurve — "je staat halve dagen +op Fundering, vooraan belast" zegt meer dan "je staat op Fundering". Een taak onder twee banden +toont per band de eigen inzet; twee toewijzingen van dezelfde resource op één taak worden +opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillende curves hebben. +Zet **Eenheden/dag en curve tonen** uit voor een smallere tabel. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index 93b51c83f..87f235422 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -454,6 +454,13 @@ export function ReportPanel() { SUBCONTRACTOR: tCommon('resource.type.subcontractor'), EQUIPMENT: tCommon('resource.type.equipment'), MATERIAL: tCommon('resource.type.material'), }), [tCommon]); + // Vertaalde curvenamen voor de toewijzingskolommen (punt 1): dezelfde sleutels als het taakraster. + const curveLabels = useMemo(() => ({ + UNIFORM: tCommon('resource.curve.uniform'), FRONT_LOADED: tCommon('resource.curve.frontLoaded'), + BACK_LOADED: tCommon('resource.curve.backLoaded'), BELL: tCommon('resource.curve.bell'), + EARLY_PEAK: tCommon('resource.curve.earlyPeak'), LATE_PEAK: tCommon('resource.curve.latePeak'), + DOUBLE_PEAK: tCommon('resource.curve.doublePeak'), TURTLE: tCommon('resource.curve.turtle'), + }), [tCommon]); // Rapportageperiode als tijdvenster (punt 3): dezelfde oplossing als het control toont; bij // *Hele project* geen venster, zodat het rapport byte-identiek blijft aan vóór deze optie. const resourceGanttPeriod = useResolvedPeriod(resourceGanttOptions.period); @@ -572,6 +579,8 @@ export function ReportPanel() { tableHeaders: { wbs: t('tableHeaders.wbs'), taskName: t('tableHeaders.taskName'), + unitsPerDay: t('tableHeaders.unitsPerDay'), + curve: t('tableHeaders.curve'), start: t('tableHeaders.start'), end: t('tableHeaders.end'), duration: t('tableHeaders.duration'), @@ -616,6 +625,10 @@ export function ReportPanel() { pageBreakBeforeGroups: reportType === 'resourceGantt' && resourceGanttOptions.pageBreakPerResource, // Punt 3: de tijdas op de rapportageperiode (alleen resourcediagram, alleen buiten *Hele project*). timeWindow: resourceGanttWindow, + // Punt 1: eenheden/dag en curve van de band op de taak als tabelkolommen (alleen resourcediagram). + assignmentColumns: reportType === 'resourceGantt' && resourceGanttOptions.showAssignmentColumns, + rowAssignments: resourceGantt?.assignmentByRowKey, + curveLabels, barColorsLegendLabels: { criticalOutline: t('legend.criticalOutline', { defaultValue: 'Kritiek pad (rand)' }), categoriesMore: (n: number) => t('legend.categoriesMore', { count: n }), @@ -626,7 +639,7 @@ export function ReportPanel() { cpmResult, barColorSelection, fieldCtx.activityCodeTypes, fieldCtx.customFieldDefs, reportTaskTypeLabels, tTask, statusLine, statusDate, resources, assignments, baselineOverlay, reportRows, reportType, resourceGanttOptions.pageBreakPerResource, tasks.length, - resourceGantt, resourceGanttWindow]); + resourceGantt, resourceGanttWindow, resourceGanttOptions.showAssignmentColumns, curveLabels]); // `options` bevat afgeleide catalogus-/vertaalobjecten die bij een lokale preview-state-update // opnieuw kunnen worden aangemaakt zonder dat hun inhoud wijzigde. De rastertaak gebruikt deze // inhoudssignatuur als effectgrens: anders start `setPreviewPages` zelf opnieuw pagina 0 en 1. @@ -638,7 +651,10 @@ export function ReportPanel() { ? (value as ViewRow[]).map(r => (r.kind === 'group' ? `g:${r.key}:${r.label}:${r.count}:${r.depth}` : `t:${r.rowKey}:${r.depth}:${r.dimmed ? 1 : 0}`)) - : value + // Een Map serialiseert als `{}`; de toewijzingskolommen (punt 1) moeten wél een herrender geven. + : key === 'rowAssignments' && value instanceof Map + ? [...(value as Map).entries()] + : value )), [options]); // Eén generatie beheert één layout + één begrensde renderqueue. Een optiewijziging annuleert het @@ -1439,6 +1455,16 @@ export function ReportPanel() { /> {t('resourceGantt.groupByType')} + {/* Punt 3: de gedeelde rapportageperiode (issue #120) als tijdvenster van dit rapport. */} ; counts: { /** Resources (op identiteit) met minstens één bladtaak. */ resources: number; @@ -85,6 +94,14 @@ export interface ResourceGanttRowsResult { }; } +/** De toewijzing achter één taakrij van een resourceband (zie de moduledoc). */ +export interface RowAssignment { + /** Som van `unitsPerDay` over de records van deze resource op deze taak. */ + unitsPerDay: number; + /** De curve als alle records dezelfde hebben (`undefined` in een record = UNIFORM), anders null. */ + curve: ResourceCurve | null; +} + /** Bandsleutel van de "(geen)"-band — dezelfde codering als de schermgroepering. */ const NONE_BAND_KEY = encodeBandKey([NONE_RAWKEY]); @@ -164,6 +181,8 @@ export function computeResourceGanttRows( // een toewijzing aan een onbekende resource telt niet (zelfde regel als `resourceNames` op het // scherm: die taak is dan "zonder resource"). const tasksByResource = new Map>(); + // Per resource-id × taak-id de opgetelde eenheden en de verzameling curves (punt 1). + const loadByResourceTask = new Map }>(); const assignedTaskIds = new Set(); let assignments = 0; for (const a of ctx.assignments) { @@ -174,6 +193,11 @@ export function computeResourceGanttRows( if (!bucket) { bucket = new Map(); tasksByResource.set(a.resourceId, bucket); } bucket.set(task.id, task); assignedTaskIds.add(task.id); + const loadKey = `${a.resourceId}\u0000${task.id}`; + const load = loadByResourceTask.get(loadKey) ?? { unitsPerDay: 0, curves: new Set() }; + load.unitsPerDay += a.unitsPerDay; + load.curves.add(a.curve ?? 'UNIFORM'); + loadByResourceTask.set(loadKey, load); } const labels = resourceBandLabels(ctx.resources, opts.locale); @@ -185,6 +209,7 @@ export function computeResourceGanttRows( .sort((a, b) => collator.compare(a.label, b.label) || a.index - b.index); const rows: ViewRow[] = []; + const assignmentByRowKey = new Map(); // Eén resourceband met zijn taakrijen, onder een optioneel typepad (diepte +1). const pushBand = (band: (typeof bands)[number], path: string[]) => { const groupPath = [...path, band.id]; @@ -194,7 +219,15 @@ export function computeResourceGanttRows( depth: path.length, levelIndex: path.length, collapsed: false, }); for (const task of sortByStart([...band.tasks.values()])) { - rows.push({ kind: 'task', rowKey: encodeGroupedTaskRowKey(groupPath, task.id), task, depth: path.length + 1, dimmed: false }); + const rowKey = encodeGroupedTaskRowKey(groupPath, task.id); + rows.push({ kind: 'task', rowKey, task, depth: path.length + 1, dimmed: false }); + const load = loadByResourceTask.get(`${band.id}\u0000${task.id}`); + if (load) { + assignmentByRowKey.set(rowKey, { + unitsPerDay: load.unitsPerDay, + curve: load.curves.size === 1 ? [...load.curves][0] : null, + }); + } } }; if (opts.groupByType) { @@ -232,6 +265,7 @@ export function computeResourceGanttRows( return { rows, + assignmentByRowKey, counts: { resources: bands.length, assignments, unassignedTasks: unassigned.length, outsidePeriod: allLeaves.length - leaves.length }, }; } diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index 82d87ac32..70f66eac8 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -67,6 +67,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "وحدة/يوم", + "curve": "المنحنى", "taskName": "اسم المهمة", "start": "البداية", "end": "النهاية", @@ -147,6 +149,7 @@ "pageBreakPerResource": "كل مورد في صفحة جديدة", "includeUnassigned": "تضمين المهام بدون مورد", "groupByType": "تجميع حسب نوع المورد", + "showAssignmentColumns": "إظهار الوحدات/اليوم والمنحنى", "emptyPeriod": "لا توجد مهام في فترة التقرير — اختر فترة أخرى أو المشروع بأكمله.", "outsidePeriod": "خارج الفترة:", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index f70003d31..462542cb8 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Einh./T", + "curve": "Kurve", "taskName": "Aufgabenname", "start": "Anfang", "end": "Ende", @@ -135,6 +137,7 @@ "pageBreakPerResource": "Jede Ressource auf einer neuen Seite", "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", "groupByType": "Nach Ressourcentyp gruppieren", + "showAssignmentColumns": "Einheiten/Tag und Kurve anzeigen", "emptyPeriod": "Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt.", "outsidePeriod": "Außerhalb des Zeitraums:", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index a655e8e77..415157c9d 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Units/d", + "curve": "Curve", "taskName": "Task Name", "start": "Start", "end": "End", @@ -135,6 +137,7 @@ "pageBreakPerResource": "Each resource on a new page", "includeUnassigned": "Include tasks without a resource", "groupByType": "Group by resource type", + "showAssignmentColumns": "Show units/day and curve", "emptyPeriod": "No tasks in the reporting period — choose another period or Whole project.", "outsidePeriod": "Outside the period:", "empty": "No resource assignments — assign resources to tasks first.", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index c77ed1dec..ee3509643 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unid./d", + "curve": "Curva", "taskName": "Nombre de tarea", "start": "Inicio", "end": "Fin", @@ -138,6 +140,7 @@ "pageBreakPerResource": "Cada recurso en una página nueva", "includeUnassigned": "Incluir tareas sin recurso", "groupByType": "Agrupar por tipo de recurso", + "showAssignmentColumns": "Mostrar unidades/día y curva", "emptyPeriod": "No hay tareas en el período del informe — elija otro período o Proyecto completo.", "outsidePeriod": "Fuera del período:", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 61e067f38..353c25aec 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -56,6 +56,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "واحد/روز", + "curve": "منحنی", "taskName": "نام وظیفه", "start": "شروع", "end": "پایان", @@ -136,6 +138,7 @@ "pageBreakPerResource": "هر منبع در صفحه‌ای جدید", "includeUnassigned": "شامل کردن کارهای بدون منبع", "groupByType": "گروه‌بندی بر اساس نوع منبع", + "showAssignmentColumns": "نمایش واحد/روز و منحنی", "emptyPeriod": "هیچ کاری در دوره گزارش وجود ندارد — دوره دیگری یا کل پروژه را انتخاب کنید.", "outsidePeriod": "خارج از دوره:", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index 40bdc1ac3..aad5aa386 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unités/j", + "curve": "Courbe", "taskName": "Nom de la tâche", "start": "Début", "end": "Fin", @@ -138,6 +140,7 @@ "pageBreakPerResource": "Chaque ressource sur une nouvelle page", "includeUnassigned": "Inclure les tâches sans ressource", "groupByType": "Grouper par type de ressource", + "showAssignmentColumns": "Afficher unités/jour et courbe", "emptyPeriod": "Aucune tâche dans la période de rapport — choisissez une autre période ou Projet entier.", "outsidePeriod": "Hors période :", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index b1d12837a..b5beec51d 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unità/g", + "curve": "Curva", "taskName": "Nome attività", "start": "Inizio", "end": "Fine", @@ -138,6 +140,7 @@ "pageBreakPerResource": "Ogni risorsa su una nuova pagina", "includeUnassigned": "Includi le attività senza risorsa", "groupByType": "Raggruppa per tipo di risorsa", + "showAssignmentColumns": "Mostra unità/giorno e curva", "emptyPeriod": "Nessuna attività nel periodo del report — scegli un altro periodo o Intero progetto.", "outsidePeriod": "Fuori dal periodo:", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index b24fbb90c..1813d42e9 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "単位/日", + "curve": "カーブ", "taskName": "タスク名", "start": "開始", "end": "終了", @@ -132,6 +134,7 @@ "pageBreakPerResource": "リソースごとに改ページ", "includeUnassigned": "リソース未割当のタスクを含める", "groupByType": "リソース種別でグループ化", + "showAssignmentColumns": "単位/日とカーブを表示", "emptyPeriod": "レポート期間内にタスクがありません — 別の期間またはプロジェクト全体を選択してください。", "outsidePeriod": "期間外:", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 0c2de207a..5d0036f1b 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "단위/일", + "curve": "곡선", "taskName": "작업 이름", "start": "시작", "end": "종료", @@ -132,6 +134,7 @@ "pageBreakPerResource": "자원마다 새 페이지", "includeUnassigned": "자원이 없는 작업 포함", "groupByType": "자원 유형별로 그룹화", + "showAssignmentColumns": "단위/일 및 곡선 표시", "emptyPeriod": "보고 기간에 작업이 없습니다 — 다른 기간 또는 전체 프로젝트를 선택하세요.", "outsidePeriod": "기간 외:", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index 2aeb65290..5d1f739fd 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Eenh./d", + "curve": "Curve", "taskName": "Taaknaam", "start": "Start", "end": "Einde", @@ -135,6 +137,7 @@ "pageBreakPerResource": "Elke resource op een nieuwe pagina", "includeUnassigned": "Taken zonder resource meenemen", "groupByType": "Groeperen op resourcetype", + "showAssignmentColumns": "Eenheden/dag en curve tonen", "emptyPeriod": "Geen taken in de rapportageperiode — kies een andere periode of Hele project.", "outsidePeriod": "Buiten de periode:", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 96248c08b..1f6429536 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -61,6 +61,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Jedn./d", + "curve": "Krzywa", "taskName": "Nazwa zadania", "start": "Początek", "end": "Koniec", @@ -141,6 +143,7 @@ "pageBreakPerResource": "Każdy zasób na nowej stronie", "includeUnassigned": "Uwzględnij zadania bez zasobu", "groupByType": "Grupuj według typu zasobu", + "showAssignmentColumns": "Pokaż jednostki/dzień i krzywą", "emptyPeriod": "Brak zadań w okresie raportu — wybierz inny okres lub Cały projekt.", "outsidePeriod": "Poza okresem:", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index c9291aeee..616c5811f 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -58,6 +58,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Unid./d", + "curve": "Curva", "taskName": "Nome da tarefa", "start": "Início", "end": "Fim", @@ -138,6 +140,7 @@ "pageBreakPerResource": "Cada recurso numa nova página", "includeUnassigned": "Incluir tarefas sem recurso", "groupByType": "Agrupar por tipo de recurso", + "showAssignmentColumns": "Mostrar unidades/dia e curva", "emptyPeriod": "Nenhuma tarefa no período do relatório — escolha outro período ou Projeto inteiro.", "outsidePeriod": "Fora do período:", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index c0e398a97..b76699da8 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -55,6 +55,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "Birim/g", + "curve": "Eğri", "taskName": "Görev adı", "start": "Başlangıç", "end": "Bitiş", @@ -135,6 +137,7 @@ "pageBreakPerResource": "Her kaynak yeni bir sayfada", "includeUnassigned": "Kaynaksız görevleri dahil et", "groupByType": "Kaynak türüne göre grupla", + "showAssignmentColumns": "Birim/gün ve eğriyi göster", "emptyPeriod": "Rapor döneminde görev yok — başka bir dönem veya Tüm proje seçin.", "outsidePeriod": "Dönem dışında:", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 12903c530..6bf763037 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -52,6 +52,8 @@ }, "tableHeaders": { "wbs": "WBS", + "unitsPerDay": "单位/天", + "curve": "曲线", "taskName": "任务名称", "start": "开始", "end": "结束", @@ -132,6 +134,7 @@ "pageBreakPerResource": "每个资源另起一页", "includeUnassigned": "包含未分配资源的任务", "groupByType": "按资源类型分组", + "showAssignmentColumns": "显示单位/天和曲线", "emptyPeriod": "报告期间内没有任务 — 请选择其他期间或整个项目。", "outsidePeriod": "期间之外:", "empty": "没有资源分配——请先为任务分配资源。", diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 5fe22355d..a2a1ff1a3 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -26,10 +26,11 @@ import { visibleBarColorCategories, type BarColorContext, } from '@/services/print/barColorCategories'; -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; import type { BarColorSelection } from '@/types/barColor'; import type { ViewRow } from '@/engine/view/visibleRows'; +import type { RowAssignment } from '@/engine/reports/resourceGantt'; import type { BaselineOverlay } from '@/types/baseline'; // BASISmaten bij rapport-lettergrootte 100%. Niets tekent hier nog rechtstreeks mee: alle @@ -89,6 +90,10 @@ const COL = { start: { w: 55 }, end: { w: 55 }, complete: { w: 45 }, + // Toewijzingskolommen van het resourcediagram (manuvarkey punt 1): eenheden per dag en de + // verdeelcurve van de resource van de band op die taak. Alleen bij `assignmentColumns`. + units: { w: 45 }, + curve: { w: 75 }, }; /** @@ -120,20 +125,24 @@ function resolveNameColumnWidth(raw: number | undefined): number { * **Voltooiing tonen** uit verdwijnt de hele Volt.-kolom uit de tabel (issue #93) — niet alleen * de waarden — dus krimpt de tabel met precies die kolombreedte en krijgt de tijdlijn die ruimte. */ -function tableWidthFor(showCompletion: boolean, nameW: number): number { - return COL.wbs.w + nameW + COL.duration.w + COL.start.w + COL.end.w + (showCompletion ? COL.complete.w : 0); +function tableWidthFor(showCompletion: boolean, nameW: number, assignmentColumns = false): number { + return COL.wbs.w + nameW + COL.duration.w + COL.start.w + COL.end.w + (showCompletion ? COL.complete.w : 0) + + (assignmentColumns ? COL.units.w + COL.curve.w : 0); } // Kolomposities van links naar rechts. `k` is de rapport-lettergrootteschaal (zie // {@link ReportMetrics}); álle kolommaten schalen mee, want een grotere letter heeft een bredere // kolom nodig. Bij k = 1 is dit rekenkundig exact de ongeschaalde uitkomst. `complete` is // `undefined` wanneer de kolom verborgen is; alle tekenpaden lezen dat als "niet tekenen". -function getColPositions(k: number, showCompletion: boolean, nameW: number) { +function getColPositions(k: number, showCompletion: boolean, nameW: number, assignmentColumns = false) { let x = 0; const next = (w: number) => { const col = { x, w: w * k }; x += w * k; return col; }; return { wbs: next(COL.wbs.w), name: next(nameW), + // Direct achter de naam: ze horen bij "wie staat hierop en hoe", niet bij de datums. + units: assignmentColumns ? next(COL.units.w) : undefined, + curve: assignmentColumns ? next(COL.curve.w) : undefined, duration: next(COL.duration.w), start: next(COL.start.w), end: next(COL.end.w), @@ -153,6 +162,8 @@ export interface PrintRow { depth: number; label?: string; // groepsband-label count?: number; // groepsband-aantal bladrijen + /** Toewijzing van de band op deze taak (resourcediagram, `PrintOptions.rowAssignments`). */ + assignment?: RowAssignment; } /** @@ -161,11 +172,15 @@ export interface PrintRow { * de volledige takenboom (oud gedrag, self-flatten), met wezen zonder gevonden ouder achteraan. * Geëxporteerd omdat het rapportpaneel dezelfde rijen nodig heeft om de naamkolom te meten. */ -export function buildPrintRows(tasks: Task[], rows: ViewRow[] | undefined): PrintRow[] { +export function buildPrintRows( + tasks: Task[], + rows: ViewRow[] | undefined, + rowAssignments?: ReadonlyMap, +): PrintRow[] { const printRows: PrintRow[] = []; if (rows) { for (const row of rows) { - if (row.kind === 'task') printRows.push({ kind: 'task', task: row.task, depth: row.depth }); + if (row.kind === 'task') printRows.push({ kind: 'task', task: row.task, depth: row.depth, assignment: rowAssignments?.get(row.rowKey) }); else printRows.push({ kind: 'group', depth: row.depth, label: row.label, count: row.count }); } return printRows; @@ -270,7 +285,12 @@ export const REPORT_FONT_SCALES = [90, 100, 110, 125] as const; * geen enkele Select kan tonen en die na een herstart dus niet reproduceerbaar is. Zelfde semantiek * als in de settings- en rapport-loaders, allemaal via {@link snapToChoice}. */ -function makeMetrics(reportFontScale: number | undefined, showCompletion: boolean, taskNameColumnWidth: number | undefined): ReportMetrics { +function makeMetrics( + reportFontScale: number | undefined, + showCompletion: boolean, + taskNameColumnWidth: number | undefined, + assignmentColumns = false, +): ReportMetrics { const pct = snapToChoice(REPORT_FONT_SCALES, reportFontScale ?? 100) ?? 100; const k = pct / 100; const nameW = resolveNameColumnWidth(taskNameColumnWidth); @@ -286,9 +306,9 @@ function makeMetrics(reportFontScale: number | undefined, showCompletion: boolea // Bewust de SOM van de twee geschaalde hoogtes, niet `(PROJECT + TIMELINE) * k`: alleen zo valt // de kopstrook-grens gegarandeerd tot op de bit samen met waar de tijdschaal-kop eindigt. totalHeaderHeight: projectHeaderHeight + timelineHeaderHeight, - tableWidth: tableWidthFor(showCompletion, nameW) * k, + tableWidth: tableWidthFor(showCompletion, nameW, assignmentColumns) * k, footerHeight: FOOTER_HEIGHT * k, - cols: getColPositions(k, showCompletion, nameW), + cols: getColPositions(k, showCompletion, nameW, assignmentColumns), }; } @@ -327,7 +347,11 @@ export interface PrintOptions { * bindend-informatie beschikbaar is (zie {@link PrintOptions.drivingSequenceIds}). */ relationStyle: string; }; - tableHeaders: { wbs: string; taskName: string; start: string; end: string; duration: string; completion: string }; + tableHeaders: { + wbs: string; taskName: string; start: string; end: string; duration: string; completion: string; + /** Koppen van de toewijzingskolommen (resourcediagram); ontbreken ⇒ Nederlandse terugval. */ + unitsPerDay?: string; curve?: string; + }; /** Label boven de gestippelde "vandaag"-lijn in het Gantt-gebied. */ today: string; /** Label boven de statusdatum-/voortgangslijn in de exportkop (#54). */ @@ -371,6 +395,18 @@ export interface PrintOptions { * wordt alleen aangeboden op het resourcediagram, dat er geen tekent. Afwezig ⇒ byte-identiek. */ timeWindow?: { from: string; to: string }; + /** + * OPTIONEEL — TOEWIJZINGSKOLOMMEN (manuvarkey op #113, punt 1; resourcediagram): twee extra + * tabelkolommen direct achter de naam — eenheden per dag en verdeelcurve van de resource van de + * band op die taak — gevuld uit `rowAssignments` (per `ViewRow.rowKey`, uit + * `computeResourceGanttRows().assignmentByRowKey`). Een rij zonder entry (bandrij, "(geen)") + * laat de cellen leeg. De tabel wordt precies de twee kolombreedtes breder; afwezig ⇒ + * byte-identiek. + */ + assignmentColumns?: boolean; + rowAssignments?: ReadonlyMap; + /** Vertaalde curvenamen (`common:resource.curve.*`); een ontbrekend label valt terug op de enum. */ + curveLabels?: Partial>; /** * Lettergrootte van het GEGENEREERDE RAPPORT als percentage (issue #25 punt 4). 100 (of * ontbrekend) = het oude gedrag, byte-identiek. Werkt bewust RELATIEF: tekst, rijhoogtes, @@ -460,6 +496,11 @@ function formatDuration(days: number): string { return `${days}d`; } +/** Eenheden per dag: geheel als "2", anders tot twee decimalen zonder nullen ("0.5", "1.25"). */ +function formatUnitsPerDay(units: number): string { + return String(Math.round(units * 100) / 100); +} + /** Format completion as "75%" */ function formatCompletion(completion: number): string { return `${Math.round(completion * 100)}%`; @@ -635,11 +676,11 @@ export function renderReport( ): RenderReportResult { // Alle maatvoering loopt via dit object — de tekenhelpers lezen de module-constanten niet meer // rechtstreeks (zie {@link ReportMetrics} voor het waarom van relatief-schalen). - const m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth); + const m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, !!options.assignmentColumns); // Rijen-bron: zie {@link buildPrintRows} — taakrijen mét diepte plus groepsband-rijen (#54) die // als samenvattings-strook tekenen. - const printRows = buildPrintRows(tasks, options.rows); + const printRows = buildPrintRows(tasks, options.rows, options.rowAssignments); const flatTasks: PrintTask[] = printRows .filter((r): r is PrintRow & { kind: 'task'; task: Task } => r.kind === 'task') .map(r => ({ ...r.task, _depth: r.depth })); @@ -1832,8 +1873,10 @@ function drawTimelineHeader( d2d.textAlign = 'left'; d2d.fillText(th?.taskName ?? 'Taaknaam', cols.name.x + m.s(4), headerY); + if (cols.curve) d2d.fillText(th?.curve ?? 'Curve', cols.curve.x + m.s(4), headerY); d2d.textAlign = 'center'; + if (cols.units) d2d.fillText(th?.unitsPerDay ?? 'Eenh./d', cols.units.x + cols.units.w / 2, headerY); d2d.fillText(th?.duration ?? 'Duur', cols.duration.x + cols.duration.w / 2, headerY); d2d.fillText(th?.start ?? 'Start', cols.start.x + cols.start.w / 2, headerY); d2d.fillText(th?.end ?? 'Einde', cols.end.x + cols.end.w / 2, headerY); @@ -1846,6 +1889,7 @@ function drawTimelineHeader( d2d.lineWidth = 0.5; const colBorders = [cols.name.x, cols.duration.x, cols.start.x, cols.end.x, m.tableWidth]; if (cols.complete) colBorders.push(cols.complete.x); + if (cols.units && cols.curve) colBorders.push(cols.units.x, cols.curve.x); for (const cx of colBorders) { d2d.beginPath(); d2d.moveTo(cx, top); @@ -1870,7 +1914,7 @@ function drawTimelineHeader( function drawTaskTable( d2d: Draw2D, m: ReportMetrics, - printRows: { kind: 'task' | 'group'; task?: Task; depth: number; label?: string; count?: number }[], + printRows: PrintRow[], canvasHeight: number, cols: ColPositions, options: PrintOptions, @@ -1943,6 +1987,17 @@ function drawTaskTable( const nameAvail = cols.name.x + cols.name.w - m.s(NAME_RIGHT_PAD) - nameX; d2d.fillText(fitText(d2d, task.name, nameAvail), nameX, textY); + // Toewijzingskolommen (resourcediagram): eenheden rechts uitgelijnd, de curve links en afgekort. + if (cols.units && cols.curve && row.assignment) { + d2d.fillStyle = PRINT_COLORS.textSecondary; + d2d.font = m.font(8); + d2d.textAlign = 'right'; + d2d.fillText(formatUnitsPerDay(row.assignment.unitsPerDay), cols.units.x + cols.units.w - cellPad, textY); + d2d.textAlign = 'left'; + const curveText = row.assignment.curve === null ? '—' : (options.curveLabels?.[row.assignment.curve] ?? row.assignment.curve); + d2d.fillText(fitText(d2d, curveText, cols.curve.w - 2 * cellPad), cols.curve.x + cellPad, textY); + } + // Duration d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(8); @@ -1978,6 +2033,7 @@ function drawTaskTable( d2d.lineWidth = 0.5; const colBorders = [cols.name.x, cols.duration.x, cols.start.x, cols.end.x]; if (cols.complete) colBorders.push(cols.complete.x); + if (cols.units && cols.curve) colBorders.push(cols.units.x, cols.curve.x); for (const cx of colBorders) { d2d.beginPath(); d2d.moveTo(cx, m.totalHeaderHeight); diff --git a/src/utils/reportSettings.ts b/src/utils/reportSettings.ts index 9ba7de7f8..51df4e2cd 100644 --- a/src/utils/reportSettings.ts +++ b/src/utils/reportSettings.ts @@ -140,13 +140,16 @@ export type TableReportPeriodKey = (typeof TABLE_REPORT_PERIOD_KEYS)[number]; * `groupByType` (manuvarkey, punt 2) zet er een laag boven: eerst een band per resourcetype * (arbeid, ploeg, onderaannemer, materieel, materiaal), daarbinnen per resource. `period` (punt 3) * is de gedeelde rapportageperiode (issue #120): alleen taken die het venster raken, en de tijdas - * exact op het venster; default `project` = het oude gedrag. + * exact op het venster; default `project` = het oude gedrag. `showAssignmentColumns` (punt 1) + * zet achter de taaknaam twee kolommen met eenheden per dag en verdeelcurve van de resource van + * de band — standaard aan: dat is de informatie waarvoor je dit rapport uitdeelt. */ export interface ResourceGanttReportOptions { pageBreakPerResource: boolean; includeUnassigned: boolean; groupByType: boolean; period: ReportingPeriod; + showAssignmentColumns: boolean; } export const DEFAULT_RESOURCE_GANTT_OPTIONS: ResourceGanttReportOptions = { @@ -154,6 +157,7 @@ export const DEFAULT_RESOURCE_GANTT_OPTIONS: ResourceGanttReportOptions = { includeUnassigned: false, groupByType: false, period: { preset: 'project' }, + showAssignmentColumns: true, }; /** Grenzen van de numerieke opties (de UI en de loader delen ze). */ @@ -340,6 +344,7 @@ function parseResourceGanttOptions(raw: unknown): ResourceGanttReportOptions { includeUnassigned: parseBoolean(s.includeUnassigned) ?? d.includeUnassigned, groupByType: parseBoolean(s.groupByType) ?? d.groupByType, period: parseReportingPeriod(s.period, d.period), + showAssignmentColumns: parseBoolean(s.showAssignmentColumns) ?? d.showAssignmentColumns, }; } diff --git a/tests/browser/resource-gantt-report.spec.ts b/tests/browser/resource-gantt-report.spec.ts index 4681738eb..2e691411d 100644 --- a/tests/browser/resource-gantt-report.spec.ts +++ b/tests/browser/resource-gantt-report.spec.ts @@ -62,6 +62,16 @@ test('resourcediagram: rapporttype rendert per resource, opties sturen samenvatt await page.locator('[data-ops-report-option="groupByType"]').check(); await expect(pages).toHaveCount(3); + // Toewijzingskolommen (manuvarkey punt 1): standaard aan; uitzetten maakt de tabel smaller en + // rastert de preview dus opnieuw. + const colsToggle = page.locator('[data-ops-report-option="showAssignmentColumns"]'); + await expect(colsToggle).toBeChecked(); + const firstImg = pages.first().locator('img'); + const beforeCols = await firstImg.getAttribute('src'); + await colsToggle.uncheck(); + await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(beforeCols); + await colsToggle.check(); + // Rapportageperiode (manuvarkey punt 3): een eigen bereik dat alleen Fundering raakt ⇒ alleen Ploeg A // blijft over; Casco (met Kraan) en Gevel vallen buiten de periode en de telling zegt dat. Terug naar // Hele project herstelt alles — de export hieronder telt dan weer drie pagina's. diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 9d57fa63d..f94e0df2d 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -524,6 +524,39 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(JSON.stringify(record(FIX_TASKS, [], cal, fixed)) === JSON.stringify(record(FIX_TASKS, [], cal, { ...fixed, timeWindow: undefined })), 'zonder venster byte-identiek'); } + + // Toewijzingskolommen (manuvarkey punt 1): twee kolommen direct achter de naam (x 180–225 en + // 225–300 bij de standaardnaamkolom van 130), gevuld per rijsleutel; de tabel wordt precies de + // twee kolombreedtes breder; zonder de optie (ook mét `rowAssignments`) byte-identiek. + { + const aBands: ViewRow[] = [ + { kind: 'group', rowKey: 'b', key: 'b', label: 'Metselaar', count: 2, depth: 0, levelIndex: 0, collapsed: false }, + { kind: 'task', rowKey: 'b/norm', task: T_NORM, depth: 1, dimmed: false }, + { kind: 'task', rowKey: 'b/crit', task: T_CRIT, depth: 1, dimmed: false }, + ]; + const rowAssignments = new Map([ + ['b/norm', { unitsPerDay: 1.5, curve: 'FRONT_LOADED' as const }], + ['b/crit', { unitsPerDay: 2, curve: null }], + ]); + const plainA = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands })); + const withCols = record([T_NORM, T_CRIT], [], cal, baseOptions({ + rows: aBands, assignmentColumns: true, rowAssignments, curveLabels: { FRONT_LOADED: 'Vooraan belast' }, + })); + ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 75, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); + const inCol = (t: { x: number; y: number }, x0: number, x1: number) => t.x >= x0 && t.x <= x1 && t.y > withCols.dims.headerHeight && t.y < withCols.dims.height - withCols.dims.footerHeight; + const unitsTexts = withCols.texts.filter(t => inCol(t, 180, 225)).map(t => t.text).sort(); + const curveTexts = withCols.texts.filter(t => inCol(t, 225, 300)).map(t => t.text).sort(); + ok(JSON.stringify(unitsTexts) === JSON.stringify(['1.5', '2']), `eenheden per rij in de kolom (got ${JSON.stringify(unitsTexts)})`); + // De testmeter rekent 6 px per teken, dus "Vooraan belast" (84 px) kapt in de 75 px-kolom af op + // een beletselteken — dat is het bedoelde `fitText`-gedrag; met het echte 8 px-font past het. + ok(curveTexts.length === 2 && curveTexts.some(t => t.startsWith('Vooraan be')) && curveTexts.includes('—'), + `vertaalde curve (desnoods afgekort), streepje bij verschillende curves (got ${JSON.stringify(curveTexts)})`); + const heads = withCols.texts.filter(t => t.y <= withCols.dims.headerHeight).map(t => t.text); + ok(heads.includes('Eenh./d') && heads.includes('Curve'), 'kolomkoppen aanwezig (Nederlandse terugval zonder label)'); + ok(!plainA.texts.some(t => t.text === 'Eenh./d' || t.text === 'Vooraan belast'), 'zonder optie geen kolommen'); + ok(JSON.stringify(record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, rowAssignments }))) === JSON.stringify(plainA), + 'rowAssignments zonder assignmentColumns ⇒ byte-identiek'); + } // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee // paginabreedtes — steeds drie body-rijen die exact op de bandgrenzen eindigen. diff --git a/tests/planning/check-report-resource-gantt-setting.ts b/tests/planning/check-report-resource-gantt-setting.ts index 027d32d2a..a3567c0b4 100644 --- a/tests/planning/check-report-resource-gantt-setting.ts +++ b/tests/planning/check-report-resource-gantt-setting.ts @@ -14,7 +14,7 @@ const expect = (label: string, got: unknown, want: unknown) => { if (JSON.stringify(got) !== JSON.stringify(want)) failures.push(`${label}: verwacht ${JSON.stringify(want)}, kreeg ${JSON.stringify(got)}`); }; -expect('defaults: blad per resource uit, taken zonder resource uit, typelaag uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false, groupByType: false, period: { preset: 'project' } }); +expect('defaults: blad per resource uit, taken zonder resource uit, typelaag uit', DEFAULT_REPORT_SETTINGS.resourceGantt, { pageBreakPerResource: false, includeUnassigned: false, groupByType: false, period: { preset: 'project' }, showAssignmentColumns: true }); expect('defaults: één bron', DEFAULT_REPORT_SETTINGS.resourceGantt, DEFAULT_RESOURCE_GANTT_OPTIONS); expect('isGanttReportType: gantt én resourceGantt', [isGanttReportType('gantt'), isGanttReportType('resourceGantt'), isGanttReportType('milestones'), isGanttReportType('resourceAssignments')], [true, true, false, false]); // Review N6: één predicaat voor "dit type tekent geen relaties" — de forcering van showDeps én het @@ -25,10 +25,10 @@ expect('showDeps staat standaard aan (de forcering doet dus echt iets)', DEFAULT // barColorSelection), dus zonder lijnen is het misleidend — verborgen op hetzelfde predicaat als de relaties. expect('reportTypeShowsCriticalToggle: verborgen waar geen relaties getekend worden', [reportTypeShowsCriticalToggle('gantt'), reportTypeShowsCriticalToggle('resourceGantt'), reportTypeShowsCriticalToggle('milestones')], [true, false, true]); -localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' } } })); +localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' }, showAssignmentColumns: false } })); const loaded = await loadReportSettings(); expect('opgeslagen rapporttype resourceGantt wordt geladen', loaded.reportType, 'resourceGantt'); -expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' } }); +expect('opgeslagen opties worden geladen', loaded.resourceGantt, { pageBreakPerResource: true, includeUnassigned: true, groupByType: true, period: { preset: 'next4Weeks' }, showAssignmentColumns: false }); localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'gantt', showLegend: false })); const zonderBlok = await loadReportSettings(); @@ -37,7 +37,7 @@ expect('… en de overige voorkeuren blijven staan', zonderBlok.showLegend, fals localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { pageBreakPerResource: 'ja', includeUnassigned: true } })); const rommel = await loadReportSettings(); -expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true, groupByType: false, period: { preset: 'project' } }); +expect('rommel in één veld ⇒ alleen dat veld terug op de default', rommel.resourceGantt, { pageBreakPerResource: false, includeUnassigned: true, groupByType: false, period: { preset: 'project' }, showAssignmentColumns: true }); // Punt 3: de rapportageperiode gaat door dezelfde tolerante parser als de tabelrapporten. localStorage.setItem('ops-reportSettings', JSON.stringify({ resourceGantt: { period: { preset: 'nonsense' } } })); expect('onbekende periode-preset ⇒ Hele project', (await loadReportSettings()).resourceGantt.period, { preset: 'project' }); @@ -48,10 +48,10 @@ expect('rommel in het hele blok ⇒ defaults', (await loadReportSettings()).reso localStorage.setItem('ops-reportSettings', JSON.stringify({ reportType: 'resourceDiagram' })); expect('onbekend rapporttype ⇒ default rapporttype', (await loadReportSettings()).reportType, DEFAULT_REPORT_SETTINGS.reportType); -await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' } } }); +await saveReportSettings({ ...DEFAULT_REPORT_SETTINGS, reportType: 'resourceGantt', resourceGantt: { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' }, showAssignmentColumns: false } }); const saved = await loadReportSettings(); expect('rapporttype overleeft een round-trip', saved.reportType, 'resourceGantt'); -expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' } }); +expect('opties overleven een round-trip', saved.resourceGantt, { pageBreakPerResource: true, includeUnassigned: false, groupByType: true, period: { preset: 'custom', from: '2026-09-01', to: '2026-09-30' }, showAssignmentColumns: false }); if (failures.length > 0) { console.log(`XX report-resource-gantt-setting: ${failures.length} afwijking(en)`); diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index a3165a2b8..847216a2e 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -20,7 +20,7 @@ import { addCalendarMonths, formatDate, parseDate } from '@/utils/dateUtils'; import { makeMonthLabeler } from '@/utils/monthLabel'; import { DEFAULT_TABLE_REPORT_OPTIONS, parseReportingPeriod, parseTableReportOptions } from '@/utils/reportSettings'; import type { Task } from '@/types/task'; -import type { Resource, ResourceAssignment } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; const S = () => useAppStore.getState(); const diffs: string[] = []; @@ -573,6 +573,29 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); const ruim = computeResourceGanttRows(base, { ...opts, window: { from: '2020-01-01', to: '2035-12-31' } }); ok('resourceGantt/venster: een venster dat alles omvat ⇒ dezelfde rijen als zonder venster', JSON.stringify(ruim.rows) === JSON.stringify(r.rows) && ruim.counts.outsidePeriod === 0); + // manuvarkey op #113, punt 1: per taakrij de toewijzing van de band op die taak — eenheden opgeteld + // over records van dezelfde resource, curve alleen als alle records dezelfde hebben (afwezig = + // UNIFORM), anders null; taakrijen onder "(geen)" hebben geen entry. + const asgFull = (id: string, taskId: string, resourceId: string, unitsPerDay: number, curve?: ResourceCurve): ResourceAssignment => ({ id, taskId, resourceId, unitsPerDay, curve }); + const loaded = computeResourceGanttRows({ + tasks: ctx.tasks, resources: [kraan, jan1], + assignments: [ + asgFull('l1', A, kraan.id, 0.5, 'FRONT_LOADED'), asgFull('l2', A, kraan.id, 1, 'FRONT_LOADED'), + asgFull('l3', B, kraan.id, 2), asgFull('l4', B, jan1.id, 1, 'BELL'), asgFull('l5', B, jan1.id, 1, 'BACK_LOADED'), + ], + }, { ...opts, includeUnassigned: true }); + const rowOf = (bandName: string, taskId: string) => { + const start = loaded.rows.findIndex(x => label(x) === bandName); + return loaded.rows.slice(start + 1).find(x => x.kind === 'task' && x.task.id === taskId)!; + }; + eq('resourceGantt/toewijzing: Kraan op A — twee records opgeteld, zelfde curve', loaded.assignmentByRowKey.get(rowOf('Kraan', A).rowKey), { unitsPerDay: 1.5, curve: 'FRONT_LOADED' }); + eq('resourceGantt/toewijzing: Kraan op B — record zonder curve telt als UNIFORM', loaded.assignmentByRowKey.get(rowOf('Kraan', B).rowKey), { unitsPerDay: 2, curve: 'UNIFORM' }); + eq('resourceGantt/toewijzing: Jan op B — verschillende curves ⇒ null', loaded.assignmentByRowKey.get(rowOf('Jan', B).rowKey), { unitsPerDay: 2, curve: null }); + const noneStart = loaded.rows.findIndex(x => label(x) === '(geen)'); + ok('resourceGantt/toewijzing: elke taakrij onder een resourceband heeft een entry', loaded.rows.slice(0, noneStart).every(x => x.kind !== 'task' || loaded.assignmentByRowKey.has(x.rowKey))); + ok('resourceGantt/toewijzing: "(geen)"-rijen hebben geen entry', noneStart > 0 && loaded.rows.slice(noneStart).every(x => x.kind !== 'task' || !loaded.assignmentByRowKey.has(x.rowKey))); + eq('resourceGantt/toewijzing: aantal entries = aantal taakrijen onder resourcebanden', loaded.assignmentByRowKey.size, loaded.rows.slice(0, noneStart).filter(x => x.kind === 'task').length); + // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). const dubbel = computeResourceGanttRows({ From 2cade9de8556b4c676420822be8f4f050140cd0c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:38:59 +0200 Subject: [PATCH 067/108] chore(b1c): dode i18n-sleutels onder resource.distribution verwijderd Het rangordeblok (rank.*), de prijsteksten (tool.price*), de bijbehorende helpteksten en vijf ongebruikte strip-sleutels zijn in alle veertien locales weg; elke sleutel gaf nul treffers in src/. stale.* blijft: die wordt dynamisch opgebouwd in DistributionDialog.tsx. Co-Authored-By: Claude Fable 5.1 --- src/i18n/locales/ar/common.json | 33 --------------------------------- src/i18n/locales/de/common.json | 25 ------------------------- src/i18n/locales/en/common.json | 25 ------------------------- src/i18n/locales/es/common.json | 27 --------------------------- src/i18n/locales/fa/common.json | 25 ------------------------- src/i18n/locales/fr/common.json | 27 --------------------------- src/i18n/locales/it/common.json | 27 --------------------------- src/i18n/locales/ja/common.json | 23 ----------------------- src/i18n/locales/ko/common.json | 23 ----------------------- src/i18n/locales/nl/common.json | 25 ------------------------- src/i18n/locales/pl/common.json | 29 ----------------------------- src/i18n/locales/pt/common.json | 27 --------------------------- src/i18n/locales/tr/common.json | 25 ------------------------- src/i18n/locales/zh/common.json | 23 ----------------------- 14 files changed, 364 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 691d84ae8..548ba2d30 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -714,16 +714,6 @@ "title": "الأداة", "allowSplits": "السماح بالانقطاعات", "allowSplitsHint": "يمكن أن يحصل العمل على أيام توقف بدلاً من الانزياح دفعة واحدة. في MS Project يسمى هذا \"Leveling can create splits in remaining work\".", - "price_zero": "لا تكلف أي يوم عمل تمديدًا", - "price_one": "تكلف يوم عمل واحد تمديدًا", - "price_two": "تكلف يومي عمل تمديدًا", - "price_few": "تكلف {{count}} أيام عمل تمديدًا", - "price_many": "تكلف {{count}} يوم عمل تمديدًا", - "price_other": "تكلف {{count}} يوم عمل تمديدًا", - "priceNone": "لا يكلف أي تمديد", - "priceOff": "إيقاف: {{price}}", - "priceOn": "تشغيل: {{price}}", - "priceSame": "{{price}} — في كلتا الحالتين", "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب", "savesOff_zero": "لن يوفّر شيئًا", "savesOff_one": "سيوفّر يوم عمل واحدًا", @@ -740,27 +730,9 @@ "savesNoneOff": "لن يوفّر شيئًا", "savesNoneOn": "لا يوفّر شيئًا" }, - "rank": { - "title": "من يُراعى أكثر؟", - "hint": "الأعلى هو الأقل تزحزحًا. اسحب المقبض على اليسار، أو استخدم أزرار الأسهم على اليمين.", - "drag": "اسحب لإعادة الترتيب", - "float": "الفائض: {{days}}", - "cost_zero": "ترك هذا المشروع وحده ينزاح لا يكلف أي يوم عمل", - "cost_one": "ترك هذا المشروع وحده ينزاح يكلف يوم عمل واحد", - "cost_two": "ترك هذا المشروع وحده ينزاح يكلف يومي عمل", - "cost_few": "ترك هذا المشروع وحده ينزاح يكلف {{count}} أيام عمل", - "cost_many": "ترك هذا المشروع وحده ينزاح يكلف {{count}} يوم عمل", - "cost_other": "ترك هذا المشروع وحده ينزاح يكلف {{count}} يوم عمل", - "costNone": "ترك هذا المشروع وحده ينزاح لا يكلف أي تمديد", - "moveUp": "نقل لأعلى", - "moveDown": "نقل لأسفل" - }, "help": { - "dragHandle": "اسحب هذا الصف إلى موضع آخر في الترتيب.", "allowSplits": "يسمح بأن يتخلل العمل أيام توقف بدلًا من إزاحته قطعةً واحدة؛ يمنح ذلك التوزيع مجالًا أوسع، لكنه ينتج مهامًا مجزّأة.", "price": "تعرض بطاقة السعر، لكل وضع، عدد أيام العمل التي يتأخرها تاريخ انتهاء المشروع الأكثر تضررًا؛ ووجود سعر واحد مع \"في كلتا الحالتين\" يعني أن السماح بالتوقف لا يغيّر شيئًا هنا.", - "rank": "الأعلى هو الأقل تزحزحًا: يحتفظ ذلك المشروع بتواريخه قدر الإمكان، بينما تتزحزح المشاريع أسفله لإفساح المجال.", - "rankCost": "ما كانت ستبلغه التكلفة لو تزحزح هذا المشروع وحده وبقي الباقي في مكانه — مقياس لاختيار الترتيب، لا نتيجة الاقتراح الحالي.", "pin": "يثبّت هذا المشروع: يحتفظ بتاريخ انتهائه وبأيام عمله، ويُحتسب حِملًا ثابتًا يتعين على المشاريع الأخرى الالتفاف حوله.", "ceiling": "أقصى عدد من أيام العمل يمكن أن يتأخره تاريخ انتهاء هذا المشروع. اسحب المقبض، أو استخدم مفاتيح الأسهم وHome (بلا تجاوز) وEnd (بلا حد). تُظهر التسمية الأثر على تاريخ الانتهاء، لا مسافة السحب.", "chart": "الآن = الحجز الحالي لجميع المشاريع المفتوحة مجتمعة؛ بعد التوزيع = ما يتبقى بعد التوزيع. الأحمر هو ما يتجاوز السعة.", @@ -771,12 +743,8 @@ "reset": "يعيد جميع الحدود والتثبيتات إلى الوضع المحايد. يبقى مفتاح «السماح بالانقطاعات» كما هو." }, "strip": { - "pin": "تثبيت", - "pinned": "مثبَّت — هذا المشروع لا ينزاح ويحتفظ أيضًا بأيام عمله", "pinnedRecorded": "مثبَّت: التواريخ كما سُجِّلت", "cannotMove": "لا يمكنه الانزياح — كل العمل هنا ثابت", - "fixedLoad": "الحمل الثابت", - "ceiling": "أقصى تمديد لتاريخ الانتهاء", "ceilingUnlimited": "غير محدود", "ceilingDays_zero": "سقف بلا أيام عمل", "ceilingDays_one": "سقف يوم عمل واحد", @@ -791,7 +759,6 @@ "endShift_few": "النهاية +{{count}} أيام", "endShift_many": "النهاية +{{count}} يومًا", "endShift_other": "النهاية +{{count}} يوم", - "requestedVsAchievable": "المطلوب {{requested}}، الأقرب القابل للتحقيق {{achievable}}", "handleLabel": "سقف التمديد لـ {{doc}}", "legend": "المربعات الممتلئة = أيام العمل · المظلّلة = يوم توقّف · المسطرة: رمادي = المرونة، أحمر = إزاحة تاريخ الانتهاء · المنقّطة = مسموح لكنه غير مستخدم", "slack": "مرونة {{days}} يوم", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index c187805a6..a39db6592 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -666,12 +666,6 @@ "title": "Werkzeug", "allowSplits": "Unterbrechungen zulassen", "allowSplitsHint": "Die Arbeit darf Pausentage bekommen, statt sich am Stück zu verschieben. In MS Project heißt das \"Leveling can create splits in remaining work\".", - "price_one": "kostet {{count}} Arbeitstag Verzug", - "price_other": "kostet {{count}} Arbeitstage Verzug", - "priceNone": "kostet keinen Verzug", - "priceOff": "aus: {{price}}", - "priceOn": "ein: {{price}}", - "priceSame": "{{price}} — in beiden Stellungen", "priceUnknown": "Preis unbekannt — Neu berechnen drücken", "savesOff_one": "würde {{count}} Arbeitstag sparen", "savesOff_other": "würde {{count}} Arbeitstage sparen", @@ -680,23 +674,9 @@ "savesNoneOff": "würde nichts sparen", "savesNoneOn": "spart nichts" }, - "rank": { - "title": "Wer wird am meisten geschont?", - "hint": "Ganz oben weicht am wenigsten. Ziehen Sie am Griff links oder nutzen Sie die Pfeiltasten rechts.", - "drag": "Zum Umsortieren ziehen", - "float": "Puffer: {{days}}", - "cost_one": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstag", - "cost_other": "nur dieses Projekt verschieben zu lassen kostet {{count}} Arbeitstage", - "costNone": "nur dieses Projekt verschieben zu lassen kostet keinen Verzug", - "moveUp": "Nach oben", - "moveDown": "Nach unten" - }, "help": { - "dragHandle": "Ziehen Sie diese Zeile an eine andere Stelle in der Reihenfolge.", "allowSplits": "Erlaubt, dass die Arbeit Pausentage erhält, statt am Stück zu verschieben; das gibt der Verteilung mehr Spielraum, erzeugt aber unterbrochene Vorgänge.", "price": "Das Preisschild zeigt je Stellung, um wie viele Arbeitstage sich das Enddatum des am stärksten betroffenen Projekts verschiebt; ein einzelner Preis mit „in beiden Stellungen“ heißt, dass Unterbrechen hier nichts ändert.", - "rank": "Ganz oben weicht am wenigsten: Dieses Projekt behält seine Termine so weit wie möglich, die darunter verschieben sich, um Platz zu schaffen.", - "rankCost": "Was es kosten würde, wenn sich ausschließlich dieses Projekt verschöbe und alle anderen stehen blieben — ein Maßstab für die Reihenfolge, nicht das Ergebnis des aktuellen Vorschlags.", "pin": "Fixiert dieses Projekt: Es behält sein Enddatum und seine Arbeitstage und zählt als feste Last, um die die anderen Projekte herum müssen.", "ceiling": "Um wie viele Arbeitstage sich das Enddatum dieses Projekts höchstens verschieben darf. Ziehen Sie den Griff, oder nutzen Sie die Pfeiltasten, Pos1 (kein Überlauf) und Ende (unbegrenzt). Die Beschriftung zeigt die Wirkung auf das Enddatum, nicht die Ziehdistanz.", "chart": "Jetzt = die aktuelle Buchung aller geöffneten Projekte zusammen; Nach dem Verteilen = was nach der Verteilung übrig bleibt. Rot liegt über der Kapazität.", @@ -707,19 +687,14 @@ "reset": "Setzt alle Obergrenzen und Fixierungen auf neutral zurück. Der Schalter „Unterbrechungen zulassen“ bleibt unverändert." }, "strip": { - "pin": "Anheften", - "pinned": "Angeheftet — dieses Projekt weicht nicht und behält auch seine Arbeitstage", "pinnedRecorded": "Angeheftet: Datumsangaben wie gespeichert", "cannotMove": "Kann nicht ausweichen — die gesamte Arbeit hier steht fest", - "fixedLoad": "Feste Last", - "ceiling": "Maximaler Verzug des Enddatums", "ceilingUnlimited": "unbegrenzt", "ceilingDays_one": "Obergrenze {{count}} Arbeitstag", "ceilingDays_other": "Obergrenze {{count}} Arbeitstage", "endUnchanged": "Ende unverändert", "endShift_one": "Ende +{{count}} Tag", "endShift_other": "Ende +{{count}} Tage", - "requestedVsAchievable": "angefragt {{requested}}, am ehesten erreichbar {{achievable}}", "handleLabel": "Verzugsobergrenze für {{doc}}", "legend": "Volle Blöcke = Arbeitstage · schraffiert = Pausentag · Messlatte: grau = Puffer, rot = Endterminverschiebung · gepunktet = erlaubt, aber nicht genutzt", "slack": "Puffer {{days}} Tage", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0b113f926..13def4f27 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -666,12 +666,6 @@ "title": "Tool", "allowSplits": "Allow interruptions", "allowSplitsHint": "The work may get pause days instead of shifting in one piece. In MS Project this is called \"Leveling can create splits in remaining work\".", - "price_one": "costs {{count}} workday of overrun", - "price_other": "costs {{count}} workdays of overrun", - "priceNone": "costs no overrun", - "priceOff": "off: {{price}}", - "priceOn": "on: {{price}}", - "priceSame": "{{price}} — in both settings", "priceUnknown": "price unknown — press Recalculate", "savesOff_one": "would save {{count}} workday", "savesOff_other": "would save {{count}} workdays", @@ -680,23 +674,9 @@ "savesNoneOff": "would save nothing", "savesNoneOn": "saves nothing" }, - "rank": { - "title": "Who gets spared the most?", - "hint": "The one on top gives way the least. Drag the handle on the left, or use the arrow buttons on the right.", - "drag": "Drag to reorder", - "float": "slack: {{days}}", - "cost_one": "letting only this project shift costs {{count}} workday", - "cost_other": "letting only this project shift costs {{count}} workdays", - "costNone": "letting only this project shift costs no overrun", - "moveUp": "Move up", - "moveDown": "Move down" - }, "help": { - "dragHandle": "Drag this row to another position in the order.", "allowSplits": "Allows the work to take pause days instead of shifting in one piece; this gives the distribution more room, but produces split tasks.", "price": "The price tag shows, per setting, how many working days the end date of the worst-affected project shifts; a single price with \"in both settings\" means interrupting makes no difference here.", - "rank": "The one on top gives way the least: that project keeps its dates as much as possible, while the ones below shift to make room.", - "rankCost": "What it would cost if only this project shifted and all the others stayed put — a yardstick for choosing the order, not the outcome of the current proposal.", "pin": "Pin this project: it keeps its end date and its working days, and counts as a fixed load the other projects have to work around.", "ceiling": "How many working days this project's end date may shift at most. Drag the handle, or use the arrow keys, Home (no overrun) and End (unlimited). The label shows the effect on the end date, not how far you dragged.", "chart": "Now = the current booking of all open projects together; After distributing = what remains once the work is spread. Red is whatever exceeds the capacity.", @@ -707,19 +687,14 @@ "reset": "Puts all ceilings and pins back to neutral. The \"Allow interruptions\" switch stays as it is." }, "strip": { - "pin": "Pin", - "pinned": "Pinned — this project does not shift, and keeps its workdays too", "pinnedRecorded": "Pinned: dates as recorded", "cannotMove": "Cannot shift — all the work here is fixed", - "fixedLoad": "Fixed load", - "ceiling": "Maximum overrun of the end date", "ceilingUnlimited": "unlimited", "ceilingDays_one": "ceiling {{count}} workday", "ceilingDays_other": "ceiling {{count}} workdays", "endUnchanged": "end unchanged", "endShift_one": "end +{{count}} day", "endShift_other": "end +{{count}} days", - "requestedVsAchievable": "requested {{requested}}, closest achievable {{achievable}}", "handleLabel": "Overrun ceiling for {{doc}}", "legend": "Solid blocks = workdays · hatched = pause day · gauge: grey = slack, red = end-date shift · dotted = allowed but not used", "slack": "slack {{days}} days", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 7b4d44de9..f117e4978 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -678,13 +678,6 @@ "title": "Herramienta", "allowSplits": "Permitir interrupciones", "allowSplitsHint": "El trabajo puede recibir días de pausa en lugar de desplazarse de una sola vez. En MS Project esto se llama \"Leveling can create splits in remaining work\".", - "price_one": "cuesta {{count}} día laborable de retraso", - "price_many": "cuesta {{count}} días laborables de retraso", - "price_other": "cuesta {{count}} días laborables de retraso", - "priceNone": "no cuesta ningún retraso", - "priceOff": "desactivado: {{price}}", - "priceOn": "activado: {{price}}", - "priceSame": "{{price}} — en ambos casos", "priceUnknown": "precio desconocido — pulsa Recalcular", "savesOff_one": "ahorraría {{count}} día laborable", "savesOff_many": "ahorraría {{count}} días laborables", @@ -695,24 +688,9 @@ "savesNoneOff": "no ahorraría nada", "savesNoneOn": "no ahorra nada" }, - "rank": { - "title": "¿A quién se respeta más?", - "hint": "El de arriba cede lo menos posible. Arrastre el asa de la izquierda o use las flechas de la derecha.", - "drag": "Arrastrar para reordenar", - "float": "holgura: {{days}}", - "cost_one": "dejar que solo este proyecto se desplace cuesta {{count}} día laborable", - "cost_many": "dejar que solo este proyecto se desplace cuesta {{count}} días laborables", - "cost_other": "dejar que solo este proyecto se desplace cuesta {{count}} días laborables", - "costNone": "dejar que solo este proyecto se desplace no cuesta ningún retraso", - "moveUp": "Subir", - "moveDown": "Bajar" - }, "help": { - "dragHandle": "Arrastre esta fila a otra posición del orden.", "allowSplits": "Permite que el trabajo tenga días de pausa en lugar de desplazarse de una pieza; da más margen al reparto, pero genera tareas divididas.", "price": "La etiqueta de precio muestra, por posición, cuántos días laborables se desplaza la fecha de fin del proyecto más afectado; un único precio con «en ambos casos» significa que interrumpir no cambia nada aquí.", - "rank": "El de arriba cede lo menos posible: ese proyecto conserva sus fechas en la medida de lo posible y los de abajo se desplazan para hacer sitio.", - "rankCost": "Lo que costaría si únicamente este proyecto se desplazara y todos los demás se quedaran quietos: una referencia para elegir el orden, no el resultado de la propuesta actual.", "pin": "Fija este proyecto: conserva su fecha de fin y sus días laborables, y cuenta como carga fija que los demás proyectos deben rodear.", "ceiling": "Cuántos días laborables puede desplazarse como máximo la fecha de fin de este proyecto. Arrastre el asa o use las flechas, Inicio (sin desbordamiento) y Fin (ilimitado). La etiqueta muestra el efecto en la fecha de fin, no cuánto ha arrastrado.", "chart": "Ahora = la reserva actual de todos los proyectos abiertos juntos; Después de repartir = lo que queda tras el reparto. El rojo supera la capacidad.", @@ -723,12 +701,8 @@ "reset": "Devuelve todos los topes y fijaciones al estado neutro. El interruptor «Permitir interrupciones» no cambia." }, "strip": { - "pin": "Fijar", - "pinned": "Fijado — este proyecto no se desplaza y también conserva sus días laborables", "pinnedRecorded": "Fijado: fechas tal como se guardaron", "cannotMove": "No puede desplazarse — todo el trabajo aquí está fijo", - "fixedLoad": "Carga fija", - "ceiling": "Retraso máximo de la fecha de fin", "ceilingUnlimited": "sin límite", "ceilingDays_one": "techo {{count}} día laborable", "ceilingDays_many": "techo {{count}} días laborables", @@ -737,7 +711,6 @@ "endShift_one": "fin +{{count}} día", "endShift_many": "fin +{{count}} días", "endShift_other": "fin +{{count}} días", - "requestedVsAchievable": "solicitado {{requested}}, más cercano alcanzable {{achievable}}", "handleLabel": "Techo de retraso para {{doc}}", "legend": "Bloques sólidos = días laborables · rayado = día de pausa · regla: gris = holgura, rojo = desplazamiento de la fecha de fin · punteado = permitido pero no usado", "slack": "holgura {{days}} días", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 31ebf25f2..f851756fd 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -666,12 +666,6 @@ "title": "ابزار", "allowSplits": "اجازه دادن به وقفه‌ها", "allowSplitsHint": "کار می‌تواند به‌جای جابه‌جایی یکجا، روزهای وقفه بگیرد. در MS Project به این «Leveling can create splits in remaining work» گفته می‌شود.", - "price_one": "{{count}} روز کاری تمدید هزینه دارد", - "price_other": "{{count}} روز کاری تمدید هزینه دارد", - "priceNone": "هیچ تمدیدی هزینه ندارد", - "priceOff": "خاموش: {{price}}", - "priceOn": "روشن: {{price}}", - "priceSame": "{{price}} — در هر دو حالت", "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید", "savesOff_one": "{{count}} روز کاری صرفه‌جویی می‌کرد", "savesOff_other": "{{count}} روز کاری صرفه‌جویی می‌کرد", @@ -680,23 +674,9 @@ "savesNoneOff": "چیزی صرفه‌جویی نمی‌کرد", "savesNoneOn": "چیزی صرفه‌جویی نمی‌کند" }, - "rank": { - "title": "چه کسی بیشترین ملاحظه را می‌گیرد؟", - "hint": "بالاترین مورد کمترین جابه‌جایی را دارد. دستگیره سمت چپ را بکشید یا از دکمه‌های پیکان سمت راست استفاده کنید.", - "drag": "برای تغییر ترتیب بکشید", - "float": "شناوری: {{days}}", - "cost_one": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", - "cost_other": "جابه‌جا کردن فقط این پروژه {{count}} روز کاری هزینه دارد", - "costNone": "جابه‌جا کردن فقط این پروژه هیچ تمدیدی هزینه ندارد", - "moveUp": "انتقال به بالا", - "moveDown": "انتقال به پایین" - }, "help": { - "dragHandle": "این ردیف را به جای دیگری در ترتیب بکشید.", "allowSplits": "اجازه می‌دهد کار روزهای وقفه داشته باشد به‌جای آنکه یکپارچه جابه‌جا شود؛ این کار فضای بیشتری به توزیع می‌دهد، اما وظایف را تکه‌تکه می‌کند.", "price": "برچسب قیمت برای هر حالت نشان می‌دهد تاریخ پایان آسیب‌دیده‌ترین پروژه چند روز کاری جابه‌جا می‌شود؛ اگر تنها یک قیمت با «در هر دو حالت» دیده شود، یعنی وقفه‌دادن اینجا تفاوتی ایجاد نمی‌کند.", - "rank": "بالاترین مورد کمترین جابه‌جایی را دارد: آن پروژه تا حد امکان تاریخ‌هایش را نگه می‌دارد و پروژه‌های پایین‌تر برای بازکردن جا جابه‌جا می‌شوند.", - "rankCost": "هزینه‌ای که اگر تنها همین پروژه جابه‌جا می‌شد و بقیه سر جای خود می‌ماندند، پرداخت می‌شد — معیاری برای انتخاب ترتیب، نه نتیجهٔ پیشنهاد فعلی.", "pin": "این پروژه را پین می‌کند: تاریخ پایان و روزهای کاری‌اش را نگه می‌دارد و به‌عنوان بار ثابتی شمرده می‌شود که پروژه‌های دیگر باید از کنارش بگذرند.", "ceiling": "تاریخ پایان این پروژه حداکثر چند روز کاری می‌تواند جابه‌جا شود. دستگیره را بکشید یا از کلیدهای پیکان، Home (بدون سرریز) و End (بی‌حد) استفاده کنید. برچسب اثر روی تاریخ پایان را نشان می‌دهد، نه مسافت کشیدن را.", "chart": "اکنون = رزرو فعلی همهٔ پروژه‌های باز روی هم؛ پس از توزیع = آنچه پس از توزیع می‌ماند. قرمز یعنی فراتر از ظرفیت.", @@ -707,19 +687,14 @@ "reset": "همهٔ سقف‌ها و سنجاق‌ها را به حالت خنثی برمی‌گرداند. کلید «اجازهٔ وقفه» بدون تغییر می‌ماند." }, "strip": { - "pin": "ثابت کردن", - "pinned": "ثابت‌شده — این پروژه جابه‌جا نمی‌شود و روزهای کاری خود را نیز حفظ می‌کند", "pinnedRecorded": "ثابت‌شده: تاریخ‌ها همان‌طور که ثبت شده‌اند", "cannotMove": "نمی‌تواند جابه‌جا شود — همه کارهای اینجا ثابت هستند", - "fixedLoad": "بار ثابت", - "ceiling": "حداکثر تمدید تاریخ پایان", "ceilingUnlimited": "نامحدود", "ceilingDays_one": "سقف {{count}} روز کاری", "ceilingDays_other": "سقف {{count}} روز کاری", "endUnchanged": "پایان بدون تغییر", "endShift_one": "پایان +{{count}} روز", "endShift_other": "پایان +{{count}} روز", - "requestedVsAchievable": "درخواست‌شده {{requested}}، نزدیک‌ترین قابل‌دستیابی {{achievable}}", "handleLabel": "سقف تمدید برای {{doc}}", "legend": "بلوک‌های توپر = روزهای کاری · هاشور = روز توقف · خط‌کش: خاکستری = شناوری، قرمز = جابه‌جایی تاریخ پایان · نقطه‌چین = مجاز اما استفاده‌نشده", "slack": "شناوری {{days}} روز", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b8dc6c562..479171247 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -678,13 +678,6 @@ "title": "Outil", "allowSplits": "Autoriser les interruptions", "allowSplitsHint": "Le travail peut recevoir des jours de pause plutôt que de se décaler d'un bloc. Dans MS Project, cela s'appelle « Leveling can create splits in remaining work ».", - "price_one": "coûte {{count}} jour ouvrable de dépassement", - "price_many": "coûte {{count}} jours ouvrables de dépassement", - "price_other": "coûte {{count}} jours ouvrables de dépassement", - "priceNone": "ne coûte aucun dépassement", - "priceOff": "désactivé : {{price}}", - "priceOn": "activé : {{price}}", - "priceSame": "{{price}} — dans les deux cas", "priceUnknown": "prix inconnu — appuyez sur Recalculer", "savesOff_one": "économiserait {{count}} jour ouvré", "savesOff_many": "économiserait {{count}} jours ouvrés", @@ -695,24 +688,9 @@ "savesNoneOff": "n'économiserait rien", "savesNoneOn": "n'économise rien" }, - "rank": { - "title": "Qui est le plus épargné ?", - "hint": "Celui du haut cède le moins. Faites glisser la poignée à gauche, ou utilisez les flèches à droite.", - "drag": "Faire glisser pour réordonner", - "float": "marge : {{days}}", - "cost_one": "décaler uniquement ce projet coûte {{count}} jour ouvrable", - "cost_many": "décaler uniquement ce projet coûte {{count}} jours ouvrables", - "cost_other": "décaler uniquement ce projet coûte {{count}} jours ouvrables", - "costNone": "décaler uniquement ce projet ne coûte aucun dépassement", - "moveUp": "Monter", - "moveDown": "Descendre" - }, "help": { - "dragHandle": "Faites glisser cette ligne à une autre place dans l'ordre.", "allowSplits": "Autorise le travail à comporter des jours de pause au lieu de se décaler d'un seul bloc ; cela donne plus de marge à la répartition, mais découpe les tâches.", "price": "L'étiquette de prix indique, pour chaque position, de combien de jours ouvrés la date de fin du projet le plus touché se décale ; un prix unique avec « dans les deux cas » signifie qu'interrompre ne change rien ici.", - "rank": "Celui du haut cède le moins : ce projet conserve ses dates autant que possible, les projets en dessous se décalent pour faire de la place.", - "rankCost": "Ce que cela coûterait si seul ce projet se décalait et que tous les autres restaient en place — un repère pour choisir l'ordre, pas le résultat de la proposition actuelle.", "pin": "Épingle ce projet : il conserve sa date de fin et ses jours ouvrés, et compte comme charge fixe que les autres projets doivent contourner.", "ceiling": "De combien de jours ouvrés la date de fin de ce projet peut se décaler au maximum. Faites glisser la poignée, ou utilisez les flèches, Début (aucun dépassement) et Fin (illimité). L'étiquette montre l'effet sur la date de fin, pas la distance parcourue.", "chart": "Maintenant = la réservation actuelle de tous les projets ouverts réunis ; Après répartition = ce qu'il reste une fois le travail réparti. Le rouge dépasse la capacité.", @@ -723,12 +701,8 @@ "reset": "Remet tous les plafonds et épinglages à l'état neutre. L'interrupteur « Autoriser les interruptions » reste tel quel." }, "strip": { - "pin": "Épingler", - "pinned": "Épinglé — ce projet ne se décale pas et conserve aussi ses jours de travail", "pinnedRecorded": "Épinglé : dates telles qu'enregistrées", "cannotMove": "Ne peut pas se décaler — tout le travail ici est figé", - "fixedLoad": "Charge fixe", - "ceiling": "Dépassement maximal de la date de fin", "ceilingUnlimited": "illimité", "ceilingDays_one": "plafond {{count}} jour ouvrable", "ceilingDays_many": "plafond {{count}} jours ouvrables", @@ -737,7 +711,6 @@ "endShift_one": "fin +{{count}} jour", "endShift_many": "fin +{{count}} jours", "endShift_other": "fin +{{count}} jours", - "requestedVsAchievable": "demandé {{requested}}, le plus proche réalisable {{achievable}}", "handleLabel": "Plafond de dépassement pour {{doc}}", "legend": "Blocs pleins = jours ouvrés · hachuré = jour de pause · règle : gris = marge, rouge = décalage de la date de fin · pointillé = autorisé mais non utilisé", "slack": "marge {{days}} jours", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index d19ddf82f..343bb6759 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -678,13 +678,6 @@ "title": "Strumento", "allowSplits": "Consenti interruzioni", "allowSplitsHint": "Il lavoro può ricevere giorni di pausa invece di spostarsi in un unico blocco. In MS Project questo si chiama \"Leveling can create splits in remaining work\".", - "price_one": "costa {{count}} giorno lavorativo di ritardo", - "price_many": "costa {{count}} giorni lavorativi di ritardo", - "price_other": "costa {{count}} giorni lavorativi di ritardo", - "priceNone": "non costa alcun ritardo", - "priceOff": "disattivato: {{price}}", - "priceOn": "attivato: {{price}}", - "priceSame": "{{price}} — in entrambi i casi", "priceUnknown": "prezzo sconosciuto — premi Ricalcola", "savesOff_one": "risparmierebbe {{count}} giorno lavorativo", "savesOff_many": "risparmierebbe {{count}} giorni lavorativi", @@ -695,24 +688,9 @@ "savesNoneOff": "non risparmierebbe nulla", "savesNoneOn": "non risparmia nulla" }, - "rank": { - "title": "Chi viene risparmiato di più?", - "hint": "Quello in alto cede di meno. Trascina la maniglia a sinistra oppure usa le frecce a destra.", - "drag": "Trascina per riordinare", - "float": "margine: {{days}}", - "cost_one": "far slittare solo questo progetto costa {{count}} giorno lavorativo", - "cost_many": "far slittare solo questo progetto costa {{count}} giorni lavorativi", - "cost_other": "far slittare solo questo progetto costa {{count}} giorni lavorativi", - "costNone": "far slittare solo questo progetto non costa alcun ritardo", - "moveUp": "Sposta su", - "moveDown": "Sposta giù" - }, "help": { - "dragHandle": "Trascina questa riga in un'altra posizione dell'ordine.", "allowSplits": "Consente al lavoro di avere giorni di pausa invece di spostarsi in un unico blocco; dà più margine alla distribuzione, ma produce attività spezzate.", "price": "Il cartellino mostra, per ciascuna posizione, di quanti giorni lavorativi si sposta la data di fine del progetto più colpito; un unico prezzo con «in entrambi i casi» significa che interrompere qui non cambia nulla.", - "rank": "Quello in alto cede di meno: quel progetto mantiene le sue date il più possibile, mentre quelli sotto si spostano per fare spazio.", - "rankCost": "Quanto costerebbe se soltanto questo progetto si spostasse e tutti gli altri restassero fermi — un metro per scegliere l'ordine, non il risultato della proposta attuale.", "pin": "Fissa questo progetto: mantiene la data di fine e i suoi giorni lavorativi e conta come carico fisso attorno al quale gli altri progetti devono muoversi.", "ceiling": "Di quanti giorni lavorativi può spostarsi al massimo la data di fine di questo progetto. Trascina la maniglia oppure usa i tasti freccia, Home (nessuno sforamento) e Fine (illimitato). L'etichetta mostra l'effetto sulla data di fine, non quanto hai trascinato.", "chart": "Ora = la prenotazione attuale di tutti i progetti aperti insieme; Dopo la distribuzione = ciò che resta dopo aver distribuito. Il rosso supera la capacità.", @@ -723,12 +701,8 @@ "reset": "Riporta tutti i tetti e i blocchi allo stato neutro. L'interruttore «Consenti interruzioni» resta invariato." }, "strip": { - "pin": "Blocca", - "pinned": "Bloccato — questo progetto non slitta e mantiene anche i suoi giorni lavorativi", "pinnedRecorded": "Bloccato: date come registrate", "cannotMove": "Non può slittare — tutto il lavoro qui è fisso", - "fixedLoad": "Carico fisso", - "ceiling": "Ritardo massimo della data di fine", "ceilingUnlimited": "illimitato", "ceilingDays_one": "tetto {{count}} giorno lavorativo", "ceilingDays_many": "tetto {{count}} giorni lavorativi", @@ -737,7 +711,6 @@ "endShift_one": "fine +{{count}} giorno", "endShift_many": "fine +{{count}} giorni", "endShift_other": "fine +{{count}} giorni", - "requestedVsAchievable": "richiesto {{requested}}, il più vicino raggiungibile {{achievable}}", "handleLabel": "Tetto di ritardo per {{doc}}", "legend": "Blocchi pieni = giorni lavorativi · tratteggio = giorno di pausa · righello: grigio = scorrimento, rosso = slittamento della data di fine · punteggiato = consentito ma non usato", "slack": "scorrimento {{days}} giorni", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index f2f63d05b..1da49b28c 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -654,33 +654,15 @@ "title": "ツール", "allowSplits": "中断を許可", "allowSplitsHint": "作業は一気に移動する代わりに休止日を挟むことができます。MS Project ではこれを「Leveling can create splits in remaining work」と呼びます。", - "price_other": "{{count}} 稼働日の延長がかかります", - "priceNone": "延長はかかりません", - "priceOff": "オフ: {{price}}", - "priceOn": "オン: {{price}}", - "priceSame": "{{price}} — どちらの設定でも同じ", "priceUnknown": "価格不明 — 再計算を押してください", "savesOff_other": "{{count}} 稼働日を節約できます", "savesOn_other": "{{count}} 稼働日を節約しています", "savesNoneOff": "何も節約できません", "savesNoneOn": "節約はありません" }, - "rank": { - "title": "誰が最も優先的に守られますか?", - "hint": "上にあるものほど動きません。左のハンドルをドラッグするか、右の矢印ボタンを使ってください。", - "drag": "ドラッグして並べ替え", - "float": "フロート:{{days}}", - "cost_other": "このプロジェクトだけを動かすには {{count}} 稼働日かかります", - "costNone": "このプロジェクトだけを動かしても延長はかかりません", - "moveUp": "上へ移動", - "moveDown": "下へ移動" - }, "help": { - "dragHandle": "この行を順序内の別の位置へドラッグします。", "allowSplits": "作業を一続きでずらすのではなく、中断日を挟むことを許可します。配分の余地は広がりますが、タスクは分割されます。", "price": "価格表示は、スイッチの各状態について、最も影響を受けるプロジェクトの終了日が何営業日ずれるかを示します。「どちらの設定でも同じ」と 1 つだけ表示される場合、ここでは中断しても違いはありません。", - "rank": "上にあるものほど動きません。そのプロジェクトは日付をできるだけ保ち、下のプロジェクトが場所を空けるためにずれます。", - "rankCost": "このプロジェクトだけがずれ、ほかがすべて動かない場合の代価です。順序を選ぶための目安であって、現在の提案の結果ではありません。", "pin": "このプロジェクトを固定します。終了日と営業日を保ち、ほかのプロジェクトが避けて通るべき固定負荷として数えられます。", "ceiling": "このプロジェクトの終了日が最大で何営業日ずれてよいか。ハンドルをドラッグするか、矢印キー、Home(超過なし)、End(無制限)を使います。ラベルはドラッグ距離ではなく終了日への影響を示します。", "chart": "現在 = 開いているすべてのプロジェクトの合計予約。配分後 = 配分したあとに残る分。赤は容量を超えた部分です。", @@ -691,17 +673,12 @@ "reset": "すべての上限と固定を中立に戻します。「中断を許可」スイッチはそのままです。" }, "strip": { - "pin": "固定", - "pinned": "固定済み — このプロジェクトは動かず、稼働日もそのまま維持されます", "pinnedRecorded": "固定済み:記録された日付のとおり", "cannotMove": "動かせません — ここの作業はすべて確定しています", - "fixedLoad": "固定負荷", - "ceiling": "終了日の最大延長", "ceilingUnlimited": "無制限", "ceilingDays_other": "上限 {{count}} 稼働日", "endUnchanged": "終了日は変更なし", "endShift_other": "終了日 +{{count}} 日", - "requestedVsAchievable": "要求 {{requested}}、達成可能な最も近い値 {{achievable}}", "handleLabel": "{{doc}} の延長上限", "legend": "塗りつぶしのブロック=稼働日 · 斜線=休止日 · 目盛り:グレー=余裕、赤=終了日のずれ · 点線=許可したが未使用", "slack": "余裕 {{days}} 日", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index f43e66f6b..807152e7c 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -654,33 +654,15 @@ "title": "도구", "allowSplits": "중단 허용", "allowSplitsHint": "작업이 한 번에 이동하는 대신 중단일을 가질 수 있습니다. MS Project에서는 이를 \"Leveling can create splits in remaining work\"라고 합니다.", - "price_other": "{{count}}근무일의 연장 비용이 듭니다", - "priceNone": "연장 비용이 들지 않습니다", - "priceOff": "끔: {{price}}", - "priceOn": "켬: {{price}}", - "priceSame": "{{price}} — 두 설정 모두 동일", "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요", "savesOff_other": "작업일 {{count}}일을 절약할 수 있습니다", "savesOn_other": "작업일 {{count}}일을 절약합니다", "savesNoneOff": "절약할 수 있는 시간이 없습니다", "savesNoneOn": "절약되지 않습니다" }, - "rank": { - "title": "누가 가장 많이 보호받나요?", - "hint": "맨 위에 있는 항목이 가장 적게 밀립니다. 왼쪽 손잡이를 끌거나 오른쪽 화살표 버튼을 사용하세요.", - "drag": "끌어서 순서 변경", - "float": "여유: {{days}}", - "cost_other": "이 프로젝트만 이동하게 하면 {{count}}근무일이 듭니다", - "costNone": "이 프로젝트만 이동하게 해도 연장 비용이 들지 않습니다", - "moveUp": "위로 이동", - "moveDown": "아래로 이동" - }, "help": { - "dragHandle": "이 행을 순서상의 다른 위치로 끌어 놓습니다.", "allowSplits": "작업이 한 덩어리로 밀리는 대신 중단일을 가질 수 있게 합니다. 분배에 여유가 생기지만 작업이 쪼개집니다.", "price": "가격표는 스위치의 각 상태별로 가장 큰 영향을 받는 프로젝트의 종료일이 며칠(작업일) 밀리는지 보여 줍니다. \"두 설정 모두 동일\"과 함께 하나의 가격만 있다면, 여기서는 중단해도 차이가 없습니다.", - "rank": "맨 위에 있는 항목이 가장 적게 밀립니다. 그 프로젝트는 날짜를 최대한 유지하고, 아래 프로젝트들이 자리를 내주기 위해 밀립니다.", - "rankCost": "이 프로젝트만 밀리고 나머지가 모두 그대로일 때의 비용입니다. 순서를 고르기 위한 잣대이지 현재 제안의 결과가 아닙니다.", "pin": "이 프로젝트를 고정합니다. 종료일과 작업일을 유지하며, 다른 프로젝트가 피해 가야 하는 고정 부하로 계산됩니다.", "ceiling": "이 프로젝트의 종료일이 최대 며칠(작업일)까지 밀릴 수 있는지입니다. 손잡이를 끌거나 화살표 키, Home(초과 없음), End(무제한)를 사용하세요. 라벨은 끈 거리가 아니라 종료일에 미치는 영향을 보여 줍니다.", "chart": "현재 = 열려 있는 모든 프로젝트의 현재 예약 합계, 분배 후 = 분배한 뒤 남는 양. 빨간색은 용량을 초과한 부분입니다.", @@ -691,17 +673,12 @@ "reset": "모든 상한과 고정을 중립 상태로 되돌립니다. '중단 허용' 스위치는 그대로 유지됩니다." }, "strip": { - "pin": "고정", - "pinned": "고정됨 — 이 프로젝트는 이동하지 않으며 근무일도 그대로 유지합니다", "pinnedRecorded": "고정됨: 기록된 날짜대로", "cannotMove": "이동할 수 없음 — 여기의 모든 작업이 고정되어 있습니다", - "fixedLoad": "고정 부하", - "ceiling": "종료일의 최대 연장", "ceilingUnlimited": "무제한", "ceilingDays_other": "상한 {{count}}근무일", "endUnchanged": "종료일 변경 없음", "endShift_other": "종료일 +{{count}}일", - "requestedVsAchievable": "요청 {{requested}}, 가장 가까운 달성 가능값 {{achievable}}", "handleLabel": "{{doc}}의 연장 상한", "legend": "채워진 블록 = 작업일 · 빗금 = 휴지일 · 눈금: 회색 = 여유, 빨강 = 종료일 이동 · 점선 = 허용했지만 사용하지 않음", "slack": "여유 {{days}}일", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 652ff6282..e70a6ea76 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -666,12 +666,6 @@ "title": "Gereedschap", "allowSplits": "Onderbrekingen toestaan", "allowSplitsHint": "Het werk mag pauzedagen krijgen in plaats van in één stuk op te schuiven. In MS Project heet dit \"Leveling can create splits in remaining work\".", - "price_one": "kost {{count}} werkdag uitloop", - "price_other": "kost {{count}} werkdagen uitloop", - "priceNone": "kost geen uitloop", - "priceOff": "uit: {{price}}", - "priceOn": "aan: {{price}}", - "priceSame": "{{price}} — in beide standen", "priceUnknown": "prijs onbekend — druk op Herbereken", "savesOff_one": "zou {{count}} werkdag besparen", "savesOff_other": "zou {{count}} werkdagen besparen", @@ -680,23 +674,9 @@ "savesNoneOff": "zou niets besparen", "savesNoneOn": "bespaart niets" }, - "rank": { - "title": "Wie wordt het meest ontzien?", - "hint": "Bovenaan wijkt het minst. Sleep aan de greep links, of gebruik de pijlknoppen rechts.", - "drag": "Volgorde verslepen", - "float": "speling: {{days}}", - "cost_one": "alleen dit project laten opschuiven kost {{count}} werkdag", - "cost_other": "alleen dit project laten opschuiven kost {{count}} werkdagen", - "costNone": "alleen dit project laten opschuiven kost geen uitloop", - "moveUp": "Naar boven", - "moveDown": "Naar beneden" - }, "help": { - "dragHandle": "Sleep deze rij naar een andere plek in de volgorde.", "allowSplits": "Laat toe dat het werk pauzedagen krijgt in plaats van in één stuk op te schuiven; dat geeft de verdeling meer ruimte, maar levert opgeknipte taken op.", "price": "Het prijskaartje toont per stand hoeveel werkdagen de einddatum van het zwaarst getroffen project opschuift; staat er één prijs met \"in beide standen\", dan maakt onderbreken hier geen verschil.", - "rank": "Bovenaan wijkt het minst: dat project houdt zijn datums zoveel mogelijk, de projecten eronder schuiven op om ruimte te maken.", - "rankCost": "Wat het zou kosten als uitsluitend dit project opschoof en alle andere bleven staan — een maatstaf om de volgorde op te kiezen, niet de uitkomst van het huidige voorstel.", "pin": "Zet dit project vast: het houdt zijn einddatum én zijn werkdagen, en telt mee als vaste last waar de andere projecten omheen moeten.", "ceiling": "Hoeveel werkdagen de einddatum van dit project maximaal mag opschuiven. Sleep de greep, of gebruik de pijltjestoetsen, Home (geen uitloop) en End (onbegrensd). Het label toont het effect op de einddatum, niet hoe ver je gesleept hebt.", "chart": "Nu = de huidige boeking van alle geopende projecten samen; Na verdelen = wat er na de verdeling overblijft. Rood is wat boven de capaciteit uitkomt.", @@ -707,19 +687,14 @@ "reset": "Zet alle plafonds en vastzettingen terug op neutraal. De schakelaar \"Onderbrekingen toestaan\" blijft staan." }, "strip": { - "pin": "Vastzetten", - "pinned": "Vastgezet — dit project wijkt niet en houdt ook zijn werkdagen", "pinnedRecorded": "Vastgezet: datums zoals opgeslagen", "cannotMove": "Kan niet wijken — al het werk hier staat vast", - "fixedLoad": "Vaste last", - "ceiling": "Maximale uitloop van de einddatum", "ceilingUnlimited": "onbegrensd", "ceilingDays_one": "plafond {{count}} werkdag", "ceilingDays_other": "plafond {{count}} werkdagen", "endUnchanged": "eind ongewijzigd", "endShift_one": "eind +{{count}} dag", "endShift_other": "eind +{{count}} dagen", - "requestedVsAchievable": "gevraagd {{requested}}, dichtst haalbare {{achievable}}", "handleLabel": "Uitloop-plafond voor {{doc}}", "legend": "Volle blokjes = werkdagen · gearceerd = pauzedag · meetlat: grijs = speling, rood = einddatum-verschuiving · gestippeld = toegestaan maar niet benut", "slack": "speling {{days}} dagen", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 1b723e7fe..ca4917a9e 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -690,14 +690,6 @@ "title": "Narzędzie", "allowSplits": "Zezwól na przerwy", "allowSplitsHint": "Praca może dostać dni przerwy zamiast przesuwać się w całości. W MS Project nazywa się to \"Leveling can create splits in remaining work\".", - "price_one": "kosztuje {{count}} dzień roboczy wydłużenia", - "price_few": "kosztuje {{count}} dni robocze wydłużenia", - "price_many": "kosztuje {{count}} dni roboczych wydłużenia", - "price_other": "kosztuje {{count}} dnia roboczego wydłużenia", - "priceNone": "nie kosztuje żadnego wydłużenia", - "priceOff": "wyłączone: {{price}}", - "priceOn": "włączone: {{price}}", - "priceSame": "{{price}} — w obu ustawieniach", "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie", "savesOff_one": "zaoszczędziłoby {{count}} dzień roboczy", "savesOff_few": "zaoszczędziłoby {{count}} dni robocze", @@ -710,25 +702,9 @@ "savesNoneOff": "nic by nie zaoszczędziło", "savesNoneOn": "nic nie oszczędza" }, - "rank": { - "title": "Kto jest najbardziej oszczędzany?", - "hint": "Ten na górze ustępuje najmniej. Przeciągnij uchwyt po lewej albo użyj strzałek po prawej.", - "drag": "Przeciągnij, aby zmienić kolejność", - "float": "zapas: {{days}}", - "cost_one": "przesunięcie tylko tego projektu kosztuje {{count}} dzień roboczy", - "cost_few": "przesunięcie tylko tego projektu kosztuje {{count}} dni robocze", - "cost_many": "przesunięcie tylko tego projektu kosztuje {{count}} dni roboczych", - "cost_other": "przesunięcie tylko tego projektu kosztuje {{count}} dnia roboczego", - "costNone": "przesunięcie tylko tego projektu nie kosztuje żadnego wydłużenia", - "moveUp": "Przenieś w górę", - "moveDown": "Przenieś w dół" - }, "help": { - "dragHandle": "Przeciągnij ten wiersz w inne miejsce kolejności.", "allowSplits": "Pozwala, aby praca miała dni przerwy zamiast przesuwać się w jednym kawałku; daje to rozdziałowi więcej miejsca, ale powstają zadania pocięte.", "price": "Metka ceny pokazuje dla każdego ustawienia, o ile dni roboczych przesuwa się data zakończenia najbardziej dotkniętego projektu; jedna cena z „w obu ustawieniach” oznacza, że przerywanie niczego tu nie zmienia.", - "rank": "Ten na górze ustępuje najmniej: ten projekt zachowuje swoje terminy w możliwie największym stopniu, a projekty poniżej przesuwają się, by zrobić miejsce.", - "rankCost": "Ile kosztowałoby, gdyby przesunął się wyłącznie ten projekt, a wszystkie pozostałe stały w miejscu — miara do wyboru kolejności, a nie wynik bieżącej propozycji.", "pin": "Przypina ten projekt: zachowuje swoją datę zakończenia i swoje dni robocze oraz liczy się jako stałe obciążenie, które pozostałe projekty muszą ominąć.", "ceiling": "O ile dni roboczych data zakończenia tego projektu może się najwyżej przesunąć. Przeciągnij uchwyt albo użyj strzałek, Home (bez przekroczenia) i End (bez ograniczenia). Etykieta pokazuje skutek dla daty zakończenia, a nie odległość przeciągnięcia.", "chart": "Teraz = bieżąca rezerwacja wszystkich otwartych projektów razem; Po rozdzieleniu = to, co zostaje po rozdzieleniu. Czerwień wykracza ponad wydajność.", @@ -739,12 +715,8 @@ "reset": "Przywraca wszystkie limity i przypięcia do stanu neutralnego. Przełącznik „Zezwalaj na przerwy” pozostaje bez zmian." }, "strip": { - "pin": "Przypnij", - "pinned": "Przypięty — ten projekt się nie przesuwa i zachowuje też swoje dni robocze", "pinnedRecorded": "Przypięty: daty tak jak zapisane", "cannotMove": "Nie może się przesunąć — cała praca tutaj jest ustalona", - "fixedLoad": "Obciążenie stałe", - "ceiling": "Maksymalne wydłużenie daty zakończenia", "ceilingUnlimited": "bez ograniczeń", "ceilingDays_one": "pułap {{count}} dzień roboczy", "ceilingDays_few": "pułap {{count}} dni robocze", @@ -755,7 +727,6 @@ "endShift_few": "koniec +{{count}} dni", "endShift_many": "koniec +{{count}} dni", "endShift_other": "koniec +{{count}} dnia", - "requestedVsAchievable": "żądano {{requested}}, najbliższe osiągalne {{achievable}}", "handleLabel": "Pułap wydłużenia dla {{doc}}", "legend": "Pełne bloki = dni robocze · kreskowanie = dzień przerwy · miarka: szary = zapas, czerwony = przesunięcie daty zakończenia · kropkowany = dozwolone, ale niewykorzystane", "slack": "zapas {{days}} dni", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index d817df4ba..a8f51360b 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -678,13 +678,6 @@ "title": "Ferramenta", "allowSplits": "Permitir interrupções", "allowSplitsHint": "O trabalho pode receber dias de pausa em vez de se deslocar de uma só vez. No MS Project isto chama-se \"Leveling can create splits in remaining work\".", - "price_one": "custa {{count}} dia útil de atraso", - "price_many": "custa {{count}} dias úteis de atraso", - "price_other": "custa {{count}} dias úteis de atraso", - "priceNone": "não custa nenhum atraso", - "priceOff": "desativado: {{price}}", - "priceOn": "ativado: {{price}}", - "priceSame": "{{price}} — em ambos os casos", "priceUnknown": "preço desconhecido — prima Recalcular", "savesOff_one": "pouparia {{count}} dia útil", "savesOff_many": "pouparia {{count}} dias úteis", @@ -695,24 +688,9 @@ "savesNoneOff": "não pouparia nada", "savesNoneOn": "não poupa nada" }, - "rank": { - "title": "Quem é mais poupado?", - "hint": "O de cima cede menos. Arraste a alça à esquerda ou use as setas à direita.", - "drag": "Arrastar para reordenar", - "float": "folga: {{days}}", - "cost_one": "deixar apenas este projeto deslocar-se custa {{count}} dia útil", - "cost_many": "deixar apenas este projeto deslocar-se custa {{count}} dias úteis", - "cost_other": "deixar apenas este projeto deslocar-se custa {{count}} dias úteis", - "costNone": "deixar apenas este projeto deslocar-se não custa nenhum atraso", - "moveUp": "Mover para cima", - "moveDown": "Mover para baixo" - }, "help": { - "dragHandle": "Arraste esta linha para outra posição na ordem.", "allowSplits": "Permite que o trabalho tenha dias de pausa em vez de se deslocar de uma só vez; dá mais folga à distribuição, mas gera tarefas divididas.", "price": "A etiqueta de preço mostra, por posição, quantos dias úteis a data de fim do projeto mais afetado se desloca; um único preço com «em ambos os casos» significa que interromper não muda nada aqui.", - "rank": "O de cima cede menos: esse projeto mantém as suas datas tanto quanto possível e os de baixo deslocam-se para abrir espaço.", - "rankCost": "O que custaria se apenas este projeto se deslocasse e todos os outros ficassem parados — uma referência para escolher a ordem, não o resultado da proposta atual.", "pin": "Fixa este projeto: mantém a data de fim e os seus dias úteis e conta como carga fixa que os outros projetos têm de contornar.", "ceiling": "Quantos dias úteis a data de fim deste projeto pode deslocar-se no máximo. Arraste a alça ou use as setas, Home (sem excesso) e End (ilimitado). A etiqueta mostra o efeito na data de fim, não a distância arrastada.", "chart": "Agora = a reserva atual de todos os projetos abertos em conjunto; Depois de distribuir = o que resta após a distribuição. O vermelho ultrapassa a capacidade.", @@ -723,12 +701,8 @@ "reset": "Repõe todos os limites e fixações no estado neutro. O interruptor «Permitir interrupções» fica como está." }, "strip": { - "pin": "Fixar", - "pinned": "Fixado — este projeto não se desloca e mantém também os seus dias úteis", "pinnedRecorded": "Fixado: datas como registadas", "cannotMove": "Não pode deslocar-se — todo o trabalho aqui está fixo", - "fixedLoad": "Carga fixa", - "ceiling": "Atraso máximo da data de fim", "ceilingUnlimited": "ilimitado", "ceilingDays_one": "teto {{count}} dia útil", "ceilingDays_many": "teto {{count}} dias úteis", @@ -737,7 +711,6 @@ "endShift_one": "fim +{{count}} dia", "endShift_many": "fim +{{count}} dias", "endShift_other": "fim +{{count}} dias", - "requestedVsAchievable": "pedido {{requested}}, mais próximo alcançável {{achievable}}", "handleLabel": "Teto de atraso para {{doc}}", "legend": "Blocos cheios = dias úteis · tracejado = dia de pausa · régua: cinzento = folga, vermelho = deslocamento da data de fim · pontilhado = permitido mas não usado", "slack": "folga {{days}} dias", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index c117d13d7..cb40d44b0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -666,12 +666,6 @@ "title": "Araç", "allowSplits": "Kesintilere izin ver", "allowSplitsHint": "İş, tek parça halinde kaymak yerine ara günler alabilir. MS Project'te buna \"Leveling can create splits in remaining work\" denir.", - "price_one": "{{count}} iş günü kaydırma maliyeti var", - "price_other": "{{count}} iş günü kaydırma maliyeti var", - "priceNone": "kaydırma maliyeti yok", - "priceOff": "kapalı: {{price}}", - "priceOn": "açık: {{price}}", - "priceSame": "{{price}} — her iki durumda da", "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın", "savesOff_one": "{{count}} iş günü kazandırırdı", "savesOff_other": "{{count}} iş günü kazandırırdı", @@ -680,23 +674,9 @@ "savesNoneOff": "hiçbir şey kazandırmazdı", "savesNoneOn": "hiçbir şey kazandırmıyor" }, - "rank": { - "title": "En çok kim korunuyor?", - "hint": "En üstteki en az kayar. Soldaki tutamacı sürükleyin veya sağdaki ok düğmelerini kullanın.", - "drag": "Sıralamak için sürükleyin", - "float": "bolluk: {{days}}", - "cost_one": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", - "cost_other": "yalnızca bu projenin kaymasına izin vermek {{count}} iş günü maliyetine mal olur", - "costNone": "yalnızca bu projenin kaymasına izin vermek hiçbir kaydırma maliyetine mal olmaz", - "moveUp": "Yukarı taşı", - "moveDown": "Aşağı taşı" - }, "help": { - "dragHandle": "Bu satırı sıradaki başka bir konuma sürükleyin.", "allowSplits": "İşin tek parça hâlinde kaymak yerine ara günler almasına izin verir; dağıtıma daha çok alan tanır, ancak bölünmüş görevler oluşturur.", "price": "Fiyat etiketi, her konum için en çok etkilenen projenin bitiş tarihinin kaç iş günü kaydığını gösterir; «her iki durumda da» yazan tek bir fiyat, bölmenin burada bir fark yaratmadığı anlamına gelir.", - "rank": "En üstteki en az kayar: o proje tarihlerini olabildiğince korur, altındakiler yer açmak için kayar.", - "rankCost": "Yalnızca bu proje kaysa ve diğerlerinin tümü yerinde kalsa neye mal olacağı — sırayı seçmek için bir ölçüt, mevcut önerinin sonucu değil.", "pin": "Bu projeyi sabitler: bitiş tarihini ve iş günlerini korur ve diğer projelerin etrafından dolaşması gereken sabit bir yük olarak sayılır.", "ceiling": "Bu projenin bitiş tarihinin en fazla kaç iş günü kayabileceği. Tutamacı sürükleyin veya ok tuşlarını, Home (taşma yok) ve End (sınırsız) tuşlarını kullanın. Etiket, ne kadar sürüklediğinizi değil, bitiş tarihine etkisini gösterir.", "chart": "Şimdi = açık tüm projelerin şu anki toplam rezervasyonu; Dağıtımdan sonra = dağıtımdan sonra geriye kalan. Kırmızı, kapasitenin üzerine çıkan kısımdır.", @@ -707,19 +687,14 @@ "reset": "Tüm tavanları ve sabitlemeleri nötr duruma döndürür. \"Kesintilere izin ver\" anahtarı olduğu gibi kalır." }, "strip": { - "pin": "Sabitle", - "pinned": "Sabitlendi — bu proje kaymaz ve iş günlerini de korur", "pinnedRecorded": "Sabitlendi: kayıtlı tarihler", "cannotMove": "Kayamaz — buradaki tüm iş sabit", - "fixedLoad": "Sabit yük", - "ceiling": "Bitiş tarihinin azami kayması", "ceilingUnlimited": "sınırsız", "ceilingDays_one": "tavan {{count}} iş günü", "ceilingDays_other": "tavan {{count}} iş günü", "endUnchanged": "bitiş değişmedi", "endShift_one": "bitiş +{{count}} gün", "endShift_other": "bitiş +{{count}} gün", - "requestedVsAchievable": "istenen {{requested}}, en yakın ulaşılabilir {{achievable}}", "handleLabel": "{{doc}} için kaydırma tavanı", "legend": "Dolu bloklar = iş günleri · taralı = mola günü · cetvel: gri = bolluk, kırmızı = bitiş tarihi kayması · noktalı = izin verildi ama kullanılmadı", "slack": "bolluk {{days}} gün", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index cbb40a27b..4ebe54a84 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -654,33 +654,15 @@ "title": "工具", "allowSplits": "允许中断", "allowSplitsHint": "工作可以插入暂停日,而不是整体顺延。在 MS Project 中这称为\"Leveling can create splits in remaining work\"。", - "price_other": "需要 {{count}} 个工作日的延误", - "priceNone": "不需要延误", - "priceOff": "关闭:{{price}}", - "priceOn": "开启:{{price}}", - "priceSame": "{{price}} — 两种设置下相同", "priceUnknown": "价格未知 — 按下重新计算", "savesOff_other": "可节省 {{count}} 个工作日", "savesOn_other": "节省 {{count}} 个工作日", "savesNoneOff": "不会节省时间", "savesNoneOn": "没有节省" }, - "rank": { - "title": "谁得到最大程度的照顾?", - "hint": "最上面的项目让步最少。拖动左侧的手柄,或使用右侧的箭头按钮。", - "drag": "拖动以调整顺序", - "float": "浮动:{{days}}", - "cost_other": "仅让此项目顺延需要 {{count}} 个工作日", - "costNone": "仅让此项目顺延不需要延误", - "moveUp": "上移", - "moveDown": "下移" - }, "help": { - "dragHandle": "将此行拖到顺序中的其他位置。", "allowSplits": "允许工作出现暂停日,而不是整块顺延;这为分配留出更多余地,但会产生被拆分的任务。", "price": "价格标签按开关的每种状态显示受影响最严重项目的完成日期顺延多少个工作日;若只显示一个价格并标注“两种设置下相同”,说明在此处允许中断没有区别。", - "rank": "越靠上的项目让步越少:它尽可能保留自己的日期,下方的项目则顺延以腾出空间。", - "rankCost": "如果只有本项目顺延而其他项目全部不动,代价会是多少——这是选择顺序的参考尺度,而不是当前方案的结果。", "pin": "固定该项目:它保留完成日期和工作日,并作为固定负荷计入,其他项目必须绕开它。", "ceiling": "本项目的完成日期最多可顺延多少个工作日。拖动手柄,或使用方向键、Home(不顺延)和 End(不限)。标签显示的是对完成日期的影响,而不是拖动的距离。", "chart": "现在 = 所有已打开项目当前的合计占用;分配后 = 分配之后剩下的情况。红色表示超出容量的部分。", @@ -691,17 +673,12 @@ "reset": "将所有上限和固定恢复为中立状态。「允许中断」开关保持不变。" }, "strip": { - "pin": "固定", - "pinned": "已固定 — 此项目不顺延,也保留其工作日", "pinnedRecorded": "已固定:按记录的日期", "cannotMove": "无法顺延 — 这里的所有工作都已固定", - "fixedLoad": "固定负荷", - "ceiling": "结束日期的最大延误", "ceilingUnlimited": "无限制", "ceilingDays_other": "上限 {{count}} 个工作日", "endUnchanged": "结束日期不变", "endShift_other": "结束日期 +{{count}} 天", - "requestedVsAchievable": "请求 {{requested}},最接近可实现 {{achievable}}", "handleLabel": "{{doc}} 的延误上限", "legend": "实心方块=工作日 · 斜纹=暂停日 · 标尺:灰色=浮时,红色=完工日期偏移 · 虚线=已允许但未使用", "slack": "浮时 {{days}} 天", From 3caeb4e9587767d22632cfcd702d9d74cdb7d24a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:40:08 +0000 Subject: [PATCH 068/108] artifacts: screenshots typelaag/toewijzingskolommen en rapportageperiode van het resourcediagram (#113) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- .../04-typelaag-en-toewijzingskolommen.png | Bin 0 -> 131534 bytes .../05-rapportageperiode-14-sep-tot-11-okt.png | Bin 0 -> 123897 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png create mode 100644 artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png diff --git a/artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png b/artifacts/resourcediagram/04-typelaag-en-toewijzingskolommen.png new file mode 100644 index 0000000000000000000000000000000000000000..76cb2d3602d2e9687507c618247693753a7da231 GIT binary patch literal 131534 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

-Gp<)}-6^z^< zcspNZX5k(z-wG_!;}}dq48$6r`BwaAmqUfrzc_PhMz8^AExM zYHlNh9|W1jDoxr3*mC;jNB?3Xo(OrWS(?Q8T#-C^yAJR#f{EZjy`p_)Xu@~<$4I|a z@dvIuKidW{3P+$x_cct3cFqD~=I=w?9tR`?>cW1FB5L>Q@v#_nbt@+q;{$g5=5oGY z&cfQ7xU5EhE+1xyyI=D|gsCkp7|4ojUQPECV&3=58$d}(Nub-_p7HS)N%f=<&m16Y zd*!ADD5tkbXc!p&`Ljv>wK<2@PO_3jX>7DI3<*?NNm!997=X@0iyLKC>{z?v$r^tt zRmpfqj)T(8(lt1n9Wi!5A+e|UaW}TWgD;rL2iTPRDuTi$>4u%cY$}GA8WoVkuy#47 zTkLGtJb9xd#x1Iq7FoW^;H;BWW2VSHONfe}b6yhn=rj#G@D$oo2j$8weh)Y*-W^3! zY^}%i>BT>Q>b*Z5ZC=j>SuV&1{9Ipe;blBZ^O582%3 zYR&_!hw6qKh>mX2jBOZw_}2&DC)&+y?~sG-LKu5Lzc*c<=a~xg1Wv1$jAGUL_LP)? z-OSb013#d;;-GnHsbzCh|9=LjWX0ySgh3|h$`+r3W=)l9VWf`nm?AQK()2~=hYel6 z>Y)a{unegYZ}p8TA+m{TY|@*@>X6k_>7ST7kJ%Y)>KSSHUzJxys!ypIZdha?W}~?CPV0N44&%^$XZCkpC1Wv$;PH@cl0Nd=9ZL6e>- z9ZXq5i>)WV^TjB?@A12graoPl&RupD?V6fu+hg2enB(X>&SLs#7tGUYjQSHg3~Wo0 zdGZ5|k1J3~=#2Z5oh7RI8fL^mzCB5J7bBYwy{{IDUEDeVLkKXM@s>S=&TfAiY$O(_* zIoK*P!N#Iv-Oialqp@(#f*Vg(&YG$f_wdGpp`>nJWWb~@ZInSBGobrx6H3Ao`P?NS z7kUoqIScGRISiD|ZeikrO?mhT)CWb58QdQAQR3gfpJYMl;mtMNpP!6#n7b;gbW~YM z)sm0Y(!1iW=vkpclYxKo3ycT*lyna#A(ich0EZ~M*IXr`fSqp3v_J6!Mi8g)e3aa0rP(J@r96B$ zLOX~N{PIOrP_iHFNdet-79Dmy zqh=nLNoCBZbz~1P_eFv-rQ)*nTiIxFv+d!^F3!)aD$?OwqOn8bnc#GOrs`-vV~3z- zHA_3d1&YBvvc~m7uSbLPzYtkUuB-Y@eTq}RePOA-5_DX>2xrgrbDh{iwUvtKf-B2e3Sx7&L9r54)u{c z`bB1ZFJjMw4jbYhW>QE)YqJO$7w-r6uwskaMwXy}JQk)o-c*c9NMOcyG0TrVaWEd3;BChrLYT;+cs)YRrO*-wm%ClcTS6iD)h z5O|=_|JSeC%`j?sn9NvF7edHJEP70-@Q`33tj(l@z1ut4gKtewl*+45PPqjigG_cv z3>Ki~sypB2f93ePT`=cV$NcX^S~tCptsC-8v6)*3R<`8OESnN8g?EqbTw8boqLEbb zt^{|kO&2gjtx{cjmrk(e5KlACt7$!}TXJcH@4@@mT}?gbjQnQj7F)zMq;lsLd&DxP zq;gm!cGFJccTV1l!O?JGSu*jGNz5|UGpvL*&`H%Soi}xz-^B*0^^w}qHJ%A!fSbcI z)zBbxI{{4aX97t<8gBfD#9!Fgf05NfR4SX$aL_x8uc?5-u4%ansYw zDvDpS{_k5~?RD??!EbBpVd5g!L`$-fLp%uHYsN-iQuy?xBNCC)MkqFJ`y5?%&LPEX z{^iW@<02UQ(&~R)z~W=|pN0(?a0w|iXlOOCp=M-m$5c=&U;B&6bz2N1(U@h|VF23< zhPafkQ&XVLLmoQ++vxj)8fnYxJ;!8=$4cN4&~gw~_Z=eq zd)I_z!%~ZQ@9`n<^1FX7C8!1DRZm15q5EqQVPuRS>8YV6=#En?I)4#MKc+H#ZI!*e z5W>ozVSP2nGVjQ`5$vGyZzM<9Mu$;%Ea}Uxsw+Bl@-V26&34(m-MI4$Hm+xak#K7u zL5*8o&Ae~2Vr0x&S~{#1n9GJJsxJ4JNga+F2MEN7 z7xoL?euA?zGbL8|7F_G!9?st$1{b;<#AXgnNXI9>|9hXG!j)vRQ3;60 zNrqPp-a&$Zxf97VQYo6i&A=&IsJlS>v?M~JA06>M8$$gvIJP5DM@*$c_hQP#0#6F< zRWX97VJ#s;c?{#<8_HE`J-04o>&&~NDSm=y%f(kfmD8If&t<^&GqV5Fgigxj3Ne%qbN1gS?dcb|RiBxxEsrOhtUL{wxA4PIRJOX@&X7TJ8qt@hST}DMb1br%13>L3R`Ij z{4E+FP=cG}xamd!F8z8?p*4E_i4j`fa0mhnbE{GNnZ`5)J^^xkw>Kd+lWR^1OOch1nhhOfNYi2NNusj_)t#ZcXg-u7$EPLM# z`t0#upp(Kc&S(zL|C;!BVx&qwBth1A-M+- zUnYWt{*MFuyM#qQ$kP31DF0bZX*2nMfBw(Q2~e;V@CG6S$6~X(2|$Y(Xcqiw2i90B zHzEalK9w+6w*D3>bM(Fw9;=TZN()zZL?Sw?B&r(pCnSZfAO&|{ib2Q0I5#4}S7c#e zHp!Ae)|yaH)u;nzLRJZbM**F0E`J44ZG}!25|>6c1`@Xj`)Wnd<>}kRL;RCb=8nVD09ThZd zEeb_sn1*H>k5Q1G=8_GE;2djp zofV9G!g`HZYH6tDd0Eg*gRN6Shv~AYXC+YBLe+&hbJxT2pHJKlgv|FPa^Yz zRT~N47Rl@+M3waw^PoCPRTWIwjK`QrMPHYZD#4@u{NLiEnml_xoY<5cAV6r_yAD_$ zN3QI&jTH6R{dq)*@gz_-4BZo2`%}Ee^0u#_>!qX!T{%uaF$Nd}Sbvq06_zKA6%|VT z3_T`efz?1#th_q=U4ieLL-Z6uNT~S=`_Up36#3Cl%Q@^;ZQAQp647971R2unMUsM~ zY>mEPZtBW2-&w8vm{hOJFwAv^EIz%PIJ7dEWEE|k_IoWU=~MKuH$ki_ReV$-qeN|c zvmy2u{U?xp9zFNe3#3478Q8zY@OM36<>k0zGA|B+3+=T0$>xijDVbm%eq~NQ7)T$| zb~7^^rt;<^N`W4jg8V!^5aCbzIg8&a)F-pxt9Lqj(Wa_XTmB4ujTnjkks1|^Kb1oT ze?CDZ_5H8>DEtBuQC@?5QF~#aWsRq1?3L1X;%V8XU3)`Y}q_up1f^Zc8NdS>i~_z@9US7T9lcV30r?6oAp-5@sqS} z`R1CkCH2^hl#I2t?WG#a31w9+4m&jH+GW#%p~Z#$9G!1=FGflhk`9j*1}MDU^5R2d zdU`;kdmCAb#PcnPByeo@W~6iD{S7f`b0d;&Wz_p1H^8XxpH;oI>|Ep~!QIxqq#a&>ZU4bOzA zIONdn}(PYqUxOug16L9(X*niBougyPj4dKoWWf6l{%6D@4ghGeW zrXBxTVE56^HfqfRL5yT*NP>v5^Ikf4E0XPM9QfVIMB&H)}C3*xOer%>gqw zwZ}(pO4;nA(_0MmJ$tJ@vt?XpaDk&`*FTfH zDGa{8$2MFW*fX#kwrdc~akSar?i;P%FJ=~WU_7NH!~ak*xXKwCI?BpYN*9JN6}}* z{tVW_&c;qjLE-bW#6-t9?MpR>EGdgdXf~>F=(R_g{r7zs|414f=aU9b4g!8fq@x#1 zo3^2~8AF!z=^6Y=Yy87#vrXr6O$5E{bisIwu<&U zt5jz~F$4hmW?&Pef=M%B1ww)tIaf~4)LRS= z`&~D>HlO4&ZEW0cZ#fBH)+RbSJE7iR7Cg@%j~IQgC&|!%qUnQmuBoZ`J{Hje^R;}% zKbbkaynWrxOmoSo^w<#SgC>zu5YSoM+%9KS_qwp_sIF$Dr*CK*APqM#w^!gAb#Gsf zys77#V^{S)nMSa+A}N5!+`PJWNSxVp?=)~J^D{29e5j#oGqtrg?xW)hB5uP9-q|-1 z__V#$qMg`UGAQv=l^b?PcV~2Dd^m8LGpp@OnofySV$OgQkI7KJJCPxMDrf~dJ+1xg z;@8qSelQo!t;>WGBI~*KB`75Paw4+S40WEL!SU!q?gi)7EK zf4bxBLOh4eE#viR-(0(Id>cryS!uDHNK&nEf9pfAWYC7$Km?+syE)aW*XNmu4d(&+ zzb1vmq#m43y>%KUAK93fH<=j`qcFqH`d}%fv;!Y|LV%pd2 z&dZInYQoUajX6Zd`bJtx0?cpNKpqgM=Y@_+wXL~j4rf!vqqL@~Dkqv>ZGJll`NZMA zkX!L9iG)OLh73h86GcGb^GP*#W^62SSfDrmdnR`sM@QF)5zeio^^k z2N}x8q|j|HpzfMl`|M1QYIb$iB7>WhoO{TCxo2W4C@F*EoQj$O7F=jRj;&IsWzj;0 z%=B2te2C5X9H{u%q)I|(MZ)JMfDhiEpjF+sWs(#qrOJUhnrk0s5wn2h<{hrJc(WoW z3|&FaA%4)dag~gpO*!p3N}rBDG!iZ>BvU@;gKiOM%#-H=K?N@8h)J>thyoFWZG4?l z3@P-MxD20$FUbd36{;N<*PeG^{`9Yht&!@wE9vXVT09CJ7Qj$VlI`tqLp6j*u29{* zCwNwe=F78`Pw$^bv4}mSE@dJkhInP0f&7UKgXgl1*l+^;zTQNP{vU*Y*HP@YuF_Q3 z5!5?Ag(@Qg(Lz=gNctK1d&$m^QDlqg?Wq0-IyiL15CKv`f!yEv5{g}4IfdQ1Sqq4D z_EGfJRRBOPxbLoWl+MSKRqL+P>H}Yqt;G~ORmI0nn=(#l5F#%$mRG4ukylU|Q*2CG zpR}DT8~j^8U(nKZlGerk9i`}wB{EgSS$i4LRb96~ooUG2k>#Fn) z`K?tLVU*|AM0@3kmcUJNDoMdEJQ~lea(cpNvrej%LUC*YSg6yM{VtMoTMB3vi$ebA zFT94Ovxv?U$G1Rmtj9~rS2?m949W_61h77$+k$=_V6c82e{9b5^yE`d!-j#uSO(CH z3w7`v6o0{*wQwS6JId0rk zv7Ls*>n7NKrL+B+A33Z+{@D7=Y4{|A1~|6sS5QuS(vfZoNeW~0rXjqh?Aj>7LaFm1Di~AVUiHGTq8ni@JYy3w9v&WkEcvd3hwh? zTO=4(^4*w2-J?|-;7!2fRV!6Ix$wyTU1218>u*b#jIR_}kwZpw)Yt}z{VL&!p9?gE zoOj}sj`Y7SZ+{omzeTI*DK*SRD5D&VaGo@-2w?8hlcy9eM--rdED$&?RN0#yjKva@ zmFVgH#JoB#uqPUkW*ou~9z|uqjK&G`hmR6?UO_0FAK^M7e{Uj3;|Wv1$*Ypa{gj=6 zed*O4#T5_@WZ^{$qgBklQ1qY~$1lIB1jw|+ivDsX)71$PZd?}F!8wU15H`ftLCZ~j z*=I$!KohJV+PS+zJBWBw;b>RPWshG^r^}!mv-k`ta43_)Y)i+@Rzxfo2t14vp6=wo z1ft?+>Zp2@u^fU||meOq9Qnx3p+bud?o z(4;+Ko+J0@-SH6$R7oK+NaPXaqlz=7{0w6}%z7k2J~QNtSDq9Ig9G8i~Wlp|MK_>ZJ76 zm0pK+b|?-WRz}ZPfh`n&OZ3YLK$rYGuL?nYvT^OyBY;?i*K4=5HiJ-hY+EA#g zYhvsoiPc@js0n4F8^-EfL50Qx7ePY!;>>ILwmo|Yzidz(f#K}q5yGom6)+G!G zSj7})L&-XNMU3T~PE$=hMU1HBX)o$cq_=S*D*K|#I;L%gVDu06n(KFFU0@n$jlw?@ zev~RP-MzI(Hi{=+Q!}KbGy3r%K41#@yog(PipyD*IIu2Uwf7G*+Fynk&ZqBG3%r}l zq8!zGM{eFtB{5S$6D;vbo+!apZZQ<6x=OVQE^&<7 ze3b#43bD}v=?^!)!~z~ic?y^!N;GT~iOy=jxv;>wh#DEjJ~Q}jgHpp8@a!0-S!ksm zsjrR7m`$tQfq4kT0GWwA-Jn~!^*y!xJO|x5EHBKeLPxGLfh;-HTr@sUj79p`OL&J% zQFLNRHDp<$(qPLxj=1NWUNC>4yqx+6B~$^@c+zk+_&ynL>_X=AWUbYTY~|g8xR%-5 zgWsye-@?O$qfjIoKpM_4Z^c!DSW2En2|9jEv$mS*9tOc`(5KVM+brSb$QDgIfo2sB z$SKL0$R>%MZJ&*{kaKv&o7YL=pao)wg4x26_wB|0#$7f3HAdfqCH@!B8j$hZTHbna z{JBR3PqVVq>NRRaM%+~e%n>*?Tx5iggmf8rT7DqX+%5kb*M{R+I|)vY!nX0gJO5Ew zM&YKaw+CNG!b&O6&?TEG8CH6N-;}e%fr7J2`Rz;&1sn!o*J5F$0m?slSN_vi3SuoX zl1k8bT3phJLrMZ9H6Iz`+#a;fXmitn`!NHd8pVPe(Ko8sXyiF<=O+c9d zu7Gwwhv(tD0wr|P#=nZx1zL2}`!&6f7iMgL!*x+-x>HXsfQuKA$_Ka0-ihuZ%_e8(K&*hVU zcq+4U9K-n=%Jpo0z#b0|;NzcP-@fzslFQguyI#+)UD*F@Zu12C9^fyUogXM(VP`u0 z$~k&;gUigK?O1Q?D?n6C8v!n-t9xz~9U|2*pH8r4FxIYI7Fe4Y9 zc^n<~`0PcH0ty(Z%(_1+H1`S%^t zsWli3T{CtzTI|-&H{ON3y$Dm`b>3%JzdbU_{Wi62e?v7ARQ$5mVm**eEQ+iP=>xi* zP-S7A-Wz2I+A=e`IxI?k4K0Y zqPfEJ)xZ$pq+C9g| zWuD`2%cLpQI(K))){5gpF66u4L6xUjGbQLY9=<7K$sY#DNezga5~4~_QBlA8YBAYz zFpdR9%#e~mJJ>Tzm%AQ0Ij-duWHebdsnH!TR0UVZEWE$U+F#b6^14jqsM;S~Ol?!* z2$VnlKIQD@`_N9{bCkoF%{BG#xMH*1R=szR1oWU;O986lvN$(9sx96!=V((p+BY}g z_Ef9X8dCuj(mRL+qt<^y?&^Jic3hsxF|BcZmjKj<9d8=D`rh9?A{Nwho|JpOP);oJ zzn^AERhWW4+jwnag50{B-a5~b*!dmlPPaGSmu}ZX&L6c6lQLn)s~ExVfO#35}Pw}x$0!qXzs8NulZFX;&>8%Axb*Ay3^L984H3W!|{e|uyU+r$l+OmiWz<7@t41QgZ z0BO|pEh*K??{|yamwQI0V`#e?auUC8TMs({jaWC;#*XBP;(*l+?EUV`8&&Z;MHG4-9w`)?+vVh9^Z4qy0 zXUUx$hpBn=`J66D9f3x1(7C>B?B=&2gV^PqZd*bA<&tif-Q?Wj4qSpoLtHFg1}WtV z)#i6;aVfLABZ#HB!k_iCW(jD_hIg%Pz7Iu9QMd$80_AwN{ZX~JCf&;p|97!Vhx-F=n??(kkEHeH{-tq3+xtyGolw@u9*l|S~x#u1J_l&jP zx@n|Ui*X4z8W4t5!M-W&ASW2#1P#a;?$o z8CjNzk3K1r?;d+53wL9s)8R3ntnH@Jd?cEW!*Z)4jg`Y@nWN6=DSFKl?%HurC3y~? z2LnDBW3*a+zsvESUpygIudIxhrIHCG<1?$kqN~O(HYCQKAMfzC>M%L@D~BP4mz>*t zJyRX<3c8}lych^zrmE~sU(jHMl$}~U@ye(HI!$fAVdv!P8t^o*uvgcR>zW_d_&TCW zWKB&9RY$XxJE|dt--G*=jO!#ry^x5y0%Gal%J|8T;xI7l6G_Ubd z;-=TKvI2Wc{3J5&I|OJmw!G2hTvH6mV@R31im67TRoVNtYOK;!r0_^PO2u~Wv~ae; zbzo0h^1M_ue9BFBo%nuAbjE*)JUW07pP?jwa`x)2$JeWI*>rUqp)Hf46$v=GQsS(A zyvTRPRiA{~%f1!c;Ens$SL5A@moH`K((dYqu{U7J=ibktq^N42N4VksU#66F5g6YP zYev(wVL`)p{_b^9Cz4?mZB~7;+S|&CWwp_eblP8R9};tEs?;=!taMv^=Durl-ye!R zUa;!5TfABr8>TC<@W44HffjUq!fLxOg@9_~$Kdj$sESc^Ar?7_M%@~hA|Alf!jiLw z4~k=Zxj;N}gSiNNbvLeuK7UKjyVB7Lwfm7jF}&5T<1Z~bYSM32eD=6Hwt!T_1$Pah zP_K#lM+Ew%#pkpr9RKX2n~qwO%k7i%Yx2g+Rmeq~m5Ibq04FNAB!n zl^OpmsJ9pQ$~G}CAdLzZX=iH;G`bksUt+(Vx6CAyjXpBdsVK>DVke17$Pj`SCM?B|g1IW_QH!U}SOb+@;r>A4x?;s`Su!4kB5bJf z+&V0{t(;#R%DZFOjd%c@@a6{buKQx+Uo61Sk~zByrQ&EujwE1yCmkNPSAyS;6i)Ax zO>MDdFEroP=^Gf4LI50HgfHQA8@_WZHye5Oo*b_D_9^BsHa;8VMJy^(@-L$CHU#9K z1d(^4bI@p=b#)EM^oC&wN(~~mb5+UsPD0spgA3I#_?4|fKtqBBcXIJ$mc+s~@g|e= zVugb*)kW1dnbQx6X0R3{tOSQelGUi$y)cYqyF}oBkIFQ33}*51*9JoqWX{QAQwx_X zj3$t3xMeqGi$?ikV|63)7Jf-(z4lh62Uw(C5hG@r;OzLA!%hF#sPCkOzr*C!qA9-d z)dHPtwpD&r%mh=GHc?j*O{5kzSo)lRo7bNu3SsfqnsN7F?X0?OY^rMg5IC&<9O5&s zAt|v`WourWXlZ#Y!N?+zn3z2rTQu^7tob*|HVj{r+;_sBZP{C1UPZ>`2+NwYWMKzQ zVp36Mj+JCGine)PMEPi(V;=L#Q{0uV+Gbp(HAP|;1uO{ZgrTnIs;(*Rgo?;MJl*iy zw*g`+Vw@mMMSIatirz$;7lJunJ)n-7z;Vr6WSX5 zlQ%k=H{0G`q4p<5Nea@U@2{fnWcJ)qsgHbQX!>7R>#2=AcbBQl8`Yvq1 zP?otg_x0TCYPN|sPRFg(j!k7S5y_lGo@RnEyFuyEROrOS4nTLw+H~c^xm(%7ZxC@(I4JhH|R_i zj+eDpI}HYU*67qy6E2dPFIyOD2HRb^k8$V6!%BOqw`p9E-5M2> zsM;6iXNYRPYz$G;>;If{YT_^U!5hfc8NOBXcsP>fdYO28zD{WO*uQIc+%ETE!U}V6 z->^9|U955%zIAE4h->G#F~vg+v|Mf)R_F$VNESdC#ne}KhojpSl9+?nwO6AZVlh@$ z)~Ts;kiQE83)ej8I}M11Uj*K?)!b00!#hAa+lo=7IDG0zf&(4&P<=zn89N)vX_Gik zHH9&zcV`|cFSkdaT_`zH{-*%#(A%vieT*pgyDHgX?>G`beCAL4Lh)YZKWPiHqkZC`bdI<<2GMsJ&gue#yKf1miACE+oY7jy_2kV7I z$Haho;r)H4JlP+>Dc^_KH<5A#%^@Y_)&>G5CUw;GO{hUP>2=^Ks#}G?m0$TFegerS zr~-muhycwb_R)Bz^|RA$v%6H)b8GfEadK$`FFc)+75cWvMqSGC%8DPjlSRvm`ORxG zz^fFbJ8j$t2^L-f>Rk}y_(7rp49^KeUtLlO#@EYNuGP0Et_*W?Ba5SoCA0qyH3{iB zx%>5{N^E`atA{TX$g(XoarHO1)m1I)%EjnKm2pI{Bwt`(#S03{#p6%1V{h!jqby=g~{6t8F3B+Y`6zK1WGc^oP2xtDj;-*E`&SFlc2 zW$Ej)V|(J)yKtX|JIko&)}WoAfuSI>t0{Ja#ZApL5;uqi??I*OZwpHJim9< zC1_DkL0TU9y=N%|`+rPyadWATCcI;j!611TF&h*fk}p!!EDsU{$j1SGs+57+&q8Ct zxgPOlAC9Fv;otsPc)zIVh17)QlSPX=pD|G|;L z{x$`ar1+^YV5nPG)x8l21_VA3@@)w>t?Yy`D#CWF|7F!(g@AXbIK} z23=gLPM8(hJcoF9{2|H05b)BnMXeU*(AZHi0c(vlqQ%}^{w>Dz=O}v1#Z0!QmzeFo*WblF2Z<#Ga&NfE%dQ|M<#bzlxF?MKJvm{OdOF% zv`rh7Y90ID<|0M?!bmUt6Klagp-Xo#9U;jsX@@)}hU@BdAc(c#cE3_iX!%Mw1>vm|?U6z%w`pJNz~y5~w?!kW1tSZ-05rlb2XTNDjjq`iLEs zCY|7~Z`C1NR*1Zyi{|%dn?EX!G{1i!euu+=+4BK2flGMA2mmtAaZgrTT^`bH^1tHh zgOSyC2ga*?xfQ4k(8l2<6Xt`mlh*%IoOZ}g1n)->OIvqoafrwpF(~|*8$(66=7f(; z`QX@IXGb+U{)-y|uqi}V8jOHZGTM#7)vwijkGXPzzWC~VQnY7pFK&$%!hq=*c7Rqg zMw~!RWshj|1;*lN6_+i%QK}D{G&3|a_iz1<@*&NQmBru^LsxX-rS;6u;fw_3GglHA z*npx=qMFvziG|%D`TJs-O(zI~p3-#QB)QAW8GQ>bK|PnFbYPrXS-PC#mM$Wd42rw3ie2YJW}0@^$ja#Gd}dAMwyGqxDAJ2FT* zYR3>pEg^~sr6{Vu6Gb0z%hVJ^MYFbP9LWjOKR!%QRu|ZbcpmD0Mq_dT5C(H&224M^ zjrFLMT7M56I5)5U44pMK$f8FS1A(RG;D-$C7CE0gMih66i?qZ50t1bRz0#WL&pICMJHbD9uzvJ&Z^j85*is zD$&)h*1214Yvb19G1vFs`a0g$WZ0Pse)SFFw(a8fNuf;TX19+@p+s$AaW-qjc;oco zMN<;+aVB)=xcF|B1VQ8Ui_x#2yJ~y)GVT^oLUs9jv;*FZE13qMzmNtaD*+aV~KxCxh_M*nT_PepV_zsudE&pV(cn+RFN7o+(nQv%Sy?nv+ z@(DDO$~8X3vY1+_<=`bPvU_L;=I3vvJjS+q+(}9$W(SQ?t@s%}?1bjMxVg25E~>e& zz&SOxHWb^{jyyZuLpx%{s2S0e~z*~DgG=-hJm@9w!iPoplvnO{M`1k z;lT4MM85HPZR>`Flvs%p*gc`FJNM0Qx3%mkWY<_GtGhP-d>;%YvB?-`6ZhV(qCh7- zc0FBz5CKC1IU!9}Zgz2o%gbmwf}yZMKHe#oA4d(JVLZ@Zz5be^Vj5v-1XHHoY^cv_ z1#V5PrZQRmrdg18od0(J+{D6SZ?f7|D@LD;FbiHa=Wzi;aiWGhOPrycWZg7q+l$LY zEnH2!6I-rGzh-~!199|Qr7Y$2H_IVGQHVvGol-^pwv9sx6wbU030|aQWPH@lp{c@B z>DYrFG^xWchoNA}`I((V=qHfJ&^(jbc$RBuEMhc?wym0!ais=#r64Mtx}W8#D$!gRAlS?K-b<3Gs=p9D;kK>yHyrzH*pN+7GNoj~Y(;a#;CFzy*Qa>>orPz#) z6gttN{Lg9yY@qB(p@wm>-ZT1K7B{CS_JDpFC071U<;@Kt2-hKklz|wUZobk*!?~$j z|7VRd%7EWTkdJmlGg|kfXKyWL{g8l=!;>{8CFbV3nx^Zva*Ootm?KiRp5tcZ3ecz# zFPw02w4QI>Jaiz#`g6-XOujvx{eH0FUUquSpfOu&Y<87D0I({Ej5LPo#88kS(`8Dt z*Yyn+;9QTz;rKH)OG9UA9&`KMgWbA=sZZRIAQ#{ucE|j(7q)vBF+cI{0LglB^>Es* zFp^{E%F5BzBTo1)sfHo_u@4->+O!X#UXNR00)9^0-WxjX7=iT6ra#5Oup}Z$Pvg)( ze#hqS-rQEwf&A3k+z(nM(L@-x)ewy(aMPc{3JB&tnVqCrhKv4Cce&ySBn{Lk0yJb0 zGq^0TgAp3=mncBp;sqA6FtZ}7mHxG^U!9HzpP!dYmmJRKAMjoE zJf4SYEfFfLdu8S!V91ot+Ujo%+8?*<=G>p>gJOWN*HkV<-@e;8IE%*?w{sriE5)Va zecA7~HXdfm&%PR>j4_@WyhUTRaQHGed8x7!6)>x+ zod8M@ohi@|y%Y4!3s_sf9rjTJsFWt%yy}-;E_NHd4t?skO#=l~w}vSG=TSDMwkmol z)BDS&vaoUsYXhucuBe2eS*P{S)3sX{kgJF5l=Nk9m%()XC@%J#H|xz_!qN0H&T2|g zwe*_~b1zRxY343F24~9g1`)p$&LULX@#d^2W$QX-TMqubHXXj`!hXm4sdbi_RPJh? z5Wy@uH09dNXlni`W;)nTObqAeOvSKDd%>e3^HM|GkV_;2T&o*B{M;9UqS^a7{5w%Te2Q}I&e`soAaUE^LhWU!=teu7Ef6$9l(f(=Xg z-FxvLC{SQ+j!&a&&1ZW6q}KV1`9oOJA3Y?`@v(-B!j1w^pR`ZcCJj+M38!X7%^-^Tn>iC6uwJV0PV{@jb?M4tY zecFuVWPp`%d}A| z)mdtmBZnywPwmOYcV-lf*L>l!`o_eTlUIvk-fsf`WpH#%8_rbQ8=NjP99wqMvDFRS zX<_}H0n`LjLvUY0M#1}v*h{TMBIi@e#_1cIDcQKvx*oB#0$Qc15 z7WOC8wZ_k@#$@C?ZR9CRRc?EbW+%9L#PosJY8LLmT8BQKI6JfBtsu`>%pg0ngu#PI zgx$9b;l0J(@(lzjglwfo(M6qP$z-4gOrl;O_vK4{04f{uHvU1B@SDN*n}`STjfIoy z<$1es2TJAq3AuocMWc~d@6?~;$~#Fb)F`gc&|uq?4RuY)9bLV8J124eTNW8O)lu7k zM%b{rt6HpmUgxJ>VwYe5`Sea46=!*Z?ow#QQx3+j4KED?xD89;*Uy|fRamlV)d>n{ z*nHFq^ZcQr-mkWzYgrY=wY0~ScKR-9`DKT*Hr1@8c{40;GRE?JE8nrr7=;doifvGR z>eB(38v4V5Zf_O%r1i>Hzh^$EGgu(VXZ1D5t(%Pb7oM9Mqp{W}6W*+dNA`5)1u~Uf zdx&dz!`|vDx-Kn=LpUu)@{BYt2O_?1!3aM&G(5|Z43&6I+AExP&DY~jmjtTxGX$`B z>SUNDxvN|?d8b4oQ@+P95e_dq31lAd^Mj5boZ+4sF=2}a?Nb<#P;vfn@F-1*LKBfL zur@bAjwxVOIWS`H1S3^}z_1s4RvMq0CN#6*cM|AP1ed61L`Ub&CVjfH!H0`=$3sn4 zp|!Ex1=vx~KFGRqbb-FT7YP#x`eA|hqn=)#4G!eEK2Y<7 zhIoj~!<^s*0}^-o21z)Kjde3wp#&J^;k)`%Vp3sXJU|sQA1Pj6`t~ zy;V;=G4#_V56hXMymNuIdE2JeR+~qRG@$Ax671WDODxD5bD2r5?Y*H9wb9g*SMzvE zeeBmTzg0w!U392zhI{Bu>S>*lJnr7s61LoCqjYIO3aBRQtgfap;StfC0@B=O&5N40 zD8vji))x`Ztf<;df9AKJF8F)aKxDg7?si<({&1oY{g(QZEsv!_Ml8P6x~`?Y8?A~3 zH{8cOVIt}TPkmkwn;PX$xM`mbr}n57X6sG%$^jJaO2bCxlCt%~Bl02XGF;edbo2uJ zWRp_^)>fF&-nc0Kv7M6|)z7n5s>Y}jTVE>zXRsu8%&@IG`_2e=%krv=t*17)_V%~5 zw_mTRO5a-jTXi2c-x}{yuQjC^UmxxQ;=(LEu8RNRi%{{{mBuAj^3rKP{rYk*T?g5N zWVA$PW{{Tl(O5G<`>RVVr?zLeRr-Uo9jn^n!VjNuCI-2~oFB~C*50sse6-$uxz9;) z3vWC(f2u#8kemFuRutWp3WMZiX4YG5})isM9}+g{&J-y26l?NBhvv6l@P8jKb9qr1}(;m4@G`INV1yZo4zn#SW{ zjkiwhiiWz^t)RXSY|kq8Vh>?&9CMl#{D&IO*HV!9WDv=J4GTWsen8M}NFhmo;Oe7$h=6YPxha>3`Z@EKj!Q z3Qis9Prwr#wwt@-c!b&@-K#0WV9Njf&;*OK5P&z}>pwVX1E0*yo5at1{6-p~h5pxB zx5Oq+{THaGOP=3dw%_w^5_)SgIA_CUWS|*eH!{C7{3dfd^trvF;$o0=lUNy!q&~B- ze}&=s_=d21U$t>{;c<0W$H$mq^)cu*k?j zg29k@U}TKL;>Mgc2$U=;aUwldh~1)iX_?? zt}SlZpIWGJSN5NH6cCL3o2^BubkT}P+uJ41^X@T+u520d0bgH zpR*X_}N##erqGGkTmm8;8}8ZS&@`h8P19hbAuCrk{qZr)(nYmZZJtpb8@4hsFDN!2dP)W{ZHVJeJ2 zq7$L&Pu-sUTEV2FqT*yd$ohW!;KiUg`jEDJgphh)r%UPzHuWs=3r4PK0rtDe&l49g zvGX3R7wi~xtTEh!S_I-P6viIAbXGw=%RFAuam!O5)h}g_FN<$5kNKUW3J=SP0;k7P zM8`pH%3iPXuCcP%$o(|zWUL`6d29Llj7h<%Nb@{U5du(y4aX3J!mkaG&2)dHMU4fc zi{v?&*e2wvE{1XkeUP>gXfm;%mlBnaC(H)pUS%&lOJCUjz)2X5K>(T% zl|BpM37J7qJrFeQAYhx&WQ0#vEWP+^$xaXdPHrKYqAwk*f7F*xGp~W6;nw-SjJ}bD zJ(UBeZ~7Q7!L$X#V^U**QUUV=Y|~@H0%bW10xT%XB1I-pQ{@WCLp=C~zS^WRTGDA+ zKTzSa#{}K0as{HZ;5v{{$svNWj|xu`k_5A6qtV(KO{FEoN-6v4{^&!bn|wke_|q$u?T?MM zvzf1|cgZD6l*fH&D98N}%jyuNzKm^maO{#^_}hyJYXaN05#*>aq3)QSz6XuhLnphX zAWP6+zCYGqHL$HUTZ5shgQE~TRj!JtlI{yty;Ui{kL;6?<;0%cXBU3DU}eaEyJH^pVkjE(!|EOBbyNc%Lh(uG z+zJXeHsD|J8&4%TBnuLG(}CBMz)DMP3}@7e_P;`H#@myuG!cL4{GEzX>2{o*&qsbaTi(iKge2b$Z7LOYzqd3h zXmdM~r6N#2Z6XSjAm>^sKfl~E?_V;AEDr;^z9Cv&Bf7Y2k>?PZ-^%p?A4(#!?K1_&K z9eW2wcenZZ`J@H;Ud~hQdOX_^_FDh?kOQqAe6~%${s80Iw^Y5OabLYF z_Xx^D1QIknNY=F`^72XE=9q32RDqSOQIY2(g+yTX(hTivDf2)7COC^Z{a-9#AlDn% zY26V&5S=ZVW9xi{-sBb0gl-Z(;Drl%hs1})%-2INHK{L<`FE?)KwkR%)Dqk1L5uB;b$v4_71fOHk3*%_xveY5RuPd~F0RWSgtogi-Dps& zRhP`Nyu6n~iaFRiuA5_usZUpcX3@H8YGZ=1Q~UG^@|f`m&_?aak9a5apP4Px0wOo= z@4%5dFR$FZ-hLsLr>E*EaGbI0HxH(aCd{bvlz>PQnDSs7Vf9So<&^b6oaz(8A!b_j zcP7+NvJv~%cP+2GF7>OfW@alBV-oc<@eP*ly&a8bkD6Sm0zB?xp$C4EnATU0E&4H7 zq4v#i5|Cr&y_bGx7AvK5NAWa8bSXdiF3(Oc0FfN0T|NU7h3ufgdZ17Q!w;rKm zbjDCWX%1k@-`RA3LR4Ph;KltrlN0Bcwa*A&Ss>Qv6f|l%k%Ky31c`7&Bpq<+4UiY^&XYqa7&!v7nUkMYHr^~z$8Clua+dGfb&cQyWFN25@!^-fc6CwFIs-l3$Rb|AeQK@{vA8<1k<3W-F*b@%DBR~;jj55G@0Ris zEB)jYcZW9^aQt|x66;ns2LnV-R#B1DLG(9ub#KEhmnF^eUhQ<+(IUlx{f_S=Ei60E zX{%qMMktfM0M;Y{5S}pSo>*NMeZl0aQLE)nKB0|HO?#yv>(e&Qx8xbIw~OOqH+L?> ze7!t+AV%Wun9Z@fpv?8`MGfefj>|cwS5_$IyizsEa?vZIkL5!aU;x91sNi4Ctu42>Dcmfx=-~k{U+_3ApLZwX{ncw7!tOZL*~v{55^dz?{m}Kk zi7;ZyBRbot5`AxqN&Xl z5{fhN&Mt@LlSg*}sRK^ZGqrVjy1*l6H1bR*5lFYYf$4g!OqHNn7tvJea?Mt*J)c4&6N!kt=K0c}!oR|gI* zLmY(KQ6>h7R52DshBC{>%V&{s<>WFf2gAa>Mtc(o#Z(96+o6OEwFlD&si`HPwRRcL zJ#oNnYHLk*yi7_lzyFmmthP-lEZP;G0Y!J9Wqq0IV@@`{svPoSV%Rb>5%} zPGAL$@z{D^L);YDU6YIRiM@(MZ8}o0 ze6IM!GrVrK5$Gb&H3C?n_XK`*XzgtOzGin!DO7g_;vRm-AmSlX09rBq0I8Gdox?0` zzS!v`pT)uNd`ldS_Ox~UA*^oV01yoENliwPTtYDORcetsDtqeN7&nm|WSn+>cpfX-(nn>PKeO;asxZ+0As-;a}l;pW6)8p((I(N+E6RS0$CCo-A* zff4-urJ*san!5a=?e7I$Iz0n}bWS&j*b=0g`3s}L4d=N+G5Pz|#`X8+0qH2RZ&SY-1KLW{Y7CgmNCDiBJU>Fk|t?F zdO^CtOg(Quw#S_md1b4^k+2negz9SZwi5>l8>B`4dHaw-;BfM>R$!4BQuhO5#Cn~J z<>rJAccTqIt#q<=^7%Hqqd#RYR0L!YXo2VeH%M=y<1Ie~1pei-)COqP=!m6M1k~d% zef>pGS}vND!)m!aHhzRf=x><>r#fl+ag;ndx6eT9Bpv2b7-mqV)L~N+V)`gp9MjSZvzftvBn+> zB}K=R(P=t&L3yb4|C@Hw_eqRIOB-`H2oHmF7aV53HzWX)p)VH6$}T5y*hq~6s6uK3Puf8Xh#)KM?Vn_ zo*Gs<-8yA&YrS#O2A@dMj;n{-;S*xvLC)DhCc_QQxETEeN-*E0USYbra;!c@fdq8P z0g+_dV*)S*8jD%NcXMq83GCc$&!1C;4{?2C(7yzd)5(l#X;;|bkV4gnMKcC9TmQ1N z8kWuC=>dyN<5a4`p*<{Fz25ZXmZ63SyHLAShGW+vvy1K_)K>1r-SFL4c<}AxOKwXJ z;!kQ^zVVmY(D~gl>5zfQcLaIjTLbH5oZhTRh+<-8qPm#e3zxuH{;pOy5c9-NQgqQ2 zStG2ZTtW`#&$eLAO;=*K@j8i{el%2DltJl%9rd#}ipcB@H$rx>l&L1hD57e_EQn8? zq~^h8NTN1u#Rf}~dav%F=ma!xoNZdpqekXb8srV>@Nu|gMJ{=KK=N?5|lpy}X z=DtM{{f64Y8UmKzf69df=?@B7i{x-<1(5lJFM@QZ$u}m$XGGKqf6&UUCn{4|MknCs zKrmR8TIVGzwdTjV|d1a_-Q@mRD$9x)0e7WMfI0uDeCA`7RP_Ld-yjR5D&ZwgG!lw3sZ|wpV z^A}{`{f{+d8*9CmUF#EiZacjik6T*knKOlbTJHMXe%F5S)b?WG3*QnHqE3jxM$>xf zEM}5Y^%4O2_3GgfnCb5qGJ{URLxs{#HSEpXDyovp%yLU9T-Zl#inh%@aJz_CyNJ2y z_QyvCgLHQWneB?D!O)hP*)M#&tCO_}4X{eY3e=+>`}H(wB4lsV80KW*HH|2dW zIlfY==zVhZb$f#!RWeETh_iZ2*|6lMRMKC5wOy}nuW(}RO+{`civ*ubynSPfjm#5wn5p3j48+{D5}&B?&{`vh$92S zC}3LMpFcl6K7W<}DqOXM6OM{lEgD>a@^`d}So_2~sh<u_IT^C+0CF+lVUp1S#32G(Of!Js9VZdks?PXepR* za2b>aX$c#rm$?h|>}rnTgfm~>xM(1t(R320MimLFtDo*tEsc`cyj-9#7MD8q{ZL;& zd+195YF8jmZ{;D*h12NpSu-_)!8-5)A~!vs5z7)$W|Ga)oD}f67aYHV;PHz#|uKq&@IE7xbIr8Sm=3< zqP+VU9dI0%S31M@^QYN0VJbTV!i?CUkW~WNu%tYCSO|1=1vR%B=k&dwf;oe_HA|+M z;y_9Ax~uS(kP=hty0BSNA}(xNsR+1#U;_x(US?O&LkP((x6ue{teL)3Y^T)q42-Y;Yrafx)&^1>TLoi4HngzI+_SL#Y0w%^#r@|3Sr3f8|*I7ZsOa`nJ~vpyHUV z?!TP3FIqbOCn`R)K>W|JlK{C18Ht8LFE&IR^Z{D7AEI`7;$A!7c9v5AuY!11oT-HV= zSwZ3VU$Ih4>~&os6@lIrro!ls2`5Dav-Vs~BC7fab<5%D5$Op)gBYs726a-O;7>rx znw(c^>rEHg)v%Uqw2&cZ+sk0Ji`UX)LlR5p5Pdn$#WXY+G4h=-5eiL5!LLLMpo|wx zW21`h=Jz)g49(J)C>Ns@j1DSg2xrk&9*fyWDB9TmZYM%PN2}&W+sgGPTz8p3u>Dcn z==KL8R=vH*RRb+?emz&E1{7_L0<9C-Tgafu2|61!RipmlgvlV1Zt%aZfB~t*jh@{3 zV_($<%>tJ3!2FmCOLd%k2ASA`a+_3Ig8|vMw6r%G2yM2U#4lU^A-bkWA*IaO0my?_ zDCM7UsJfYp{hJ?$KnQIlKvq!Q}Ty}HjqNlJ<;&FKL zk(nX?he#QyTK?lk`0vJ@^yi;y1{h6eWk2DHb}?5W&~cl2jxUXY%r^N9h)~~_+}vJo z!zKQXy0x_TKlTPXWLN5qB#S4F7lF(ZOoSy1E==|_p@zu&J`0dTSO2%;<_KT-_VL!| zDejx%4UTet=Oq9iB>@8EXKu=99?BKda{iVQ(c?ZE}U17NC91UoT$;rabD*){CI=DWRNpM9E^{C{Dq%VXH!^}e9J!vJsyKVJGX z?f#0ryS;sV?q54Qx?)N*uUj(9XuqN7dOfh099mqh{7+91tMgCP31N$3fw?*V~UgTm_VR}khSPda|TTLqqm3!5rI80h{!-9)FpwzN7H*y zFBDR2PKPPG&ueaPD?e4*09yH*H9BLOaog@;gxnapEHaGuebQ%6r|bQT{QRj*!4>N( zI2eqP;lVMGAkl~8{Lv-e&hxjY2%1z=X7B?RYQUj0JgonEQsm*bWuh^8V2d;U-y#2} z@rsP{1E88g~h~1M+ciObxbl|=fpG~CQme;JRAU&%>jU9 z^CfLwUn3COpH>+G{OxjTdFZm8>t(sWT$Qcf+Fndd>YDMd^<%P_mj~4^S}Bgx z;_Q2)qM67^slk8%Z1;CSrLHfQy5!BWEf#K9tuiri-<-oGYd(CEh&jFz-$ z)~uu#47pwik8xE7PDlCL$OXz>$P&(ky)~sB(5)p!OHCw&0rD#csvisPf;9nZxQcZJ zSf|IY5Q!tL2go|E{9~;D=<<7cQn({&yGJyVZIyv$6r37-FUH^6FxT0xEKVjVnV%w^vY zkB9O>3gO9BeSZJMerFraTmI-o0Pa7*hVSbt2@`Y70JxT6DisM(j^rQ|Oehnu zIHE6t0i6}bR32QA-7iudGY@Uk1npoV-LkpfZE@J%(iL?hjYWRF1X9eIsn3J+1oTcS zmzd8$E;tZRNJu&pHL<`L9TXo`nOmL1z(BRKvH%nmzq=1_NOYbtqP}Cp$zJY4p2Bn^ zkpLJ4z7)ofr5A8qoKrUz?a(!30>N&>w2 zWqneyC9O@J_)YKj`eFRy7bI3~%%6coIQ(^q?P@o5c8Jqq>(H5r7;y-c0r_Lp$>EC zY8T1qAZMt#jQEQ^zq5K}J(kC*qsAo*-3Xb*W>}L;ruZWMPFs`~APwM7V=^RbgcL~{hPeKrmr4241sXE?~2 zr7`MdNac#+p5=t5&1DL!V7j3y>&2#7Geg?C;d9!WxP}5I$Bl9-oSpcV-Tj#Qbi`8qpUc!vt*1B$%+=BLCE}NzPCpbBo_@Z{cZ8 z+UpBA;0IaZW+Y}jyEdRO!Vy1(HaWzQA&kNhyIu?67%@fWkOmg9z z^lVquz}U)D+1oanZtDHfTKYo-n}ttUJNv~ohDAG;yA3-+upmGQ9*V`co?jlhd#p#J zi2+&M_8t`u8{Um@VFg?_gA^IBdm$!sC5v@BOk^4gZl)*c98XUmUS-sCX&U!9Nrj&B zc_S4t$zz^Ycj+$)lqyg%!&%+^7*vVX?YDt*M^n|#k*I(`(RSfC@q!W-kUVjMOO0r5 zT}=-h2mP@fpBacWH8Bygmk_jwFqk0@kz}g(?p;~2x#_1Sybwg}nHn|AC5kdk7 z{sE|djFJ4ob?R{_eh^nl8C42Pj`LdQ=7FCXuG%@0loRK&3R`QT=J=`)p7dy;dLYKNP%aQzVswgMXy&}&%z~vf`S8%-HU8AMV687qsh7=5bFaZ zq^Mi0l}sAxA|pYg`1RV6(I_)#Bxhz_pgvPuqr<+3s%Gfc?``Y; z?G^iiq>}{D-fMmuz^^lF!^RXwo0)ks|DYvgkZ_6USd~>&c`;p@(YoP-aL15gr1ZVr zdNBJT8}IqZPXPwzDguY2&Gn!Q@ZM4mS)u0r-a;;G;Qv~(k*abX8f=R6`dDHq4lL%o9J+4iLH zf3W}+AOwb|o7JvKZ(-93SB=^uV@zZ(vopYOKhV1O<9@>iY$yQ6A5(SFODJw5x&?Ix2=nqP|zpC7$xUb#C zXuMr%(7e2M_5@WbSGGN2pl<4+1&QH7V&!X_3rIe@=xZ=RtAlI;|lQ0xQF|- z#h=*PTD#}dhzRzJI|Gv=_`mggAK074p*U|{gF6I8&L1-;U|4M-MHqC9`o%|m^gnG~>D zV#{R-qC~$+Z-sPeUG=;A7U=2?DAm1VL6?B&k2jJ~`qwc{}%HvYbw>0GU=8v_ezbd|p--*We!9-QC?Cf(K~`?#;hR z-tRr<{O8_V)kUhPG&H+=ueIi!V?3jdw5}n85CbcJH$m(Oq4)eviQ~1kSYvzrI?rt~ zAX=h!))b16xuV4v5cS)X`t(1h%o+RYe}tR7u_q)0IIB!GgxIJ0#mPyPfZP{>YLzd7dn z+5tuvQ2st&UIX4Kn3Xy|mE&_A8OsceVB1)qT{(iVKtJ4}$$YrHpliK9kkfgpz+n55 z=egU=B6b*s&$TYEp`a=st+hk_O5&+{?kUbv01xPU`#%57&2nD9Zo(NhO`vco^pl@I zG&<*&D`Nulf7ymk(77)gho0^^f~Z+4tVb2p$yCrc4->asGTEoRZf}33T`&AvD76@# zmOpF>noq%0HUfSs5>xy8>@JHpZwJ!VG9)D8DLl&i;y^=TMUrlh?J?qpFsbJNpZCr%mm$QB>bLEl@9U8~?zADhtwC8*W zEkNr*+hJYnLU)AYUJ%62v9Gj!vV^G2*V$>t9{ zK;xH^&a*|+Ch10g8!yZ8yCHJw{RtadQB!96ok)K)i6yS`43^+Q5z*TmW3GjADQV`? zc4j2_7=<2v{deb%f7{7MFOaqm`|tIh4;8}(da%^zXDZKfqQl-=ZC1}k_P>v^ zDQvtczpBCGYG{Oe!`6N=W7mG6fL$@c6?&r?7xMZYh87C7*2V^Rug_5N9GKVlD&qy= z1Uz7jx=oUFZaY!sBMMy(2aDt>aFxg8$lpnGb_ZLw$QIPBpukecV399P%OM*nN((s{ z;@z@#nN9v76&OQ@Yao`2=iQAJd~`d4H?uE)IFC3QwgNYk>(iw$k!Lb5%rQ5!OPQ9Z zhq3?11svMk$7!mpYAT9tX^GwXn>^1RliHmWVIgB};E}b3@aLP)#t!<G6D@6Df8 zJY}Az&1Nbh*X3ho18+H1H=b(f${aXbxlW$JrnvkRMvSSpjb~&vL$zn12AFWGg|d#l zxJ-||g96}>!wy!EgGBKTe5!Gh;rQ9QlX;t#;>N1D?eFmWHvJt|Uya{r@>ICWs`e=& z)R-jmF+={sXGM}(D%k^gk-K=&E<{K9;<#m((9GpJ9b(9R({a^RA;YLqjhezLsUsyJ z&}?!l0XnK<@2?ESGR>rYdKV8vj{u{v@AW!|C}KmIlVPd4gduw@m<5BA#91-AF)S8$ z=Cdl69;A%eG2z!axZuNFcXsJG6q{Z{+oalI&B#1)ih^SA>Ts8_@WZ^oknjDZ6O*8- zIZ_$3-YM&$I!!bNSrE#&_%~_Nejhl4&bhr%G%V}!tIU?7Ri!q*&vQu__zwdYHdegP z2amxeBQn$Tx{5cmJ?-o-66`pGSBU0}1!)g8+A`|;HC6~wu@#^C(hK)cn$ z#oHA=kc-^gHzS(O9yYSKSyjeEkVcCpslLt$Mgvw)h6xQW+cRY3{MjB zTos+X84Ndr&m`aO#gcPyfad&kKZoTDvOsrS*e2@pqsH3i>Z64LG}8`{5+dN?fp%GJ zYv}OWbk>L&D5q%=1hfvb;d6F4cypBIhA@d?28@5nN{+$xjRWFgzw1Ky1$uS1hQeGv~)_jTmzr4>Za zt>1AN!4(R7|14TTim^5O0swBj1SE3r0qL)iYU=ym*i&k%1{}tHy~K_7gSy1MK_$d? zoL~|T;{1gKbPXS>VQjdoBiAiLtQ1MwaJErdAHNarow?ns@l7g-B9eQ) zeK4NZ2b}lRE5=OgkE9kn#BmIBjXpl(D-~3VDvK(>vzy0K5d-mW&sbC@+Se9^qidQI zattVOvUJ~{M1La~MEX@hLPsx$W%}updZ-|BN3cZ0(>D4Ka8m6VC&nFeAQpPa!SNIL zSE11~Qz>R9W5OYf&@9`NVwnNv{ilFt_EMeKth#dU8Nh)aw3(8Wrp$LuuPFqV%nnbO zH%xnu!N-?1fguw7#y#=hUxN=m6B#DFp;r&R0A_rN*;n!~EXmQPryZi2v_Eu%r zedg3s1uD2{878w)lPSnws%MmrNJ0ia1!%@FR}x5wcq#~xDFd=5vzK4bprT)?#54s- zVwPx&XutX`+c3q^GK+lZ;Qq!Tu?zb{ zvD)7q;N`2s6l0*He$%y`*GU-@z!RXU`4A7y`m~vC14=CBYOf9$gj8$qsUE?|AwRM zcDyjL`Y@KsAx?J~45wUBzm9T1NgY*?JhlH0E=o5W{otsAKGbDG1r&F* z@n_gbIOY{rQcsr)9o|@88e`nDWhkRNs_e0ZSR-avXjXD!2gr z(QC^d<~y@U(F*M1>!_R2HM4pG@n)En zd}HHrFV}aC$s9V}>XfNtiR+e$#rHvGYzaO}rWg>g1KpYE!zWmtkEcM#9=B-7*cyn) zxRbWh%j6>_rWuQo{6YKsw(W*rM@s4eNTa~A_B~23JEH3_-fmf$FV~owU$0ME{(xs-vE+uQc|@5-6TEQTNr;4kvpaz=&?1<6V{AC2Xyi1>h326RU(}i z5Teqi+u$XUuw;#$)&zjNTHD-xOx!(#O3suoS~*O{ySV`(#TDB|%hjG0Y-Dw1v0?Cp zhlQ@8!cTWGOUb7*(civZt#47dbnP-@wb)UrG*M@8DS@^DY1kf$@hn`2`!|E#?cP`s zGL+S9p+a54T-c4JHye@7(WJ(X(LrpDm;VZdI8)>S~)_G%r9$rQ~m!2Z|g zr<4)Z#L@4fWK00xwONO!hh(v(1HmbCX_quB>WjQ{E!bG%tQIIro_7Wox@jl1-E?2& z5?Mzu5ZnL)>K#OA(?n^+pdNxMzj;v|gi+g?ZpvAFJNfa3{JPxigW8cf$E$>-wi__5 zd%s?_^FUCRrmE_w;^BIRH>}<5Y(k}L7Y+%0P~mZvUDD6Q3Iw{$g_@^! z5BlKM^L1VP7i9F+9n^=KyW1gA3Q9P*5o|aIhAvD%#370*DwO7N-IN6UV5jCLkpjRW zU*v-8#TJu&}dP)u#5gO950MIxVKVmliKGT?b^;%-tS3T)VLiA4l06 zs+W0wK$?XGm(O|pm?f!Ei!GqD%jod|953;yn0vupuCnLW5_CTXEz zAq~MPkj1YHiZnGd@t67-Rnf5U4h$Dd9xq`A>G!ifea919KIcR^|zG%7%4!DF__=KIIuX9SRsJV(wZiDaNYvSTSM)=xH_O?-kVj z#xl}hycA$Pn8!IbFlF)`qYeO>T*lC4&)J~+{)rTwyR z;tA+s09C_a-}`lbU{#T3DV^^HLZASfwBX#f9B<8OXIJa0wUyyk`;&8*KAg|>2@ieQ z;9Y@O#^(=v`}=#3cQG9_y6jQXKE->8c=h$ho7%qgjfIv7))OVvUpGSDycw?VGduxZ z>G&Kl{>AIQJ$FE%-UDRa#1k#jI|4CTrA12qy-?)&;#yGqT)w*Q4K1#=CRh^}UHouh zK03`C%j_z~&VSx-MgB2H4dj?6M$tk^yjR&4OENlC|}h4w;+ofj~YL=V>Fy&u{zXQt?Y-Ot4} z|E&;sw&Vd37kuE~2PJOA~!$?4mE% z8c+u`mZCucELKh&#K#n&!2f{%!t+nGTyz-rNbww&=goVDxBe8N{vHPG?oCjl1vs2$ zxD)R<(<2pRNCz%jIZ+2WG2fP`$s+9%wuOJf_}p&}M1dL29=sMMlWaNW?)wN2b63bk zB1k5h+g0kINabj09PH9d0O!#rYjXqZPhy$3t4CvcMH2WYb^tFf?&W2oAN#R5G>QvP zDCf-LcP zk%l?g6{^+SYyUpp?E?zpR!TDG1(-NmfgsXe-V%1=-64Pif zH-WsXc|W~?Ep8S^zZ9ND6GW^!CQIM=SW;md46G(Vt@|TDFy`0px(tJ`=x`A7+_-oX z9MPD>W~r2r3lhulU16A(1!-_|T)Z}{a3~XG_8bD`ad_5sz(E#Gn@nHl-*N$N)9n^55+UuES1y0|3YV}f9u5xqrgRZvy=0ItuE3+2kvm) z(G29YZ`4|w|057E($+SUa0po>?!5Dx^j1*QS3oih-&x}1o0U+};q+8r@>RhL%WvHE ziJ5gIn!OI1qy-_@EyiCGTMl7`W`3?dGat%#k-$xP1N7r+Ndu9MV*#Vv;2W4nnA{iw zRGfc|vrTYcv?#6?L*E7LW2shT#=7!yx+x;}0Z6%unW|`XCfS(SjJHl_JQ$*En){G< zhnKn25NUp}Tmr#TxK8lglJtI^lQ;N=&whiymV(@~R8IYIA-f1zGVSeSX#;7_e@@kt`r0$+e8R3sk@pL}AdS3h^9ZaQk#M?e;#-ikMPnp0r#IcI{z zl-vlp75F2Sj^TAIa3jBmS_tZ}oK+8u4XJb5^eNA49&GQCgk(p?oHf^q&G7nZo?|jg zKqo{0#8oGTT|TN$oZ-0p2Ay2Ev%{s8n)+X7_`lWwS~xp9U?;<10cI41YU}H^6$MFu zR)v4ohGYSVBf&E_5AL=$KmZu4gd3uYQ!d5c&i|N=7OONPIlSlQA>Sq#Ezvo|6Z2&E z4F>GBui#sW_|QU(!9weLgX;TSP`x(kb_l3#{{HSk5yWDe|MEY6wKZhbQvd2lunmn7 z0{zwRum3Tc9R(rS_bG+EBW)}tJF{kdc4-hba1oa?Q%js`GP8Vp9Ct6#w|~{ef8ijQ zGR7c6+LdYoJ1-)f$U=~4!J@rFOx*R*Q}eZ`9K7h>?|J&2S54lHgtd<_7k&+bLG=MN ztYi!#3oHGp1=|a#j8pEL`3oDKzE%=vmlJD`2kW|W6lh!S7Q00nOj41!JT8Y$7suxs zA8LwfMr4A&XC`^(f!+N3x-Y!W!ifT>(yh(?djVSNfNa|A(bbYha#M*?7I3 zc2pa!*LC7a>;!T}-Ed!|dux1FFpA?d*RV5_*gTf4K^2Y{;U(nUt8dVL`$cD}|lI3?#rIc$9tyC_WJz%vZnmHvN|3#gs~!u=iMhn^|BBkcn=0UaRdK$ znC*lmRhsX4=tiLMej3Bh{Sbe`59x>T%JMufFa-gS7J43c5xgB8`S}TdKTO(}e^q1ZFvE9yuvO1}4OnKdA@RrsiSehp*W5Z=60${1Cqfq-3 z{BoY&iUP0{_yk!_!M?H7vH6_rp|R%-`%-yibK;D0yyB^05ZV0XgDE+6*M~8+U zGwn9g6BF-2&tT5SK%2qO_q91MGOrW>^yWM?y~jiYB$EDf&tvfXRsNO+QrLw(J;V9A z2If%2j}~9L2c^QVSV~#i@c?=6D6z@up6iEePSqI%;^DzLn0+5T{!cgzlkb#^q;CcB zHrXeU0Xi>C4_TZ+E9C1r3fvIDW+G^SWa#w5=8fFP>i%_=TO9~L@vfDCKY|nkUORZj z1yquz^HLH69PIAHtB?>t`1LXu$b|}?eW!O%-c}UvOZ5iE4$(kzosB40X0tZ-z6W+( z>*(_zk?)`Wz@KLe{(Z~-zJb73#_TRYcm?(UP@jPywEx>WDNQEd84AVf>7Z)sW2Pn5 z&_aa<{~K2<+Fo#?x;oH2jlt1nC3!|y4iPY$z*s02h^MXKM{Xxma>hZJui}#b`hF`M zzU_|p0(1{il;}Iv&MUoD_~fjMgxO6|a^45XLO1mSdLarwM&rEHlF>uqjD4T{rLh1$ zul(^4rE1C3;-)5W&f(`dzHm3lZCFW&QxnJBdeLw-sUt}|+9?Vj2$LIs{Fw-WzF;~Ry@E3aIp8&15n^l`~M37%<9SbkB02! zg@hr*1DRkzKJ#DQ*Q2U(1sOd}0ZxtM#Tg6dzt_`W+VWri><52oYfdgrrI~49jf;zG zx6N)S^KH*7SMn-EEO7V-upb?UoCam}0#9vT(}7N23=qoxBjgcp<@jydn4L0HGdijg zxJ9bqor=4GSHWQI~RG_CXEfO$-R0d@{AU1veNPWSEt z5s~-$+lpbr z7Dcb!o2~|A`BOf#PcJR8See=rU~+ojOS`C&_0a3sHgsOh6rf{81Okrd-HQev(;gs^ zsK&x7c5Ux-UTtlC0-7B8-;Xs0K6}9Dok|#}i9Yu}50VppECC7RpBbtC|LFw)B|zNg zFO8|+zGX7vt_;25*6?Z*C?H>>>AtT>cH#_-3NGQbb6~q5aGSFUWj}W=Rgg5QXVi)C z(oJsPCfTS=qUwkT*&>VhDVH+EQ=56)Dj>l+zzZ~-uH85`lsr89oLj{N zBl4WzuD7x1(6Z%ix`T~{w)Mc*m-C<&E>2FiwhmCTncix(jhge7pG;~4UKt<4`TW5i z6M-F#M(fXUIxkvGc>lfE9REM1E^_Sm;qRr>L~jU0#x#diJFqpt8b&T5x&>yPjAm_{ zJ0E!|;~?Hy5oq*zlzeTv*Oi(iCNn!Cc<&WGg_=P=ubj@3z@aj*!sHZfCWvC>NWUi> z$U^_x@p7&rKr8ZdhOEIe&L&n^)iyn&z~*ZRX3E9NckG-*SPZFjBp zGVxtZ^78Tc+!c|aNyqdu-ImRnJ-FLDz#xls*@C=ehBgW|a&pYKRi(UyU0YXIe&3xp zqI|E;s1^VPlepZ=q6ai7CBLU}7u-(8H|Dg`Tt5>-pH%&{aT_X?%xrM|u3cK6cRkOv zLzG+FdTzc3+#7CzbVZ}bs~4)LKjN;S1q_AJ`Y5g^U-Y&y)JXP!9oZpNo8rBl)uXpG zy9cM}Jw6h@{Xykq5}S;gTift|EY}RYJj^|tm0kLn75oA$p`X*HkSZKQDyd6mfbfbh zy-*5}Re|A<+;lp8Fsy+R^R-{XUH-~z19?_AiLYu#%(`7D&^TRkAjV4w=x&rRlB<(R z0mYIRuO49bx=5b?dmc-SiGfMT%g?9bquaks{vhu7>$k!n2^CWhKHQ}Tw@WGw149=O zd)I9MY=ge-m*+>k9li#0+8uo6Ro}tqy)zqaHz%|4#(!2Yh0d3~g*aJ;l!r+S+??26 zfpv%fsMkJr(7}V*$^#9P^hM0D;NS4j+fOfxbaEpJi3DDCJgByt9S#)^g${7}=jcL^ z#aXy$JhM_L{cai5$a+jpOM~th8M&{k+#z}3PpQ_(@4??%meXaqozQ!JJzC8wBsMUocI$WlV3=>ihq6#ToT0XcX-k-Doozt`+o-IT4_ zPprw!1`UZbaPIknxiUFCHrC{Dd(nfDptA`>mr?B^*yT{3xyfP9-TP<^w;Ew2Ih`JH zk;DogdpMTWI?l|! zkHk!kTqH!Yhny?xrwWpN+eTCs%`fUc+BgNlx+}&SIkB)diPBa8c__&GqMM>3xR;)i zC>u&R1NfI%=}nAUgV+}^N3j-7wcS@zTF5nfQlZisQenAnlliQAHzXAtJjLv!?K;_=S0u z1&chKvY%U4j)Vm@(jB!dyM@eeiD2L~lodTl3rnVJ}fsj!)h$Lkm7Gr;O8!m=5eRu+O*Y8#cCe zs%t){t`$_5^#P8O5p%we;0hPxXIRqbJKdxyQLp+~!|J;vy|&Y<{?C?kppEAf=OtZ? zA^XOS`-3QOV~sOWq58WIwDlzF_3a(*!B4R&B~RYqLmqtKo>1-tv9HF*vG-uU-)?%l z`HK)vFu-UFok4+sY#RXH!-++%GzIsX(}z!g3c#J{X=}fqVxR?pQUq}FMnkWO`B;BZ zn}+5p%Zjad&cLxE!JzaaNaj6%oRND5Tgilnnh zT|Y0!_M$$SRi5Gh=pG57B>gdH&H?oe!8u`%#S^OhOY&0AG|I&SF?4igK(hAe0C>HvxjA;;;zibOa(^!_n0> z16jJoHY^l>=3klpE5Z?|$U6w1whOHtklLX9Zz`NY46@yDqTg+p&MSy4P-(M&bU3xP zKS>-7KnZy=LBtA@p|P^$p|d4q^B=hO64Szdke+V568J<*!%?Rr7){v^h)AptyN`I2 zH*R+jeH=%H7F*|B*WA}g3-Ff@C!GG(V66M*@(@VKmKuqgn3(2e|M(#gTS%SUbj{zX z$Ve5H#!=fbWitqvq3;z1x!YItCK6ISVjFsi>EN`s(ZqK2g^4YXrr5<@9`hw;gb{|hWj{3*y zI!ZEL5u;@zcWm~j{`>~&Z`i=5FV|@Rr^xvnCKqJ>(~neK6fV>K=b+U?FAAC1R=7I< zc)};p2HCi`X16e=r^Z;u^t^}jkCvkBHWUYAmgnB<=d{**LGub^VpAB#vG4VeOvY9T zpKn5b_EcQ7n#1n&V-UEJxMt9xAr8ykqTpLoW@Gk$R2-SNH|_C0o+Zv*R~LDu^FEt_ zTjHr)24jb$r@Y{u#LnnapNH6%U;m0WK3$PEL+N7D@>?7k89Cv-0z{vt!ua3l)b03F()0r zlHHzKaGjOPv{}2md2o?rYERvVALCnf{(Pa(^i^eh9WR27*3r~tNfMXU;Q-`H2j|zi zOK-9w=@>qDwY>pr^)OHK=EJ)|mC7=6^-zzi!x6fZ#rnUk1IUUkZ{8a{{CA+WlI3ic zJ^64S?p#$eMRH~V9_46SNlGY+wy&JX!5ON1AuS=}SqCHuzb1kWr1SIVTfkp2x7LYY zG`IrMXU0JAT4J46@BkCNG5was1@`%<^*tIr{MYbK*++AqI81Zsz%S&lF+?Hk~rleB+=w z%4!=|_gP}gbKIYDpYa;@;){aljdg;*e%7ro`nVu zl|Nl$YV<4506!;53lBMYm^6y8!m6Gp5TIG6S!G`7y%6+}M4k4~l>r6B5PnMJ^(8=p z`^f3w1%xyL{h{PnaQ?R^dw7ofqX0(;z*`m092D;*-duGpkVweLe2l#ne;S%G(=?m- z#Nk%hUNcg6A4>`h2SIo`um~TWKkNkdY}Q}Pve(U`9ej=M2b~NJjdL%}!h3RY>UgBv z8mRPvQ+jX*CX#h5xo^rwzmVIyo)+yT7P~9*0Cenntk}Qsrj_nAbujhyD#Yp&1E^(aAmDu~) z&9$bxADUi^NiW@~vG*R?P? z-z8b+%H+Ilh3D2#y6x6?g~VNpchSl!x@mfOV(@#>BdP;(tQlo9#{Yg3CEuAQ!_Sxyh>DM743v-sG%{O*9;LId-ymcMEwbdP4uXAughwsJ&*r$yd zW%6O(8?k-*Fy;9u`!1-hyf)SMVQV4J-A9X+@7AeoLL|f6ZJdAo+qv!|a~@wqoj zldKb3{VT*&=yv|O`{|l1&^=Iig#uK5+fkRB4o8P^?LxsQBuYQ=nkfjE&$tT!SmS!hFv2##Vo^QLEHGOymeKN56Eb2#d%q}PqtTvMd?%f{ zWDRLDvMH|9Y)*@%l?C^MtT0vl3M=Eew?ns3AzpW zL8~?q(m&&#!!2Iug8!qvDRouEr$Sg+j~#IC(f z#`v(q575~v8|nZqb!|5w{-|C;`PpbJ48H>-sbOdc`77$(-q_x2zhvq-=1pxyO>?my z03$He*(&z9KJ)Rh;rRme9I#;!nX1AGH7o6mqrze*o7rI}j^n(GZl&I~sYlluo0g`` z;wvk12lRJeYL#D1K-|ne`1iM@Fx!qR&l?fQUi~+K(q?uZrrd3}spvKXiq3|%2{eAH zYd~O~w^=8KI92VyQcJ#io!>H8f?u1jdvRr_M>f_s-Yk25;`0!OpEg$fm$AGETE34W z$A&si%hu2c9WX~I{k$!ur+Gb%w4K;;NR`%~nCo2jlrmMmG7))s0eak(;Yt|$@&0D* zEHM{Jk#+u~;}w;{>8k6)dI6QBLvTs`24=bU-ue-%ancT^PJ~SL?iGGoYDb-=#Nf*1 zqjyvtyJ8Q(8@!KiO<4yosjav3)#Y_tSQb^-r0bt2z)PyZp`ZdWf+hn>o!F3YxTU(j zM`m`fNK0B;Jm*NIdH{?W!I+t5`_otjpsdksHZ~CTz>Zni)o{K0goNPX$wO0S2LjeBpoR9lOPcb9kiFGF16$*o)HU z4s+DxC7fF5xTE2{8fQa-ud8=P+uOgqgM#Y!Z^bRNgL?P8R2DM#aHJS(J**qK4d{|L zd3yhiJZ8#QB$MwBNP z1&3_C_y&aZ!4W36log&(@MX-C5JySJ^DVbV6A&Lj9a4Fu?(C$*AD|;6q!hSzHP><& zly^}#zmQ4iYiH%1TddR;pPCBvmsP~^EbQVN>Ah2w6tF(fr9IyiWM%>&#ZpXwkT*Co zcDE3QBbYsza5S&oY}p6oqXTa1dLpPUT|j6dmMl$ozkv_#HfD?oiQt_5pLxYt@GvG^ zd~8+#oe}pOljqj4Qx-T&csZ4@yu=fmtU2BCyrdO7^gHeM-_+CTFrWU8*#@AMHQ&eH zya8D9`*y3fHl{p8t!L+RBLr09ttJB>Q(5fiN3n+2@mg;Df3h@Gm!3>k>C@Nh z`+Q3zC%8_xX6I=sB^iq`Y|!YUDr6n^Uwx_z0=!^x8DvAcT&|~IR{<6yYy|wZOVcXg zsJqx3UB21`Giq*BnSVi1j#T**UA(`)+qlXTIg92mtrHrWMKc^2QGAyG2yhsgc_o9m zEOAsc6n@y|LJPbKxwt{(eH(jZliBhY;K4{n&*45U5Jw(-UJv*|z=8jZ0qzi^psDL# zd-5uoTKyfBWCRVq^LBGEW_#ZM24|w{l35qZ8xqF<*isaOrk-* zN1CFim>B_5t@>y()`79p7kM#u+bq|K*FY?d3|Y@2tRzIS0y|2`N>e~L!KVYKTnAd* ziLdGawDZL;4oL?Gwbn%o-H6lxT5fPyn&gNjn3Z?D^k`c~_~q363nY9p#ng-7Jbj9Y zg06Y+uiBed(+;%rQXm$0;`hN!~HPqxAC zv1`M{=Tn1egCIqC5Tcjx+F%dX-OdnFF0`R-1KBT1+3B!UfZ?VSKgVD4AAtAtH?MLX zi>kSb6wyh;C`%~>*Dt^KL|qMH{FuJAZr`q@XC(LV`wL^vKEq2u52%pOsQ!`n*m z`1*NOIr!eIj0PimIi$!Dn?-B zmOF@q7>?ry2@{McD7Rd$E9D1#6WY{g&lyi6j z+)8xJLJOMLW~yc)a#keKOh}^DvLq=Y=I;Z$1S*nCr0sQ1A7NdEvjS!xP&y~`^lL#! zhwp|ttT4;Sxyt(a5XxM+xDgJ7a5>XRK)Zv-&@g_S3d%0m?w)`~d--s<7>~gZ8w=zT z!U~(H_*Lc3N(9APex9gLVyu}&ZRee3#g3F#mP{M;hfq9}nReX#t>B`*k8_D>^k`&4SYQE%QOtAjWM$k zK7YMg!FEZ%w$z(KjQJ1KEYC&^L?}85VmTAAS(bCjo zu0)$fBx<9Lqb% zJrazPePs(8iL^DV4>m8SXl=~@tGMa=Z#S<^arPlwHq8F!m>{+l;D8Ab9I&t~EFzT| zo&EOnb)%BD8wm>zPEI6bB!Ez)A3sPaKSaju@n7HH$HrMxb1AgD=#<;KO+qI%dvb@@ z8YC4gkcz52M~Vj=fvzxRMBP8Ff-l|&3bwVi_d+vae$A%$(!R@5ycq=&{PhNZFgE= z`m=x0GZl9Xxa>dAn>ofY0(&~3`X**cCdh?Rd$95z!t4DmOoo!Zz$zU1krWV8y2MahHXFZ3@S4nz` zZ4EA<6m-DIOG-SZ5S=+Qz1)7e2`1!d{oXwK@KN7i%pQ<%7u`uFn9@n7fVQ z8R$wRj9>uz`qRlxZvYS7xgL+kg==z>1|%Pyz7jzsq8IK)g|51j=6YOr{Ta=Nq}^og zN1`u61Po<;K`ciwk<|`;-Gsbfcc6#WL<6Cw8$CU!s7xcJJ0Y(mhsq8vu_nt}y}k3} zS~X?80+>#J75FAbhmh(Eh>*iErUYX6e|<#!%e?Qehhe zI=i~|4-|wGty_sf94%4V(oA#%d^x(S0ddI(+q(nz0}E!X{VNbjN^od{aC`U z;EzPiEMT_i37_})`bt%rtU?it=EZ*$Pm zdCoj=5hKek&2?bTu=5Odv>N1Y;M1qd1RcrY^2~oeb`u)wBA#_lU*>8C7!btgIgBxm zvM&7q5FNcnimkn6EhfcUUn9DWR;#YI+VBI1)(yX=;`Jvn32ABbQ>S$HrTFjqQ|GX} z)j-(lbgV^|DEf!XsOn_ivRCIr6J!3w-DW|zt=?uVxFw}d{Q#CgZ2l>9@H{Oxwa*%+ zZpxfqU^BoTv^*7{bNkNF1^ApA|0D$j!Y(aippzns)VaA`N!e<(QChB<>1M~9jEYs~ zhtS{6wcG;^3kvVXi2mK#{C+aDhM${SOMO556bw z#}6$O)G^a{R`f{5X+Mc#^y!g)-!!#w<~)j#7YkZQpvw zr%+`?UfXBrL1pX+WKXJBcsLMG{OTB?z%;jIFP0jqT(o2bG|`@8VYm~2o6!9KO zzCVdMml;$1^y%*rVkfuAG^r1_Ep-wm+XW2wx|gZMap3~ZN1P-(0vGe^xHu4?1%U0a z{)H6&+Rwb!NY$$QTf@$IXj1$Z&Ju=)hiT-aGYwwwfnGoVSsPzN*NV{h*qew!b0zk& zJn(P7i+RptS~POuDbOJSdnVVPHdHWc4B`n3^*uU6L0m|H&pb660Q48iOfH%j%0Br- zlgv<4tG)ytE#ND%-r3vJLis4w|u_m!)WH5f%iw5CrE$)_>i0(^S*x%j2X#zT(8oPkPT%w?%@Qnts?C z80Q4NB#}6cPEkK-zEpGv}k}cb|y9X;^Yf&nShFiX$ixHDm2*4<_=La&T#w`oeCt(#rMWL(|LkOx6 zn!}yxP3bXrzM1zC4)&Tee^-&UGy%W?A@)0Hi%(V5L~oMG)kjM$=a>KFLq-0SOadBr z81Qe^fq#^s8!Zrmwz=<|NV-G(*;V#CL$}q#KYx$g=#N^#RmYtW*?=%`>yClrCXJlO zRfn92o=Sm|LW-Bzfp+MkaMbyx68AkV-Aa@P3N6M=+#sg`RTJZ5Bu1ecLt_tpeAZcY zTy3(_FIhpiOKuS?2-iZFWuq2lAtL1EI%2`4(BiL>Yiiwr0uvYSeBYT-1QP#ACZAMw zaw6w2=?XBHCnHqGU{fXyPS%~1AAE;Rf2diy3*W}R6VeQ=>HKYQve{cAA!Hd8){DY0 zCVrkHD`(N&DN+1|6ysXHB~}r`nL**D;3oS`ps#QkeyM`)%*SB<(Bsj@d43kU_~qj3 zz7}f!+kRw~-gwMnE;svWjAmW=HwIOrqAfZ^hi~D{K5=2uD0;A#?$6CAPZ2|bQgtWa zqgD~hDy~q8Q_I+i9pu1$@I#S^%&py-c2cgYTa3$jNhaq^UpMa+^D#dBQilZ;(xFK3 zfruY@Ww$~pcw%EIoA=r<#fBt~eJgP*_+_#j{j|Ic8o}6_P#If-xJ*fCrBKdSwRpyt z0g?v{As=}_1|}YOEcnH7Rm%smZ*%%P*(^U)=VarTnN%EPFon;mi6P~d!>7phe@Mpc zq0*sLKqQQZ7A%HCBR6zXBt|8Ar&7EB*r+_%$bl6lp0OYye+WBboW-C}6XVOIz=@S4 z<%S8Z8{GazC9p2GaA3N>kCrXg-^Hq@Fc$Txu#QhNTun-rbjUPSV++v3s_?&nkc>m+ z8LR@4uBgoa)cHupmbs~+>Azya0M7juR4bu`j1-#|3b2sk6nq-DSJu$q!nn}}PHl^OP1Tf^5@MT;rmeZ`)h^8Z zK^OeG4@4dXCT*NP;%yb*?=ARpZWqD<4 z>2DzY%kQMc{8y!UYt1_KCP)xS!vnsp3f|{(#phJsa5}6#-v^?ZuZK&X+mFVz^gNAn ziCIWVNe}jLDjqsA*ZC^FZwISRGS>AT@1sI@fdknIC?a*k%QJ3a;bs!w$AG@_-QGmg zQzDM<<;(+}PTS*wZ`ft&dn5 zW5lW@*abv}vv%pf`s4B$jv*ev#+K74Q(gQZT$P*aD4??Y{Imn+nR@`*l{XdpdhdgJ z<~*%n>JevgLxqW;68Y(GfA&OG=(!$rHTUFD@RKF!)gKNOWVBtQs@-|{;N{jVq0G!o z1MS)`c$XDFl*ZIbTbR+{*_hM*j{9F;(93#89K?d@7D+tV`;XyBWE9k}s;Xx~e>N%;$hGVfE|WNag>JwYLt7s%zWERa8_&kZwc? zY3T+90qO3P?#`iaKpLcm2FVeLk!}V=Kw5I7yJ5&-WT@X(yzl3Y=lkCGIDX$8{;_4p zT6^!c*L7a!b*<~fx!eRwk*EEtQ<}NM@(OoasPP0&Aun5rQ)y{oZUN-pvy@mm-4*SAGTZ{{o}=jsp#sJ+8sims+?3J^8E+Jy!jx8+=_>Zlm< zmH^f6IqC0F;lL+kZ##zrh2J)-DoJOxo2zb{H&r!SkLG3#b4}Al5j5yJ}x?MLqMdecSyS_nbURjJj5V(Kky1Yf+U6y?lRvXrC_m z-zJrdCfs@Y^s(Z1;f-(Jh)}H@Kg6m|mF(4C04!AG6Rz2p5x;qyb85Nbs4=aIQEVnpc07i#p^DeGZV-~Snq|DnjlW>=2zxhD7Qiza! z%Q|WCE3Genn4|w2J*~n}|J#M}u=B&<_$wLvhI2ic7XV%9)f686FZsc@IKVnNTr+-A zzgeDrV1@v)m_LBQxQzc~hd}=5>Sr~TWZ(bW&%~ehv-@a-&xaiYl#k&31 z{reV80}oJPA|ly7xd$@%$mac-(li+#Ga1O&Kbv`y~&pO@Ob&P`04 z7O<6*jxxGXP*>O{g{?W~0Zl2Do57W+BSQ$Dn8NZ_=M7nu@^;4CMAdl%CC9&O$gm^(4KMb>}tsxLTc4^(EgX zVv~641R=&><Rz-7tBd#7{6+QAIZRjGfOCi0yYNOoQ37*_%|fg!8TD zgCzV~MH460RSX+VQHK1JfP-FGM5_{qwKbjr!%`8VRxjmwev2sF$f**dSV#3yq|yRe z*@C&1dB+>~C?Co2N}TM6A+QX;`vBMb`xKbnz?hTlmCbEyM_?f~R)dmOtb;Mq>9~QA zt1y-8;d2nFH6!aTrPRb{lgV38Qy4cifpy=X>mZQ2mX;C|560j2<~|_GY8pDMtI9FG zSOzWk3_D{jR)jnob15C?<4^0gVm0(b;z8;VWp$|9cgdL#aP4>NU>S$f;+9anL1KQ0hx~3nNRRdcfsO$Y<3*WN1@NcVl&^!)_R|zp9Co~unIF7 z{!m^Gxh)sh-Qjv3cqfEwzuSzHNm2ar?P>i9tt}KK%qL_)&b7x>yG%=cj3*`iV13iF z+zfMFUv+23bK%7sfyg_zi$@YRSxSQQ`_n{R&&s#@qq)V=L-)p*pT^avX>iUugyC41 zo3Px8ps^52%oK1dm3Q&Dn#b*($?TtM%wG(5!@wL47Jj=dZ+o_2|I=^F4ePZbGzBo) zRUU4QEJRg!?;qz=TzrpT2I~G+@m5sVTb-4Q$apjjTPb&0wW%DtuyZMo0>W?rf6&2( zQSKiMJQDdg0P~6!J(z5CHrN7Zck=Q)->CI5bi~$^6TQq4JD>6$+29nUEFZ>|vx!SN$Z_>YgZT=e+oZCx{w zI#~vY*Ix=UcQxELd+9zH>DSs=Kf&exyu)qT3g2dSSqqLf;g+onk0R>7e_MRfsoqTL z@wouBl7)ljEeW+ei|NRF(UC;7c@|JtjwUghJFHn`n);!v14+z#0qvx7?7JSDuR4s$ z{^kOfPBFSE@8ZT;7&&m$aRS3XRj=skt7pH|5nvE9-?${OGW3C1st>l&P=KXR)oL* z7;Q<%i|C_!F&U#Q$y<`bz=j%IhQ<5G4bKu*zF1;ewhvPeOu~w){S!J$s`T6As~L?Yt$H z9r16Fb5n9)_7LvOv;UkDb*-Q{}W)2Fv* z7X(p$aYrKeb`;$gXCQ>?HGZ{MH=m=Wlbw}p==t-<0lq>`GpKB7+(*wt>ZyLh?8G|G z4?9iRyd!VMp-ZKQ-{*OqR4pDyUSRQyM?&j`{S~?6dndE-S!LcR*5`rP?^P-}M=|$$ z-*`SJ?`@X5@kXGxOJkdTw=Q)6V-xelCO++p@3vqcX7{_flnTL0kRr_F7W&?~=3N>< zA8kMXM@*ON-EHfbSv8DEg&xk1nt9X1^$pAtEtl+(SD1=w2s`9(&!gC`_k)_7o9m>{ z(u{I*D)`&&C>`=l_|a{-J(Kth>)FMX*g=#O(@Og80;FKKd_wUT44E@U2+DYh`Sc2uQ(tvrk_YPxEkwteuYZLFnR=Dx2WyBW#B-re3+1uiug9 z0`(J=pzs9aATuZYJ9RQa#`h5IcM+uv6Md_Q;yKUHHD$|1N&yrOdEql3kjGv#A}uj7 zmTT($At$(^;VCaTLrI_A>|1JH+%__>Id~(hM(YHcPE@jUj1zaOOkrW85Bs#;l0I3k ztLJ|hx_(m6Q2Uq0*|%9EY;rjmDESaFATYlAQRCjmpNNevj5K!cxd^$sP zAnvKe-t4&UgUs4pbQ8Qf@qcCu0^C(T8-U|n( z2T(0iMW9 z>dvP%jUA`}VEBz{Z8n86JYQ-831ufcLi~QZub`Nn-nU6Uw^8S5MfDd zZ)LkD?7Q-HHRYFg~I!-tahCO0G2Q;^DJ8$=!9N0P4-&2KP>m zJoFT1cXM;66ae{JY(D&qGarioik`>x#GE>U8+JN|;h05Q$_)rDnR{C-m<{Aw$h@pI zTbshTDc4gf?6UU#QmT%o&TiGl>uPjkAAB$M`Z@v5f6B&-FbjaQSqn{}@PHE@tfM z9L;iCzb&3@n9XEvY)JAx=kCzl(+5v%?#p;=k92Av5LEmRnrUed?TOA2$d|zrK*`QU z4^%-Beemi+Zaz?$1X#1|)ZMj(oc7$+sqy7#29R3J%9pi?UuXcRVQaMTxTqz+1PH(I z={ezdZjh|T{z@hc;>Pf_ax$H8YwR@Rl&!i+Kb@gB1wf(!i%>mg^TF13PfaH=P4!gI zV{MG)J0W5hE(|R7t_gQ<;R6zol5Ib+X{u8Kwy^jJq^D-?+iJ3VqYfDAZUbQITm$@0 z>U+LyxqkZ9cWO^FB7q$b&S&hq+7eAqMRg*@+}Dw5-E|WcDYK$|2Va>lnH}_%QK-6z zs?x7--+TjVno;~5rJfsv9fFa?h}uAN#L}I!96y)D|BwWo{ZXVwyo1EAn^*WiNW7Gg z&o3Zrukg%+Ol#gB^_QK?@vbGGdYz&MqyElG#P zBCgxAN_TB2i}K#xlpZMLZaqQPjt-*Y2$E?*Nv`whzmsOapQPd0Rd{5fbX4e#m6%a_H)c$71om2Yv3p zuiSWH@@+)&NFRYXpRv1kjybK1)A`767d2<@oOlp_Q?+F`pn4#^5G&VUt4dNY5u8!N zE&X8T6{Dx^dVf4$oiOgbN3B(=YqfeXi%=epo2p;>#J)EZ+}u8tOKBg)pRvUjeejpO z2q2-wNpe_Tf7I0X^5r2P?26@^hBGLUk=osdYyuQ80S|-Q!lJ4xw15{7Qt1UyqS^-L4asBU)1Y9a3ptZ9K9F2F68Mk*z z5W2fQ=+>CrPd;L>v9n`~?g695+RN-P8wkRRT(VA;o5dVstfZZ?@|Te=W>k%1Fef6p zQ`Ow1nJ!G)+m^{5+>Pq-5lcV6Gk24zs_Xf~G-9c=mOCq~;->q~5P>7i+M^@11{3bL z0txN2xa`C%nQI6}&*cOyoN*}As}g4(_#njHvB0=LnDuPv#S6>%KyzcVrl9*fBZGrZ z((0aW_c)OxP@0fZu2DSxS^9y7FWxg$sylC<_%kM(Mclsi7{oT09w5@oYTz>}0yj^P zU>B=BD9swsJ54L0@VUCwHVx#jx=zVnS;?I8P-<4xDo3-{tRS}G$#9rce=T~dgtBZ1 zUR&GnyvzS)pRWIffo+rF&&xH+8A0JB_w-az;{ z&wS<`F6h50Wi>H9UZ;L)ro#y_2b~C??H7&F8MV5;8Xgp>95-|sP_X?r5mqasW?hl} z?!T0HGoD#*Ba5_!cZm6IHi`Lc)IfviJ$FuX`~rfxnT5vhX_q*_Om+Ga=O@rE87LA3WZZb;w~UuKcZ!1-V3P=5t0h(K#Z^_>80%BwCuXi%~kg_lF@G22FY29)I>-olbci(2=1e zf*M&%m`0o<2=9K8A~WJUc0-kD1f5zGWF!@PTYGcpT?^J!SCn zae2T6gP3ipQ^>Y$oWX5$@@*hH^PfJt#CNQ$2w<|JX?v$luEe~>`F~5_{=3@60DHmZ zD`Rf4o?Qpx%PM8KhxaUR?Jv+jGAHr(7#5iPuP+Tq)xUlCF8)|Y1|TO$vJ?%&(&Mk% zuSic(SC)JJH!_%TF0RC^6BCPQ3%NrRl&xM)Kd4@q61+*cP2I-4WaedFso+e!J|3YJ zrp30yIoTOC8i8OQyvUsCaSQIFuNeNGQGaXlB9g9xW@K z{ixUzaNwR+g^0kqz|c`9ZA=ej$Xn4i=-aBIZr@EyI#YG<#X`1Wj=S@%8^3?yZ*Ob= z*|H|K!}Gk+c3K!Q`qlX^4e-}5{24eYB)3>@)W5b`UcU3|IQ>ZeP~7ViIAU&K{j;?_ za|a{Hi*?<&OAyJSO3irl_Z~Lg+uG~p6~ig-jd8>6w@eE}Rj=FCuO@lvh9L997L>sY zw-=Pzv;9c?`;3LW&U>YCW>CbxpY-1In3&cg zW95Zg5pWxPyoZ7^VrC!5y$eRRXz>xXzwXVALH4z_>pOiZ|LlLcwagT<9wwD9BSV>K zO!EG{@%JsaTf}c(;9A)opZX#hY!$1ktMC23AE$$7ZGt%RGCdMu*kaE8-j%go^BcAg z?%rX0?^VW|Nh6zL6z{sT@i^`*>CgQO+$v>fV)TALoT~2sS^de~kDG5>;Q4H0SxIG^ zq~gzn-Oa8?f&NS?#x|A=S*mbb>PqP+f^dV_4+zdkf(Sb@ zr76H~JI2FgfA0;9BDy)zAjg3t;M~uMJFQ6JSy=Fh==ZLLJjpq0G)LuNtogV9c<5zI|_l|li=Lf$= zC-5^NyLo;6|I?NxETNzD6rVw?J8PeCyfNW%{TI=J|nL5)+x)3O)kGZ<_hfjzaGA<)qpq3YP9* zn4a{XVznmQ;#sohN!BFXS=YRNQf(;`%xs{$!MqDtcO_Z4)&~MWZ(mJfp$6yIRK@w$ z*#b;1vzh{a7I_vZ@KZ)kS{$p}=~ZdEfTDF0=~vd)(+(Nx&xK_BzA&2?mQnR2BxmHN zpppffb2Fsm92|;t_T0gUs=Aa2v;J=y+jV_Kr4JL~8fzcznF$*5r%W!>h?sS;QdxAs z1TfPrjw5?UIbmETu}WP+@)AzK?3 zhT)CXw*A=+Ue?*tVEHe$y}bmIL!!-Y`J=+82j3_I4%5S}4tV0empD(mvTqenPcCozHHpaBHtpH^|&zj{YC?!(f&fAFK|Dir%U>Be79iW z3mdJGar}4WWBV-DW^*-(M|Nwr!P)+&A>`0O`K`}66Lx*DgLLO-3RB;B5}{Oy z4jW_Q5F{njSo%n}k!weF3D@YCaVrIxcb61C^l1YiXa{M0g*WAy+)mkY?n++ak%h?q zj*pAl#zfC7l*LFxzv8T>Gp_u(OHJG-PNhuLPZsNoJa1R(RL{M<7=Jym&N}PG_!q|k z?~TRNxC_q;XVtf04x3T>L|3FKYA_2m&>-fyGMN2X(79qc*1L7y-CY7_t1bD-6ycg#ATc|2oF);;lhOdHW%1Uy)5tIidik8kvvu5R#*b5HSI z%D7w@UxoP`{ZK&7i!`I#E?Zn)PZm_npU)Yj1w9*Y+=k3(H=~mk9vN(>o7P0|5h{A2 zYT$LJf=SWZIRg1D;u33odoZruDx4Er$4sHAX0*a-Q9x67^J=J(zy5R%16BHQ0SULR&&LAWGjh4Ql5JJIE`^*bf!XZv@*%-0j%)^i?yMtMpF12hrLZ z5W6}kTfz~$Hcbm91@pI9ve+)bXT(Ymwh~mUr7xRjcgv?cRcHK} zWQqTrPT{4*woyA=AI$O?&qWP*&S)4rX{k3PxC(3iDxLNz;nvKSD{eQy9|mAKJBI4&I@rRb5lON4a27ktkXYhwx@zxz0lKXIddG2I+MsvBoHu^b4E;QaoNhgodA!Y5%i6Pilyl`bXuRJl=~cL zk(-Z(oE{CRS|2wSjAhC+fS;zw^=63#sQVtcvwhoc%xX5Rg^Q$RUh(|BA0CD*uk<@_~$G0i&9!zcKj zQrhaKg&(uT)JJYqxjVEug>$j}3`;8gWFmq#YiM^HhJUYR)t2Z2vVPE=ZFKcM{c)Q7 zoPlk&w~K3G0(oKSQ!OH){y56&E(wn3iR%x-jTj%$)-EXG}Eq8$BZrgp)%KPXwm!*}vhBu|W#RYz0C zL%nygO_tMgZHm@%@)AKm%(*6p9sy?N&XUYEg2K|7VUJs;dqRdLApe39mJW~~NQT?d^8+R%z83k@0|$=GZ)gLKo337Jb~`kh7lTv2kt=lA~?wO6}gzy$MZ$+pVwX zDzTY0-zdgiPhRz22H$RA83{U?TSxr)%g4^^@8iakcaM#)ZP}Bef4`O01mqJy&56#n zA)-=S&#sL;fH!Vz&+zb~@$eqrs!;HY9+UnDykLY68stM?F~|Tr7&iT|k^f-TAa{g~ zZ7hyr)q}6KG!kY1`C>KI)fBE&6_18+!!;%rd9^eOulq{O1%R%vYM#A%TGH+9VdWsv#??qPVn%7{*wb;d-1HoCtPzY; z2ZRGi(huD~|8B+2SR9Q15CeE(x>ly944ETPCBV*HISIU>#)P=+4$=-Z-8C}j#JZahnzC|UzR{D z*U9v>NL=^)O%7&+{}}U^>}amG5i-@0u)+*%UFTNPhq`6Fj00e>+vT>XkuX)_CQQ{j(Fmadb>I$hB-nGdTt=$I`_8yh8L^68kFJNkdH{vHzh$-_oLal9@2L6}&H zD_b8X!A~8%S5x-J9>E0FD4tjN2qw$-Mz*&46ghj&iuqqniR2eWi&S6YG}#0{C1Om@ zm}sIONb~gR`NDJZ4V&@Qm6$C%dC{4vSkrtv(wsSpscMdtlnP!FUYQC6$ z%RD(#gV_N~lr)j1>u%9%pQeK+d%wyG`eOfr6wDqrBHrsmRs+w?mcs%whMWN{mlS`Vx z#(tE#`3lU@p#-J&?PRTjScX1j=-B@vWEm|pLdFU6zVc+qzzBNFH+V4^GDr&M;8LGU z5Lj;An>|y)?~YC5Ke`M^i1L+93{5H6CQO#8&%ImTL_P-T5OaybGyfByNyzSY9m@3L{DtMNdw99_;nJI?tR zsNv2isw|71*d697=F66sJS9?RJ3{f`hWM?BMbylML~iGO=A zv<^q9bQU?`2yd$2Y z-m_-#70-`<|Ae%#1&udezck;%!5wV z(UjU~fwYpKdo9I?`TX=*qN=c@&3OU8=Wao z4pED#XS@N2Ql<#(`wq+O9$rf2w}(;34}N_-fzVR_2cPAa5$VYqC+lK}Vt4A|kME(R z#q_$^i@rVY4d1W&>~*GymGZ6(`B;gcfgT^9hR|?pBo7($GFX?4=UL~E%rs5392)k9 zVD7P#SWhvd%=0`wJ1e#dE=r8%4Vn8>;z+kd*RY0y3XdB6i0S)OndUFYuN0ZR`iZFQ zR%cP@NFF(`=tWnSKh5T<0yCJPiGWcykZvWx9ZJCNYqr`bKqV4m#>c(sISH;gQ)=`Zk3jif-8@)_QOh-4#9!5`U^4<}N?FZbavME= z`S!}PQ2>WEdj-5bMkY@Bj;3WlTcAoj3IIwc2b`hpQzGP%m57#r16ILYZ1wZiag0TQ z&j=1j@5K~q6#NbrVfVYep&w3bMSim=EO=JyDyu=0)5J8GrDVlT9n`^ahlx%sM`-+TDx3C=g(w9)m?GNzH>`HgAwYG*$;u$(vpW)>-G&B?z z3fLZdK_TT~y3s{J4G_JrgnO}kTe=bzyo z{Xc+cd*OEu{^kO5+%LT8MCYBQj|rY=>#hD_^$**;vzjcAEAl|AoWh2;7+AL6f;9(Be+JCCtH<*{^4N$*9KwWmvoRbAh|$r5!ov z*r)$weF3G5K92Rr+xriLw9P3UTs1U)5A+*O05gRa7j}mJi+k!{f2MU)8ZOuF|e}M96HndVMH^H`h%`_2Bt~{ z{=eNSBE0BJP0AF0E}3Ke-DW!d(cR!nsrll>O1J^T?`L=;Fc!_zCRG3?Uz7$mW_=|zow&QUPM2pE?#~@!yH~nWl z4fyRfR=N@XUFjy6gHb?KbZ%L6T!K_-R;El-sEu3s&s8AEQO1y-U{#>G79!hYo`rSJP@A(NO{$Lm#)Ia`)RaA58>bKpL;AEXdDz|rjk% z6}@fyu|Q}~PP41U{K__!kM(AMUnsP<6;)N#d@6PdFTuP&x=~!^$mws`3}Xyvmy28X zexzPB3z{#Bz>HEdAHacUYC4@|MkwSE1WdUhP388!69gzDcveNKf3|)V(KBd>d##?z z1v6DLJ6e$z;vJsD@8Mh=W=n>8ErH{M4iR4&HlD#8EMR zoYH-&px0=!SkQ!cevvSd}3BjRmfbLYHpCOK|-k7wx1eL9^2JUv!^CsU8%TU9y1+EV@2cI#H}gHg3gfacjt^X*h? zbT}Qsek9r3cs6X;{fWB#$1YE77HkPKbdr3YJB_weL$x~BYFCh_>03JT`(`@Vda1UT zCE^VFdV4PIfu+cB1w(>;*Ri5P61e&iZK7SGg=d-m?g{g7BdaI>ihN1+v_Cpf5Mgqx zv@^40JH2ay_+h7KU;aajX?10a$zCV#SSvYx{Vkf3JcZpWe=I|_exsN)k)_kovpLL> z%8}W7?UGPP+`YB>E0bI87ews!)U@E+XmzVJ7J@4a{^H|*xR0ctMC_;1^}Ki@vB&D_ zEglpLU$vUpp8IvKF^|V2DMvXuiQ~e3`e< zYc&#Ox69L~2>Di*?iMdGaBRR-?YkxBtFH_*#hxHJR@9`4ZCAG ztvwbCbk!!o3PqcV^a_fEC-7du6$L6Kdxeh-kvos+ z>U78`4W_V7TVeuJ@#EM|o3U*ntslh^(}|3RELw4N$+<%AB`3ilODES6i(aP zt1*!53JI=hJsnTC#&(}h$goe^jVwD7Vaaf<94nWtZ6gxLSb z*&Q4`#@GRND7$v0$$(uAS)to_-qV`GXdz+|UZbDLJde>x98}Vdb&1YT%8YEk%CT(@ zduAS1U(jw68AOZy{F05ZoH2YSVrgsYHT(;NnK*RTeSCYv>zk#ga^6I1i>f>)f+^Ab z!eNqoLd|54fd5p%I}SQ$JR)~Fl0r-vP#zwO+OLJrpQ9^NCWhSj%_L8LCI{w!Z2IT! zxF_87ta=xC7dL|?<)%|`2L=XMSqF6TyM095MaE3Hu}eKDcXQirsExD*1%tA>MZA@_ ziW6&)hIJbl`Sy0T>UuVwe(r1ms8z~J@2yJ0P$44LSrQ%hu(jy4)5*CJycCmuz5S7V zv=)^?Ym25n-v5fcqD%csdGYk0Am|16t&+KADr0qt(pQMa#Mxw(y1TbzsYjU~lO$>AEED<4YzrL(;rN5J%hYn%6d9*txQ882rt9cf#6 zWzjPEVZ4@y^5NZ!m{zqK9z!Lg?my9*g_3+D43=cL0p#+UJ-YF7{_Fpm2fJ?UNf8pI zaW6)u2e8t|_wSa~mTelp*l;|~5@d9B7r!=BZsX9Kn&~_uiuiEly<#CoK0dyh^6022 zxQr^$!8S4+dLa6k>YtX9XdzMYy#vubml^Ai{MM6>=( zZq^|dt*vQWJ3ynVDDR?jWKLf8q+rw4;y#TJZ*mgzTC;U%+Lu;)HJOi(T>k_97;@`R zjn7~ekY$#fNGqE=F|;&KUv+6JC9(IO{9?GY{|@JM`SZ!oXsfD&PYq@EyAMUkRBaH) z&g% zP*YvSAJ5vZQ>^EKS)Z+C^^V7t{W0-aMMI48mn&IWkbZnBzsusZ6qzE9(Y+@3H#)E2OobmF*jBT%v)q!7xPp!X0{n|l_zwfq_sct(GZeZc$UtjSfU_Af7 z;F2pZ+ixasVCV_Ga&ps=`bQ1bqdDgXtn`z~pd$=dwvriqvGvgIBVkOYQHPejiLb#z zNe6t>pF~-BhE97-;XFGtSzbyj6M4Zkiw&(0O%a#F&+cgG$rV`3X|ZIgquQG^YBBxK zmABfniX5*tGB8?XOk4dWKbA=#QxqBmx_Q5u?Yg*G->Dl)`*AFD08!8qt38elUq+8P zG+Nl$YT2f z%-nCcD(TM)FYTHLi5g~PYy}odX>mCFc*a{qw|`9<>!C|O4to!=6^{m@a<|`hoXOOF*{n^lL>N_c*ir~x6^2^ms=>{KG%uVw5#Q4Z#h&-i zY3t%q@vO4WH+rKUW~|rnOz)V?Cms<_Iu63P*5l9n7sKQemY5i)l&d|m?q!KTKVECb z%pOeQ6**d&x+p51kDLLe*V#20^iyWomkbb|$rfhn>lL!fC0%~1oMU6JDcSLOKl2^hB# z(n6GH8AabD5-Tl4#^gq`m@<*Cfu~nFu(5`?o`v|yiH!nRapJ%#1kk0FvCK}hQJ+5j zY~`2B?gEXI3~pT?%qr6f1oV{;1Z@ditrVnAqUm61BvgL+^>eY65VJJ5AtkETIofGT zsw!F;hB^yuf47PEe~ew7I^ z=RFKLLzV|wuX$f^4e3^`Hk=+&8M3Y`4v-dTWJ-z!&^?Zv`I`&y8YViL33YXeUMqd1 zHzZ%mHXTJ-B%{W);&&!j2cIfZd@<$eW~0E4^m1D(_04b+TzR>^yw&KlF6!wDa~C~{ z%^I>pH$xRXk6s@8|9yLs0B?K9hjvRS@ZW3Du9wf}$BHyP>a%?S7J*$-(a2=)vY+oC zKhH%U+7}juDnYYUmyfqX=b>O0(8a(PbM`Wr^g)SCv?~3rt=4TUaH%$z-Ud>3^OK~c zo+D*E?lXfCZ4=J(&A_v=iF0e><;#eg zW7!ZX?p^lB#A(2luRE)P9Hr3yv|OGK;548c$oucUoqirOcaLdoEp9md1!As``w;Bk z^5AVvNpC9m3-9+jj=WYi_6nH6uLAP-)a_&7_^zhV>jRzIk+y+_hmG4~0e?7zjf_j7 zzjMdG~?+*8e(AcNj*G<2qEoQ@|=i`oY%0K#e-Q`Dlbu z+G!*vI5G3$d9Hv@txmnk(WLF#Thw}9Rb8+SMQYSlw#%O&@xJtI;e<~hsz$sw@35N?7| z0G?(SBD9Z60vv#v4Bqp-UWlP?i^J|nT7#4`RT>1Sc<9@>nkU;u+ZUo!w;I3p8=M8o z8Hx=A@yx93nf5%Ek%cF#P8>_GS?uTv`mCPlcBTs0ZYE$_?3~37m=s8ITC8hlP0!6C zRHI4pu^6;l&LSgGeUb0`(&PO#(<2ncxPIjz=pe8#PYEERus%`x@b2!mk2#l z@|+Ot$U3!a)50LgH#{|+v?DXR-j65lFf*Ep>-ke&%N+p@2|*8rk=9&u%>OnuH-ZQjRq~qxvAzEfxaOAxZ z%BVYM^V2-v<$Qpf*2-H!1^8>|?YB~Ds=#LqL^*GuCAUiu_*bnje+_lwFGJaSx%bsl zcox}_meXaQfs9RV5k-pIuao~Hd6CVf_Niyj#asMY>Y+t{i+~;;)mhio(Y=4nrtJiN zLo6|sVXFdBnyV&DGZ*SYD{p++(@wv2Ax70vb}6cU`V5;T08=~@aVF-a^mAHj@C|*&oF0*EeaQPP#dt@a&8L_ln@aHI z;iqpiil^MXALZ~3ymu)ZK_lFS%N4XZBPhdbm+EEz7DQ%- z{FWc3ls5Io6kVtFJk7MlJRhUvVWX^^0e!}>hKU%Fu-Z$uqtqi;JDaEGgS~b4Arg7)lB}SpK1dGc?F2ZJnpa zaGZ-nvDLUI zlBa5%jj^zVi17Z~-g?(z)1y!$b{oF3pH50W**sb&b0yEEuWkMJ{Q5>-=X7|*bgohT zvGwR-A$y9H+xXb&3!D_@SV|}`DQ&{; zlaXI2qreVsNc#t_J)GVLBb$Xv>^ghlNP(eeO`zJa2=2bm0DYJowsVVL4w$3-e&Ahb zpkCLgU4Mu`;6Vx>f+xQe>mSd-)aUP{1RZMh7YyAG&c{HFJ+!hxFt(}IAw)$NkNOayd6epWqh!hFs0+{u5-*^&f7ai*o)>IKDB6{XZnd{+%+b8(pAeeK`D1l@CFzUcsVFkbpItxqzViZ-T=73itA**ORb`ag%= zyYPIl7}x6WmM}ib*kY5C`Mvb6c2ujs`kyX7Ml-fGTK_C&4jb#X<`zj(sawM?hxuz8 z@J-WI+1vRmzSF_GkO4B4*0n))qp2GFRWJIUU!S{Aw*k4aU8~}b;l|oRME;~^#(%Q- zIeE0{dbxg`$0-iN=X~9v?I&G1}=8fufHs<8ORm z=Ld8qkZ7HuGF{!&r-M!GP>bJ91Zw|c1+e)}&$L}$Cyclk3V5Qqt^5AkgXwSaSPX#d z6g4Mq=oqjTt|`K3?RhRDVng5~)+P7x4)SHboY5z-b0Q!qk0745>6TE{nNzt_| zQS*ju8Dl)Zq6#CMHmeEPC2_pu&~zb7Fm9!#noOan?Z3>fVGW*btilen%ezlW(P@cA zt7)sU7n|8~;J^8V2)n@^j)s%s3l$-oYb&m`soGE1pLGUk*l>N37ExuK*R7r|baPey z!wSr=%8Yw%1%D3loUbdkf|Xupk_T$zJIcx;-nz=kMWF|Ty*}n0C%u$Ghk&4vuv@O( z6XC7#OM3n&Ya2-NK~KIzy#S==U^D0wem9N-^VIr?p~rHTnyX!~{KQ|It`8E$ye-!m zk3yt#0&LdIRdw5UUFmuKRQ1zh`ShMXUxDPO7t*dzzC}KMy2|G3QTLUfU`s z`p*8lKtt&tzClT=ME~N)h~uMU`j}HRA$`pDd**||Rk8jfijVS5$W}dk93skNU53je zv)x#}c_<#ZCk}SJtQST;`k)+!;0!+Mh$&n<%QgZ<$#@jbowsH7=}T9_t{k;ATdQX2 z>_&;%vt71=?Q?uG#vD?Qs3V_iHt1cI<#_CLFuJa%M^UuaHua5ojQvA$D9(?oJlZNO z>xC$&D2k|Ng9P7|{9 zqtfN{F64BrQ%R{umIr-gxh`nv<#o!dWA?YBJ>#I2SoL262f1(6M3hVXR^@9F)C>qq z=PJxmD~s9i3A-Z##rS`$d|@GhUQk2Pn=-Xtrc8(HfI@Ru#gUP&6_GgG#XQ40bLD(y zfaTz`)6F>}81#I4puwU^*txR7P9ma&m3d2>g=<$CB1&?BF9i2ZH_I6FACrpGNND*& zu=_!{a2w1%yPzC0eAIgARgZI;I1pQB+a=%UQHoxSoXt(wI{)y($J9FFI;HK#*Mz8N zZ4WwSCE3}ZgLEoa22!s!mX8eNF4@i(-*QDRX`CmJt)|+69Mk&nAB37g)K>HqWQYH4 zq@nS?m10f?ykcrlKO;w{Kpy6L@TuEg_Mq2%C|Ub(z$HTBk^4(~l}B|@uPmNOoeh>m z#AQzhJj1N1!xT(goa-wcZ@E|<;q8)J?1pR|l>pK975F@ja^IAIr%y{#)xcLbRzHY` z?!@~&cT7LN*ddkk=i+Eqac;Rzyt-kcD5kJ9hx`5B@EI*4udvW?Q*c4KQr^#`GcUtF zA-!UUnKe0-c2h7#WrZ_NKuntTS-;LH@1oncNIQ0qRC zR|q2jJY1guzypo~L^ld1vi>86Sc|R~b}R@m(y_Y5QiD+`z`#6qGm&oEhTu!8L27*5R$Nyg&N1<}?lO`fX9H=b9mBt~nf{E? zWz%5@MSN*-(M$#dnO>z63dK&B@2PiHoUZcR`5o%Oy>`E`%{fkXlT8wwwbEF&J^P9( zvhjSv&U;(S#;W%>Yt(Eq4O{k6FR)epRvp6p7e^op>@BNl$l&fZESTY#gKmYKm`&vm zl)u+4&rX$NbVg{Op>9Cjk`@hfZO^9*l1gV-67AUC$GUojmjQ0O5X;_C ztir^n4kIPHwqjD%zPQD{mH9sa8fDP)cb7>Pq!ZhtO^5FdZdeu<78c^@<-xYHKYIM) z(ig-@GA+ifmel`}nHgs1lro>hL2M#*;7ZRv&w0`riIiTV-@2#Q|3vh1^YJB9D8*(Q z7ANWY^gMd3meh-kqTXrjw=C-lf#y7GIQB43mK8%xVFl#w_q|eIkP3AM*XNKdb9K!cqOR_~^z5$E821lDHlBK3{hNcl7_y7F*~I zxr`(WIk}NYb`>%1Kb`!DW=!PxfdH*6M%=9fE_ zagkK<|B-gyVNESv+qWE1q^XD~RZ&Db(mN_5(wp?&YY4rEpa=*ED82XIBE5vri*zXo zQX?R}NhhJ?+X&}5=PB>|J=gW-FC}4T&+K7l&06>Un*bEfP#vV`B}ItfGpxsYZrcdy z>A3zNpgr@l0osc3)bwYm%isox<FB;|bwu#^PHg$QrRfg3Vb#Jh&XRDrR`)4!dd%KY}RwxdJ ztk+7a?Ofn836Uq4B=MTD2#F`O%;*S{3*V1{J2&g9j?=I?52-Djlvh-&%%UE@)5Q)= zy@xOF=;razFL~LbWA0?1wHPRL;WX`^>swbw0Ls7(d6_(sc`hAy=O%^~J!pRtpdf_O z(0sDCl30#h*~9Hcp`nl4y|+DGP4PmJkEh=7Pfw2Mq3633?QxSW1thCz&#p+`!NkZ% zYzhj__XyUH#!f2W#dYA?wB~7f+5TGRnO`yb-|e{VSNUjTBSsbHP;&7`DAhvUP59+? zlzeP@1+qd61nRWHXqy2CZ0*XSBSVh`Xa-(t#TR=8aF|T(g7;&q>V9>HSqGjEgFatx zf-g>j+fgbfqg1ack}YLe^gUZS))QLtPfD>0E-5ZQdr5kF6wvjmAAEF<#$*yguM)+e zF+VDd2d&ynw0E`HizjD?{<3?}{ct$~O;V0qBa8XQU~-nx8qDX$0U;{y_i;KCQnKql#)o@-J`$}?)!_=LIShHb$&q;9I>QC>4}@sc6vdXd(d5QwOO0U*c^8lC7ATU0R3zE?U+_cnnjOq zn6P^U1zsoZk!?jw4z+7`cB+n2lH0~U?G5m;h=)pr=hTVC(J`R?(_!wu=o%Az*#%T! z4HdKytjKYMdZ}8GlCZit#IygfzVSBWoxq>thbx3<5;ZvJT}EFwF}O$n($NtJi5*dk zn|cbmBo%k%E*I!ZwviiCxNaO@In+PiWeek1AhhIl{E{zCOz~>!q)~S)guPyM{jhYb zS%SHjvk)ll%sqf#=u?(ywYJ{>Pm}Ulg=hGEL)D!RI_sGgRCa2AP0={@2dk2`!}jP{ z40W0O-SBc%aD&$Ld0Dmkd#j?)+~{G#!hX3U1_m1IZ?udAG`fjU0{-5Dnll?gX}6fFxG;DHQzxq_f^ zPFv5kk?Xg(*(~G#JHOf}QH+}hJ}Ao@FI${1eQ}4tT-6j10<>qoJ-<*7uvr|m;1(}x z(Nzt&@+~Y;lBuZWRc`;EqJJ$Q>q^8)BH-P_=;Q5LHx^|?bJo&EiGq|%EaFB+_fxN> zqOaU;cwp~y)&=B6-tny>51M{}ckolufCFSW=Y01JymS4_nbFk8J4bZ$Z*QEy|FoLg z5&!QC4c7A3e%XCZhi$H24`-=Oe&%i}HrQZ5QjfbIY1Uw`kPhU@M3rr($(&YX=`k_v zfUXbPadu<%Mn<-OM4KdW;Fe6qG=F_N3LqD4aA~RB?eF;_g;+N&4MC)F^qVasOeoKs zr(=*_t6xs&^p5MGSN6noOffI06Fz~~9nY`#O=&fxQQ3X3q4LK7(naDfpgVGN>6>)d zRiYo-Fqo?OL;XmQ}3ty*8Lfn+dbKVO3l!Dum?RS_ixT+~OQr`a`BGw&G~6 zgCLSKKoU5mN-r|+=1^R`U!;)drq|}ok+}?ri<}+2(vPdFTdAiH&JNsC7NB>{+wV=O zdu*1Nca+OeYD@rk@!TB5Z+geXX%vxiyRHsQItZf8e*wh@vN^H-Ia%`MLMMcnf zTWGb$`OF9I3yUHTyN*2P`w$C{UJ-#=Ibi*`=ZKV1P`* zyj>qicc3lDtGw1%YXG&1$B;3nRCcn*2r9)jG==G345rB&uH6}`Gy?&|lhOZTUvQUS zVA1o~!NrGAf9A6R%S)mzg(Pm{Gu$rm!-z&?~5QG-qpX=7K9 z_V4==(KE`MXJcFH4mKMEI_{lbo z)~MBe9lfNhMKrSdU1OQhPrff4l*Pj(GP%F$1bt5@L^Zf0K#;8simR!)Gq;vIr0YBm zYS7W6NPaQ}I6@y~C=r?8eMjFaNoym+eb=^OHeq=yx;Nn^;8g~nV8Xy;(1W*Ue`Naf zz%c8C>)Rw`Ka-s3{Ccg{W#ipi<-#iX|d8(94bIX{r-X<%=Fm`4?ey+bxay3 zD94%-_I4$UiKwr#Gg;G?Wqvg!Zs<&RU1eh$Q(EqS!qUU(JZ8Eat}ntTWbPABE|o^b zm_GllUUNu0SaX|UA=@7i*9fzCQC!$i6!!Iy{U2|sS=sbg&^&@ zf7jkmg~DTn6QWOy_6@gR2Vg$uKy5uuN4FZ@Fy-v)A|RbdWGm}$ZZ`}~7b*!`ZTue0=I8}%gVm9)DIuC(c7BBMbQryHivN}DhBnK@WN{Up6WVe*yh@HeLK zx8pB?R^S_FBA;JTnLu?^OKS{V`94Pbe=d%ec7(EzH%KN*zLJSnbr57iO#kWxHvR_;K|3bd_ z57Kdz!bLTX8y`mWZf%36jWYgexdPTPK-P|rk9ToLXg4rN^!V(m)fT%$xli=PebpF% zrEkeeSzP>l_63$cinq?nqM|0bsuzxJKbU^+j6a!Fk^M6laIWXmDF@bLD@#j(d0}87 zzwu10l(^qc?H|(nm4<>F9?Q!>9AKic$Om{J`}0)v{=wcT^G0x{cIb}KacgEchS53# z5No7OqzEQvjAs8Z;r!IXn9og|BdWg|QjVdS1 zE6JDkhJ6VQMxET#b`)iW#j0XD;35}&eR zK$=gXeI@^7@6;;}3M(SCZ0Qjc_lVY<+O@CIw&<H_*rUnas<4&2j@P64uq_SY zQf1krYJ6+%vgaP{zC_+wERUnS_gwIH%IIomVuR6iqLff{v7Xz$m2Ml1OIC6GjbX*@ISdbs9FVC$+)Cug*d-ZFJ>FoIHiWzPOc|IPkQbu5p2mTw~@D5-$a_ z;Ae@?NsJ@59a-CZ*;e%HZZcWNc;#85{#&J2~$XL>22^j9Q!O(_2HQ~ za*q`{w%$D~!e3$5-M()Xu*});IXQh2THc)(@Hj$h)9rZLG_2#H=0^{n54XaL?9A%T zX9vT7X)sA_9+*y(d$)<*FH*t09~*=ip~S=bdB_j9&F12FN<~Y(w7J?#9HUPf)v!H| z#BEx4E80R$ZMnh{u|-l1TDuGBk(LC}qiUi+Ua9Ep2{dZf8syxFZ(g}HJ(tPtyYaQ; zo5MHVl{<0sF9&W+QB6@0R;u1_ZOl~-pnIgP{r>j#vPipfCyxWwl_g(5$z4S))Z9it z&JxC5Cyp5lS0%Wk+84{qD>H?t|E|3FYgn*LA%=v@tWT@XF5ViM9wlV*ATGV?d)h}$ zHLD=?BAv2_hYZTatn9MuO4nC&%)_K7Z_u~ zZOxGd?MFN>m=Z0VqHCL5U}g$iHaaSU&fg*iE9yj{dqFYk-!p#Wq&Rv-w-=BS$SG@rqUJTo35v$_6#jUUh{1>I9iW@6#G8 zx|aFTYJ;Zkte`BrlhO``Ac{5{hx&SpB|JD^diC1;)jP*O`fU2+uaFe6@a!ro$8OKV zG&tq>6Wdt|tdS(-pf9dPn*Yn}$*zg}L!&-J8;Utn`y4I?UO!OK19tZcxu#{T(6a=b zYQ4qbU66pnGxR%oOI2$Osl>$4Z>aR-85?SDS_B z)>OekOKFfYk0*>hHGB)6Q`qTrzEv!Ku=)g7XJs3sV2pm=OXdjO6SQN>C`$rCgBa}o zh>r44x^s8>LFul74^m?AJJ@@pxTmpXtTo{}L*3!n%6O>jy^y)nYLMv7>!n*ATF1q7 znwj(FulGXe(}r{FEa0x*#iqFrx7(!z%rbuQyVN1(M{ilK9^G4Js~ujYk+Jo5u~o{& z?=<1Y59^@JGQ$N~m{P&z%A)H`BC3jYcct;q!^mgQ>idocmMZPCm8uQ9Gwj}&Wde3+ zE_1a@<^zxCot(}C{m|VVzTGTJR{^q!&P=zulV5j$Sxf4bl#;Nw=A6>e=fmB+O#9Wu zEU_<9woe>h=4qlUKDG%vELS~tcRi|kom_4(dKAcbwxnLruP5NT#LU?HL&$+8mF>!8 zh3#ic`P*{?eGlINWt!wF_qp#i%+bQ4vtl?g9<5Tpbb7G$%Pco*YB<_um0#s#XJR>7 zPlkfaoK=s%x58?mi@NlatZvsKJo0I`wA^NXlr^v5?Tkdo@ny`N**&duomykeO7mZH z%@Ei5T$5&fPi8tpfxUV(-Aq}!h$@~<|v0WAi1cDE`MZJH;q1G{ieiLOp(@`)#c)g zFhovxbhG=mxye$};8212^)}UVU}HSkH^=JNT^s4XAlJbqriqZh8IC?_=;n(dJ*Q;s zT>dXITvzOFN|zC?0EJ5TuWUeMacgsI>^OQ6R?Co|bo!L4Z{X!<^2GB;)fVDI^Tc?j zH;S&%#RyrlUQLYpsHC~)>*)bO>Bg#@2e3Y(bpK`W_1`km{`b!f1)cD?$9lG#GJvGI zF6cV0#ABc7NF)K;=&O)L+4H&QndC8r0qY5Npk&_vIessIOM1l2Q1Nt91G2`^ui#4j z598lWaKT>E*DDZi!q%&NuO({!awe#;VALtS?+k6u3YVHyb*n| zLU}LDqwVi`uKg_K_vjtXyhA0;NDRu7_G z>2wumkBdk!l4uuq*ZJ#damwLxvwyy3(cZ5F-k6fhucrRk>SUdRW zik~KUeSD25B)#jm{NUW#7?3DviA~ge@ikeFeol`O@K63x;Hs~*=`Bzy%}8JH1AD1# zq~2XJu0YQPqyZ@(bwf^Y2{EN{jzV&oCNBO>M>rkzmj*%RC37mx2d(kfrhxfD5tJs) z68==EV|ik4&n1TLte3t$2V9+jkh|W<{Nv^KNq0di*LUFh+RW>RCqHer*KcXYZf$5P zRvQlthOnvhC1T3DPtXO@(QPJou8c+_BzvoHBh8l@bWwnlW4soyi+)GsFo*Y49uZa~ zMkbguPcn?JYuY)s39VOB%M4fNSIE-lD+~SFc_$mzO@GMhXeR4sJJ2svw4JQ?X_jTG zxCdg^-kKt2nlhUAtR$8Lx>;3N+G1Siy*#|H&Z4_Os6N9os{8X)Q472l^Yvjb4zAoS z%bEKSt{)l22#@p|Dpi)Q9C2YA{56W|g`0mDh82yb#cj$K&-w+#fFmU46UhiRdLTFn zfrY(4=ig&^2z^bu&2D}#ahnY(od&|Vd%d#-NnItNCHc&~+cVe7{pFB%NBbPX)RW!p z+iV_I?T=(O$8m>0y(IP$wl{j*ABmlo(Jwuee?9cCsyv;C?{l0cx3Vay2-gQpqJuBlO;50KXAh81Y8}Wt&zu$=r5jrkv!X&HkGYjA!%W&$C2So z_@gEz6uY5Ae!JmxG44Hn0+qXSE-GsEa{PSV6`@@~U@5Q8Ctx+?!DFv1SQMzNYV!Gw zb++m)w$4*;9a_!i%`Axs;1$>qFdMWfQ6KRYmO5Z`{?(LUHIk; z+!(Rvj(R5dUS1}=I|`Q>dDKh=-tKgzD_PzQ4?(LUuKz}lv$hS zK?YXlAiCG7*wppT>&DA2zx{gW`?uuZ^eCWitkvAj3etZ8lwzyOxy?gpHg<=16@v%3 zzl=GT2V5EyJXSKo~VG{pmg!CtMYEa$#ClA>U(3nh>L zDbFDzLtFVcd&)Q zq{lOwI`qwd<^ujr{$?ZvtDSXTWiKJsIBQEH-!Wg{A(v3VQgLw?tuibOWoE3nTF85B z6Qiml{IgeP-uC3hxx)He0U#uwR-w&NxM%OsZt9G>D1Df?PINJ(Obpk&(3okf@V82~IY? zz1)V$T-w9?7Iy7R{ga`_=YXgf)1>_NZfbVGu3lKJqN{Rcwt4PnpBO@drvcTcONM@A z`jesvy(^b47O54mF)%O`tR%$8FE1}sP*PG-P~0LVZHsw#n}GbnMHIOaMXU{XoX#n0 zsY6rMk~%r<@s;y*j*m?Dhno+%<11NC&4CU>2b;53>?{p$?K|ewD-%`_yQ)&(`JmeO zFbZY(@V$Qn$$hgVcNZ%^=Jc1Ba|{RpzlM#%-q~qL@y7OmLOwWP4M@NaadlVpvkC4PSVj!m zSW1}-1k^R9B9n(g^{*GnpK`ledC$nVI)q~H(X!E7blJAu?!?amcr7Oj~k92?qZpVi*PN)W1^DJ{t3JUr@nZ< zJr|m2MQcb_Q#kbjK#GJ8wL%Vs_SL+AlYj4u0j>2p`LT^TnLB~1qCW)vN*LVR{Ve_^ zwwIb*@jvcz!fN|Iscq~amk0ghd@nkpUfWy1CP5UA$k>;d#^mD;?f#yjcy?MbQC7tg zSFqoLlkU{kK2B@0HlyDh;ApUg(j2r7#G6SllUyi~hmd@8FnhqgBPh?!!xPebufcXQ z{@sxmp7CYiZ=-t%fRUAY?(4cvP7dD&ETv%2?NN-?XyX-^Ciyll(?7;@^dX32_>i(y7=}0tv@}wJ^9$-w)c>VSooc3r0TA{`I zD&r|zXdnSFb=jScPfvti%*yxfhNC$!v`P(9kXL_&2yBVlwv1T~;vgXJyOJWq^i8n> z(w$Oy$mvWXUo={}i5N?(?Ez=zSae^SeN0k-?Oqleg^YdX6XW0#gB+sdg;4*7B}(e1 zb>?Uh^!ZM9(@-K&!p-A`T5!B!pxezH%w)WC!|~c!^ta6QX}almH%HZj4JuuM@w3BX zq*gE_HdYL!zSUJcBhEl#^)hE#UAK?+54Kp1SVEB-*KigIBqC1MsaUQ*aYAVorvd_B zr9Fc8RhZ_(eZ78R>z^>(H{&rNQZguJkrS2n*Ms;zE`>Hbgj4A=w3R>TBhlBS6#LWMvoCzPR-J zrx$u|=z9grTU;iCy6-k(SkW*C@S$3zHFAq*XhBQRmc#5PQ0HVzDCLyZQ;(b7`N5L$ zS3zNYNIrb$1l#7G)+kvxTXg!UuVdt|bqAD&5DUQJXs zXjS!Kq8a7Dn%<&)V}QONh_V{hld|C#-^3HkQ1>$lt9)^ElX&SzAZ099CE@kc(lS9O zD+{C+H9tQe9X($-FpEg8rLlS%kEaF?4T8n4mdtkVZ7j)1Nrk_sGZj19&Z;CLVHf4P z)O}jlkl7Y4HMymyqL%%{V{bZ(GHJdu45q^=7a7yVSTj1sM=o4jIxK9B=?)`*VoDsQ z=jNx$q_?x!1yMekpbs|J6fXS|Z_UT5U+Hj|#Z~@4z;P2pl4RMZA|zs;qM2 z2V&=YzBw^MYGhBz?=+p3lWK<^_zyab4TC(8hAe6^2})Xqf86O)ZO>&nY!WGoC<)|J zkdX^u$a?Ir@Sra8Nl-kCloFp50IzKm?+(_Xv!35(HtYWi!I9-u?WhyzGmov^iq4bK zuZD4~C=B+xj_t9gz<+`QzD!pB%=ZmW9w_kD>;e-<&-RXEC~jJhhN>Gxzz2vKL{$Kh&eUEAI- zT0;eY!e|@YGGZ*hykF!djsC!|`^DIwp3azMV0YO!{>f>;{Opn30*Ca&+9SjAq5RM} zcwY3h`oY#s16{YA05hrZO!bJok9hd9>Xv0c(vP9NoJ5O0O`qy%uPu4h zrY<_bw=>lT2ATc!AH2F@plhIP)t9Lw^SPUTPCmtKd)2`${Bito){5Q{^3^D};fYt- z8w%k{Yq%pSQix&AtyrV80c2Jz#YB3sre`|EbU?_3e3Wdvb#$Acw{P&6&0Z?7=gVuD z9pj3yEOVkE{!RO~Ca$My7R7s&Mmbz;1BMExQKM6VwHZBK&b^@0w*#sli2l$ZkDd9f zvQYO6<%M{JZ$7||id5^~!aH;f1Jt6fBHnEeU-;(N9il{PbcE{-kfCHo8{^v2)}_Ny z4xP2?LFA&P4wF=2bXnIAmjQg6>7~NgRu*J7*YgRq(Gc|uR1Qx}C5i2}3f&T;2F zF`Xj@A5cB(XlJ1PvT({(^;Xw@Opkx)FX38$Gyb}$^}Gyosc;ZoWLK|(Sh>4|v|6#; zMtMvvTiXHC$HnXVWJ>Rf<+-Y=8n`T$T$KBwL*b`OB(uy=4oE43x#>G|YqB@tmvXc5 zgA&VnaZi#rnD+p{aN@o?)xIBZW+n-lgDR-e3v;k(`8EjQ$t=8VB|gSc*7qsdG};xS zGi4w>h?%HQt-kH9<02UlfxBH(_fphRg>ylq)WNX64)#V(x$N=xL>#;nQp=o$(o<%> z&{`=<)Vy4&GZHtRQ6I7BeHyFP*VfWTWGz)bLk|H1Bn`cz?i0nmmw<*dbJ-R0Ze};* zZ`AK$`&y6Us)8HpuneB}=khgO!zMT3Ya@M$(aN4Z=G9xv?-WpolVl;s_G4EPnCKTM zjD;}H^>|!lo|XM*Yj#YAsd+0Jq*3z1!NY?w`>Rki6gLn!l#Tvm%A(z{S|cY!6|-u` z{DYZ&=T0j9n91rI&~5aZMS@Arr?K5jq;qqDZLsAZ8>}~du?&ra*&pL<3cOd^Dm=Z$ z%B@f4#z#UYFbm-dmt5}r0+U^=ngy>+ zeW#((FQ3n&pSkt3h=p~@x^rzse~RsbP9awC8naI2Y@Dxd<@K8Yex6hp7`v(L+z7B} z_hQhr`l15hzWUePCE@y5!-y*qXwI{0>>z-QxoN73b?OK*Ju zNMAPB)96J@QI{!hpiw_2oa5W5KWGBUqME<3$bZ6+vBc})uzGtft(FsKGEx1(e+BW8 zGJYnrV4nkYZC*Tyz4r^bgevrGz8nYn5>}rsT;w@{d$X%n1_xwVbs!}C&B1FIKKL5J zmxmwuI}`aLqG}|1qp;q%*nMQ9z_%$?>;B5rAvrpobnIE1zMjZ8L!68=pyAW!S%@?Y z{5pikGvL6Yd@5{rk|aIeBM(Y>4f}>W^>DQY(j;OnfZd4B^{y}6ZN}b5q9d!%2`krIDRQaU2TP3V|pKxehT?T z@?N=j`_9mC$CIx*Rv(@&2oe{>Xr1m7e$hWfH2d9Ruq!^Ri@*Jz{hI~sbGHR|a2ojy zOv|yb-G60}By?6DZ{K6^!Q@;x< z1e?Q1_lcaZUyLH0t9u=nBLQ%mPw@Ns-}+;n`*n;GHb%sS>|L|P);Ht-1_$nN#%E6j z4tRLblp3)k?yr?3$%LR8WY&qKL$h)G-Y(bMw5oo9oRWA){#HBBX84oaYvsRaom)lw zo!Lj`g)8{+waU}RRbI=t+XOJV3R+rLbF(k*T=*vDwtYG^|0wYNQy+OMvmRZ~kNqg?4@yg3!Svpc;|V}-zxAs5CoH}qPT;R z<3V(`^Bj=bmCpx+rqEuF4Fy-CzY?|tU%YpA^QG=oJ!Y#HsTGvV@TOnUE|!4}wFc(k z*(WDSWNk{Hu{?jk+Me@}P0>!}bE|v-9Q5dA(qw zZxub&g~cl##wQso!IejIy*ORmo_|B3yP15bQXB0lw9dgfHZ6l!*ke3GYFyd1g%_|z01+?q zW1t`DE2cwUvb7kp2XS0wy+P=Bb%W?&K9HkZ3;do?@VQ)Hvkcr8`8(} zz@$Gr++ib*SURs}2>kVy>3CHnf21B0qkSIpBjfo@OLwyxWwc&Ei46L0S=zyp*i)WN z_3@258kMQcuPAt4nKhq0`4hlAcIZT@8=B<|DnHCm@Ey~giF{NuQOrA<1*}!4ujBhu zk&zQEK!|&1H^KdvqQvIDqU<2Uu&0Q~?O378Yz6UJZVE=dqxGTTzXR=pUYF}aw@srq z)~b3AHyiXsUdO&tNPY?ADP9sfm?o>T{x=Gq zd~_9Ck-^~Ih<4{i1IxL*Y(??Fm`S&VVXE=_5fuj5#O9w0Q={%(!ZkV4Yz0S)um2*+ z0ouHtA4hh^CAfTf_>lwClpz1hn8 z@M@fYitX{iQsV2yKdSw@k#o^**2ETquT?j^Xy6$M_0* z=b32yW&8f!dkflI_seutitq`ph^O;B8!Tt{13U3uk|CuaBvaWz;XBgud49ZiZj>UZ(V?8RcA6n7T|-mqjUU|8E}4nDjQNTQ7b6F?#F9Y2`r;(#^t&%Ai-*79Xheb+WNM9rT~l1v5blX`q|d4XT%>M9QVa1%xb{< z9(W9H)+^ii4UlvhcEcZxtd3HxrJgmRui?q&XI?Kl!|oW7p5-svP>CSezUCM1nJ<#) z78i-B&3SB`0$OCZSK?2ET-pfpse{|E68le}H(EAz!Kh8aAZFI>6kVZiSi72syT16J zR;%uqdYxO4(1&d$H8IkO<9g8Tu`_6kr|=6#(zk4IC++R{cc%TY1_mcl^lz9-P_u0q zeBZWidn=x~r9$erwQAM!RcY})av^BZK~Z0w#45Yx0dr{w><=mlh`^KI4f5G$qN0`G%WEbh5I3Dsa<38_w zwoc{uJe`_vuWOV$WfXLDwuPMesU~+NvhRzrM|s&VOoZg_s|JkyXSiHC0GF!+Hu2YU z&j2jHZ>q0q=c7`}_&xwx<*|?dS~K3y@*Zq)s2nr1Z|y-Vf{fVL?Nlcz036Oqz;XoV z?Cq7K?}+GUMJx-!jG=)C>3uTv`V*M7S0-I zwlKlFV>cMMLqzr6Ow_u`g+D9)-JIU29t*^6e2)?fMQ)Dpe$P+%##krFwmcR}5D%C) za@I|}bW;4S&n|pHM25-$fX2kf$2U7xRq5IM+NosIRMX*$Dwo@ya>1s(axATUEXpST z`!;~Iwrrgm-~FeX!%{Uo9=FUn3l?X;XmK&hwZ4+;c$cPaPqE9~r=(=dL1?iwZzF#8 zOR4G)kym&etaM$?tXq5c=4h^}RzzDRV09mAX%R`>uSR7#Yk7TpVmfgz0VjI0k?4eJ)CUy+P`k-oiLZ*K}jD<3HZu$<1ya2FQK{%E=2 zXr(2Ad22RMh$mO{Hezx5sA-6Krnc~=Qa>Fb4Ef%HIph_neI+k=Xt^9PbIF%~h@Lk) z?+K^Wz~Hc$N5>b2p7GQwgVkx62vBRpNDCl((H=Tp`9#_>1nQ7ecm^OFX13jx(s6r)27Ffrc^GF^+ zdP^~D`Yh<|%3kyq=||U+8p)*#xHs*`P~VuiT|GTNugl;6SWPWp!z8TW>3LIbrMh5f zGs@!d4#P~mc6^JWT3vRQfR3oBFyKZ0dS36Tuchn5A1+rYD;A*ElE_x4ZZ42-fzjm; zxD?kXJlB}!Uy=1E>nqTDF{;TAgjfpQT3Uixrav{$?jxr1i8}GH6Gbr6rRjNZ+ynKs z1p=#=VJ)zi3l|CwpKBy>vd9HzLvCm+YV)^Q-PE0jl7Q%%&mLKIre(T>MiH70MtPSW z5yV0bIfz=kW#CNlKC9iRsI0~{!Z5HBGuba&fV{P-{}Oukjdqyb=JbhEXkoI^O)Wwyf?eydY0)(Bu`@y zzv~yol>EKuYjUeoE=5gy0|uZfZ9!F}^SK)`hF31~5tH;AGLUNISotLP0ApZK&zF~n ziiMS(FnO53P)#VK8bQmssD({QSAqPtuC2A8&9W^`p)F})?#1X2uL}hW0~5FMS(|PZ z*{i+`r+>LqMs+sYeyI-dR4rGh%cx~7*&eCx1D}pJbWW-fFzj zT`GgXN+orr1v4>fLr4 zSNreJemQH^{cMQDB^F=C&inO)AB#BIQ!MvTR9FMNFEZ=-PYB@WnJh#aF{^@WTT4| z_gzgXuWd-TWWliEq!Hgcs#Vg40peKZrT&}Q7@P0A%kEEc;w{~<~a@S#fAdx7+%{R&W{ z9RZn1d$Pgi+JOS}EBhm12{(mL_VS*olwUubnee13BAI{G4GpC{q#b>@em8LuCZu`C z;^u-lt&pWO#ffx1(y=Z(orGjm!1@@3?OV%%t}r}`+IUj5wuZ@-OWFHHQtZ4jsWKZ@ zYX9~9hUhN@JbThhW%_-|*s=l%(*OGIN#aPnWWU=lG!C?0fp-{i)daH9@X+6Iqz?gI zFH{u2l5r`3ga$t*5T)o!53bv2axcq9I}GvEbTX{XA?qDIP~}!kPx#$~UmpZWl7g%q zZHteN_y}ETM&#=s zo39=`&EUG)ks|0lF&?9kRy&N(rwh(2aN3(jlp21|j3ZUHrs`NmlE~v%GFHhoC~uUi z^W|0%J}gd%OK~dPs)o(g@sKlmH<}Ka^;H{A92`wlLN)O_R{OB*8*;9P$8iymIQshH z{Kpf~wu)v`JbwCzT79OdM-nbbO#kvjle(p?&MRvBH*>0v7iaP2ChWEs+gqZhwqxGB zW=z(HEJU6-&b~$Q?QNbg)T=;(%^OiF$G4MqH$$o_b3*7A-L^Y1;On2?*x@oFy7k>l za>t(k-$z`S8-X!M-sm8RA5SV~jo4kPi9ww#YyPvnwvyuY1lhHo!PQF9UXH)y*F>2mA z%zXr)u}n?AM{C~4d2|7HU0Qti6;i7Av*bERJt2M80h9b-7gR~uK2`T^2$mW+%FX#O z1Vl7izxma*-X7VcUWt5Voy)|iB{lh#5$6@*(vhE2N%K@L_V8H9ELun{sY1V=5 zy(c@DA(hJ)L)maKk;)V_EEXOc3Me$qhzU6rbZ-p1kD`xiH+g4DJ&kBiIasNEf?H8u zz9qT^2bbgF)N)i?CB&r_Z82KMsGy5k`rhk!K);pEguj}bi=CqeBh-r?Wrn0U^;Q_m zA6dHYVj$WJLUY3Ox@I&tTqlNDh@`pjC8Xt_wj+mmR(izouyejs?7yzgCi5VnCskl)co6e-2#0*c)_uF7B6VVwVrRZ${vZmJZ4YbJsgy2xMZg9ECC7#OT`->9_I{0 zz$KEopVUqg7YG;`cxVCoLX@Kv5NQZx>(PxB*7&94-?R^(H_j6@6V& zoAx`kVTC6eysrE~VqNa8XkL$lXXBT*iLBNAMCYbBT=znj1a|{x3%-}`xdP`^zCcdJ z&D)d6%J5N69O91Jm%|F)Ax5ayz7s9Yf|^8Dr1QO=Pk%32GB^Rv4ibje(`#fuS}DRt zu&)Z2a_txvy2Dn=Qn+(MK8A78hiQp;8-(avlQG~Lw&piXgHzT;y+M6*>O_&nUun(9 zJb&!lS0asD6~u3T++!Li!+n0r;l!kp$QP_y~zyth2y{OL^XV@%}J~o6}jp6uydNUuzEumdUbH^FJ;;-?1ZqLH?wrI!q z8|z8eFfo=sa}6Cio}%dO2+}d=vPL!?w!mybG!~DrJ{{AlkVKShR#KMlV2+*RqnLF` zwwe@J&tE;FT_NtBWMJ-$fnl&elAvgeeokwM07mQ@RiZY^PU296ioeFHKWF)EzkH6oSxt=CjlB(8e#YSH)t$rzmaSPF zzq^E*K5>~st$QVduITO*SeNe&_g5tHqOV<*=&PjUsitL6gO`W)N@@QZ@$xNah)3U5 zYu6&Z&E#>fj!VE%7K<$#uNBw6!}dqi{M5QsOYd58IE^SeM~07;GlSlWK&R|w6L{ZH zX&euSvWLsz!8&(6muJZ!vwZ&aM-45TNxaMz?E!jRm2AL|BM*q70Z%m*j{BYTC3*hO$D6Ag5v~|N(9R}~PjC}G#o0w|z4xMc-rOfxoMHy*K1Bst$E>nM9T06*KWbx;ULJSUUku9)L3&Pk zS}V8}nb1IvRW;3aMHF?0HhpOkIDkaw+`Fqz%*D;q#?0X2{RO0d9LLn`Cz;^9i6@me zf!IC14l|K{$}>U@6HBT-uqvn%h<@(xOGB?ISD4R4p*++Rj*!1+aqrPj5$9%M59t7k ziOr@V#9Cw#d98lTw3M?)c#$&e3fa zg1Q)u`jjbBxzq+Ya~gk*(;n>c#Elm~_~fKuTDYfeIx$vr?6tvZHN>2i9t}rOl!_nX&Sq$$iknV zX{_t|tB0#WN9C?EdnZ#lcPz%6B2C=Cj@4OpNa7a4C}X@a)KY=-KBAqT2X7&g``q** zY8=?&=SUnG{Z>fCEuxdl^kr7Q^O1%6x=&~FBU09o&JzP~jx&f^+oi^;6}oXx(M88O ziLhQufyGHkX@`OLsx|LIa*|jOK>Qn~4~9;Pne>}3QID-UUGwid79NiDR&Z!h5X1h= zn14@smBb>YLQ%sF3RQ6b0S5x&P~RckDv(>v0T;ddT9@1ql~rfuRsX zu`hqR~!X1$!q1{bn?sZY$slw`Pw99sB3 zoPFu}=!y5hl;R%c+^8*l3FU=OdC@@bZ>U1+Zep@{uYgAqgS<11UWeA&&%`<@opcZ( zpMSo+CIJ4LxcOuMarV*+<3^W;&6(QER&YGWm30UcJ1yuKm+)Z4pHi~c^|%P`|Nfzt z)b_XAj)Q9??60LcbK;QR^#{xIL5`GA%SvoJlK8toqfl}4BGq85r_;0H0adet{iaL( zs+LCJ0iROhEAcy8N_(MmX|wj(57tw{LUD5|EjTr_4o8|tXGQ(x6mD zCpegLNrQ=1AD&s&Jr|wC-7 z2F-@uqH?iezjh{9!Ji5)f6!7}LFGN>wuiUbX6yJSVhM9oY?|U|rpy5gc`@?FU`u|4~W_>!x(} z(yjp;L`E%NY?YCV+Bj;`x9ev^ioj6`!nP7(uY=Tw7&(5rg4DlFs;O+HJ)M4((>~D z7F|-JoBK1r443h=@D6lJ*%O;ay;6>L!II*x-8%{X{uCh*wZhz?59LTpl}UBj+AK=q zCPE)3vkNvCNr|P_7^#erB73W3VJ9&KM;jo{4{Rv}JNVRShk}i%!>tqCD%vf@*@vuf zQe~&*+i-Y)3wfFaf<~wb0jB<&$_x^rf6J z&RB);t^Lr#MY^`?WOuOgJW|qh^nz$@fo8x>zJqiN0$Z|zaXHpKa z;c|Yq7KA^_5tS$Zpw_Ve6Fwd1Yf^QT_XQ&Ibp7FepuCVYVA4O3_g*Ao>TNZS zEAJP%PF5k2C6ev%(xR^EK`|5`df0eGq`t2gGgq5opW)3hzomrim=bv)lptZ@U6{R&*O*OVCl8UNXM5>C`t(gvp zQ1g_uH74e2jWvW2Q%osQL`)G9NnY-I@ALb1e&^F!XPvcvYwzFQXY;A5^X}c$RcDpS z=HV_#&OR`mq6(3E@0RUZX?hwVuNq$@Di?Ym6}wUST*s-(dwss3I`KF(X|C&zdHrJT(lmHo z?h=2oR-*hRWlg54&~CX#IOfKN!G)QCy?Y-+DJYg7HH0frkyL}?rEt?9CbozgWu4_@ zvMGYv%-1{TV0U6s30!2svfe+GUj@3^Yp|7~eXb*cg%AV94ouo$^u!cTeHSD5{@1O1 z(D+li@eeC^pFu4lVcY82l+VnR_6bsspWmG-O}ZHW($DZ4UyAb{Ill~m>7f6q+~08x zC@NaIPs~Aj(dvxFhl*FUa-VhS1*0d$Krh`c?DVTJ`AUB1wrj6ny#(# zXh>G{u?RI~=2#R#<$3s_KwM8nk5phk885 zzl-A!TqE^vPP)5h-RyBsU0HixrCR1oN{KdhdHA?_+w)*9CrPWY3}v(a!26TkcR*Y+ z=4r+38z;g29y)%=^!BX>EEIS^yZNLbDqZD?HgMX`ZfbgrrYW)vUMdCoY2J;uFm`mr zMj88UDZFo9nQ|^7(gMk^2C3T-k|*RIj;=w*_wRIMHGN&ZQ}lZ9ietP?QbZ$Mf^kU_ zH)0DQfP`voRjJ!?GStQN-c5b*Vs?r1CCKHOhodscmGclQEgIKeDalUudb$re-c<^fuJ^Z^*v;{QfxBA3OhsWERY zzkd%^XYkYY^juz~TG8aHfOL z_2d+u%9rRPkggjVRyE=D{-E7QC%v5DX9ncE8#Ct+2oZ z96NO(o%%C|aR=16o4|HE;I{G0fT#N7J^50KCeBVU)F7znC!_JFh)k^niYX$+eevMz zdD6)Mwskgl=! zTuvv5*5$pQ!`Nor85TS)q@owiAJxna`%D9ZGew1`;g-Rp?Hl2-x#e6Fq~geA2pBwe zBGJx!0kcAVH8SV>HZVd=%6`A|rg(%K~v)eEhdZ?LCfP}tc0Nm2~MOK`?+3b$Xc%ZifV`mskhnL?mAM`^$oV`Cp8je8U zDuTBa04L44hAugUVrVC|m-=>qg!&p3_&9&fXVx~Dsp7pg5Rp1rR5keHBXN1`XO`P( z;a#5M?*buRFZVW;+ZZ-3@#|M|xo(Ie#J!3hQ7=Gk&gDnom%8($#AppVK9(#f6l&1Mht#m094rk0L+Z7)#00g3y;uu*Sqt0x73SQ&l4OcgSjT zUGS!Ur5>z5u~x&RYV`wcB|9)qtg>=L>kq@{Q?%l}?@H?9%Qoj8!kp5+_5U#KJS?m= zdri#BUnlN}USPtFTGhxFPqp2ZgVbxih9l1Qi#`pVPXG%*<3KR7hnr&d z1L6fUWyC6TmLVou`_V8tDN7l-(=}U(lhImT?=h?)_WGp9O&AyF$Sx{HZmEJ8YA{Ys z7+fgjCA=#M-BRgmGYo@3o>g)zJ8oIpW=se_^o{Q0{8))cjVvpUal`rABS}omW7UfKIr+_p3ppsOnj#gr@3)s6#8VN zZ1*x0R^6vi&W|jhD=Otar=zqYes47BfGMuZJ$jHDgmc~E7QaqR9{u#bg?t2W<4tID ze}^cgC})L1&dYwAU$vyPEfv5-ARjxBfdw_=_^g1Q&eRi$4Xc(1wPuh>7x=T{Zw=V~ zkLmH;u)?yX??nE2{XMvQ+ujhCk{}xh#H^&wK`X#x?(PHLT&e8*m_`qC|UfSIC_H=)DS<~*!`4Awhf9330e2# zunX2BuIAB+_{SS5sf1+_?7PYv)^vGyw7$byrS5M@r0&nlDh^J_do^5Fnc+^)BIP)g zf1_Tr0LPxzAcO|(@q>$ZzWC~58qbE+4ekpD+?Ip`!1j|OmQhw4H@jp+-4t zAt&)$*6_txi?B5kTut&WP=-W?!LmNt0f@3E?5!W^L3bm)AN0+{q{-78&auay)}H4X z#zeKi+7Pwhbg!__^!SJc>#NO5xm5fKC54LJ-)XGh9}lSV!99A=d};o`*;imiJu{{x z+8V(2Ba52;aj8o-tzYqu~>)@DbwiRfD!#^8pv{b=l|ifFsLX3Jt_ z8p_C&(u&Qvh4Qx*TO#sNt$Uvz-J2;r8~{CLIJ{s9#MykKK$I;EG)z0L3CD&cTE5;I zx;_fxHbuEV*}X#F92`8iYST1~Ww#l4?K{!@za(xs7iEU@5Ivd%aN$!6g2CkS@lBhM zsS^(E<-n=()=-;1ieuq^ggZNxQTd!_Y7}bafxit#V=4UC52b>xvepWnHuS# zt$41_l?BO;>Pr0Tmt!nmD_ovG)OtX!X8k89TG7dMA6>vORLlm<>~$%4{T!F}Z_wL$ z3g0~l7|ac3_OpMfd1b!vE@5oNw?GCS5*Ujkm_PN`%WZ2tK-oe4hW+Q%v70!AIKKn6 zw@mJ=Y;JMHC2s5J*awY#UXTz61dcv&$$?l1DE8b?Za=$x0Br1cHI3vYF7QBee@2fT zWfh_1ya|2u*YZ)L`QJ49!w(XA(yF6gExbv;T_wnU%k$v?qPKFnb9*m0`!oRxkZJte zLeRmqZCwG!EY~+XwYd?}-KqvU=p|#3sL1q|2@V@;33hq;d5QEU;YPJ97UydR(Vrj5 z{t>@!HJI6ra3Jw(YuF=C@{^x?=luS~>a(aDc%(#DV3%EB^}ul97suB&$G}4U`GH3( zKI#3HV9A4V7cIq0c}9Cp18Sj0nBPAIX_+gTj2enG*o`-F-lXPnixA2YbCUu7hX-Ch zrroVoJ6{nMf)?tt=e=htv!YhJF3Uth5AU~i&eFva>AHQiNKllCXq{7w)+MmH=Vgb@ z{vkR6-5L>L>stdXcwb4u!|6j4p_iT6gNM;62Cj`zmZL-~bU-0+1)zAHD!kW9Bs}i! z;fqq#%@zB30ErSFeP{NsUmZQIdJfy)xtVwW#@im4fQsQ&|F{;cUFWu>qm!`g1h9stRiMN3 z5PSOXf!7<#$M`B_jjO8M1D{9|H9=-p-rg?j*Foo-*%=&h)#<=ItMu|MYruViJV|wC zz*aqxfDZ1B2?YY`d^f7&!J*WR`CEeux|sdn)Uf3sy%VLuqjw!myOPK{CT02g#_KgI zXRb{`C${JF0mT;GVB4C$(f6&Is-ghY{+y@pM&*QkEq22m&NbX5YKq9HS^#JE!+oCF z`DJA4OO(G^eO?&7qTdJ<49}7^c20cGhYoUV^L6;PoLx^zi^*vn1xt>`LHZ?si^ z!XPGxmH7L9lSa$@M$)Bue7#E2Rr!g1>&1Zl2b4&^nQp3bf>t}?muFt>7Ns$HCXD&k zU+aQ-0@(%xt?uE5XT%rdTUJt7t7OrGNK5*J^YbsJ><;7VVD_d#q&f9y-G^5@Q;B7a z(PP{;H7|X%fwT`g*m|a!#pdzlXTo3_eZ5lNrr89=kz~@$qrhQ{q=OEithqg3A1XpA1wmpy9g(Ir4lYPq(uZx zQ~QokTA>QA{^}OhDW~02lDS@|erF*_NJLDMSsR>4H0cD~Q;%CBOtJ%W!8I^3x26GC zr|YlT?4-cT+b;Xcf4IzUlowN+B(!B00ILMNeXJ**{jLu-!&%1LZ+l8TZVbw*>i;CzzHpH8zNesePUJpXhY-D#@x7QpEs?w2 zzGHUW{r?m+ooLpv^#5HSqMiDGe~6moOC0Jn>x&c|Pv~DR;1Hj0vgYg}D24wmg$@Zz XDvSDh6buV?4w)KR+{XUx{_?*8p#q}o literal 0 HcmV?d00001 diff --git a/artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png b/artifacts/resourcediagram/05-rapportageperiode-14-sep-tot-11-okt.png new file mode 100644 index 0000000000000000000000000000000000000000..aab8ffe263c4d096bf97a29e0aff0a64f45f6185 GIT binary patch literal 123897 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

+3|;)bk^m3Sp^n^|gH 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�|FYq`7jo7^47VrhXWEZH*4ng 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 Q@b`Iu5(?ra?+pF_7yReLj{pDw literal 0 HcmV?d00001 From 3b435c8ba36eb6a10e91ba8cf1c72a63120d900a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:41:49 +0200 Subject: [PATCH 069/108] test(b1c): browsertests voor de herontworpen verdeeldialoog Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 234 +++++++++++++------- 1 file changed, 149 insertions(+), 85 deletions(-) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 8ff814a0c..9221ed9e5 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -266,8 +266,9 @@ test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ( await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); const strip = page.locator('[data-ops-distribution-strip]').first(); - // De pin is een echte toggle-knop: STABIELE naam, toestand uitsluitend via `aria-pressed` (§6). - const pin = strip.getByRole('button', { name: /Vastzetten|Pin/ }); + // De pin is sinds het herontwerp een TEKSTknop ("vastzetten" ↔ "vast — losmaken", spec §6), geen + // icoon meer. Het testanker blijft `data-ops-distribution-pin`; de toestand blijft `aria-pressed`. + const pin = strip.locator('[data-ops-distribution-pin]'); await expect(pin).toHaveAttribute('aria-pressed', 'false'); await pin.click(); await expect(pin).toHaveAttribute('aria-pressed', 'true'); @@ -287,10 +288,21 @@ test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ( .ui.levelingDistribution!.ceilings[Object.keys(window.__OPS__!.store.getState().ui.levelingDistribution!.ceilings)[0]])) .toBe(1); + // PageUp/PageDown zijn drie werkdagen tegelijk (§5) — het grovere stapje naast de pijltjes. + await handle.press('PageUp'); + await expect(handle).toHaveAttribute('aria-valuenow', '4'); + await handle.press('PageDown'); + await expect(handle).toHaveAttribute('aria-valuenow', '1'); + await handle.press('Home'); await expect(handle).toHaveAttribute('aria-valuenow', '0'); await handle.press('End'); await expect(handle).toHaveAttribute('aria-valuetext', /onbegrensd|unlimited/i); + // Onbegrensd zet de handle aan het einde van de as (spec §5) — meetbaar: hij staat rechts van + // het laatste dagblokje van dezelfde rij. + const handleBox = (await handle.boundingBox())!; + const lastDay = (await strip.locator('[data-ops-distribution-day="work"]').last().boundingBox())!; + expect(handleBox.x).toBeGreaterThan(lastDay.x + lastDay.width); }); test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', async ({ page, ops: _ops }) => { @@ -311,42 +323,49 @@ test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); // Het label meldt wat er met de EINDDATUM gebeurt (+1 werkdag), niet hoe ver de handle stond (3). await expect(strip.locator('[data-ops-distribution-effect]')).toContainText(/eind \+1 dag|end \+1 day/i); - // Toegestaan maar niet benut: gevraagd 3, dichtst haalbare 1. - await expect(strip.locator('[data-ops-distribution-achievable]')).toBeVisible(); + // "Toegestaan maar niet benut" is sinds het herontwerp geen ZIN meer maar een VORM (spec §4): de + // gestippelde lege doos van het nieuwe fase-einde tot de handle-stand. Gevraagd 3, benut 1 ⇒ die + // doos bestaat en heeft een meetbare breedte. + const tail = strip.locator('[data-ops-distribution-tail]'); + await expect(tail).toHaveCount(1); + expect((await tail.boundingBox())!.width).toBeGreaterThan(0); + // De legenda die uitlegt wat de blokjes, de arcering en de meetlat betekenen staat er één keer. + await expect(page.locator('[data-ops-distribution-legend]')).toBeVisible(); }); -// --- B1c-plan3 taak 10 — pointer-slepen op de plafond-handle, en rangorde met de muis ------------- +// --- B1c-plan4 taak 6 — pointer-slepen op de plafond-handle, nu met LIVE meerekenen --------------- -test('plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten', async ({ page, ops: _ops }) => { +test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ page, ops: _ops }) => { await seedTwoSingleDayDocuments(page); await openDistributionFromConflictRow(page); await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); - // De stroken staan in RANGORDE; nr. 2 is degene die moet wijken (zelfde fixture als de vorige - // test). Home = plafond 0 — een deterministisch startpunt voor de sleep, ongeacht de benutte - // uitloop; expliciet herberekend zodat het effectlabel al bij díé stand hoort vóór de sleep begint. + // De rijen staan in plaatsingsvolgorde; nr. 2 is degene die moet wijken. Home = plafond 0, een + // deterministisch startpunt ongeacht de benutte uitloop. const strip = page.locator('[data-ops-distribution-strip]').nth(1); const handle = strip.locator('[data-ops-distribution-handle]'); await handle.focus(); await handle.press('Home'); - await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) + .not.toContain('…'); - // Geen magisch getal: de dagbreedte komt uit de gedeelde tijdas (`occupancyAxis.ts`), zichtbaar via - // hetzelfde `data-ops-distribution-day-width`-attribuut dat de sleeppositie zelf ook gebruikt. + // Geen magisch getal: de dagbreedte komt uit de gedeelde tijdas, zichtbaar via hetzelfde + // attribuut dat de sleeppositie zelf ook gebruikt. const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); expect(dayWidth).toBeGreaterThan(0); + const effectBefore = await strip.locator('[data-ops-distribution-effect]').textContent(); const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); - // Tijdens het slepen: de PLAFOND-waarde beweegt mee, het EFFECT-label niet (spec §3.4 — "nooit per - // sleep-pixel"). - const effectBefore = await strip.locator('[data-ops-distribution-effect]').textContent(); + // Spec §5, het eigenaarsbesluit dat §3.4 van de oude spec bijstelt: tijdens het slepen wordt er + // ECHT gerekend. Zowel de waarde als het uitkomstlabel moeten VÓÓR `mouse.up()` meebewegen. await page.mouse.move(box.x + box.width / 2 + 3 * dayWidth, box.y + box.height / 2, { steps: 6 }); await expect(handle).toHaveAttribute('aria-valuenow', '3'); - expect(await strip.locator('[data-ops-distribution-effect]').textContent()).toBe(effectBefore); + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) + .not.toBe(effectBefore); // De muis verlaat het element en het slepen loopt door — `setPointerCapture` op de handle, geen // document-brede listener nodig. @@ -354,10 +373,6 @@ test('plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten' await expect(handle).toHaveAttribute('aria-valuenow', '5'); await page.mouse.up(); - // Loslaten is een discreet rekenmoment (§3.4) ⇒ de waarde gaat de ui-state in en het overzicht - // rekent automatisch door (dezelfde route als een toetsaanslag of de pin-knop) — het effectlabel - // wordt dus uiteindelijk bijgewerkt, zonder dat de test zelf op "Herbereken" hoeft te klikken. - await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()).not.toBe(effectBefore); const shiftedDocId = await strip.getAttribute('data-ops-doc-id') ?? ''; await expect.poll(() => page.evaluate(docId => { const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; @@ -365,40 +380,6 @@ test('plafond-handle: slepen snapt op hele werkdagen en rekent pas bij loslaten' }, shiftedDocId)).toBe(5); }); -test('rangorde: slepen verandert de volgorde en laat het voorstel vervallen', async ({ page, ops: _ops }) => { - await seedTwoConflictingDocuments(page); - await openDistributionFromConflictRow(page); - - const rows = page.locator('[data-ops-distribution-rank-row]'); - await expect(rows).toHaveCount(2); - const firstDocId = await rows.nth(0).getAttribute('data-ops-doc-id'); - const secondDocId = await rows.nth(1).getAttribute('data-ops-doc-id'); - - // Sleep rij 2 boven rij 1 — POINTER-EVENTS aan de greep (gebruikstest 2026-09-12, gebrek 2: - // HTML5-dnd deed in de iframe-preview en de Tauri-webview niets). Dus echte muisbewegingen met - // `steps`: een pointerdown op de greep, een paar tussenstappen zodat `pointermove` het doel kan - // bepalen (zonder move commit de sleep bewust niets), en loslaten boven de BOVENSTE helft van - // rij 1 — een loslaten op de onderste helft zou de rij weer op zijn oude plek laten vallen. - const grip = rows.nth(1).locator('[data-ops-distribution-rank-grip]'); - const gripBox = (await grip.boundingBox())!; - const targetBox = (await rows.nth(0).boundingBox())!; - await page.mouse.move(gripBox.x + gripBox.width / 2, gripBox.y + gripBox.height / 2); - await page.mouse.down(); - await page.mouse.move(targetBox.x + 20, targetBox.y + targetBox.height * 0.2, { steps: 8 }); - // De rij licht op tijdens het slepen en de doelrij toont de invoegindicator. - await expect(rows.nth(1)).toHaveAttribute('data-ops-distribution-rank-dragging', 'true'); - await expect(rows.nth(0)).toHaveAttribute('data-ops-distribution-rank-drop-before', 'true'); - await page.mouse.up(); - - await expect(page.locator('[data-ops-distribution-rank-row]').first()) - .toHaveAttribute('data-ops-doc-id', secondDocId!); - await expect(page.locator('[data-ops-distribution-rank-row]').last()) - .toHaveAttribute('data-ops-doc-id', firstDocId!); - // Een herordening is een rangordewijziging ⇒ hetzelfde `staleReason = 'rank'` als de knoppen. - await expect(page.locator('[data-ops-distribution-dialog]')) - .toHaveAttribute('data-ops-distribution-last-stale-reason', 'rank'); -}); - // --- B1c-plan3 taak 11b — de voor/na-grafiek (spec §7) --------------------------------------------- test('voor/na-preview: de na-stand blijft binnen de capaciteitslijn', async ({ page, ops: _ops }) => { @@ -500,15 +481,101 @@ test('toepassen: schrijft in beide projecten en biedt daarna "alles terugdraaien await expect(page.locator('[data-ops-distribution-applied]')).toHaveCount(0); }); -test('toepassen is uitgeschakeld-met-reden zolang er een tekort is', async ({ page, ops: _ops }) => { +test('een tekort kleurt de validatiestrook rood en houdt Toepassen tegen', async ({ page, ops: _ops }) => { await seedUnsolvableShortfall(page); await openDistributionFromConflictRow(page); - await expect(page.locator('[data-ops-distribution-shortfall]')).toBeVisible(); + + // Het tekortblok is weg (spec §3.5): het tekort staat nu BIJ NAAM in de validatiestrook, en de + // einddatum-badge van het getroffen document draagt dezelfde markering — een lege balk zonder + // uitleg was de klacht uit de gebruikstest. + const status = page.locator('[data-ops-distribution-status]'); + await expect(status).toContainText(/tekort|short/i); + await expect(page.locator('[data-ops-distribution-end-badges] [data-ops-distribution-badge-shortfall]')) + .not.toHaveCount(0); + await expect(page.locator('[data-ops-distribution-preview-shortfall]')).toBeVisible(); await expect(page.getByRole('button', { name: /^(Toepassen|Apply)$/ })).toBeDisabled(); await expect(page.locator('[data-ops-distribution-apply-reason]')).toContainText(/tekort|shortfall/i); }); +test('de validatiestrook staat er altijd en meldt de uitkomst of het tekort', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + + // Spec §3.5/§7: ALTIJD gerenderd, ook wanneer alles goed is — en dan groen met de grootste + // einddatum-verschuiving erin. + const status = page.locator('[data-ops-distribution-status]'); + await expect(status).toBeVisible(); + await expect(status).toContainText(/Conflict opgelost|Conflict resolved/i); + + // Alle deelnemers vastzetten ⇒ er valt niets te herverdelen, en de strook zegt dat met een uitweg. + for (const pin of await page.locator('[data-ops-distribution-pin]').all()) await pin.click(); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect(status).toContainText(/staan vast|is pinned/i); +}); + +test('Reset zet plafonds en vastzettingen terug, en laat de schakelaar staan', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + + await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ }).click(); + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + await strip.locator('[data-ops-distribution-handle]').focus(); + await strip.locator('[data-ops-distribution-handle]').press('ArrowRight'); + await page.locator('[data-ops-distribution-pin]').first().click(); + await expect.poll(() => page.evaluate(() => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return Object.keys(ui.ceilings).length + Object.values(ui.pinned).filter(Boolean).length; + })).toBeGreaterThan(1); + + await page.locator('[data-ops-distribution-reset]').click(); + + // Spec §3.6: alle plafonds en pins terug, de SCHAKELAAR ongemoeid. + await expect.poll(() => page.evaluate(() => { + const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; + return { + ceilings: Object.values(ui.ceilings).filter(v => v !== null).length, + pins: Object.values(ui.pinned).filter(Boolean).length, + splits: ui.allowSplits, + }; + })).toEqual({ ceilings: 0, pins: 0, splits: true }); +}); + +test('de dialoog flitst niet: dezelfde boundingBox over een herberekening en een sleep', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const dialog = page.locator('[data-ops-distribution-dialog]'); + const before = (await dialog.boundingBox())!; + + // (1) Een herberekening. De bezig-toestand mag zich alleen via kleur/opaciteit tonen — nooit door + // een blok toe te voegen of weg te halen (spec §7). + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + const during = (await dialog.boundingBox())!; + await expect(page.locator('[data-ops-distribution-status]')).toBeVisible(); + const after = (await dialog.boundingBox())!; + + // (2) Een sleep, inclusief de live herberekening die eronder loopt. + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 4 * dayWidth, box.y + box.height / 2, { steps: 8 }); + const dragging = (await dialog.boundingBox())!; + await page.mouse.up(); + const released = (await dialog.boundingBox())!; + + for (const [name, box2] of [['tijdens', during], ['erna', after], ['slepend', dragging], ['losgelaten', released]] as const) { + expect(box2.x, `x ${name}`).toBeCloseTo(before.x, 0); + expect(box2.y, `y ${name}`).toBeCloseTo(before.y, 0); + expect(box2.width, `breedte ${name}`).toBeCloseTo(before.width, 0); + expect(box2.height, `hoogte ${name}`).toBeCloseTo(before.height, 0); + } +}); + test('het voorstel vervalt met reden zodra er in een betrokken document gewerkt wordt', async ({ page, ops: _ops }) => { await seedTwoConflictingDocuments(page); await openDistributionFromConflictRow(page); @@ -534,37 +601,33 @@ test('het voorstel vervalt met reden zodra er in een betrokken document gewerkt await expect(page.locator('[data-ops-distribution-stale]')).toHaveCount(0); }); -// --- B1c-plan3 taak 13 — kostenlabels per project en prijskaartjes per gereedschapsstand (spec §4 -// stap 1 / §6) -------------------------------------------------------------------------------- +// --- B1c-plan4 taak 6 — het VERSCHIL-prijskaartje bij de gereedschapsschakelaar (spec §2.2) ------ -test('kostenlabels en prijskaartjes verschijnen en verdwijnen met het voorstel', async ({ page, ops: _ops }) => { +test('het prijskaartje is één VERSCHIL en volgt de stand van de schakelaar', async ({ page, ops: _ops }) => { await seedTwoConflictingDocuments(page); await openDistributionFromConflictRow(page); await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); - // Klein project, onder de ondersteunde schaal: beide rangorderijen krijgen een gecacht kostenlabel - // en de gereedschapsschakelaar krijgt een prijskaartje van BEIDE standen (§6) — geen "prijs - // onbekend" meer. - await expect(page.locator('[data-ops-distribution-rank-row] [data-ops-distribution-cost]')).toHaveCount(2); - await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/werkdag|no overrun|geen uitloop|workday/i); - // Gebruikstest 2026-09-12, gebrek 1: de prijs mag nooit als twee kale, naamloze getallen naast - // elkaar staan. Er staat óf "uit: … · aan: …", óf — wanneer beide standen even duur zijn — één - // prijs met "in beide standen". - await expect(page.locator('[data-ops-distribution-tool-price]')) - .toContainText(/uit:|off:|in beide standen|in both settings/i); - - // Een externe bewerking laat het voorstel vervallen (§6a — buiten de modale dialoog om, zie de - // bestaande taak-12-test hierboven). De labels zijn gecachet TOT invalidatie, niet live: zodra het - // voorstel niet meer actueel is, vervalt ook het label — geen oud getal tonen bij een vervallen - // voorstel. + // Eigenaarsbesluit 2026-09-12 (spec §2.2): geen twee bedragen naast elkaar meer, maar één + // verschil. Schakelaar UIT ⇒ een belofte ("zou … besparen"); AAN ⇒ een constatering ("bespaart + // …"). Nul is expliciet, niet leeg. + const price = page.locator('[data-ops-distribution-tool-price]'); + await expect.poll(() => price.textContent()) + .toMatch(/zou .*besparen|would save|zou niets besparen|would save nothing/i); + + await page.getByRole('switch', { name: /Onderbrekingen toestaan|Allow interruptions/ }).click(); + await page.getByRole('button', { name: /Herbereken|Recalculate/ }).click(); + await expect.poll(() => price.textContent()) + .toMatch(/^(?!.*zou)(?=.*(bespaart|saves))/i); + + // Een externe bewerking laat het voorstel vervallen (§6a); een prijs bij een vervallen voorstel + // zou liegen, dus die gaat terug naar "prijs onbekend". await page.evaluate(() => { const s = window.__OPS__!.store.getState(); const task = s.tasks[0]; s.updateTask(task.id, { time: { ...task.time, scheduleDuration: 4 } }); }); - - await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); - await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/prijs onbekend|price unknown/i); + await expect(price).toContainText(/prijs onbekend|price unknown/i); }); /** @@ -624,8 +687,8 @@ test('boven het documentenplafond rekent de dialoog alleen op de knop', async ({ await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(7); await expect(page.locator('[data-ops-distribution-degraded]')).toBeVisible(); - // Gedegradeerd ⇒ geen labelpas, dus geen "Bezig…" maar de expliciete route. - await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); + // Gedegradeerd ⇒ geen verschil-pas, dus geen prijs maar de expliciete route. + await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/prijs onbekend|price unknown/i); }); test('boven de ondersteunde schaal rekent de dialoog alleen op de knop', async ({ page, ops: _ops }) => { @@ -636,15 +699,16 @@ test('boven de ondersteunde schaal rekent de dialoog alleen op de knop', async ( await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); await expect(page.locator('[data-ops-distribution-degraded]')).toBeVisible(); - // Boven de schaal is elk label zelf een volledige extra run — dat is precies de kost die de - // degradatie voorkomt, dus er wordt niet automatisch gerekend: "druk op Herbereken" i.p.v. een - // getal. - await expect(page.locator('[data-ops-distribution-cost]').first()).toContainText(/Herbereken|Recalculate/); + // Boven de schaal is de verschil-pas zelf een volledige extra run — dat is precies de kost die de + // degradatie voorkomt, dus er wordt niet automatisch gerekend: "prijs onbekend" i.p.v. een getal. + await expect(page.locator('[data-ops-distribution-tool-price]')).toContainText(/prijs onbekend|price unknown/i); - const handle = page.locator('[data-ops-distribution-strip]').first().getByRole('slider'); + const handle = page.locator('[data-ops-distribution-strip]').first().locator('[data-ops-distribution-handle]'); await handle.focus(); await handle.press('ArrowRight'); - await expect(page.locator('[data-ops-distribution-effect]').first()).toContainText(/Herbereken|Recalculate/); + // Boven de schaal rekent een plafondwijziging NIET door (spec §5): de reden staat in de + // validatiestrook, met de expliciete route erbij. + await expect(page.locator('[data-ops-distribution-status]')).toContainText(/Herbereken|Recalculate/); console.log(`seedLargeDegradedProject-test duur: ${Date.now() - start}ms`); }); From 659ba70401fc1534dbc05b885df984857ea13f1d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:42:10 +0200 Subject: [PATCH 070/108] refactor(b1c): de verdeelhook zonder compat-velden, dubbele helpers en NUL-bytes costByDoc en toolPrice zijn weg nu de dialoog ze niet meer leest; de lokaal gekopieerde maxEndShiftWorkdays/savingsWorkdays komen weer uit stripGeometry; de drie NUL-bytes die als join-scheidingsteken dienden staan nu als escape in de broncode, zodat grep het bestand als tekst ziet. Co-Authored-By: Claude Fable 5.1 --- .../useDistributionProposal.ts | 52 ++----------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts index 792892c34..79344eb3f 100644 --- a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -44,9 +44,7 @@ import { type DistributionProposal, } from '@/services/library/distribute'; import { documentFingerprint } from '@/services/library/proposalFingerprint'; -// TODO(plan4-T1): vervang deze twee lokale helpers door -// `import { maxEndShiftWorkdays, savingsWorkdays } from './stripGeometry';` zodra taak 1 die module -// heeft toegevoegd — de signaturen en het gedrag hieronder zijn letterlijk die van het plan. +import { maxEndShiftWorkdays, savingsWorkdays } from './stripGeometry'; import { documentTitle, untitledOrdinals, displayDocumentTitle } from '@/utils/documents'; /** Meer taken dan dit in één deelnemend document ⇒ handmatig herberekenen (§3.4). */ @@ -67,24 +65,6 @@ export const MAX_BOOKING_TASKS_AUTO = 40; */ export const MAX_DOCS_AUTO = 6; -// TODO(plan4-T1): deze twee helpers horen in `./stripGeometry.ts` (taak 1 van plan 4) en staan hier -// tijdelijk lokaal omdat die module in deze worktree nog niet bestaat. Bij het samenvoegen: dit blok -// schrappen en de import bovenaan activeren — signatuur en gedrag zijn identiek aan de plantekst. - -/** De grootste einddatum-verschuiving over de deelnemers — de UITSCHIETER, niet de som (§2.2). */ -export function maxEndShiftWorkdays(docs: { endShiftWorkdays: number }[]): number { - return docs.reduce((max, doc) => Math.max(max, doc.endShiftWorkdays), 0); -} - -/** - * Het verschil-prijskaartje van "Onderbrekingen toestaan" (§2.2): hoeveel werkdagen de aan-stand - * bespaart ten opzichte van de uit-stand. Geklemd op 0 — onderbreken kán in theorie duurder - * uitvallen, en dan is "bespaart niets" het eerlijke antwoord. - */ -export function savingsWorkdays(offMax: number, onMax: number): number { - return Math.max(0, offMax - onMax); -} - /** Waarom het huidige voorstel niet meer actueel is — 1-op-1 de `resource.distribution.stale.*` * sleutels. De vier tune-assen komen uit `diffReason`; `'edited'` komt uit de * vingerafdruk-bewaking (taak 12, spec §6a). @@ -161,19 +141,6 @@ export interface DistributionProposalState { * `null` ⇒ nog niet (her)berekend; de dialoog zegt dan eerlijk "prijs onbekend". */ savings: { workdays: number } | null; - /** - * @deprecated COMPAT — TAAK 4 RUIMT DIT OP. De rangordelijst met haar kostenlabels is met deze - * taak vervallen (§2.1/§8) en de per-deelnemer-isolatieruns die deze map vulden zijn verdwenen; - * hij staat hier alleen nog omdat `DistributionDialog.tsx` pas in taak 4 herbouwd wordt en - * anders niet compileert. Altijd leeg. Verwijder dit veld zodra de dialoog het niet meer leest. - */ - costByDoc: Record; - /** - * @deprecated COMPAT — TAAK 4 RUIMT DIT OP. Vervangen door `savings` (één verschil in plaats van - * twee bedragen, §2.2). Wordt gevuld uit dezelfde twee prijsruns als `savings`, dus hij kost niets - * extra; alleen de dialoog van vóór taak 4 leest hem nog. - */ - toolPrice: { off: number; on: number } | null; } /** @@ -377,7 +344,7 @@ export function diffReason( next: DistributionUiState, ): DistributionStaleReason | null { if (prev.allowSplits !== next.allowSplits) return 'tool'; - if (prev.order.join('') !== next.order.join('')) return 'rank'; + if (prev.order.join('\u0000') !== next.order.join('\u0000')) return 'rank'; if (!samePins(prev.pinned, next.pinned)) return 'pin'; if (!sameCeilings(prev.ceilings, next.ceilings)) return 'ceiling'; return null; @@ -399,12 +366,6 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const [lastStaleReason, setLastStaleReason] = useState(null); const [staleDocs, setStaleDocs] = useState(''); const [savings, setSavings] = useState<{ workdays: number } | null>(null); - // COMPAT — TAAK 4 RUIMT DEZE TWEE OP. `costByDoc` blijft per definitie leeg (de isolatieruns die - // hem vulden zijn met de rangordelijst verdwenen); `toolPrice` wordt uit dezelfde twee prijsruns - // als `savings` gevuld. Beide bestaan alleen zolang `DistributionDialog.tsx` ze nog leest. - const [costByDoc] = useState>({}); - const [toolPrice, setToolPrice] = useState<{ off: number; on: number } | null>(null); - // Bewaakt de labelpas (taak 13) tegen twee soorten inhaalslag: (1) een NIEUWE hoofdrun start — // elke run verhoogt de teller, dus een oudere labelpas herkent zichzelf als ingehaald; (2) een // 'edited'-invalidatie zónder nieuwe run (§6a rekent bewust niet automatisch door) — die zet @@ -506,9 +467,6 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr () => { if (offPrice !== null && onPrice !== null) { setSavings({ workdays: savingsWorkdays(offPrice, onPrice) }); - // COMPAT — TAAK 4 RUIMT DIT OP: dezelfde twee getallen nog even in de oude vorm, zodat - // de nog niet herbouwde dialoog blijft compileren en draaien. Kost niets extra. - setToolPrice({ off: offPrice, on: onPrice }); } }, ]; @@ -543,7 +501,6 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr // een nieuwe run start. Een oud getal tijdens "Bezig met verdelen…" is even misleidend als een // oud getal na een invalidatie. setSavings(null); - setToolPrice(null); // COMPAT — TAAK 4 RUIMT DIT OP. // De labelpas van de vorige generatie is hiermee ingehaald; zijn bezig-toestand hoort dus ook // weg. Wordt er straks weer een pas gepland, dan zet die 'm zelf terug aan. setLabelsBusy(false); @@ -595,7 +552,7 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr // Rekenmoment 1: OPENEN, en het wisselen naar een ander poolitem. Beide zijn "een nieuw onderwerp", // dus het oude voorstel gaat weg vóór de nieuwe run. - const subjectKey = tune ? `${tune.companyId}${tune.libraryItemId}` : null; + const subjectKey = tune ? `${tune.companyId}\u0000${tune.libraryItemId}` : null; const lastSubjectRef = useRef(null); const lastTuneRef = useRef(null); useEffect(() => { @@ -610,7 +567,6 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr setLastStaleReason(null); setStaleDocs(''); setSavings(null); - setToolPrice(null); // COMPAT — TAAK 4 RUIMT DIT OP. if (subjectKey !== null) runRef.current(); // `tune` bewust buiten de deps: alleen het ONDERWERP is hier de trigger, niet elke tune-tik. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -668,7 +624,5 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr return { proposal, busy, labelsBusy, staleReason, lastStaleReason, staleDocs, degraded, recompute, inputs, savings, - // COMPAT — TAAK 4 RUIMT DEZE TWEE OP. - costByDoc, toolPrice, }; } From b518d900f8b52e2eedfaaed443c4e9c8f0a6271c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:44:22 +0200 Subject: [PATCH 071/108] fix(b1c): bij een tekort is de uitkomstpil rood, niet groen Een project waarvan taken niet passen toonde nog "eind ongewijzigd" in het groen naast een rode badge en een rode strook. PhaseStrip krijgt daarvoor shortfallCount (0 = geen tekort) plus de bestaande tekortzin als title, en markeert de pil met data-ops-distribution-effect-shortfall. Geen nieuwe vertaalsleutels. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 6 ++++++ .../dialogs/DistributionDialog/PhaseStrip.tsx | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 2f9ea2b1b..6d16a7320 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -546,6 +546,8 @@ export function DistributionDialog() { {(stripView?.docs ?? []).map(doc => { const recorded = doc.pinnedReason === 'dates-as-recorded'; const pinnedNow = recorded || tune.pinned[doc.docId] === true; + // Een tekort kleurt de uitkomstpil rood — dezelfde bron als de badge hieronder. + const short = shortfallTitleByDoc.get(doc.docId); return ( setPinned(doc.docId, tune.pinned[doc.docId] !== true)} onCeilingChange={next => setCeiling(doc.docId, next)} diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 418fc2dc6..2ae84377b 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -95,6 +95,14 @@ export interface PhaseStripProps { liveCommit: boolean; /** Er loopt een berekening: de uitkomstpil toont "Bezig…" ZONDER van maat te veranderen (§7). */ busy: boolean; + /** + * Hoeveel taken van dit document niet passen (0 = geen tekort). Een tekort maakt de uitkomstpil + * ROOD, ook bij een ongewijzigde einddatum: "eind ongewijzigd" in het groen naast een rode badge + * en een rode strook las als "alles in orde", terwijl er juist taken onbediend blijven. + */ + shortfallCount: number; + /** De volle tekortzin voor de `title` van de pil (leeg bij geen tekort). */ + shortfallTitle?: string; /** Datumnotatie voor de plafonddatum — de dialoog levert één `Intl.DateTimeFormat` voor alle rijen. */ formatDay: (iso: string) => string; onTogglePin: () => void; @@ -104,7 +112,7 @@ export interface PhaseStripProps { export function PhaseStrip({ docId, title, axis, beforeLoadByDay, afterLoadByDay, fixedLoadByDay, isWorkingDay, color, slackWorkdays, endShiftWorkdays, ceiling, pinned, recorded, cannotMove, liveCommit, busy, - formatDay, onTogglePin, onCeilingChange, + shortfallCount, shortfallTitle, formatDay, onTogglePin, onCeilingChange, }: PhaseStripProps) { const { t } = useTranslation('common'); @@ -213,7 +221,9 @@ export function PhaseStrip({ setDragValue(null); }; - const tone = shiftTone(endShiftWorkdays); + // Een tekort weegt zwaarder dan de einddatum: past er een taak niet, dan is de rij rood, ook + // als het eind niet verschuift. Anders staat er een groene pil naast een rode badge. + const tone = shortfallCount > 0 ? shiftTone(Number.POSITIVE_INFINITY) : shiftTone(endShiftWorkdays); return (

0 + ? { 'data-ops-distribution-effect-shortfall': 'true', ...(shortfallTitle ? { title: shortfallTitle } : {}) } + : {})} style={busy ? { background: 'color-mix(in srgb, var(--theme-text-dim) 14%, transparent)', color: 'var(--theme-text-secondary)' } : tone} From 9ecbe4bf8a7117e38db82390f467dd5c16d4bae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:03:52 +0000 Subject: [PATCH 072/108] =?UTF-8?q?Rapport:=20resourcediagram=20=E2=80=94?= =?UTF-8?q?=20reviewfixes=20curvetoestand,=20ruitklem,=20lege=20staat,=20k?= =?UTF-8?q?olombreedte=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyperkritische review op de drie punten van manuvarkey ("nee, nog niet"): 1. De Curve-kolom zei "Uniform" waar de app zelf "Contour" of "Geïmporteerde curve" zegt: de engine las alleen `assignment.curve`, terwijl de lastverdeling (`assignmentDayUnits`) en het eigenschappenpaneel drie lagen kennen. `RowAssignment.curve` is nu `RowCurve` (contour op de taak via `matchContoursToAssignments` > `curveValues` zonder OPS-vorm = 'imported' > `curve`, afwezig = UNIFORM); het paneel geeft de twee toestandslabels uit `task:properties.assignments.*` mee. 2. Met een tijdvenster kon een mijlpaalruit (en de baselineruit) op de eerste vensterdag bij lage zoom half over de tabel hangen: de zichtbaarheidstest was geen klem. Het middelpunt wordt nu binnen [tabelrand + size, chartrand − size] geklemd; de testrecorder legt gevulde paden vast en bewaakt dat niets in het chartgebied links van de tabelrand komt. 3. `emptyPeriod` wees naar de periode terwijl er wél taken in het venster stonden zonder toewijzing: nieuw `counts.inPeriod`, de melding komt alleen als er géén bladtaak meer in het venster is. 4. De default (kolommen aan) blijft — het resourcediagram is nog in geen release uitgeleverd, dus er is geen bestaand gedrag dat breekt — maar de gidsen nl+en zeggen nu expliciet dat de tabel breder en de tijdas smaller wordt, en wat *Contour*/*Geïmporteerde curve* betekent. 5. Gecomprimeerde as + venster zonder werkdag kleefde naar een datum buiten het venster: valt voor dat venster terug op de kalender-as. 6. Curve-kolom 75 → 92 px (pl/pt-labels pasten niet, gemeten op Inter). 7. De 3 px-minimumbreedte van een balk steekt niet meer over de chartrand. 8. Kleurvakken (resourcekleurmodus) worden op de ruwe tijdas verdeeld en per vak op het chartgebied geknipt, zodat een afgekapte balk de kleuren van het zichtbare stuk toont. 9. Relaties worden bij een venster nooit getekend (`showDeps && !windowed`) i.p.v. alleen per conventie. 10. Lege-staat-tekst gaat door `fitText`; gids: het getal achter een typeband telt taakrijen. Regressie: check-reports (imported/contoured/OPS-vorm/contour-per-resource, inPeriod), check-print-report (ruit binnen het chartgebied bij 4 px/dag, minimumbreedte geklemd bij 1 px/dag, tabelbreedte +137). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- public/docs/en/gids-rapporten-printen.md | 10 ++- public/docs/nl/gids-rapporten-printen.md | 10 ++- src/components/panels/ReportPanel.tsx | 8 ++- src/engine/reports/resourceGantt.ts | 48 ++++++++++--- src/services/print/printPreview.ts | 88 ++++++++++++++++-------- tests/planning/check-print-report.ts | 27 +++++++- tests/planning/check-reports.ts | 35 ++++++++-- 7 files changed, 171 insertions(+), 55 deletions(-) diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index 1f62bf699..a1a47c3b5 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -104,8 +104,9 @@ left out — an assignment on a summary task (which an import can produce) is no at the bottom, so the meeting can see at a glance what nobody owns yet. **Group by resource type** adds a layer above: first a band per type — labour, crew, subcontractor, equipment, material, in that fixed order — with the resources inside it; useful when a meeting discusses the people first -and the equipment after, or when you only want to print the equipment block. With *Each resource on -a new page* a type band starts a new sheet together with its first resource. **Reporting period** — +and the equipment after, or when you only want to print the equipment block; the number after a type +band counts the task rows below it, so a task under two resources of the same type counts twice. +With *Each resource on a new page* a type band starts a new sheet together with its first resource. **Reporting period** — the same control as on the table reports, see further down — limits the diagram to a time window: only tasks that touch the window take part, the time axis runs exactly from the start to the end date, and a bar that continues beyond it is cut off at the edge. That lets a meeting put the @@ -116,7 +117,10 @@ default: how many units per day the band's resource is assigned to the task, and distribution curve — "you are on Foundations half days, front loaded" says more than "you are on Foundations". A task under two bands shows each band's own load; two assignments of the same resource on one task are added up, and a dash in the curve column means those assignments carry -different curves. Turn **Show units/day and curve** off for a narrower table. +different curves. If the assignment has its own hour distribution or an exact curve imported from +MS Project or P6, the column says *Contour* or *Imported curve* respectively — the same as in the +properties panel. The two columns make the table wider and the time axis narrower; turn **Show +units/day and curve** off for the narrow table of the Gantt print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 3fb74d211..2994684fd 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -107,8 +107,9 @@ resource meenemen** komt er onderaan een band *(geen)* bij, zodat je in het over nog niemand heeft. Met **Groeperen op resourcetype** komt er een laag boven: eerst een band per type — arbeid, ploeg, onderaannemer, materieel, materiaal, in die vaste volgorde — en daarbinnen de resources; handig als je in het overleg eerst de mensen en dan het materieel bespreekt, of alleen het -materieelblok wilt afdrukken. Bij *Elke resource op een nieuwe pagina* begint een typeband op een -nieuw vel samen met zijn eerste resource. Met **Rapportageperiode** — dezelfde keuze als bij de +materieelblok wilt afdrukken; het getal achter een typeband telt de taakrijen eronder, dus een taak +die onder twee resources van hetzelfde type staat telt twee keer. Bij *Elke resource op een nieuwe +pagina* begint een typeband op een nieuw vel samen met zijn eerste resource. Met **Rapportageperiode** — dezelfde keuze als bij de tabelrapporten, zie verderop — beperk je het diagram tot een tijdvenster: alleen taken die het venster raken doen mee, de tijdas loopt precies van de begin- tot de einddatum, en een balk die verder doorloopt wordt aan de rand afgekapt. Zo leg je in dezelfde vergadering het look-ahead-rapport @@ -119,7 +120,10 @@ dag de resource van die band op de taak staat en met welke verdeelcurve — "je op Fundering, vooraan belast" zegt meer dan "je staat op Fundering". Een taak onder twee banden toont per band de eigen inzet; twee toewijzingen van dezelfde resource op één taak worden opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillende curves hebben. -Zet **Eenheden/dag en curve tonen** uit voor een smallere tabel. +Heeft de toewijzing een eigen urenverdeling of een uit MS Project of P6 geïmporteerde exacte curve, +dan staat er *Contour* respectievelijk *Geïmporteerde curve* — hetzelfde als in het +eigenschappenpaneel. De twee kolommen maken de tabel breder en de tijdas dus smaller; zet +**Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index 87f235422..39a0cbc06 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -460,7 +460,9 @@ export function ReportPanel() { BACK_LOADED: tCommon('resource.curve.backLoaded'), BELL: tCommon('resource.curve.bell'), EARLY_PEAK: tCommon('resource.curve.earlyPeak'), LATE_PEAK: tCommon('resource.curve.latePeak'), DOUBLE_PEAK: tCommon('resource.curve.doublePeak'), TURTLE: tCommon('resource.curve.turtle'), - }), [tCommon]); + // Dezelfde twee toestanden als het eigenschappenpaneel: contour op de taak, geïmporteerde curve. + contoured: tTask('properties.assignments.contoured'), imported: tTask('properties.assignments.importedCurve'), + }), [tCommon, tTask]); // Rapportageperiode als tijdvenster (punt 3): dezelfde oplossing als het control toont; bij // *Hele project* geen venster, zodat het rapport byte-identiek blijft aan vóór deze optie. const resourceGanttPeriod = useResolvedPeriod(resourceGanttOptions.period); @@ -559,8 +561,10 @@ export function ReportPanel() { labels: { // Resourcediagram zonder één toewijzing: zeg wat er ontbreekt, niet "geen taken" — tenzij er // écht geen taken zijn, dan is "wijs resources toe" het verkeerde advies. + // Leeg door het venster (géén bladtaak meer in de periode) ⇒ wijs naar de periode; leeg terwijl + // er wél taken in de periode staan ⇒ die zijn niet toegewezen, en een andere periode helpt niet. noTasks: reportType === 'resourceGantt' && tasks.length > 0 - ? (resourceGantt && resourceGantt.rows.length === 0 && resourceGantt.counts.outsidePeriod > 0 + ? (resourceGantt && resourceGantt.counts.inPeriod === 0 && resourceGantt.counts.outsidePeriod > 0 ? t('resourceGantt.emptyPeriod') : t('resourceGantt.empty')) : t('noTasks'), diff --git a/src/engine/reports/resourceGantt.ts b/src/engine/reports/resourceGantt.ts index e9c4e9770..ab2a9eb76 100644 --- a/src/engine/reports/resourceGantt.ts +++ b/src/engine/reports/resourceGantt.ts @@ -1,6 +1,7 @@ import type { Task } from '@/types/task'; import type { Resource, ResourceAssignment, ResourceCurve, ResourceType } from '@/types/resource'; import { encodeBandKey, encodeGroupedTaskRowKey, NONE_RAWKEY, type ViewRow } from '@/engine/view/visibleRows'; +import { matchContoursToAssignments } from '@/engine/contour/contourEngine'; import { dayOf, taskFinish, taskStart } from './reportCommon'; import type { ResolvedPeriod } from './reportingPeriod'; @@ -45,7 +46,12 @@ import type { ResolvedPeriod } from './reportingPeriod'; * * Per taakrij onder een resourceband levert `assignmentByRowKey` de TOEWIJZING van die band op * die taak (punt 1): eenheden per dag en de verdeelcurve — de rij is een taak, maar wat de lezer - * wil weten is "hoe zwaar staat déze resource erop". Twee records van dezelfde resource op één + * wil weten is "hoe zwaar staat déze resource erop". De curve volgt dezelfde drie lagen als de + * lastverdeling zelf (`ResourceLoad.ts`'s `assignmentDayUnits`) en het eigenschappenpaneel + * (`TaskAssignmentsSection`): een opgeslagen CONTOUR op de taak wint (`'contoured'`), dan een + * exacte geïmporteerde curve zonder OPS-vorm (`curveValues` zonder `curve` ⇒ `'imported'`), dan + * pas `curve` (afwezig = UNIFORM) — een P6-/MSP-import met een front-loaded curve mag hier nooit + * "Uniform" heten (hyperkritische review, bevinding 1). Twee records van dezelfde resource op één * taak (één rij) worden opgeteld; de curve is alleen bekend als alle records dezelfde hebben * (anders `null`, de render toont een streepje). De "(geen)"-band heeft geen toewijzing en dus * geen entry. De render tekent dit als twee tabelkolommen (`PrintOptions.assignmentColumns`). @@ -91,15 +97,20 @@ export interface ResourceGanttRowsResult { unassignedTasks: number; /** Bladtaken die door het tijdvenster zijn weggelaten (0 zonder venster). */ outsidePeriod: number; + /** Bladtaken ín het venster (zonder venster: alle bladtaken) — ook als er geen toewijzing op zit. */ + inPeriod: number; }; } +/** De curvetoestand van een toewijzing: contour op de taak, geïmporteerde exacte curve, of de OPS-vorm. */ +export type RowCurve = ResourceCurve | 'contoured' | 'imported'; + /** De toewijzing achter één taakrij van een resourceband (zie de moduledoc). */ export interface RowAssignment { /** Som van `unitsPerDay` over de records van deze resource op deze taak. */ unitsPerDay: number; - /** De curve als alle records dezelfde hebben (`undefined` in een record = UNIFORM), anders null. */ - curve: ResourceCurve | null; + /** De curvetoestand als alle records dezelfde hebben (`curve` afwezig = UNIFORM), anders null. */ + curve: RowCurve | null; } /** Bandsleutel van de "(geen)"-band — dezelfde codering als de schermgroepering. */ @@ -181,9 +192,27 @@ export function computeResourceGanttRows( // een toewijzing aan een onbekende resource telt niet (zelfde regel als `resourceNames` op het // scherm: die taak is dan "zonder resource"). const tasksByResource = new Map>(); - // Per resource-id × taak-id de opgetelde eenheden en de verzameling curves (punt 1). - const loadByResourceTask = new Map }>(); + // Per resource-id × taak-id de opgetelde eenheden en de verzameling curvetoestanden (punt 1). + const loadByResourceTask = new Map }>(); const assignedTaskIds = new Set(); + // Geldige records per taak, voor de contourkoppeling (dezelfde `matchContoursToAssignments` als + // de lastverdeling en het eigenschappenpaneel: per taak, in recordvolgorde). + const validByTask = new Map(); + for (const a of ctx.assignments) { + if (!leafById.has(a.taskId) || !resourceIds.has(a.resourceId)) continue; + const list = validByTask.get(a.taskId) ?? []; + list.push(a); + validByTask.set(a.taskId, list); + } + const contouredIds = new Set(); + for (const [taskId, list] of validByTask) { + const task = leafById.get(taskId)!; + if (task.timephasedContours && task.timephasedContours.length > 0) { + for (const id of matchContoursToAssignments(task.timephasedContours, list).keys()) contouredIds.add(id); + } + } + const curveOf = (a: ResourceAssignment): RowCurve => + contouredIds.has(a.id) ? 'contoured' : (!a.curve && a.curveValues ? 'imported' : (a.curve ?? 'UNIFORM')); let assignments = 0; for (const a of ctx.assignments) { const task = leafById.get(a.taskId); @@ -194,9 +223,9 @@ export function computeResourceGanttRows( bucket.set(task.id, task); assignedTaskIds.add(task.id); const loadKey = `${a.resourceId}\u0000${task.id}`; - const load = loadByResourceTask.get(loadKey) ?? { unitsPerDay: 0, curves: new Set() }; + const load = loadByResourceTask.get(loadKey) ?? { unitsPerDay: 0, curves: new Set() }; load.unitsPerDay += a.unitsPerDay; - load.curves.add(a.curve ?? 'UNIFORM'); + load.curves.add(curveOf(a)); loadByResourceTask.set(loadKey, load); } @@ -266,6 +295,9 @@ export function computeResourceGanttRows( return { rows, assignmentByRowKey, - counts: { resources: bands.length, assignments, unassignedTasks: unassigned.length, outsidePeriod: allLeaves.length - leaves.length }, + counts: { + resources: bands.length, assignments, unassignedTasks: unassigned.length, + outsidePeriod: allLeaves.length - leaves.length, inPeriod: leaves.length, + }, }; } diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index a2a1ff1a3..a745597af 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -26,11 +26,11 @@ import { visibleBarColorCategories, type BarColorContext, } from '@/services/print/barColorCategories'; -import type { Resource, ResourceAssignment, ResourceCurve } from '@/types/resource'; +import type { Resource, ResourceAssignment } from '@/types/resource'; import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; import type { BarColorSelection } from '@/types/barColor'; import type { ViewRow } from '@/engine/view/visibleRows'; -import type { RowAssignment } from '@/engine/reports/resourceGantt'; +import type { RowAssignment, RowCurve } from '@/engine/reports/resourceGantt'; import type { BaselineOverlay } from '@/types/baseline'; // BASISmaten bij rapport-lettergrootte 100%. Niets tekent hier nog rechtstreeks mee: alle @@ -93,7 +93,7 @@ const COL = { // Toewijzingskolommen van het resourcediagram (manuvarkey punt 1): eenheden per dag en de // verdeelcurve van de resource van de band op die taak. Alleen bij `assignmentColumns`. units: { w: 45 }, - curve: { w: 75 }, + curve: { w: 92 }, }; /** @@ -389,7 +389,9 @@ export interface PrintOptions { * OPTIONEEL — rapportageperiode als TIJDVENSTER (manuvarkey op #113, punt 3; resourcediagram): de * tijdas loopt exact van `from` t/m `to` (ISO-dagen, inclusief) zonder de gebruikelijke marge van * 7/14 dagen, en balken, mijlpalen, speling, voortgang en baseline worden op de chartrand - * afgekapt — `Draw2D` kent geen clip, dus de geometrie zelf wordt geklemd. Welke rijen in het + * afgekapt — `Draw2D` kent geen clip, dus de geometrie zelf wordt geklemd (ook de 3 px- + * minimumbreedte van een balk en het middelpunt van een ruit; relatiepijlen worden bij een + * venster helemaal niet getekend). Welke rijen in het * venster horen beslist de rijenbron (`computeResourceGanttRows`), niet de render; een rij die * er toch buiten valt tekent gewoon geen balk. Relatiepijlen worden niet geklemd: het venster * wordt alleen aangeboden op het resourcediagram, dat er geen tekent. Afwezig ⇒ byte-identiek. @@ -405,8 +407,9 @@ export interface PrintOptions { */ assignmentColumns?: boolean; rowAssignments?: ReadonlyMap; - /** Vertaalde curvenamen (`common:resource.curve.*`); een ontbrekend label valt terug op de enum. */ - curveLabels?: Partial>; + /** Vertaalde curvenamen (`common:resource.curve.*`, plus `contoured`/`imported` uit + * `task:properties.assignments.*`); een ontbrekend label valt terug op de enum-/toestandsnaam. */ + curveLabels?: Partial>; /** * Lettergrootte van het GEGENEREERDE RAPPORT als percentage (issue #25 punt 4). 100 (of * ontbrekend) = het oude gedrag, byte-identiek. Werkt bewust RELATIEF: tekst, rijhoogtes, @@ -692,7 +695,7 @@ export function renderReport( d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(14); d2d.textAlign = 'center'; - d2d.fillText(options.labels?.noTasks ?? 'No tasks to display', 300, 100); + d2d.fillText(fitText(d2d, options.labels?.noTasks ?? 'No tasks to display', 580), 300, 100); // Geen kop-/tijdschaalstrook in de lege-staat (alleen een centrale melding) ⇒ niets te herhalen. // Het meldingsvak zelf houdt z'n vaste 600×200; alleen de tekst erin volgt de schaal. return { width: 600, height: 200, tableWidth: m.tableWidth, headerHeight: 0, footerHeight: 0 }; @@ -730,11 +733,20 @@ export function renderReport( // scherm-Gantt beslist of de kalender werkelijk gecomprimeerd kan worden (een kalender zonder // werkdag valt gecontroleerd terug op de gewone kalender-as). const calEngine = new CalendarEngine(calendar); - const compressed = isCompressedEffective(calEngine, !!options.compressNonWorkdays); - const measureAxis = resolveGanttAxis({ + let compressed = isCompressedEffective(calEngine, !!options.compressNonWorkdays); + let measureAxis = resolveGanttAxis({ calendar: calEngine, compressNonWorkdays: compressed, origin: minDate, chartOriginX: 0, zoom: 1, scrollX: 0, }); + // Tijdvenster zonder één werkdag (b.v. een weekend) op de gecomprimeerde as: de as zou dan naar + // de eerstvolgende werkdag búiten het venster kleven. Val voor dít venster terug op de kalender-as. + if (options.timeWindow && compressed && measureAxis.daySpan(minDate, maxDate) < 1) { + compressed = false; + measureAxis = resolveGanttAxis({ + calendar: calEngine, compressNonWorkdays: false, + origin: minDate, chartOriginX: 0, zoom: 1, scrollX: 0, + }); + } const timelineDays = compressed ? Math.max(1, Math.ceil(measureAxis.daySpan(minDate, maxDate))) : calendarDays; @@ -1038,8 +1050,11 @@ export function renderReport( const x = dateToX(date) + zoom / 2; const cy = y + barHeight / 2; const size = barHeight * 0.45; - // Tijdvenster: een ruit buiten het chartgebied wordt niet getekend (geen halve ruit). + // Tijdvenster: een ruit die het chartgebied helemaal mist wordt niet getekend; een ruit op de + // rand wordt met zijn middelpunt naar binnen geklemd, zodat hij nooit half over de tabel of + // over de rechterrand hangt (hyperkritische review, bevinding 2). const inWindow = !windowed || (x + size >= m.tableWidth && x - size <= canvasWidth); + const cx = windowed ? Math.min(canvasWidth - size, Math.max(m.tableWidth + size, x)) : x; if (inWindow) { const advies = colorAdvice(task); @@ -1050,17 +1065,17 @@ export function renderReport( d2d.lineWidth = 1; } d2d.beginPath(); - d2d.moveTo(x, cy - size); - d2d.lineTo(x + size, cy); - d2d.lineTo(x, cy + size); - d2d.lineTo(x - size, cy); + d2d.moveTo(cx, cy - size); + d2d.lineTo(cx + size, cy); + d2d.lineTo(cx, cy + size); + d2d.lineTo(cx - size, cy); d2d.closePath(); d2d.fill(); if (advies.outline) d2d.stroke(); // Task name label (rechts van de ruit, valt terug naar links/ellipsis bij de rand) if (options.showTaskNames) { - barLabelJobs.push({ name: task.name, barRightX: x + size, barLeftX: x - size, y: cy + m.s(3), bold: false }); + barLabelJobs.push({ name: task.name, barRightX: cx + size, barLeftX: cx - size, y: cy + m.s(3), bold: false }); } } } else if (task.childIds.length > 0) { @@ -1126,10 +1141,11 @@ export function renderReport( const segments = task.splitGaps && task.splitGaps.length > 0 ? computeSplitSegments(task.splitGaps, start, end, false, calEngine) : [{ start, end }]; - const segs = segments.map((s, i) => ({ - x1: clampX(i === 0 ? rawX1 : dateToX(s.start)), - x2: clampX(i === segments.length - 1 ? rawX2 : dateToX(s.end)), - })).filter(s => !windowed || s.x2 > s.x1); + const segs = segments.map((s, i) => { + const rx1 = i === 0 ? rawX1 : dateToX(s.start); + const rx2 = i === segments.length - 1 ? rawX2 : dateToX(s.end); + return { rx1, rx2, x1: clampX(rx1), x2: clampX(rx2) }; + }).filter(s => !windowed || s.x2 > s.x1); const split = segs.length > 1; if (split) { @@ -1142,15 +1158,24 @@ export function renderReport( } for (const s of segs) { - const sw = Math.max(s.x2 - s.x1, split ? 2 : 3); + // De minimumbreedte (3 px, 2 bij splits) op de RUWE maat, en het einde bij een venster op de + // chartrand geklemd zodat dat minimum er niet overheen steekt. + const rawSw = Math.max(s.rx2 - s.rx1, split ? 2 : 3); + const sw = (windowed ? Math.min(s.rx1 + rawSw, canvasWidth) : s.rx1 + rawSw) - s.x1; if (advies.kind === 'segments') { - let sx = s.x1; + // Kleurvakken op de ruwe tijdas verdeeld en daarna per vak op het chartgebied geknipt: een + // afgekapte balk toont zo de kleuren die bij het zichtbare stuk horen (review, bevinding 8). + let sx = s.rx1; advies.segments.forEach((seg, si) => { const isLast = si === advies.segments.length - 1; - const w = isLast ? s.x1 + sw - sx : Math.round(sw * seg.weight); - d2d.fillStyle = seg.color; - d2d.roundRect(sx, y, w, barHeight, si === 0 ? 3 : 0); - d2d.fill(); + const w = isLast ? s.rx1 + rawSw - sx : Math.round(rawSw * seg.weight); + const vx1 = windowed ? Math.max(sx, m.tableWidth) : sx; + const vx2 = windowed ? Math.min(sx + w, canvasWidth) : sx + w; + if (!windowed || vx2 > vx1) { + d2d.fillStyle = seg.color; + d2d.roundRect(vx1, y, vx2 - vx1, barHeight, si === 0 ? 3 : 0); + d2d.fill(); + } sx += w; }); } else { @@ -1212,11 +1237,12 @@ export function renderReport( const x = dateToX(parseDate(baseline.start)) + zoom / 2; const cy = baseY + baseHeight / 2; if (!windowed || (x + baseHeight >= m.tableWidth && x - baseHeight <= canvasWidth)) { + const bcx = windowed ? Math.min(canvasWidth - baseHeight, Math.max(m.tableWidth + baseHeight, x)) : x; d2d.beginPath(); - d2d.moveTo(x, cy - baseHeight); - d2d.lineTo(x + baseHeight, cy); - d2d.lineTo(x, cy + baseHeight); - d2d.lineTo(x - baseHeight, cy); + d2d.moveTo(bcx, cy - baseHeight); + d2d.lineTo(bcx + baseHeight, cy); + d2d.lineTo(bcx, cy + baseHeight); + d2d.lineTo(bcx - baseHeight, cy); d2d.closePath(); d2d.fill(); } @@ -1249,7 +1275,9 @@ export function renderReport( // tegel geëmit), dus daar was dit nooit stuk. De omkering repareert dus feitelijk de RASTER-preview // en brengt die in lijn met wat de export altijd al deed — wat precies de bedoeling is, want die // twee horen WYSIWYG te zijn. - if (options.showDeps) { + // Bij een tijdvenster worden relaties nooit getekend: `drawDependencies` klemt niet, en het + // venster wordt alleen op het resourcediagram aangeboden, dat sowieso geen relaties tekent. + if (options.showDeps && !windowed) { // #54 volg-weergave: alleen relaties waarvan béide endpoints een zichtbare rij zijn (zelfde // regel als het scherm). rowIndexOf indexeert printRows (groepsbanden meegerekend) en is // daarmee tegelijk het zichtbaarheids- én het y-positie-bron; in boom-modus (= alle taken diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index f94e0df2d..8e6788b90 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -47,6 +47,9 @@ function record(tasks: Task[], sequences: Sequence[], calendar: WorkCalendar, op const rects: RectEv[] = []; const paths: PathEv[] = []; const roundRects: RoundRectEv[] = []; + // Gevulde paden (mijlpaal- en baselineruiten, samenvattingshaakjes) — `fill()` legde ze eerder + // stil weg, waardoor een ruit die over de tabel steekt onzichtbaar bleef voor de tests. + const fills: PathEv[] = []; let seq = 0; let curPath: { x: number; y: number }[] | null = null; const st = { font: '10px x', fillStyle: '', strokeStyle: '', lineWidth: 0, textAlign: 'left' as TextAlign, textBaseline: 'alphabetic' as TextBaseline, dash: [] as number[] }; @@ -62,14 +65,14 @@ function record(tasks: Task[], sequences: Sequence[], calendar: WorkCalendar, op fillRect(x, y, w, h) { rects.push({ x, y, w, h, color: st.fillStyle, seq: seq++ }); }, strokeRect() {}, beginPath() { curPath = []; }, moveTo(x, y) { if (!curPath) curPath = []; curPath.push({ x, y }); }, lineTo(x, y) { if (!curPath) curPath = []; curPath.push({ x, y }); }, - closePath() {}, fill() { curPath = null; }, + closePath() {}, fill() { if (curPath) fills.push({ pts: curPath, color: st.fillStyle, dash: [], seq: seq++ }); curPath = null; }, stroke() { if (curPath) paths.push({ pts: curPath, color: st.strokeStyle, dash: [...st.dash], seq: seq++ }); curPath = null; }, roundRect(x, y, w, h) { roundRects.push({ x, y, w, h, color: st.fillStyle, strokeColor: st.strokeStyle, mode: 'fill', seq: seq++ }); }, fillText(text, x, y) { texts.push({ text, x, y, color: st.fillStyle, font: st.font, seq: seq++ }); }, measureText(t) { return measure(t); }, }; const dims = renderReport(() => d2d, tasks, sequences, calendar, 'P', options); - return { texts, rects, paths, roundRects, dims }; + return { texts, rects, paths, roundRects, fills, dims }; } // ── Fixtures ─────────────────────────────────────────────────────────────────────────────────── @@ -523,6 +526,24 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(rec.texts.filter(t => t.text === T_HIDDEN.name && t.x < d.tableWidth).length === 1, 'taak buiten het venster houdt zijn tabelrij'); ok(JSON.stringify(record(FIX_TASKS, [], cal, fixed)) === JSON.stringify(record(FIX_TASKS, [], cal, { ...fixed, timeWindow: undefined })), 'zonder venster byte-identiek'); + + // Review-bevinding 2: een mijlpaal op de eerste vensterdag bij lage zoom (4 px/dag, ruit ± 6 px) + // hangt niet half over de tabel — het middelpunt wordt naar binnen geklemd. Niets in het + // chartgebied (gevulde paden, balken, lijnen) ligt links van de tabelrand of rechts van de chart. + const ms = mkTask('t-ms', 'Mijlpaal', { isMilestone: true, time: mkTime({ earlyStart: '2026-01-10', earlyFinish: '2026-01-10', scheduleStart: '2026-01-10', scheduleFinish: '2026-01-10' }) }); + const low = record([ms, T_CRIT], [], cal, { ...fixed, customZoom: 4, timeWindow: win }); + const ld = low.dims; + const inBodyPt = (y: number) => y >= ld.headerHeight && y < ld.height - ld.footerHeight; + const bodyFills = low.fills.filter(f => f.pts.every(p => inBodyPt(p.y))); + ok(bodyFills.length >= 1, `de ruit wordt getekend (got ${bodyFills.length} gevulde paden)`); + ok(bodyFills.every(f => f.pts.every(p => p.x >= ld.tableWidth - 1e-6 && p.x <= ld.width + 1e-6)), + `ruit binnen het chartgebied (got ${JSON.stringify(bodyFills.map(f => f.pts.map(p => Math.round(p.x * 100) / 100)))}, tabel ${ld.tableWidth}, chart tot ${ld.width})`); + const lowBars = low.roundRects.filter(r => (r.color === CRITICAL || r.color === NORMAL) && inBodyPt(r.y)); + ok(lowBars.every(b => b.x >= ld.tableWidth - 1e-6 && b.x + b.w <= ld.width + 1e-6), 'ook bij 4 px/dag blijft de balk binnen de chart'); + // Review-bevinding 7: de 3 px-minimumbreedte steekt niet meer over de rechter chartrand. + const tiny = record([T_CRIT], [], cal, { ...fixed, customZoom: 1, timeWindow: { from: '2026-01-10', to: '2026-01-12' } }); + const tinyBar = tiny.roundRects.find(r => r.color === CRITICAL && r.y >= tiny.dims.headerHeight && r.y < tiny.dims.height - tiny.dims.footerHeight); + ok(!!tinyBar && tinyBar.x + tinyBar.w <= tiny.dims.width + 1e-6 && tinyBar.w > 0, `minimumbreedte geklemd op de chartrand (got ${JSON.stringify(tinyBar)}, chart tot ${tiny.dims.width})`); } // Toewijzingskolommen (manuvarkey punt 1): twee kolommen direct achter de naam (x 180–225 en @@ -542,7 +563,7 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ const withCols = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, curveLabels: { FRONT_LOADED: 'Vooraan belast' }, })); - ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 75, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); + ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 92, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); const inCol = (t: { x: number; y: number }, x0: number, x1: number) => t.x >= x0 && t.x <= x1 && t.y > withCols.dims.headerHeight && t.y < withCols.dims.height - withCols.dims.footerHeight; const unitsTexts = withCols.texts.filter(t => inCol(t, 180, 225)).map(t => t.text).sort(); const curveTexts = withCols.texts.filter(t => inCol(t, 225, 300)).map(t => t.text).sort(); diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 847216a2e..486bc6021 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -491,7 +491,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); const r = computeResourceGanttRows(base, opts); eq('resourceGantt: één band per resource, op naam', r.rows.filter(x => x.kind === 'group').map(label), ['Kraan', 'Ploeg 1']); // Ploeg 1 → A, B, D; Kraan → B; zonder resource: C, E (mijlpaal is óók een bladtaak), F, G. - eq('resourceGantt: tellingen', r.counts, { resources: 2, assignments: 4, unassignedTasks: 4, outsidePeriod: 0 }); + eq('resourceGantt: tellingen', r.counts, { resources: 2, assignments: 4, unassignedTasks: 4, outsidePeriod: 0, inPeriod: 7 }); eq('resourceGantt: Ploeg 1 heeft A, B en D', [...bandTasks(r.rows, 'Ploeg 1')].sort(), [A, B, D].sort()); eq('resourceGantt: Kraan heeft alleen B', bandTasks(r.rows, 'Kraan'), [B]); ok('resourceGantt: B staat onder beide banden', r.rows.filter(x => x.kind === 'task' && x.task.id === B).length === 2); @@ -516,7 +516,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); assignments: [asg('a1', A, jan1.id), asg('a2', A, jan2.id), asg('a3', B, jan2.id)], }, opts); eq('resourceGantt: gelijknamige resources ⇒ twee banden met volgnummer', twins.rows.filter(x => x.kind === 'group').map(label), ['Jan #1', 'Jan #2']); - eq('resourceGantt: gelijknamig — tellingen op identiteit', twins.counts, { resources: 2, assignments: 3, unassignedTasks: 5, outsidePeriod: 0 }); + eq('resourceGantt: gelijknamig — tellingen op identiteit', twins.counts, { resources: 2, assignments: 3, unassignedTasks: 5, outsidePeriod: 0, inPeriod: 7 }); eq('resourceGantt: gelijknamig — Jan #1 heeft A', bandTasks(twins.rows, 'Jan #1'), [A]); eq('resourceGantt: gelijknamig — Jan #2 heeft A en B', [...bandTasks(twins.rows, 'Jan #2')].sort(), [A, B].sort()); ok('resourceGantt: gelijknamig — rijsleutels uniek', new Set(twins.rows.map(x => x.rowKey)).size === twins.rows.length); @@ -527,7 +527,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); assignments: [asg('a1', A, 'res-leeg')], }, opts); eq('resourceGantt: naamloze resource ⇒ band "#2" (positie in de projectlijst)', naamloos.rows.filter(x => x.kind === 'group').map(label), ['#2']); - eq('resourceGantt: naamloze resource — A telt als toegewezen', naamloos.counts, { resources: 1, assignments: 1, unassignedTasks: 6, outsidePeriod: 0 }); + eq('resourceGantt: naamloze resource — A telt als toegewezen', naamloos.counts, { resources: 1, assignments: 1, unassignedTasks: 6, outsidePeriod: 0, inPeriod: 7 }); // manuvarkey op #113, punt 2: twee lagen — typeband (vaste volgorde: mensen, dan materieel, dan // materiaal), daarin de resourcebanden op diepte 1, de taken op diepte 2; "(geen)" blijft achteraan @@ -567,6 +567,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); ok('resourceGantt/venster: elke getekende taak raakt het venster', windowed.rows.every(x => x.kind !== 'task' || overlapsB(x.task.id))); ok('resourceGantt/venster: elke bladtaak die het venster raakt staat er ook', leavesAll.filter(t => overlapsB(t.id)).every(t => windowed.rows.some(x => x.kind === 'task' && x.task.id === t.id))); eq('resourceGantt/venster: outsidePeriod telt de weggelaten bladtaken', windowed.counts.outsidePeriod, leavesAll.filter(t => !overlapsB(t.id)).length); + eq('resourceGantt/venster: inPeriod + outsidePeriod = alle bladtaken', windowed.counts.inPeriod + windowed.counts.outsidePeriod, leavesAll.length); ok('resourceGantt/venster: tellingen volgen de gefilterde set', windowed.counts.assignments < r.counts.assignments && windowed.counts.unassignedTasks < r.counts.unassignedTasks); const buiten = computeResourceGanttRows(base, { ...opts, window: { from: '2030-01-01', to: '2030-01-31' } }); eq('resourceGantt/venster: venster zonder taken ⇒ geen rijen, alles buiten de periode', [buiten.rows.length, buiten.counts.outsidePeriod, buiten.counts.resources], [0, leavesAll.length, 0]); @@ -576,7 +577,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); // manuvarkey op #113, punt 1: per taakrij de toewijzing van de band op die taak — eenheden opgeteld // over records van dezelfde resource, curve alleen als alle records dezelfde hebben (afwezig = // UNIFORM), anders null; taakrijen onder "(geen)" hebben geen entry. - const asgFull = (id: string, taskId: string, resourceId: string, unitsPerDay: number, curve?: ResourceCurve): ResourceAssignment => ({ id, taskId, resourceId, unitsPerDay, curve }); + const asgFull = (id: string, taskId: string, resourceId: string, unitsPerDay: number, curve?: ResourceCurve, extra: Partial = {}): ResourceAssignment => ({ id, taskId, resourceId, unitsPerDay, curve, ...extra }); const loaded = computeResourceGanttRows({ tasks: ctx.tasks, resources: [kraan, jan1], assignments: [ @@ -595,6 +596,28 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); ok('resourceGantt/toewijzing: elke taakrij onder een resourceband heeft een entry', loaded.rows.slice(0, noneStart).every(x => x.kind !== 'task' || loaded.assignmentByRowKey.has(x.rowKey))); ok('resourceGantt/toewijzing: "(geen)"-rijen hebben geen entry', noneStart > 0 && loaded.rows.slice(noneStart).every(x => x.kind !== 'task' || !loaded.assignmentByRowKey.has(x.rowKey))); eq('resourceGantt/toewijzing: aantal entries = aantal taakrijen onder resourcebanden', loaded.assignmentByRowKey.size, loaded.rows.slice(0, noneStart).filter(x => x.kind === 'task').length); + // Review-bevinding 1: dezelfde drie lagen als de lastverdeling — een contour op de taak wint, dan + // een geïmporteerde exacte curve zonder OPS-vorm, dan pas `curve`. Nooit "Uniform" bij een + // P6-/MSP-import met een echte curve. + const curveValues = [0, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const dMetContour = { ...byId(D), timephasedContours: [{ resourceUid: null, resourceId: jan1.id, periods: [] }] }; + const layered = computeResourceGanttRows({ + tasks: ctx.tasks.map(t => (t.id === D ? dMetContour : t)), resources: [kraan, jan1], + assignments: [ + asgFull('c1', A, kraan.id, 1, undefined, { curveValues }), // geïmporteerd, geen OPS-vorm + asgFull('c2', B, kraan.id, 1, 'FRONT_LOADED', { curveValues }), // OPS-vorm aanwezig ⇒ die wint + asgFull('c3', D, jan1.id, 1, 'BELL'), // contour op de taak ⇒ contoured + asgFull('c4', D, kraan.id, 1, undefined, { curveValues }), // geen contour voor Kraan ⇒ imported + ], + }, opts); + const rowOf2 = (bandName: string, taskId: string) => { + const start = layered.rows.findIndex(x => label(x) === bandName); + return layered.rows.slice(start + 1).find(x => x.kind === 'task' && x.task.id === taskId)!; + }; + eq('resourceGantt/curve: curveValues zonder curve ⇒ imported', layered.assignmentByRowKey.get(rowOf2('Kraan', A).rowKey)?.curve, 'imported'); + eq('resourceGantt/curve: curveValues mét OPS-vorm ⇒ de vorm', layered.assignmentByRowKey.get(rowOf2('Kraan', B).rowKey)?.curve, 'FRONT_LOADED'); + eq('resourceGantt/curve: contour op de taak voor deze resource ⇒ contoured', layered.assignmentByRowKey.get(rowOf2('Jan', D).rowKey)?.curve, 'contoured'); + eq('resourceGantt/curve: contour geldt alleen voor de gekoppelde resource', layered.assignmentByRowKey.get(rowOf2('Kraan', D).rowKey)?.curve, 'imported'); // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). @@ -604,7 +627,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); }, opts); eq('resourceGantt: dubbele toewijzing ⇒ één rij', bandTasks(dubbel.rows, 'Kraan'), [A]); // …maar de telling volgt de records, zoals het tabelrapport Resourcetoewijzingen (review N8). - eq('resourceGantt: onbekende resource ⇒ taak zonder resource; toewijzingen tellen records', dubbel.counts, { resources: 1, assignments: 2, unassignedTasks: 6, outsidePeriod: 0 }); + eq('resourceGantt: onbekende resource ⇒ taak zonder resource; toewijzingen tellen records', dubbel.counts, { resources: 1, assignments: 2, unassignedTasks: 6, outsidePeriod: 0, inPeriod: 7 }); // Bandvolgorde: taal-/cijferbewust ("Ploeg 2" vóór "Ploeg 10"), hoofdletterongevoelig. const p10 = res('p10', 'Ploeg 10'); const p2 = res('p2', 'ploeg 2'); const aa = res('aa', 'Aannemer'); @@ -635,7 +658,7 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); // Leeg: geen toewijzingen ⇒ geen rijen; geen taken ⇒ ook geen "(geen)"-band en nultellingen. ok('resourceGantt: geen toewijzingen ⇒ leeg', computeResourceGanttRows({ ...base, assignments: [] }, opts).rows.length === 0); const leeg = computeResourceGanttRows({ tasks: [], resources: base.resources, assignments: base.assignments }, { ...opts, includeUnassigned: true }); - eq('resourceGantt: leeg project ⇒ geen rijen, nultellingen', [leeg.rows.length, leeg.counts], [0, { resources: 0, assignments: 0, unassignedTasks: 0, outsidePeriod: 0 }]); + eq('resourceGantt: leeg project ⇒ geen rijen, nultellingen', [leeg.rows.length, leeg.counts], [0, { resources: 0, assignments: 0, unassignedTasks: 0, outsidePeriod: 0, inPeriod: 0 }]); } // ── Uitslag ────────────────────────────────────────────────────────────────────────────────────── From 87927052c414c711b62a6379ed98714ad5d084ba Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:10:22 +0200 Subject: [PATCH 073/108] fix(b1c): de as-projectie van de balk respecteert de gatcompressie `advanceWorkdays` liep over de dagen die de as overhield, terwijl `buildOccupancyAxis` elk gat van meer dan 30 kalenderdagen wegklapt. Een plafond van vijf werkdagen landde daardoor maanden te ver (probe: 2026-06-05 in plaats van 2026-03-13) en `slackBar`/`freeBox` liepen dwars door de breuk. De telling gaat nu over de KALENDER (`addCalendarDays` + `isWorkingDay`); pas de tekenpositie is een asvraag, via de nieuwe `rightEdgeX`, die een dag in een ingeklapt gat op de rechterrand van het voorafgaande segment klemt. De datum zelf blijft echt en gaat onverkort als `ceilingEndIso` naar het uitkomstlabel. `PhaseStrip` tekent de `axis.breaks` nu ook in de track, zoals het histogram. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/PhaseStrip.tsx | 14 ++++ .../DistributionDialog/stripGeometry.ts | 67 ++++++++++++++----- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 2ae84377b..92bdd97c4 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -322,6 +322,20 @@ export function PhaseStrip({ fill="var(--theme-text-dim)" opacity={0.14} /> ))} + {/* De BREUKEN van de as (bevinding B1). Een gat van meer dan `GAP_COMPRESS_DAYS` + kalenderdagen is weggeklapt; zonder markering leest de balk alsof de twee segmenten + aan elkaar vast zitten. Zelfde "⋯" als het histogram en de voor/na-grafiek. */} + {(axis?.breaks ?? []).map((x, i) => ( + + ⋯ + + ))} + {/* Weekscheidingen (1 px) en de donkerdere maandlijn. */} {(geometry?.weekLines ?? []).map((x, i) => ( diff --git a/src/components/dialogs/DistributionDialog/stripGeometry.ts b/src/components/dialogs/DistributionDialog/stripGeometry.ts index 250e63c8a..ec435cd68 100644 --- a/src/components/dialogs/DistributionDialog/stripGeometry.ts +++ b/src/components/dialogs/DistributionDialog/stripGeometry.ts @@ -22,7 +22,7 @@ // balk vol arcering staan en zou een echte, ingevoegde onderbreking niet meer opvallen — precies het // gebrek dat de gebruikstest van 2026-09-12 aanwees ("pauzes waren onzichtbaar"). import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; -import { parseDate } from '@/utils/dateUtils'; +import { parseDate, formatDate, addCalendarDays } from '@/utils/dateUtils'; /** De maatvoering van één rij, letterlijk uit het prototype (tabblad 4, `.prow`/`.ptrack.tall`). * Eén definitie, gedeeld door `PhaseStrip.tsx` (de rij zelf) en `DistributionDialog.tsx` (de @@ -136,27 +136,62 @@ function firstBookedDay(loadByDay: Record): string | null { } /** - * Loop vanaf (exclusief) `fromIso` `workdays` WERKdagen vooruit over de as en geef de dag waarop je - * uitkomt plus de x van zijn rechterrand. Loopt de as op vóór het aantal bereikt is, dan levert hij - * het aseinde — de handle mag de as nooit verlaten. + * De x van de RECHTERrand van `iso`. + * + * DE AS IS NIET DE KALENDER (bevinding B1 van de review). `buildOccupancyAxis` klapt elk gat van + * meer dan `GAP_COMPRESS_DAYS` kalenderdagen weg, dus een datum kan bestaan zonder een plek op de + * as te hebben (`xOf` geeft dan `null`). Zo'n dag krijgt hier de rechterrand van het segment waar + * hij ACHTER ligt: de balk stopt netjes op de breuk in plaats van er dwars doorheen te lopen. De + * datum zelf blijft onaangeroerd — die gaat als `ceilingEndIso` gewoon naar het uitkomstlabel. + */ +function rightEdgeX(axis: OccupancyAxis, iso: string): number { + const x = axis.xOf(iso); + if (x !== null) return x + axis.dayWidth; + let edge = AXIS.padLeft; + for (const segment of axis.segments) { + if (segment.days[0] > iso) break; + edge = segment.x0 + segment.days.length * axis.dayWidth; + } + return edge; +} + +/** Ruime bovengrens op de kalenderwandeling hieronder, zodat een kalender waarin (bijna) niets een + * werkdag is nooit een oneindige lus wordt. */ +function walkLimit(workdays: number): number { + return workdays * 7 + 400; +} + +/** + * Loop vanaf (exclusief) `fromIso` `workdays` WERKdagen vooruit — over de KALENDER, niet over de + * as — en geef de dag waarop je uitkomt plus de x van zijn rechterrand. + * + * WAAROM DE KALENDER EN NIET DE AS. Tot bevinding B1 liep deze functie over de dagen die de as nog + * overhad. Bij een gecomprimeerd gat (twee projecten maanden uit elkaar) sloeg hij daarmee de hele + * weggeklapte periode over en landde het plafond maanden te ver: een greep van "vijf werkdagen" + * kwam in juni uit in plaats van in maart. De telling hoort in kalenderdagen; pas de TEKENpositie + * is een asvraag, en die loopt via `rightEdgeX`. + * + * Vindt de wandeling het aantal niet binnen de limiet, dan is er geen datum (`iso === null`) en + * valt de x terug op het einde van de as. */ function advanceWorkdays( axis: OccupancyAxis, - days: { iso: string; x: number }[], fromIso: string | null, workdays: number, isWorkingDay: (iso: string) => boolean, ): { iso: string | null; xEnd: number } { - const startX = fromIso === null - ? AXIS.padLeft - : (axis.xOf(fromIso) ?? AXIS.padLeft) + axis.dayWidth; + if (fromIso === null) return { iso: null, xEnd: AXIS.padLeft }; + const startX = rightEdgeX(axis, fromIso); if (workdays <= 0) return { iso: fromIso, xEnd: startX }; let remaining = workdays; - for (const day of days) { - if (fromIso !== null && day.iso <= fromIso) continue; - if (!isWorkingDay(day.iso)) continue; + let cursor = parseDate(fromIso); + const limit = walkLimit(workdays); + for (let step = 0; step < limit; step++) { + cursor = addCalendarDays(cursor, 1); + const iso = formatDate(cursor); + if (!isWorkingDay(iso)) continue; remaining -= 1; - if (remaining === 0) return { iso: day.iso, xEnd: day.x + axis.dayWidth }; + if (remaining === 0) return { iso, xEnd: rightEdgeX(axis, iso) }; } return { iso: null, xEnd: axis.width - AXIS.padRight }; } @@ -184,7 +219,7 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // Een niet-werkdag zonder boeking is geen pauze maar gewoon weekend: niets tekenen. } } - const phaseEndX = last === null ? AXIS.padLeft : (axis.xOf(last) ?? AXIS.padLeft) + dayWidth; + const phaseEndX = last === null ? AXIS.padLeft : rightEdgeX(axis, last); // (2) De vaste last, samengevoegd tot aaneengesloten banden. const fixedBands: StripBand[] = []; @@ -200,8 +235,8 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // (3) De meetlat, verankerd aan het OORSPRONKELIJKE fase-einde. const origEnd = lastBookedDay(beforeLoadByDay); - const origEndX = origEnd === null ? AXIS.padLeft : (axis.xOf(origEnd) ?? AXIS.padLeft) + dayWidth; - const slack = advanceWorkdays(axis, days, origEnd, Math.max(0, Math.floor(slackWorkdays)), isWorkingDay); + const origEndX = origEnd === null ? AXIS.padLeft : rightEdgeX(axis, origEnd); + const slack = advanceWorkdays(axis, origEnd, Math.max(0, Math.floor(slackWorkdays)), isWorkingDay); const slackEndX = Math.min(trackRight, slack.xEnd); const slackBar = slackEndX > origEndX ? { x: origEndX, w: slackEndX - origEndX } : null; const overrunBar = phaseEndX > slackEndX ? { x: slackEndX, w: phaseEndX - slackEndX } : null; @@ -223,7 +258,7 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // de vrije ruimte dan stilzwijgend met die verschuiving krimpen terwijl er niets van de uitloop // verbruikt is. const remaining = Math.max(0, Math.floor(ceilingWorkdays) - Math.max(0, Math.floor(endShiftWorkdays))); - const ceiling = advanceWorkdays(axis, days, last, remaining, isWorkingDay); + const ceiling = advanceWorkdays(axis, last, remaining, isWorkingDay); handleX = Math.max(AXIS.padLeft, Math.min(trackRight, ceiling.xEnd)); ceilingEndIso = ceiling.iso; } From d1a28ff524b0b5dfc9659a86e2c50120efd4da00 Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:11:06 +0200 Subject: [PATCH 074/108] fix(b1c): bij een volledig tekort ankert de rij op het oorspronkelijke einde Boekt een document na de verdeling geen enkele dag meer, dan was `last` leeg en viel het fase-einde terug op de linkerrand van de as. De greep stond daarmee aan het BEGIN van de tijdlijn, onder de balken van andere projecten, met een plafonddatum die niets met dit document te maken had (probe: "max 10 sep" voor werk op 16-17 sep) en een meetlat die losgekoppeld in de lucht hing. Het anker valt nu terug op het einde van de VOOR-stand; alleen wanneer ook die leeg is blijft `AXIS.padLeft` over. Zowel `phaseEndX` als de plafondwandeling gebruiken datzelfde anker, dus de gestippelde rest blijft aan de rij vastzitten. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/stripGeometry.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/stripGeometry.ts b/src/components/dialogs/DistributionDialog/stripGeometry.ts index ec435cd68..dd5413802 100644 --- a/src/components/dialogs/DistributionDialog/stripGeometry.ts +++ b/src/components/dialogs/DistributionDialog/stripGeometry.ts @@ -77,7 +77,8 @@ export interface StripGeometry { overrunBar: StripBand | null; /** x van het MIDDEN van de handle. */ handleX: number; - /** x van de rechterrand van de laatste geboekte dag. */ + /** x van de rechterrand van de laatste geboekte dag — of, bij een volledig tekort (niets meer + * geboekt ná de verdeling), van het oorspronkelijke fase-einde. */ phaseEndX: number; /** De kalenderdag waarop het plafond uitkomt; `null` bij een onbegrensd plafond. */ ceilingEndIso: string | null; @@ -219,7 +220,16 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // Een niet-werkdag zonder boeking is geen pauze maar gewoon weekend: niets tekenen. } } - const phaseEndX = last === null ? AXIS.padLeft : rightEdgeX(axis, last); + // HET ANKER VAN DE RIJ (bevinding B2 van de review). Bij een VOLLEDIG tekort boekt dit document + // na de verdeling geen enkele dag meer — `last` is dan `null`. Het fase-einde op `padLeft` zetten + // legde de hele rij aan het BEGIN van de as: de greep landde links onder de balken van andere + // projecten, de meetlat raakte losgekoppeld en de pil beloofde een plafonddatum die met dit + // document niets te maken had. Het eerlijke anker is dan de VÓÓR-stand: daar stond dit werk, + // en daar hoort de bediening bij. Alleen als ook die leeg is (niets vóór, niets ná) valt er + // niets te ankeren en blijft `padLeft` over. + const origEnd = lastBookedDay(beforeLoadByDay); + const anchorEnd = last ?? origEnd; + const phaseEndX = anchorEnd === null ? AXIS.padLeft : rightEdgeX(axis, anchorEnd); // (2) De vaste last, samengevoegd tot aaneengesloten banden. const fixedBands: StripBand[] = []; @@ -234,7 +244,6 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { } // (3) De meetlat, verankerd aan het OORSPRONKELIJKE fase-einde. - const origEnd = lastBookedDay(beforeLoadByDay); const origEndX = origEnd === null ? AXIS.padLeft : rightEdgeX(axis, origEnd); const slack = advanceWorkdays(axis, origEnd, Math.max(0, Math.floor(slackWorkdays)), isWorkingDay); const slackEndX = Math.min(trackRight, slack.xEnd); @@ -258,7 +267,7 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // de vrije ruimte dan stilzwijgend met die verschuiving krimpen terwijl er niets van de uitloop // verbruikt is. const remaining = Math.max(0, Math.floor(ceilingWorkdays) - Math.max(0, Math.floor(endShiftWorkdays))); - const ceiling = advanceWorkdays(axis, last, remaining, isWorkingDay); + const ceiling = advanceWorkdays(axis, anchorEnd, remaining, isWorkingDay); handleX = Math.max(AXIS.padLeft, Math.min(trackRight, ceiling.xEnd)); ceilingEndIso = ceiling.iso; } From 4256f8854eb9ee5a6542cad5b0319599ccd661db Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:12:05 +0200 Subject: [PATCH 075/108] test(b1c): vier randgevallen voor de balkgeometrie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De check dekte drie brave gevallen op één aaneengesloten as — precies de vorm waarin de twee ernstige bevindingen van de review zich niet konden voordoen. Erbij: een deels benutte uitloop met een eindig plafond (4), een as met een ingeklapt gat waarin de plafonddatum de echte kalenderdatum moet zijn en geen band door de breuk mag lopen (5), een volledig tekort zonder na-boeking (6), en speling die voorbij de laatste asdag loopt (7). Co-Authored-By: Claude Fable 5.1 --- .../check-distribution-strip-geometry.ts | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/tests/planning/check-distribution-strip-geometry.ts b/tests/planning/check-distribution-strip-geometry.ts index 566ec764f..6b4514104 100644 --- a/tests/planning/check-distribution-strip-geometry.ts +++ b/tests/planning/check-distribution-strip-geometry.ts @@ -1,9 +1,14 @@ // B1c-plan4 taak 1 — de PURE balkgeometrie van de verdeeldialoog (spec §4/§9). // -// Drie gevallen uit spec §9, plus de verschil-prijs van §2.2. De as is bewust minimaal: één +// Drie gevallen uit spec §9, plus de verschil-prijs van §2.2. De hoofd-as is bewust minimaal: één // aaneengesloten reeks kalenderdagen, zodat elke x-positie met de hand na te rekenen is // (`AXIS.padLeft + index * dayWidth`). // +// Daarnaast vier randgevallen uit de review (bevinding B4), die stuk voor stuk een echte fout +// afvangen: een deels benutte uitloop (4), een as met een INGEKLAPT gat waarin de telling over de +// kalender moet lopen en niet over de asdagen (5), een volledig tekort zonder enige na-boeking (6), +// en speling die de as uitloopt (7). +// // De werkdagdefinitie komt als predicaat binnen — deze check gebruikt "ma t/m vr", zodat er geen // kalendermotor aan te pas komt en de pauzedag-logica geïsoleerd getoetst wordt. import { AXIS, buildOccupancyAxis, expandDays } from '@/components/panels/occupancyAxis'; @@ -134,6 +139,121 @@ ok(dw > 0, `de dagbreedte is positief (${dw})`); ok(g.monthLines.length === 0, '3 — geen maandlijn binnen september'); } +// ── Geval 4 (bevinding B4a): BENUTTE UITLOOP MET EEN EINDIG PLAFOND ────────────────────────── +// De uitloop is deels opgesoupeerd (`endShiftWorkdays` 2) terwijl het plafond 5 werkdagen is. Wat +// er dan nog openstaat zijn 3 werkdagen ACHTER het nieuwe fase-einde — dat is de gestippelde rest +// en de stand van de greep. +{ + const before = load(['2026-09-09', '2026-09-10', '2026-09-11']); + const after = load(['2026-09-14', '2026-09-15', '2026-09-16']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: after, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 1, + ceilingWorkdays: 5, + endShiftWorkdays: 2, + }); + + // Vanaf wo 16: do 17, vr 18, ma 21. + ok(g.ceilingEndIso === '2026-09-21', `4 — plafond 5 met 2 benut komt uit op ma 21 (kreeg ${g.ceilingEndIso})`); + ok(g.handleX === xAt('2026-09-21') + dw, '4 — de greep staat drie werkdagen voorbij het fase-einde'); + ok(g.freeBox !== null && g.freeBox.x === g.phaseEndX && g.freeBox.x + g.freeBox.w === g.handleX, + '4 — de gestippelde rest loopt van het fase-einde tot de greep'); + ok(g.overrunBar !== null && g.overrunBar.x + g.overrunBar.w === g.phaseEndX, + '4 — voorbij de ene werkdag speling staat rood tot aan het fase-einde'); +} + +// ── Geval 5 (bevinding B4b): EEN AS MET EEN INGEKLAPT GAT ──────────────────────────────────── +// Dit document boekt 2 t/m 6 maart; een ander boekt pas 1 t/m 5 juni. `buildOccupancyAxis` klapt +// dat gat van bijna drie maanden weg, dus de as heeft twee segmenten. Het plafond moet dan op de +// ECHTE kalenderdatum uitkomen (vr 13 maart), terwijl er niets dwars door de breuk getekend wordt. +{ + const mine = load(expandDays('2026-03-02', '2026-03-06')); + const theirs = load(expandDays('2026-06-01', '2026-06-05')); + const gapAxis = buildOccupancyAxis([...Object.keys(mine), ...Object.keys(theirs)], { targetWidth: 560 })!; + const segmentEnd = gapAxis.segments[0].x0 + gapAxis.segments[0].days.length * gapAxis.dayWidth; + const g = buildStripGeometry({ + axis: gapAxis, + beforeLoadByDay: mine, + loadByDay: mine, + fixedLoadByDay: theirs, + isWorkingDay, + slackWorkdays: 5, + ceilingWorkdays: 5, + endShiftWorkdays: 0, + }); + + ok(gapAxis.segments.length === 2, `5 — de as valt in twee segmenten (kreeg ${gapAxis.segments.length})`); + ok(gapAxis.breaks.length === 1, '5 — er is één breukmarkering'); + // Vijf werkdagen ná vr 6 maart: ma 9, di 10, wo 11, do 12, vr 13. Dat is een KALENDERwandeling; + // over de asdagen zou hij in juni uitkomen. + ok(g.ceilingEndIso === '2026-03-13', `5 — het plafond komt uit op vr 13 maart (kreeg ${g.ceilingEndIso})`); + ok(g.handleX === segmentEnd, '5 — de greep wordt op de rechterrand van het eerste segment geklemd'); + ok(g.handleX < gapAxis.breaks[0], '5 — de greep staat vóór de breuk, niet erachter'); + const beyondBreak = (band: { x: number; w: number } | null): boolean => + band !== null && band.x + band.w > segmentEnd; + ok(!beyondBreak(g.slackBar), '5 — de spelingsmeetlat loopt niet door de breuk heen'); + ok(!beyondBreak(g.freeBox), '5 — de gestippelde rest loopt niet door de breuk heen'); + ok(!beyondBreak(g.overrunBar), '5 — de rode meetlat loopt niet door de breuk heen'); + ok(g.blocks.every(b => b.iso <= '2026-03-06'), '5 — er staat geen dagblokje in het tweede segment'); +} + +// ── Geval 6 (bevinding B4c): VOLLEDIG TEKORT — NIETS MEER GEBOEKT ──────────────────────────── +// Past er niets, dan is de na-stand leeg. De rij hoort dan nog steeds bij het werk zoals het er +// vóór stond en niet aan het begin van de as te landen. +{ + const before = load(['2026-09-16', '2026-09-17']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: {}, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 2, + ceilingWorkdays: 3, + endShiftWorkdays: 0, + }); + + ok(g.blocks.length === 0, '6 — een lege na-stand tekent geen dagblokjes'); + ok(g.phaseEndX === xAt('2026-09-17') + dw, + '6 — het fase-einde valt terug op het oorspronkelijke einde, niet op de linkerrand'); + ok(g.phaseEndX !== AXIS.padLeft, '6 — de rij ankert dus niet aan het begin van de as'); + // Drie werkdagen ná do 17: vr 18, ma 21, di 22. + ok(g.ceilingEndIso === '2026-09-22', `6 — de plafonddatum rekent vanaf dat anker (kreeg ${g.ceilingEndIso})`); + ok(g.handleX === xAt('2026-09-22') + dw, '6 — de greep staat rechts van het oorspronkelijke einde'); + ok(g.slackBar !== null && g.slackBar.x === g.phaseEndX, + '6 — de meetlat sluit aan op datzelfde anker'); + ok(g.overrunBar === null, '6 — zonder boeking is er geen einddatum-verschuiving te tekenen'); +} + +// ── Geval 7 (bevinding B4d): SPELING DIE DE AS UITLOOPT ────────────────────────────────────── +// Tien werkdagen speling vanaf wo 23 sep loopt ruim voorbij de laatste asdag (vr 25). De meetlat +// mag de track niet verlaten, maar de plafonddatum blijft de echte kalenderdatum. +{ + const before = load(['2026-09-22', '2026-09-23']); + const trackRight = axis.width - AXIS.padRight; + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: before, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 10, + ceilingWorkdays: 10, + endShiftWorkdays: 0, + }); + + ok(g.slackBar !== null, '7 — er is een spelingsmeetlat'); + ok(g.slackBar!.x + g.slackBar!.w === trackRight, '7 — die wordt op de rechterrand van de track geklemd'); + ok(g.handleX === trackRight, '7 — de greep verlaat de track niet'); + // Tien werkdagen ná wo 23 sep: do 24, vr 25, ma 28 … vr 2 okt, ma 5, di 6, wo 7. + ok(g.ceilingEndIso === '2026-10-07', + `7 — de plafonddatum blijft de ECHTE kalenderdatum, ook buiten de as (kreeg ${g.ceilingEndIso})`); +} + // ── De verschil-prijs (§2.2) ───────────────────────────────────────────────────────────────── ok(savingsWorkdays(5, 2) === 3, 'prijs — uit 5, aan 2 ⇒ bespaart 3 werkdagen'); ok(savingsWorkdays(4, 4) === 0, 'prijs — even duur ⇒ bespaart niets'); From e20f6a6fa37c301f0a827d146c8e23391492e6ba Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:13:07 +0200 Subject: [PATCH 076/108] perf(b1c): de prijspas rekent het hoofdvoorstel niet nog eens over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scheduleSavingsPass` draaide beide standen van "Onderbrekingen toestaan", terwijl het hoofdvoorstel de ingestelde stand net had gerekend over dezelfde, pure `built`-invoer — gegarandeerd hetzelfde getal. De prijs van die stand komt nu uit `proposalResult`; alleen de tegenovergestelde stand wordt nog berekend. Halveert het rekenwerk achter elke sleepstap. Co-Authored-By: Claude Fable 5.1 --- .../useDistributionProposal.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts index 79344eb3f..e07143499 100644 --- a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -433,15 +433,22 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr * op het scherm zelf ook al niet meer bij de documenten hoort. * * DIT WAS VIER KEER ZO DUUR. Tot 2026-09-12 deed deze pas óók per deelnemer een isolatierun voor - * de kostenlabels van de rangordelijst. Die lijst is weg (§2.1) en die runs dus ook: er blijven - * twee runs over in plaats van N+2. Dat is wat het live meerekenen tijdens het slepen (§5) - * betaalbaar maakt. + * de kostenlabels van de rangordelijst. Die lijst is weg (§2.1) en die runs dus ook. + * + * EN ÉÉN VAN DIE TWEE WAS ER AL (bevinding B3 van de review). Het hoofdvoorstel is per definitie + * al de run van de INGESTELDE stand van `allowSplits`, en `computeDistribution` is puur over + * dezelfde `built` — dus die stand nog eens uitrekenen levert gegarandeerd hetzelfde getal op. + * De prijs van de ingestelde stand komt daarom uit `proposalResult`; alleen de TEGENOVERGESTELDE + * stand wordt hier nog gerekend. Eén run per voorstel in plaats van twee, wat het live + * meerekenen tijdens het slepen (§5) betaalbaar houdt. */ const scheduleSavingsPass = ( myGeneration: number, tuneAtRun: DistributionUiState, pool: CompanyPool, built: DistributionDocInput[], + /** Het zojuist berekende hoofdvoorstel — de run van `tuneAtRun.allowSplits`. */ + mainProposal: DistributionProposal, ): void => { const superseded = () => generationRef.current !== myGeneration || fingerprintsRef.current === null; // Alleen de teller van DEZE pas mag de bezig-toestand weer uitzetten: een nieuwere pas loopt @@ -451,18 +458,17 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr const priceOf = (p: DistributionProposal): number | null => p.blocked ? null : maxEndShiftWorkdays(p.docs); - let offPrice: number | null = null; - let onPrice: number | null = null; + // De ingestelde stand is het hoofdvoorstel zelf; alleen de andere stand moet nog gerekend. + const mainPrice = priceOf(mainProposal); + const otherStand = !tuneAtRun.allowSplits; + let offPrice: number | null = tuneAtRun.allowSplits ? null : mainPrice; + let onPrice: number | null = tuneAtRun.allowSplits ? mainPrice : null; const steps: (() => void)[] = [ () => { - offPrice = priceOf(computeDistribution( - tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: false }, - )); - }, - () => { - onPrice = priceOf(computeDistribution( - tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: true }, + const other = priceOf(computeDistribution( + tuneAtRun.companyId, pool, tuneAtRun.libraryItemId, built, { allowSplits: otherStand }, )); + if (otherStand) onPrice = other; else offPrice = other; }, () => { if (offPrice !== null && onPrice !== null) { @@ -529,7 +535,7 @@ export function useDistributionProposal(tune: DistributionUiState | null): Distr // een volledige run erbij — dat is precies de kost die de schaal-degradatie voorkomt, dus // daar blijft het bij "druk op Herbereken". if (pool && proposalResult && !proposalResult.blocked && !isDistributionDegraded(built, current)) { - scheduleSavingsPass(myGeneration, current, pool, built); + scheduleSavingsPass(myGeneration, current, pool, built, proposalResult); } } finally { busyRef.current = false; From 6ddd3bc8b537d616a9c08208e75f8842f2888457 Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:13:23 +0200 Subject: [PATCH 077/108] docs(b1c): de gids zegt eerlijk wat een gearceerd blokje betekent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De gids beweerde dat arcering alleen verschijnt met "Onderbrekingen toestaan" aan. De balk arceert elke boekingsloze werkdag binnen de looptijd — ook een natuurlijk gat tussen twee taken en een verschil tussen taakkalenders. Zin herschreven in nl en en. Co-Authored-By: Claude Fable 5.1 --- public/docs/en/gids-verdelen-restcapaciteit.md | 6 +++--- public/docs/nl/gids-verdelen-restcapaciteit.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index 0faba8da0..a6412d6eb 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -76,9 +76,9 @@ Below the switch, each project gets one bar on one shared timeline. On the left a colour dot and the slack it still has, in the middle the bar itself, on the right the outcome. Inside the bar you see one block per workday in the project's colour, separated by a thin white line -— so you can literally count the days. A **hatched** block is a pause day: a workday on which the -distributor holds the work still to make room for another project. Those only appear when "Allow -interruptions" is on. +— so you can literally count the days. A **hatched** block is a day inside the run in which this +project does not use this resource: an inserted pause (which happens when "Allow interruptions" is +on) or a natural gap between two tasks. A thin gauge runs along the bottom of the bar. The **grey dotted** part is the slack this project still had: it may shift that far without moving its end date. The **solid red** part is everything diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index 2949fd277..f27606c52 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -79,9 +79,9 @@ Onder de schakelaar staat per project één balk op één gedeelde tijdas. Links kleurstip en de speling die dat project nog heeft, in het midden de balk zelf, rechts de uitkomst. In de balk zie je per werkdag een apart blokje in de kleur van het project, met een dun wit lijntje -ertussen — zo kun je de dagen letterlijk tellen. Een **gearceerd** blokje is een pauzedag: een -werkdag waarop de verdeler het werk even stillegt om ruimte te maken voor een ander project. Die -verschijnen alleen als je "Onderbrekingen toestaan" aan hebt staan. +ertussen — zo kun je de dagen letterlijk tellen. Een **gearceerd** blokje is een dag binnen de +looptijd waarop dit project deze resource niet gebruikt: een ingevoegde pauze (die ontstaat als je +"Onderbrekingen toestaan" aan hebt staan) óf een natuurlijk gat tussen twee taken. Onderin de balk loopt een dunne meetlat mee. Het **grijs gestippelde** deel is de speling die dit project nog had: zoveel mag het opschuiven zonder dat de einddatum meegaat. Het **massief rode** From 967ea44b9561eadf583426b89d8f4e0a38cff96f Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:13:40 +0200 Subject: [PATCH 078/108] chore(b1c): de dode sleutel resource.distribution.tool.title weg Geen enkele aanroep in src, tests, public, docs of scripts; de gereedschapsrij van de verdeeldialoog heeft geen kop meer. In alle 14 locales verwijderd. Co-Authored-By: Claude Fable 5.1 --- src/i18n/locales/ar/common.json | 1 - src/i18n/locales/de/common.json | 1 - src/i18n/locales/en/common.json | 1 - src/i18n/locales/es/common.json | 1 - src/i18n/locales/fa/common.json | 1 - src/i18n/locales/fr/common.json | 1 - src/i18n/locales/it/common.json | 1 - src/i18n/locales/ja/common.json | 1 - src/i18n/locales/ko/common.json | 1 - src/i18n/locales/nl/common.json | 1 - src/i18n/locales/pl/common.json | 1 - src/i18n/locales/pt/common.json | 1 - src/i18n/locales/tr/common.json | 1 - src/i18n/locales/zh/common.json | 1 - 14 files changed, 14 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 548ba2d30..bbf2afd2b 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -711,7 +711,6 @@ "noDemand": "لا يحجز أي من المشاريع عملاً هنا فعليًا." }, "tool": { - "title": "الأداة", "allowSplits": "السماح بالانقطاعات", "allowSplitsHint": "يمكن أن يحصل العمل على أيام توقف بدلاً من الانزياح دفعة واحدة. في MS Project يسمى هذا \"Leveling can create splits in remaining work\".", "priceUnknown": "السعر غير معروف — اضغط على إعادة الحساب", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index a39db6592..b82227d26 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -663,7 +663,6 @@ "noDemand": "Keines der Projekte bucht hier tatsächlich Arbeit." }, "tool": { - "title": "Werkzeug", "allowSplits": "Unterbrechungen zulassen", "allowSplitsHint": "Die Arbeit darf Pausentage bekommen, statt sich am Stück zu verschieben. In MS Project heißt das \"Leveling can create splits in remaining work\".", "priceUnknown": "Preis unbekannt — Neu berechnen drücken", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 13def4f27..9695503a6 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -663,7 +663,6 @@ "noDemand": "None of the projects actually book work here." }, "tool": { - "title": "Tool", "allowSplits": "Allow interruptions", "allowSplitsHint": "The work may get pause days instead of shifting in one piece. In MS Project this is called \"Leveling can create splits in remaining work\".", "priceUnknown": "price unknown — press Recalculate", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f117e4978..aaeb6092d 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -675,7 +675,6 @@ "noDemand": "Ninguno de los proyectos reserva trabajo realmente aquí." }, "tool": { - "title": "Herramienta", "allowSplits": "Permitir interrupciones", "allowSplitsHint": "El trabajo puede recibir días de pausa en lugar de desplazarse de una sola vez. En MS Project esto se llama \"Leveling can create splits in remaining work\".", "priceUnknown": "precio desconocido — pulsa Recalcular", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index f851756fd..8f924b5d5 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -663,7 +663,6 @@ "noDemand": "هیچ‌کدام از پروژه‌ها در اینجا واقعاً کاری رزرو نمی‌کنند." }, "tool": { - "title": "ابزار", "allowSplits": "اجازه دادن به وقفه‌ها", "allowSplitsHint": "کار می‌تواند به‌جای جابه‌جایی یکجا، روزهای وقفه بگیرد. در MS Project به این «Leveling can create splits in remaining work» گفته می‌شود.", "priceUnknown": "قیمت نامشخص — روی محاسبه مجدد بزنید", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 479171247..b34e75b78 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -675,7 +675,6 @@ "noDemand": "Aucun des projets ne réserve réellement du travail ici." }, "tool": { - "title": "Outil", "allowSplits": "Autoriser les interruptions", "allowSplitsHint": "Le travail peut recevoir des jours de pause plutôt que de se décaler d'un bloc. Dans MS Project, cela s'appelle « Leveling can create splits in remaining work ».", "priceUnknown": "prix inconnu — appuyez sur Recalculer", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 343bb6759..bab0e3a6f 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -675,7 +675,6 @@ "noDemand": "Nessuno dei progetti prenota effettivamente lavoro qui." }, "tool": { - "title": "Strumento", "allowSplits": "Consenti interruzioni", "allowSplitsHint": "Il lavoro può ricevere giorni di pausa invece di spostarsi in un unico blocco. In MS Project questo si chiama \"Leveling can create splits in remaining work\".", "priceUnknown": "prezzo sconosciuto — premi Ricalcola", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 1da49b28c..19ae372c1 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -651,7 +651,6 @@ "noDemand": "どのプロジェクトもここで実際に作業を予約していません。" }, "tool": { - "title": "ツール", "allowSplits": "中断を許可", "allowSplitsHint": "作業は一気に移動する代わりに休止日を挟むことができます。MS Project ではこれを「Leveling can create splits in remaining work」と呼びます。", "priceUnknown": "価格不明 — 再計算を押してください", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 807152e7c..bea3aed9c 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -651,7 +651,6 @@ "noDemand": "어떤 프로젝트도 실제로 여기에서 작업을 예약하지 않습니다." }, "tool": { - "title": "도구", "allowSplits": "중단 허용", "allowSplitsHint": "작업이 한 번에 이동하는 대신 중단일을 가질 수 있습니다. MS Project에서는 이를 \"Leveling can create splits in remaining work\"라고 합니다.", "priceUnknown": "가격 알 수 없음 — 다시 계산을 누르세요", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index e70a6ea76..8bc2e16e1 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -663,7 +663,6 @@ "noDemand": "Geen van de projecten boekt hier daadwerkelijk werk op." }, "tool": { - "title": "Gereedschap", "allowSplits": "Onderbrekingen toestaan", "allowSplitsHint": "Het werk mag pauzedagen krijgen in plaats van in één stuk op te schuiven. In MS Project heet dit \"Leveling can create splits in remaining work\".", "priceUnknown": "prijs onbekend — druk op Herbereken", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index ca4917a9e..139582918 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -687,7 +687,6 @@ "noDemand": "Żaden z projektów faktycznie tu nie rezerwuje pracy." }, "tool": { - "title": "Narzędzie", "allowSplits": "Zezwól na przerwy", "allowSplitsHint": "Praca może dostać dni przerwy zamiast przesuwać się w całości. W MS Project nazywa się to \"Leveling can create splits in remaining work\".", "priceUnknown": "cena nieznana — naciśnij Przelicz ponownie", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index a8f51360b..9c5536832 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -675,7 +675,6 @@ "noDemand": "Nenhum dos projetos reserva efetivamente trabalho aqui." }, "tool": { - "title": "Ferramenta", "allowSplits": "Permitir interrupções", "allowSplitsHint": "O trabalho pode receber dias de pausa em vez de se deslocar de uma só vez. No MS Project isto chama-se \"Leveling can create splits in remaining work\".", "priceUnknown": "preço desconhecido — prima Recalcular", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index cb40d44b0..32742f859 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -663,7 +663,6 @@ "noDemand": "Projelerin hiçbiri burada gerçekten iş rezerve etmiyor." }, "tool": { - "title": "Araç", "allowSplits": "Kesintilere izin ver", "allowSplitsHint": "İş, tek parça halinde kaymak yerine ara günler alabilir. MS Project'te buna \"Leveling can create splits in remaining work\" denir.", "priceUnknown": "fiyat bilinmiyor — Yeniden Hesapla'ya basın", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 4ebe54a84..0e89cf088 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -651,7 +651,6 @@ "noDemand": "没有任何项目在此实际预订工作。" }, "tool": { - "title": "工具", "allowSplits": "允许中断", "allowSplitsHint": "工作可以插入暂停日,而不是整体顺延。在 MS Project 中这称为\"Leveling can create splits in remaining work\"。", "priceUnknown": "价格未知 — 按下重新计算", From 34dd3969f438f8fb5493d26a9cfeb18ddce3af83 Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:15:00 +0200 Subject: [PATCH 079/108] fix(b1c): de rode pil zegt het tekort, en de greep krijgt focus na een klik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twee kleine bevindingen uit de review. B6: bij een tekort werd de uitkomstpil rood maar bleef de tekst "eind ongewijzigd" — rood met een geruststellende zin erin. De pil toont nu de tekortzin zelf, in de korte vorm van de bestaande tekortsleutel (de projectnaam staat al links in de rij); de volle zin plus de einddatum-uitkomst blijven in de `title`. De pil truncateert, dus de vaste maat van het uitkomstlabel blijft. B9: `preventDefault()` op `pointerdown` onderdrukte ook de focus-stap, zodat een pijltje na een muisklik op de greep niets deed. De greep pakt de focus nu zelf, meteen na het capturen. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/PhaseStrip.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 92bdd97c4..534acffa0 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -149,6 +149,22 @@ export function PhaseStrip({ ? t('resource.distribution.strip.maxDate', { date: formatDay(geometry.ceilingEndIso) }) : t('resource.distribution.strip.maxDate', { date: t('resource.distribution.strip.ceilingUnlimited') }); const usedText = t('resource.distribution.strip.used', { used: endShiftWorkdays }); + // BIJ EEN TEKORT STAAT DE TEKORTZIN ZÉLF IN DE PIL (bevinding B6 van de review). De pil wordt + // rood zodra er een taak niet past, maar de tekst bleef "eind ongewijzigd" — rood met een + // geruststellende zin erin las als een tegenspraak. De korte vorm hergebruikt de bestaande + // tekortsleutel met een LEGE documentnaam: die naam staat al in het label links, en in alle + // veertien locales staat `{{doc}}` vooraan, gevolgd door een scheidingsteken dat er hier dus + // afgesneden wordt. De volle zin blijft in de `title` staan, samen met de einddatum-uitkomst. + const shortfallText = shortfallCount > 0 + ? t('resource.distribution.shortfall.doc', { doc: '', count: shortfallCount }) + .replace(/^[\s:\uff1a\u00b7\u2013-]+/, '') + : ''; + const pillText = busy + ? t('resource.distribution.compute.busy') + : (shortfallCount > 0 ? shortfallText : shiftText); + const pillTitle = shortfallCount > 0 + ? `${shortfallTitle ?? shortfallText} · ${shiftText}` + : undefined; const valueText = `${ceilingText} — ${shiftText}`; const clamp = (value: number) => Math.max(0, Math.min(CEILING_MAX_WORKDAYS, value)); @@ -184,6 +200,11 @@ export function PhaseStrip({ if (pinned || dragRef.current) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); + // `preventDefault()` onderdrukt óók de standaard focus-stap van de browser (bevinding B9): na + // een klik op de greep stond de focus nog op wat er daarvóór actief was, en deed een pijltje + // dus niets — terwijl de greep er wel "aangeraakt" uitzag. Focus dus zelf zetten, ná het + // capturen, zodat toetsenbordbediening naadloos op de muis aansluit. + event.currentTarget.focus(); dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startCeiling: stepBase, moved: false, value: stepBase }; setDragValue(stepBase); }; @@ -440,15 +461,15 @@ export function PhaseStrip({ data-ops-distribution-effect > 0 - ? { 'data-ops-distribution-effect-shortfall': 'true', ...(shortfallTitle ? { title: shortfallTitle } : {}) } + ? { 'data-ops-distribution-effect-shortfall': 'true', ...(pillTitle ? { title: pillTitle } : {}) } : {})} style={busy ? { background: 'color-mix(in srgb, var(--theme-text-dim) 14%, transparent)', color: 'var(--theme-text-secondary)' } : tone} > - {busy ? t('resource.distribution.compute.busy') : shiftText} + {pillText} {`${maxDateText} · ${usedText}`} From c9f779eab818d1fe8baf17731156fab36045ed1f Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:16:12 +0200 Subject: [PATCH 080/108] test(b1c): browserassertie dat de greep de focus houdt na een muisgebaar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleept de gebruiker de greep en laat hij los, dan moet een pijltje het meteen doen. De test klikt bewust zonder `focus()` vooraf, controleert dat `document.activeElement` de greep van DEZE rij is, en drukt daarna ArrowRight via het toetsenbord — dat kan alleen slagen met de focus op de greep. Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 9221ed9e5..f1541f823 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -380,6 +380,43 @@ test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ p }, shiftedDocId)).toBe(5); }); +// --- Fixronde bevinding B9 — de greep houdt de focus na een muisgebaar --------------------------- + +test('plafond-handle: na loslaten heeft de greep de focus en werkt een pijltje meteen', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + expect(dayWidth).toBeGreaterThan(0); + + // BEWUST GEEN `handle.focus()` VOORAF — dat is precies wat deze test moet aantonen. De greep + // roept `preventDefault()` aan op `pointerdown` (nodig om tekstselectie tijdens het slepen te + // voorkomen), en dat onderdrukt óók de focus-stap van de browser. Zonder de eigen `focus()` in + // de component staat de focus na dit gebaar nog op wat er daarvóór actief was. + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 2 * dayWidth, box.y + box.height / 2, { steps: 4 }); + await page.mouse.up(); + + // (1) De focus staat op DEZE greep — niet ergens anders in de dialoog. + await expect.poll(() => page.evaluate(() => + document.activeElement?.hasAttribute('data-ops-distribution-handle') ?? false, + )).toBe(true); + const focusedDocId = await page.evaluate(() => + document.activeElement?.closest('[data-ops-distribution-strip]')?.getAttribute('data-ops-doc-id') ?? null); + expect(focusedDocId).toBe(await strip.getAttribute('data-ops-doc-id')); + + // (2) En een pijltje werkt meteen, zónder eerst ergens te klikken of te tabben: de toets gaat + // naar `document.activeElement`, dus dit kan alleen slagen als de greep de focus echt heeft. + const before = Number(await handle.getAttribute('aria-valuenow')); + await page.keyboard.press('ArrowRight'); + await expect(handle).toHaveAttribute('aria-valuenow', String(before + 1)); +}); + // --- B1c-plan3 taak 11b — de voor/na-grafiek (spec §7) --------------------------------------------- test('voor/na-preview: de na-stand blijft binnen de capaciteitslijn', async ({ page, ops: _ops }) => { From 88fa1e23c7e1c4f3c3af3ed3e2df9a762db3611a Mon Sep 17 00:00:00 2001 From: Nozzit Date: Mon, 14 Sep 2026 18:22:35 +0200 Subject: [PATCH 081/108] feat(i18n): eigen sleutel voor de tekortzin in de uitkomstpil De pil leende de tekortzin nog van `shortfall.doc_*` met een lege `{{doc}}` en sneed het scheidingsteken dat dan vooraan bleef staan eraf. Dat leunt op de woordvolgorde van veertien vertalingen en breekt stil zodra iemand de documentnaam achteraan zet. `resource.distribution.strip.shortfall` is nu een volwaardige meervoudfamilie in alle veertien locales, met de CLDR-categorieen van de taal zelf: zh/ja/ko alleen `_other`, pl `_one/_few/_many/_other`, fr/es/it/pt `_one/_many/_other`, ar `_zero/_one/_two/_few/_many/_other`, de rest `_one/_other`. De teksten zijn die van de bestaande tekortzin zonder de documentnaam ervoor -- die naam staat al in het label links van dezelfde rij. De volle zin blijft in de `title`. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/PhaseStrip.tsx | 13 +++++++------ src/i18n/locales/ar/common.json | 6 ++++++ src/i18n/locales/de/common.json | 2 ++ src/i18n/locales/en/common.json | 2 ++ src/i18n/locales/es/common.json | 3 +++ src/i18n/locales/fa/common.json | 2 ++ src/i18n/locales/fr/common.json | 3 +++ src/i18n/locales/it/common.json | 3 +++ src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 2 ++ src/i18n/locales/pl/common.json | 4 ++++ src/i18n/locales/pt/common.json | 3 +++ src/i18n/locales/tr/common.json | 2 ++ src/i18n/locales/zh/common.json | 1 + 15 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 534acffa0..96cdf5585 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -151,13 +151,14 @@ export function PhaseStrip({ const usedText = t('resource.distribution.strip.used', { used: endShiftWorkdays }); // BIJ EEN TEKORT STAAT DE TEKORTZIN ZÉLF IN DE PIL (bevinding B6 van de review). De pil wordt // rood zodra er een taak niet past, maar de tekst bleef "eind ongewijzigd" — rood met een - // geruststellende zin erin las als een tegenspraak. De korte vorm hergebruikt de bestaande - // tekortsleutel met een LEGE documentnaam: die naam staat al in het label links, en in alle - // veertien locales staat `{{doc}}` vooraan, gevolgd door een scheidingsteken dat er hier dus - // afgesneden wordt. De volle zin blijft in de `title` staan, samen met de einddatum-uitkomst. + // geruststellende zin erin las als een tegenspraak. De pil krijgt de KORTE vorm zonder + // documentnaam: die naam staat al in het label links van dezelfde rij. Dat is een eigen + // meervoudfamilie (`strip.shortfall_*`) en niet de langere `shortfall.doc_*` met een lege + // `{{doc}}` — zo'n truc leunt op de woordvolgorde van veertien vertalingen en breekt stil zodra + // een vertaler de documentnaam achteraan zet. De volle zin blijft in de `title` staan, samen met + // de einddatum-uitkomst. const shortfallText = shortfallCount > 0 - ? t('resource.distribution.shortfall.doc', { doc: '', count: shortfallCount }) - .replace(/^[\s:\uff1a\u00b7\u2013-]+/, '') + ? t('resource.distribution.strip.shortfall', { count: shortfallCount }) : ''; const pillText = busy ? t('resource.distribution.compute.busy') diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index bbf2afd2b..3b0c43745 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -742,6 +742,12 @@ "reset": "يعيد جميع الحدود والتثبيتات إلى الوضع المحايد. يبقى مفتاح «السماح بالانقطاعات» كما هو." }, "strip": { + "shortfall_zero": "لا توجد مهام غير مناسبة", + "shortfall_one": "مهمة واحدة غير مناسبة", + "shortfall_two": "مهمتان غير مناسبتين", + "shortfall_few": "{{count}} مهام غير مناسبة", + "shortfall_many": "{{count}} مهمة غير مناسبة", + "shortfall_other": "{{count}} مهمة غير مناسبة", "pinnedRecorded": "مثبَّت: التواريخ كما سُجِّلت", "cannotMove": "لا يمكنه الانزياح — كل العمل هنا ثابت", "ceilingUnlimited": "غير محدود", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index b82227d26..89e14b7f3 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -686,6 +686,8 @@ "reset": "Setzt alle Obergrenzen und Fixierungen auf neutral zurück. Der Schalter „Unterbrechungen zulassen“ bleibt unverändert." }, "strip": { + "shortfall_one": "{{count}} Vorgang passt nicht", + "shortfall_other": "{{count}} Vorgänge passen nicht", "pinnedRecorded": "Angeheftet: Datumsangaben wie gespeichert", "cannotMove": "Kann nicht ausweichen — die gesamte Arbeit hier steht fest", "ceilingUnlimited": "unbegrenzt", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 9695503a6..8431e064b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -686,6 +686,8 @@ "reset": "Puts all ceilings and pins back to neutral. The \"Allow interruptions\" switch stays as it is." }, "strip": { + "shortfall_one": "{{count}} task does not fit", + "shortfall_other": "{{count}} tasks do not fit", "pinnedRecorded": "Pinned: dates as recorded", "cannotMove": "Cannot shift — all the work here is fixed", "ceilingUnlimited": "unlimited", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index aaeb6092d..2987a0515 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -700,6 +700,9 @@ "reset": "Devuelve todos los topes y fijaciones al estado neutro. El interruptor «Permitir interrupciones» no cambia." }, "strip": { + "shortfall_one": "{{count}} tarea no cabe", + "shortfall_many": "{{count}} tareas no caben", + "shortfall_other": "{{count}} tareas no caben", "pinnedRecorded": "Fijado: fechas tal como se guardaron", "cannotMove": "No puede desplazarse — todo el trabajo aquí está fijo", "ceilingUnlimited": "sin límite", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 8f924b5d5..0b7cca656 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -686,6 +686,8 @@ "reset": "همهٔ سقف‌ها و سنجاق‌ها را به حالت خنثی برمی‌گرداند. کلید «اجازهٔ وقفه» بدون تغییر می‌ماند." }, "strip": { + "shortfall_one": "{{count}} وظیفه جا نمی‌شود", + "shortfall_other": "{{count}} وظیفه جا نمی‌شود", "pinnedRecorded": "ثابت‌شده: تاریخ‌ها همان‌طور که ثبت شده‌اند", "cannotMove": "نمی‌تواند جابه‌جا شود — همه کارهای اینجا ثابت هستند", "ceilingUnlimited": "نامحدود", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b34e75b78..5419beae2 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -700,6 +700,9 @@ "reset": "Remet tous les plafonds et épinglages à l'état neutre. L'interrupteur « Autoriser les interruptions » reste tel quel." }, "strip": { + "shortfall_one": "{{count}} tâche ne tient pas", + "shortfall_many": "{{count}} tâches ne tiennent pas", + "shortfall_other": "{{count}} tâches ne tiennent pas", "pinnedRecorded": "Épinglé : dates telles qu'enregistrées", "cannotMove": "Ne peut pas se décaler — tout le travail ici est figé", "ceilingUnlimited": "illimité", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index bab0e3a6f..b40fefda4 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -700,6 +700,9 @@ "reset": "Riporta tutti i tetti e i blocchi allo stato neutro. L'interruttore «Consenti interruzioni» resta invariato." }, "strip": { + "shortfall_one": "{{count}} attività non entra", + "shortfall_many": "{{count}} attività non entrano", + "shortfall_other": "{{count}} attività non entrano", "pinnedRecorded": "Bloccato: date come registrate", "cannotMove": "Non può slittare — tutto il lavoro qui è fisso", "ceilingUnlimited": "illimitato", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 19ae372c1..99a308b36 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -672,6 +672,7 @@ "reset": "すべての上限と固定を中立に戻します。「中断を許可」スイッチはそのままです。" }, "strip": { + "shortfall_other": "{{count}} 件のタスクが収まりません", "pinnedRecorded": "固定済み:記録された日付のとおり", "cannotMove": "動かせません — ここの作業はすべて確定しています", "ceilingUnlimited": "無制限", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index bea3aed9c..b8ffe2598 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -672,6 +672,7 @@ "reset": "모든 상한과 고정을 중립 상태로 되돌립니다. '중단 허용' 스위치는 그대로 유지됩니다." }, "strip": { + "shortfall_other": "{{count}}개 작업이 맞지 않습니다", "pinnedRecorded": "고정됨: 기록된 날짜대로", "cannotMove": "이동할 수 없음 — 여기의 모든 작업이 고정되어 있습니다", "ceilingUnlimited": "무제한", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 8bc2e16e1..0bae0ce4f 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -686,6 +686,8 @@ "reset": "Zet alle plafonds en vastzettingen terug op neutraal. De schakelaar \"Onderbrekingen toestaan\" blijft staan." }, "strip": { + "shortfall_one": "{{count}} taak past niet", + "shortfall_other": "{{count}} taken passen niet", "pinnedRecorded": "Vastgezet: datums zoals opgeslagen", "cannotMove": "Kan niet wijken — al het werk hier staat vast", "ceilingUnlimited": "onbegrensd", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 139582918..848baab12 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -714,6 +714,10 @@ "reset": "Przywraca wszystkie limity i przypięcia do stanu neutralnego. Przełącznik „Zezwalaj na przerwy” pozostaje bez zmian." }, "strip": { + "shortfall_one": "{{count}} zadanie nie mieści się", + "shortfall_few": "{{count}} zadania nie mieszczą się", + "shortfall_many": "{{count}} zadań nie mieści się", + "shortfall_other": "{{count}} zadania nie mieści się", "pinnedRecorded": "Przypięty: daty tak jak zapisane", "cannotMove": "Nie może się przesunąć — cała praca tutaj jest ustalona", "ceilingUnlimited": "bez ograniczeń", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 9c5536832..7ed7eb1fe 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -700,6 +700,9 @@ "reset": "Repõe todos os limites e fixações no estado neutro. O interruptor «Permitir interrupções» fica como está." }, "strip": { + "shortfall_one": "{{count}} tarefa não cabe", + "shortfall_many": "{{count}} tarefas não cabem", + "shortfall_other": "{{count}} tarefas não cabem", "pinnedRecorded": "Fixado: datas como registadas", "cannotMove": "Não pode deslocar-se — todo o trabalho aqui está fixo", "ceilingUnlimited": "ilimitado", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 32742f859..4e9722efe 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -686,6 +686,8 @@ "reset": "Tüm tavanları ve sabitlemeleri nötr duruma döndürür. \"Kesintilere izin ver\" anahtarı olduğu gibi kalır." }, "strip": { + "shortfall_one": "{{count}} görev sığmıyor", + "shortfall_other": "{{count}} görev sığmıyor", "pinnedRecorded": "Sabitlendi: kayıtlı tarihler", "cannotMove": "Kayamaz — buradaki tüm iş sabit", "ceilingUnlimited": "sınırsız", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 0e89cf088..55e1ec3f3 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -672,6 +672,7 @@ "reset": "将所有上限和固定恢复为中立状态。「允许中断」开关保持不变。" }, "strip": { + "shortfall_other": "{{count}} 项任务无法安排", "pinnedRecorded": "已固定:按记录的日期", "cannotMove": "无法顺延 — 这里的所有工作都已固定", "ceilingUnlimited": "无限制", From 8b6bbc7563b33aa03764f8dc56756c623aa5c433 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:32:03 +0000 Subject: [PATCH 082/108] =?UTF-8?q?Rapport:=20resourcediagram=20=E2=80=94?= =?UTF-8?q?=20reviewronde=202:=20curve-kolom=2098=20px,=20eerlijke=20curve?= =?UTF-8?q?toestand-doc,=20contourkoppeling,=20lege-staat-wrap=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyperkritische review ronde 2 ("ja, mits"): - Curve-kolom 92 → 98 px: "Obciążony na początku"/"Zaimportowana krzywa" (pl) pasten op de echte Inter-metriek nog niet in 84 px netto. - De moduledoc claimde "dezelfde drie lagen als de lastverdeling"; dat is onwaar (`assignmentDayUnits` verdeelt met `curveValues` óók naast een `curve`, en negeert een contour zonder periodes). De doc zegt nu wat het is: de weergaveregel van het eigenschappenpaneel, zodat rapport en paneel gelijk lopen; een gedeelde toestandshelper is een vervolgstap. Gids nl+en: "Geïmporteerde curve" alleen als het geen van de acht eigen vormen is, en de tijdas kan bij lange namen met afkappen uit bijna verdwijnen. - Contourkoppeling op de volledige recordlijst per taak (ook records naar een onbekende resource), zoals `contourLookup` en het paneel — anders viel de legacy-terugval (`assignments.length === 1`) hier anders uit dan daar. - Lege staat: woord-wrap (`wrapWords`) i.p.v. één afgekapte regel — de Duitse `emptyPeriod` kapte bij 100 % al halverwege de instructie af. - Ruitklem alleen als het middelpunt zelf in de chart ligt (anders niet tekenen): een naar binnen geklemde baselineruit suggereerde een dag waarop de mijlpaal niet valt. - Minimumbreedte van een balk bij een venster: naar binnen geschoven zoals de ruit i.p.v. afgeknepen tot sub-pixel; zonder venster ongewijzigd. - Test: ruit-assertie eist `pts.length > 0` (was vacuüm vervulbaar), wrap-test op de lege staat, legacy-contour met een record naar een onbekende resource. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- public/docs/en/gids-rapporten-printen.md | 10 +++-- public/docs/nl/gids-rapporten-printen.md | 9 +++-- src/engine/reports/resourceGantt.ts | 34 ++++++++++------ src/services/print/printPreview.ts | 50 ++++++++++++++++++++---- tests/planning/check-print-report.ts | 15 +++++-- tests/planning/check-reports.ts | 9 +++++ 6 files changed, 95 insertions(+), 32 deletions(-) diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index a1a47c3b5..b797740ee 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -117,10 +117,12 @@ default: how many units per day the band's resource is assigned to the task, and distribution curve — "you are on Foundations half days, front loaded" says more than "you are on Foundations". A task under two bands shows each band's own load; two assignments of the same resource on one task are added up, and a dash in the curve column means those assignments carry -different curves. If the assignment has its own hour distribution or an exact curve imported from -MS Project or P6, the column says *Contour* or *Imported curve* respectively — the same as in the -properties panel. The two columns make the table wider and the time axis narrower; turn **Show -units/day and curve** off for the narrow table of the Gantt print. +different curves. If the assignment has its own hour distribution the column says *Contour*; if +the curve was imported from MS Project or P6 and is none of the eight built-in shapes it says +*Imported curve* — the same as in the properties panel. The two columns make the table a good 130 px +wider and the time axis narrower; with long task names and *Truncate task names* off the time axis +can all but disappear. Turn **Show units/day and curve** off for the narrow table of the Gantt +print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 2994684fd..ae6040f10 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -120,10 +120,11 @@ dag de resource van die band op de taak staat en met welke verdeelcurve — "je op Fundering, vooraan belast" zegt meer dan "je staat op Fundering". Een taak onder twee banden toont per band de eigen inzet; twee toewijzingen van dezelfde resource op één taak worden opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillende curves hebben. -Heeft de toewijzing een eigen urenverdeling of een uit MS Project of P6 geïmporteerde exacte curve, -dan staat er *Contour* respectievelijk *Geïmporteerde curve* — hetzelfde als in het -eigenschappenpaneel. De twee kolommen maken de tabel breder en de tijdas dus smaller; zet -**Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. +Heeft de toewijzing een eigen urenverdeling, dan staat er *Contour*; is de curve uit MS Project of +P6 geïmporteerd en is het geen van de acht eigen vormen, dan staat er *Geïmporteerde curve* — +hetzelfde als in het eigenschappenpaneel. De twee kolommen maken de tabel ruim 130 px breder en de +tijdas dus smaller; bij lange taaknamen met *Taaknamen afkappen* uit kan de tijdas daardoor bijna +verdwijnen. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het diff --git a/src/engine/reports/resourceGantt.ts b/src/engine/reports/resourceGantt.ts index ab2a9eb76..e936eec1a 100644 --- a/src/engine/reports/resourceGantt.ts +++ b/src/engine/reports/resourceGantt.ts @@ -46,12 +46,17 @@ import type { ResolvedPeriod } from './reportingPeriod'; * * Per taakrij onder een resourceband levert `assignmentByRowKey` de TOEWIJZING van die band op * die taak (punt 1): eenheden per dag en de verdeelcurve — de rij is een taak, maar wat de lezer - * wil weten is "hoe zwaar staat déze resource erop". De curve volgt dezelfde drie lagen als de - * lastverdeling zelf (`ResourceLoad.ts`'s `assignmentDayUnits`) en het eigenschappenpaneel - * (`TaskAssignmentsSection`): een opgeslagen CONTOUR op de taak wint (`'contoured'`), dan een - * exacte geïmporteerde curve zonder OPS-vorm (`curveValues` zonder `curve` ⇒ `'imported'`), dan - * pas `curve` (afwezig = UNIFORM) — een P6-/MSP-import met een front-loaded curve mag hier nooit - * "Uniform" heten (hyperkritische review, bevinding 1). Twee records van dezelfde resource op één + * wil weten is "hoe zwaar staat déze resource erop". De curveTOESTAND is exact de weergaveregel + * van het eigenschappenpaneel (`TaskAssignmentsSection`), zodat rapport en paneel nooit twee + * antwoorden geven: een aan de toewijzing gekoppelde CONTOUR op de taak ⇒ `'contoured'`, anders + * `curveValues` zonder OPS-vorm (`curve` afwezig) ⇒ `'imported'`, anders `curve` (afwezig = + * UNIFORM). Let op: dat is een WEERGAVEregel, niet de verdeelregel van `ResourceLoad.ts`'s + * `assignmentDayUnits` — die verdeelt met `curveValues` zodra die er zijn, óók naast een `curve` + * (het gewone P6-pad: naamterugval + exacte waarden), en negeert een contour zonder periodes. Het + * rapport toont dus wat het paneel toont ("Vooraan belast"), terwijl het histogram P6's exacte + * waarden gebruikt; een echt gedeelde toestandshelper is een vervolgstap (review ronde 2). Een + * P6-/MSP-import met een curve zonder OPS-vorm heet hier in elk geval nooit "Uniform" + * (ronde 1, bevinding 1). Twee records van dezelfde resource op één * taak (één rij) worden opgeteld; de curve is alleen bekend als alle records dezelfde hebben * (anders `null`, de render toont een streepje). De "(geen)"-band heeft geen toewijzing en dus * geen entry. De render tekent dit als twee tabelkolommen (`PrintOptions.assignmentColumns`). @@ -195,17 +200,20 @@ export function computeResourceGanttRows( // Per resource-id × taak-id de opgetelde eenheden en de verzameling curvetoestanden (punt 1). const loadByResourceTask = new Map }>(); const assignedTaskIds = new Set(); - // Geldige records per taak, voor de contourkoppeling (dezelfde `matchContoursToAssignments` als - // de lastverdeling en het eigenschappenpaneel: per taak, in recordvolgorde). - const validByTask = new Map(); + // Contourkoppeling op de VOLLEDIGE recordlijst per taak — ook records naar een onbekende + // resource (die filtert de rij-opbouw hieronder pas weg) — precies zoals `ResourceLoad.ts`'s + // `contourLookup` en het eigenschappenpaneel de lijst aanbieden; anders kan de legacy-terugval + // in `matchContoursToAssignments` (`assignments.length === 1`) hier anders uitvallen dan daar + // (review ronde 2, bevinding 3). + const recordsByTask = new Map(); for (const a of ctx.assignments) { - if (!leafById.has(a.taskId) || !resourceIds.has(a.resourceId)) continue; - const list = validByTask.get(a.taskId) ?? []; + if (!leafById.has(a.taskId)) continue; + const list = recordsByTask.get(a.taskId) ?? []; list.push(a); - validByTask.set(a.taskId, list); + recordsByTask.set(a.taskId, list); } const contouredIds = new Set(); - for (const [taskId, list] of validByTask) { + for (const [taskId, list] of recordsByTask) { const task = leafById.get(taskId)!; if (task.timephasedContours && task.timephasedContours.length > 0) { for (const id of matchContoursToAssignments(task.timephasedContours, list).keys()) contouredIds.add(id); diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index a745597af..6f96d303b 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -93,7 +93,7 @@ const COL = { // Toewijzingskolommen van het resourcediagram (manuvarkey punt 1): eenheden per dag en de // verdeelcurve van de resource van de band op die taak. Alleen bij `assignmentColumns`. units: { w: 45 }, - curve: { w: 92 }, + curve: { w: 98 }, }; /** @@ -499,6 +499,23 @@ function formatDuration(days: number): string { return `${days}d`; } +/** + * Breek `text` op woordgrenzen in regels die binnen `maxWidth` passen (dezelfde px-eenheid als + * `d2d.measureText`). Eén woord dat alleen al te breed is wordt met een ellipsis afgekort. + */ +function wrapWords(d2d: Draw2D, text: string, maxWidth: number): string[] { + const lines: string[] = []; + let line = ''; + for (const word of text.split(/\s+/).filter(Boolean)) { + const candidate = line ? `${line} ${word}` : word; + if (d2d.measureText(candidate).width <= maxWidth) { line = candidate; continue; } + if (line) lines.push(line); + line = d2d.measureText(word).width <= maxWidth ? word : fitText(d2d, word, maxWidth); + } + if (line) lines.push(line); + return lines.length > 0 ? lines : ['']; +} + /** Eenheden per dag: geheel als "2", anders tot twee decimalen zonder nullen ("0.5", "1.25"). */ function formatUnitsPerDay(units: number): string { return String(Math.round(units * 100) / 100); @@ -695,7 +712,12 @@ export function renderReport( d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(14); d2d.textAlign = 'center'; - d2d.fillText(fitText(d2d, options.labels?.noTasks ?? 'No tasks to display', 580), 300, 100); + // Woord-wrap binnen de 600 px brede doos: een instructie ("kies een andere periode …") mag + // niet halverwege afkappen (review ronde 2, bevinding 4). Verticaal is er ruimte zat. + const emptyLines = wrapWords(d2d, options.labels?.noTasks ?? 'No tasks to display', 560); + const emptyLineH = m.s(18); + const emptyTop = 100 - ((emptyLines.length - 1) * emptyLineH) / 2; + emptyLines.forEach((line, i) => d2d.fillText(line, 300, emptyTop + i * emptyLineH)); // Geen kop-/tijdschaalstrook in de lege-staat (alleen een centrale melding) ⇒ niets te herhalen. // Het meldingsvak zelf houdt z'n vaste 600×200; alleen de tekst erin volgt de schaal. return { width: 600, height: 200, tableWidth: m.tableWidth, headerHeight: 0, footerHeight: 0 }; @@ -1053,7 +1075,9 @@ export function renderReport( // Tijdvenster: een ruit die het chartgebied helemaal mist wordt niet getekend; een ruit op de // rand wordt met zijn middelpunt naar binnen geklemd, zodat hij nooit half over de tabel of // over de rechterrand hangt (hyperkritische review, bevinding 2). - const inWindow = !windowed || (x + size >= m.tableWidth && x - size <= canvasWidth); + // Middelpunt buiten het chartgebied ⇒ niet tekenen (een naar binnen geklemde ruit zou + // een dag suggereren waarop de mijlpaal niet valt — review ronde 2, bevinding 6). + const inWindow = !windowed || (x >= m.tableWidth && x <= canvasWidth); const cx = windowed ? Math.min(canvasWidth - size, Math.max(m.tableWidth + size, x)) : x; if (inWindow) { @@ -1160,8 +1184,18 @@ export function renderReport( for (const s of segs) { // De minimumbreedte (3 px, 2 bij splits) op de RUWE maat, en het einde bij een venster op de // chartrand geklemd zodat dat minimum er niet overheen steekt. - const rawSw = Math.max(s.rx2 - s.rx1, split ? 2 : 3); - const sw = (windowed ? Math.min(s.rx1 + rawSw, canvasWidth) : s.rx1 + rawSw) - s.x1; + const minW = split ? 2 : 3; + const rawSw = Math.max(s.rx2 - s.rx1, minW); + // Zichtbare breedte: zonder venster de oude `max(breedte, minimum)` op x1; bij een venster + // de geklemde breedte, en is die smaller dan het minimum, dan schuift het minimum naar + // binnen (zoals de ruit) in plaats van te worden afgeknepen, zodat een eendagstaak op de + // laatste vensterdag zichtbaar blijft (review ronde 2, bevinding 7). + let sx1 = s.x1; + let sw = windowed ? s.x2 - s.x1 : rawSw; + if (windowed && sw < minW) { + sw = Math.min(minW, canvasWidth - m.tableWidth); + sx1 = Math.max(m.tableWidth, Math.min(s.x1, canvasWidth - sw)); + } if (advies.kind === 'segments') { // Kleurvakken op de ruwe tijdas verdeeld en daarna per vak op het chartgebied geknipt: een // afgekapte balk toont zo de kleuren die bij het zichtbare stuk horen (review, bevinding 8). @@ -1180,13 +1214,13 @@ export function renderReport( }); } else { d2d.fillStyle = advies.fill; - d2d.roundRect(s.x1, y, sw, barHeight, 3); + d2d.roundRect(sx1, y, sw, barHeight, 3); d2d.fill(); } if (advies.outline) { d2d.strokeStyle = advies.outline; d2d.lineWidth = 1; - d2d.roundRect(s.x1, y, sw, barHeight, 3); + d2d.roundRect(sx1, y, sw, barHeight, 3); d2d.stroke(); } } @@ -1236,7 +1270,7 @@ export function renderReport( if (baseline.isMilestone) { const x = dateToX(parseDate(baseline.start)) + zoom / 2; const cy = baseY + baseHeight / 2; - if (!windowed || (x + baseHeight >= m.tableWidth && x - baseHeight <= canvasWidth)) { + if (!windowed || (x >= m.tableWidth && x <= canvasWidth)) { const bcx = windowed ? Math.min(canvasWidth - baseHeight, Math.max(m.tableWidth + baseHeight, x)) : x; d2d.beginPath(); d2d.moveTo(bcx, cy - baseHeight); diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 8e6788b90..8f1de7378 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -534,7 +534,9 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ const low = record([ms, T_CRIT], [], cal, { ...fixed, customZoom: 4, timeWindow: win }); const ld = low.dims; const inBodyPt = (y: number) => y >= ld.headerHeight && y < ld.height - ld.footerHeight; - const bodyFills = low.fills.filter(f => f.pts.every(p => inBodyPt(p.y))); + // `pts.length > 0`: een lege `beginPath(); roundRect(); fill()` (voortgangsoverlay) mag de + // assertie niet vacuüm vervullen (review ronde 2, bevinding 9). + const bodyFills = low.fills.filter(f => f.pts.length > 0 && f.pts.every(p => inBodyPt(p.y))); ok(bodyFills.length >= 1, `de ruit wordt getekend (got ${bodyFills.length} gevulde paden)`); ok(bodyFills.every(f => f.pts.every(p => p.x >= ld.tableWidth - 1e-6 && p.x <= ld.width + 1e-6)), `ruit binnen het chartgebied (got ${JSON.stringify(bodyFills.map(f => f.pts.map(p => Math.round(p.x * 100) / 100)))}, tabel ${ld.tableWidth}, chart tot ${ld.width})`); @@ -543,7 +545,14 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ // Review-bevinding 7: de 3 px-minimumbreedte steekt niet meer over de rechter chartrand. const tiny = record([T_CRIT], [], cal, { ...fixed, customZoom: 1, timeWindow: { from: '2026-01-10', to: '2026-01-12' } }); const tinyBar = tiny.roundRects.find(r => r.color === CRITICAL && r.y >= tiny.dims.headerHeight && r.y < tiny.dims.height - tiny.dims.footerHeight); - ok(!!tinyBar && tinyBar.x + tinyBar.w <= tiny.dims.width + 1e-6 && tinyBar.w > 0, `minimumbreedte geklemd op de chartrand (got ${JSON.stringify(tinyBar)}, chart tot ${tiny.dims.width})`); + ok(!!tinyBar && tinyBar.x + tinyBar.w <= tiny.dims.width + 1e-6 && tinyBar.w >= 3 - 1e-6, `minimumbreedte naar binnen geschoven en op de chartrand geklemd (got ${JSON.stringify(tinyBar)}, chart tot ${tiny.dims.width})`); + // Lege staat: een lange instructie wordt op woordgrenzen gewrapt, nooit halverwege afgekapt. + // De testmeter rekent 6 px per teken; deze tekst is 143 tekens = 858 px, dus minstens twee regels. + const lang = 'Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt, um alle Vorgänge des Projekts wieder zu sehen.'; + const leeg = record([], [], cal, baseOptions({ labels: { ...baseOptions().labels!, noTasks: lang } })); + const leegTexts = leeg.texts.map(t => t.text); + ok(leegTexts.length >= 2 && leegTexts.join(' ') === lang && leegTexts.every(t => !t.includes('…')), + `lege staat gewrapt zonder afkappen (got ${JSON.stringify(leegTexts)})`); } // Toewijzingskolommen (manuvarkey punt 1): twee kolommen direct achter de naam (x 180–225 en @@ -563,7 +572,7 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ const withCols = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, curveLabels: { FRONT_LOADED: 'Vooraan belast' }, })); - ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 92, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); + ok(withCols.dims.tableWidth === plainA.dims.tableWidth + 45 + 98, `tabel precies twee kolommen breder (got +${withCols.dims.tableWidth - plainA.dims.tableWidth})`); const inCol = (t: { x: number; y: number }, x0: number, x1: number) => t.x >= x0 && t.x <= x1 && t.y > withCols.dims.headerHeight && t.y < withCols.dims.height - withCols.dims.footerHeight; const unitsTexts = withCols.texts.filter(t => inCol(t, 180, 225)).map(t => t.text).sort(); const curveTexts = withCols.texts.filter(t => inCol(t, 225, 300)).map(t => t.text).sort(); diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 486bc6021..6f2089fd8 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -618,6 +618,15 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); eq('resourceGantt/curve: curveValues mét OPS-vorm ⇒ de vorm', layered.assignmentByRowKey.get(rowOf2('Kraan', B).rowKey)?.curve, 'FRONT_LOADED'); eq('resourceGantt/curve: contour op de taak voor deze resource ⇒ contoured', layered.assignmentByRowKey.get(rowOf2('Jan', D).rowKey)?.curve, 'contoured'); eq('resourceGantt/curve: contour geldt alleen voor de gekoppelde resource', layered.assignmentByRowKey.get(rowOf2('Kraan', D).rowKey)?.curve, 'imported'); + // Ronde 2, bevinding 3: de contourkoppeling loopt op de VOLLEDIGE recordlijst van de taak, ook + // met een record naar een onbekende resource erbij — dan slaat de legacy-terugval (één contour + // zonder resourceId bij precies één record) níét toe, net als in het paneel en de lastverdeling. + const dLegacy = { ...byId(D), timephasedContours: [{ resourceUid: null, periods: [] }] }; + const legacy = computeResourceGanttRows({ + tasks: ctx.tasks.map(t => (t.id === D ? dLegacy : t)), resources: [kraan], + assignments: [asgFull('g1', D, kraan.id, 1), asgFull('g2', D, 'res-onbekend', 1)], + }, opts); + eq('resourceGantt/curve: legacy-contour telt niet bij twee records (ook als er één naar een onbekende resource wijst)', legacy.assignmentByRowKey.get(legacy.rows.find(x => x.kind === 'task' && x.task.id === D)!.rowKey)?.curve, 'UNIFORM'); // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). From 3516e2835e493a40243cc5135a44350d08713e39 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:44:48 +0200 Subject: [PATCH 083/108] fix(b1c): de verdeelbalk kleurt mee met het donkere thema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--theme-text-secondary` bestaat in geen enkel thema. De greep viel daardoor met zijn rand terug op currentColor — knalwit op donker — en zijn drie grijpstreepjes werden juist onzichtbaar. Vervangen door `--theme-text-dim`, en de greep krijgt het zwevende oppervlak met de bestaande bedieningsrand, plus een accentrand zodra hij de focus heeft. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 4 +-- .../dialogs/DistributionDialog/PhaseStrip.tsx | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 6d16a7320..7e5fe5a18 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -615,7 +615,7 @@ export function DistributionDialog() { /> {`${doc.title}: ${doc.projectEndAfter ? formatDay(doc.projectEndAfter) : '—'}`} @@ -709,7 +709,7 @@ export function DistributionDialog() { : statusLine.tone === 'bad' ? 'var(--error)' : 'var(--theme-text-dim)', color: statusLine.tone === 'ok' ? 'var(--success)' - : statusLine.tone === 'bad' ? 'var(--error)' : 'var(--theme-text-secondary)', + : statusLine.tone === 'bad' ? 'var(--error)' : 'var(--theme-text-dim)', }} role="status" aria-live="polite" diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 96cdf5585..b6106c234 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -120,6 +120,12 @@ export function PhaseStrip({ // event-grenzen heen), `dragValue` de renderbare afgeleide. Niet-`null` ⇒ er wordt nu gesleept. const dragRef = useRef<{ pointerId: number; startX: number; startCeiling: number; moved: boolean; value: number } | null>(null); const [dragValue, setDragValue] = useState(null); + // De greep moet ZICHTBAAR de focus hebben (spec §5): hij wordt met pijltjes bediend, en zonder + // omranding weet je niet welke van de rijen die toets opvangt. Dat kan hier niet met + // `:focus-visible` in CSS omdat de hele greep inline gestyled is (zijn x volgt de geometrie), dus + // de focus is gewone React-state. `onFocus`/`onBlur` en niet `:focus-within`: de greep IS het + // focusbare element. + const [focused, setFocused] = useState(false); const dayWidth = axis?.dayWidth ?? 0; const trackWidth = axis?.width ?? AXIS.padLeft + 200; @@ -431,26 +437,38 @@ export function PhaseStrip({ onPointerMove={onHandlePointerMove} onPointerUp={onHandlePointerUp} onPointerCancel={onHandlePointerCancel} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} data-ops-distribution-handle + {...(focused ? { 'data-ops-distribution-handle-focused': 'true' } : {})} className="absolute flex items-center justify-center rounded-[4px] border" style={{ left: handleX - STRIP.handleWidth / 2, top: STRIP.handleTop, width: STRIP.handleWidth, height: STRIP.handleHeight, - borderColor: 'var(--theme-text-secondary)', - background: 'var(--theme-surface)', + // ZWEVEND OPPERVLAK MET EEN BEDIENINGSRAND, geen wit blokje. Vóór deze polishronde stond + // hier `var(--theme-text-secondary)` — een variabele die in geen enkel thema bestaat, + // dus viel de rand terug op `currentColor` en was de greep in het donkere thema knalwit + // (gemeten 2026-09-14: `borderTopColor: rgb(241,243,245)`), terwijl de drie + // grijpstreepjes op diezelfde niet-bestaande variabele juist ONZICHTBAAR waren + // (achtergrond → transparant). `--theme-control-border` is de bestaande rand van + // bedienbare elementen; in focus wordt dat het accent. + borderColor: focused && !pinned ? 'var(--theme-accent)' : 'var(--theme-control-border)', + background: 'var(--theme-surface-elevated)', + boxShadow: focused && !pinned ? '0 0 0 2px color-mix(in srgb, var(--theme-accent) 35%, transparent)' : 'none', cursor: pinned ? 'not-allowed' : 'ew-resize', opacity: pinned ? 0.45 : 1, touchAction: 'none', padding: 0, + outline: 'none', }} > {/* Drie grijpstreepjes (`.phandle::before` met twee box-shadows in het prototype). */} - - - + + +
@@ -467,7 +485,7 @@ export function PhaseStrip({ ? { 'data-ops-distribution-effect-shortfall': 'true', ...(pillTitle ? { title: pillTitle } : {}) } : {})} style={busy - ? { background: 'color-mix(in srgb, var(--theme-text-dim) 14%, transparent)', color: 'var(--theme-text-secondary)' } + ? { background: 'color-mix(in srgb, var(--theme-text-dim) 14%, transparent)', color: 'var(--theme-text-dim)' } : tone} > {pillText} From 43e37226af02d13132303679393e869abee4689f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:45:36 +0000 Subject: [PATCH 084/108] test(browser): just-updated-dialog stubt de GitHub Releases-API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De twee updater-specs faalden op CI (run 34869219752) op een 403 van api.github.com — de onbevoegde Releases-API is op een gedeelde runner rate-limited — en in een sandbox achter een proxy op ERR_CERT_AUTHORITY_INVALID; beide als console.error, die de harness als fout telt. De tests asserteren niets uit die vergelijking (de pure functies staan in tests/planning/check-just-updated.ts), dus de route antwoordt nu deterministisch met een lege lijst. Lokaal 2/2 groen, waar dezelfde spec eerder in deze sandbox rood stond. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- tests/browser/just-updated-dialog.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/browser/just-updated-dialog.spec.ts b/tests/browser/just-updated-dialog.spec.ts index c07247345..d1d22c856 100644 --- a/tests/browser/just-updated-dialog.spec.ts +++ b/tests/browser/just-updated-dialog.spec.ts @@ -7,6 +7,18 @@ declare global { } } +/** + * De dialoog bevraagt de GitHub Releases-API voor de releasevergelijking (grootteverschil, tijd + * tussen releases). Onbevoegd is die API op een gedeelde CI-runner rate-limited (403) en achter + * een sandboxproxy niet vertrouwd (ERR_CERT_AUTHORITY_INVALID); beide geven een console.error die + * de harness als fout telt, terwijl deze tests niets uit die vergelijking asserteren. Antwoord + * daarom deterministisch met een lege lijst — de pure functies erachter zijn headless getest in + * tests/planning/check-just-updated.ts. + */ +async function stubReleasesApi(page: Page): Promise { + await page.route('https://api.github.com/repos/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })); +} + async function selectLocale(page: Page, option: string, expectedLocale: string): Promise { await page.evaluate(() => window.__OPS__!.store.getState().setUI({ showSettingsDialog: true })); const settings = page.locator('.settings-dialog'); @@ -21,6 +33,7 @@ async function selectLocale(page: Page, option: string, expectedLocale: string): } test('update-highlights volgen de app-ready route en houden externe link open', async ({ page, ops: _ops }) => { + await stubReleasesApi(page); await page.evaluate(() => { window.openedReleaseUrls = []; window.open = ((url?: string | URL) => { @@ -50,6 +63,7 @@ test('update-highlights volgen de app-ready route en houden externe link open', }); test('update-highlights werken smal, licht/donker en RTL', async ({ page, ops: _ops }) => { + await stubReleasesApi(page); await page.setViewportSize({ width: 390, height: 420 }); await selectLocale(page, 'NL — Nederlands', 'nl'); await page.evaluate(() => window.__OPS__!.store.getState().setUI({ uiTheme: 'light', justUpdated: { from: null, to: '2026.8.1' } })); From a908436ab9d3a486c83e81bd5ade5145a4fcd0d6 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:48:03 +0200 Subject: [PATCH 085/108] fix(b1c): de balk vult de dialoog, en de dagbreedte ligt vast per sessie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De as werd op een vaste richtbreedte van 560 gebouwd terwijl hij tegen zijn dagbreedteplafond van 16 px aan liep: de hele track was 74 px en driekwart van de dialoog bleef leeg. De dialoog meet nu de ruimte tussen label en uitkomstlabel en geeft die als `minWidth` mee, met een ruimer dagbreedteplafond van 28 px; weinig dagen geeft dus lege track in plaats van metersbrede blokken. Daarnaast legt de eerste as van een dialoogsessie de dagbreedte vast (`fixedDayWidth`). Zonder dat fitte elke herberekening de groeiende dagenset opnieuw, kromp de tijdas onder de muis tijdens het slepen en sprong de andere rij mee. Een aanroep zonder de nieuwe opties — het bezettingshistogram — blijft onveranderd. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 67 +++++++++++++++++-- src/components/panels/occupancyAxis.ts | 24 +++++-- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 7e5fe5a18..a053b5674 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -41,7 +41,7 @@ // links `STRIP.labelWidth + STRIP.gap` en rechts `STRIP.endWidth + STRIP.gap` marge, zodat hun // plotgebied exact samenvalt met de tracks; de `AXIS.padLeft` van 34 px zit ín de as en geldt dus // voor beide. -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; import { useAppStore } from '@/state/appStore'; @@ -60,6 +60,15 @@ import { PhaseStrip } from './PhaseStrip'; import { DistributionPicker } from './DistributionPicker'; import { BeforeAfterChart } from './BeforeAfterChart'; +/** De inhoudsbreedte van het dialoogpaneel: `w-[960px]` min de `p-4`-padding aan weerszijden. + * Alleen de terugval vóór de eerste ResizeObserver-meting; daarna telt de gemeten breedte. */ +const DIALOG_CONTENT_WIDTH = 960 - 32; + +/** Het plafond op de dagbreedte IN DE DIALOOG. Het bezettingshistogram houdt zijn eigen, smallere + * plafond (`AXIS.maxDayWidth` = 16) omdat het een heel project moet tonen; hier gaat het vaak om + * een handvol dagen die je moet kunnen aanwijzen en tellen. Boven ~28 px worden het luiken. */ +const DIALOG_MAX_DAY_WIDTH = 28; + export function DistributionDialog() { const { t, i18n } = useTranslation('common'); const tune = useAppStore(s => s.ui.levelingDistribution); @@ -76,6 +85,41 @@ export function DistributionDialog() { const close = () => setUI({ showDistributionDialog: false }); + // --- DE BREEDTE VAN DE TRACK (polishronde 2026-09-14, bevinding 2) ------------------------------ + // + // De as werd met een vaste `targetWidth: 560` gebouwd terwijl de dialoog 960 px breed is, en dat + // getal deed er bovendien niets toe: met een handvol dagen liep de dagbreedte tegen zijn plafond + // van 16 px aan en was de hele track 74 px — twee blokjes tegen de linkerrand met driekwart + // dialoog leeg ernaast. De track hoort de volle ruimte TUSSEN het label en het uitkomstlabel te + // beslaan; we meten die ruimte dus echt, in plaats van 960 aan te nemen (de dialoog is + // `max-w-[95vw]` en wordt op een smal scherm smaller). + const scrollerRef = useRef(null); + const hasTune = tune !== null; + const [scrollerWidth, setScrollerWidth] = useState(0); + useEffect(() => { + const el = scrollerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => setScrollerWidth(el.clientWidth)); + observer.observe(el); + setScrollerWidth(el.clientWidth); + return () => observer.disconnect(); + }, [hasTune]); + /** De ruimte die er voor de track zelf overblijft; vóór de eerste meting de 960-px-rekensom. */ + const trackSpace = Math.max( + 240, + (scrollerWidth > 0 ? scrollerWidth : DIALOG_CONTENT_WIDTH) - STRIP.labelWidth - STRIP.endWidth - 2 * STRIP.gap, + ); + + // DE DAGBREEDTE LIGT VAST ZOLANG DE DIALOOG OPEN STAAT (bevinding 3). De dagenset groeit mee met + // het plafond, dus een as die zich elke herberekening opnieuw op de beschikbare breedte fit + // kreeg bij élke commit een andere dagbreedte: de tijdas kromp onder de muis tijdens het slepen + // en de x van de ándere rij sprong mee. Nu bepaalt de eerste as van een dialoogsessie de + // dagbreedte en houden alle volgende die vast; groeit de dagenset, dan wordt de as breder naar + // RECHTS, in de horizontale scroll-container die er toch al omheen zit. + const dayWidthRef = useRef<{ key: string; dayWidth: number } | null>(null); + const sessionKey = tune ? `${tune.companyId}/${tune.libraryItemId}` : ''; + if (dayWidthRef.current !== null && dayWidthRef.current.key !== sessionKey) dayWidthRef.current = null; + const poolItem = tune ? pools[tune.companyId]?.resources.find(r => r.id === tune.libraryItemId) : undefined; const itemName = poolItem?.name || tune?.libraryItemId || ''; @@ -235,8 +279,23 @@ export function DistributionDialog() { )) days.add(iso); } - return { axis: buildOccupancyAxis([...days], { targetWidth: 560 }), docs }; - }, [tune, proposal, poolItem, orderBase, bookingByDoc, afterByDoc]); + // De as vult de GEMETEN ruimte tussen label en uitkomstlabel (`minWidth`), met een dagbreedte + // die de eerste as van deze dialoogsessie vastlegde (`fixedDayWidth`). Zijn er weinig dagen, + // dan blijft de rest lege track met week- en maandlijnen — dat is precies de bedoeling: je + // ziet dan waar de ruimte is die je met de greep kunt pakken. + const axis = buildOccupancyAxis([...days], { + targetWidth: trackSpace, + maxDayWidth: DIALOG_MAX_DAY_WIDTH, + minWidth: trackSpace, + ...(dayWidthRef.current ? { fixedDayWidth: dayWidthRef.current.dayWidth } : {}), + }); + if (axis && !dayWidthRef.current) dayWidthRef.current = { key: sessionKey, dayWidth: axis.dayWidth }; + return { axis, docs }; + // `dayWidthRef`/`sessionKey` horen bewust NIET in de deps: de ref is een sessiecache, geen + // invoer — hem hier opnemen zou de as juist opnieuw laten fitten, wat deze hele constructie + // moet voorkomen. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tune, proposal, poolItem, orderBase, bookingByDoc, afterByDoc, trackSpace]); // Eén kleurtoewijzing voor de hele dialoog (fixronde-2 bevinding B9): de balken EN de // legenda/staven van de voor/na-grafiek lezen dezelfde map, zodat "project B" boven en onder @@ -541,7 +600,7 @@ export function DistributionDialog() { {/* (3)(4) DE BALKEN EN DE UITKOMST, in ÉÉN horizontale scroll-container zodat ze kolom-op-kolom uitgelijnd blijven — ook tijdens het scrollen (spec §3.4). */} -
+
{(stripView?.docs ?? []).map(doc => { const recorded = doc.pinnedReason === 'dates-as-recorded'; diff --git a/src/components/panels/occupancyAxis.ts b/src/components/panels/occupancyAxis.ts index 0cc004083..487e6784d 100644 --- a/src/components/panels/occupancyAxis.ts +++ b/src/components/panels/occupancyAxis.ts @@ -65,10 +65,23 @@ export function expandDays(from: string, to: string): string[] { * zodat de tijd daar proportioneel blijft. * * Geeft `null` wanneer er geen enkele dag is — de aanroeper toont dan zijn eigen lege toestand. + * + * DE DRIE EXTRA OPTIES ZIJN ER VOOR DE VERDEELDIALOOG (polishronde 2026-09-14) en veranderen het + * bestaande pad — een aanroep zónder die opties, zoals het bezettingshistogram — in niets: + * + * - `maxDayWidth` verruimt het plafond op de dagbreedte. Het histogram wil smalle kolommen over een + * heel project; de dialoog toont vaak maar een handvol dagen en mag die breder tekenen. + * - `minWidth` rekt alleen de TOTALE breedte op, nooit de dagbreedte: de dagen blijven staan waar ze + * stonden en de rest is lege track. Zo vult de balk de dialoog zonder dat twee dagen metersbrede + * blokken worden. + * - `fixedDayWidth` slaat de fit-berekening over. De dialoog bepaalt de dagbreedte ÉÉN keer bij het + * openen en houdt hem daarna vast: zonder dat verandert elke herberekening de dagbreedte (de + * dagenset groeit mee met het plafond), schoof de as onder de muis weg tijdens het slepen en + * sprong de andere rij mee. */ export function buildOccupancyAxis( bookedDays: string[], - opts?: { targetWidth?: number }, + opts?: { targetWidth?: number; maxDayWidth?: number; minWidth?: number; fixedDayWidth?: number }, ): OccupancyAxis | null { const sortedBooked = [...new Set(bookedDays)].sort(); if (sortedBooked.length === 0) return null; @@ -89,9 +102,9 @@ export function buildOccupancyAxis( ranges.push({ from, to: prev }); const totalDays = ranges.reduce((n, r) => n + diffDays(r.from, r.to) + 1, 0); - const dayWidth = Math.max( + const dayWidth = opts?.fixedDayWidth ?? Math.max( AXIS.minDayWidth, - Math.min(AXIS.maxDayWidth, Math.floor((opts?.targetWidth ?? AXIS.targetWidth) / totalDays)), + Math.min(opts?.maxDayWidth ?? AXIS.maxDayWidth, Math.floor((opts?.targetWidth ?? AXIS.targetWidth) / totalDays)), ); const segments: OccupancyAxisSegment[] = []; @@ -106,7 +119,10 @@ export function buildOccupancyAxis( segments.push({ days, x0: cursor }); cursor += days.length * dayWidth; } - const width = cursor + AXIS.padRight; + // `minWidth` rekt ALLEEN dit eindgetal op — `cursor` en dus elke dag-x is er al langs. De extra + // breedte is lege track rechts van de laatste dag; week- en maandlijnen tekent de aanroeper op + // zijn eigen segmentdagen en lopen daar dus niet doorheen. + const width = Math.max(cursor + AXIS.padRight, opts?.minWidth ?? 0); const xByIso = new Map(); for (const segment of segments) { From 74fdacd378e60853ee8cd9c1248c5a20135effb8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:49:07 +0200 Subject: [PATCH 086/108] =?UTF-8?q?fix(b1c):=20de=20naam=20staat=20nog=20?= =?UTF-8?q?=C3=A9=C3=A9n=20keer=20in=20de=20kop,=20en=20het=20grafiekpanee?= =?UTF-8?q?l=20is=20compacter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De ondertitel herhaalde de itemnaam die er als titel al boven stond; hij zegt nu alleen nog wát je hier doet. Sleutel aangepast in alle veertien locales, dus `{{item}}` is nergens meer nodig. Daarnaast gaat de plothoogte van "Nu" en "Na" van 72 naar 56 px: twee lage staafjes in een paneel van 253 px las als "er is iets niet geladen". Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/DistributionDialog.tsx | 6 +++++- src/components/dialogs/DistributionDialog/chartGeometry.ts | 6 +++++- src/i18n/locales/ar/common.json | 2 +- src/i18n/locales/de/common.json | 2 +- src/i18n/locales/en/common.json | 2 +- src/i18n/locales/es/common.json | 2 +- src/i18n/locales/fa/common.json | 2 +- src/i18n/locales/fr/common.json | 2 +- src/i18n/locales/it/common.json | 2 +- src/i18n/locales/ja/common.json | 2 +- src/i18n/locales/ko/common.json | 2 +- src/i18n/locales/nl/common.json | 2 +- src/i18n/locales/pl/common.json | 2 +- src/i18n/locales/pt/common.json | 2 +- src/i18n/locales/tr/common.json | 2 +- src/i18n/locales/zh/common.json | 2 +- 16 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index a053b5674..0c04b3bd9 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -535,7 +535,11 @@ export function DistributionDialog() { een paneel dat meldt dat er geen conflict is. */} {tune && (
- {t('resource.distribution.subtitle', { item: itemName })} + {/* ZONDER DE ITEMNAAM (polishronde 2026-09-14, bevinding 5). De titel erboven IS de + itemnaam; hem hier herhalen gaf "Gedeelde kraan" met eronder "Gedeelde kraan — + restcapaciteit verdelen…". De ondertitel zegt sindsdien alleen nog wát je hier + doet. */} + {t('resource.distribution.subtitle')}
)} {/* Eén regel die de hele handeling uitlegt (gebruikstest 2026-09-12, gebrek 4). Bewust diff --git a/src/components/dialogs/DistributionDialog/chartGeometry.ts b/src/components/dialogs/DistributionDialog/chartGeometry.ts index 077959b5b..20d12c1ea 100644 --- a/src/components/dialogs/DistributionDialog/chartGeometry.ts +++ b/src/components/dialogs/DistributionDialog/chartGeometry.ts @@ -18,7 +18,11 @@ import type { OccupancyAxis } from '@/components/panels/occupancyAxis'; /** Verticale tekenmaten van één mini-histogram (viewBox-eenheden ≈ px). */ export const CHART_PLOT = { - height: 72, + // 56 px plot + 4 px kop = 60 px per stand; met het "Nu"/"Na"-label erboven is dat ongeveer 76 px + // per rij. Vóór de polishronde van 2026-09-14 was dit 72 px en stonden er twee lage staafjes in + // een paneel van 253 px hoog — veel leeg vlak dat de indruk gaf dat er iets niet geladen was. De + // hoogte blijft VAST (spec §7): dit is een maatvoering, geen rekentoestand. + height: 56, padTop: 4, }; diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 3b0c43745..c01ef6268 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -703,7 +703,7 @@ "open": "توزيع…", "title": "التوزيع بين المشاريع", "back": "العودة إلى الإشغال", - "subtitle": "{{item}} — توزيع السعة المتبقية على المشاريع المفتوحة", + "subtitle": "توزيع السعة المتبقية على المشاريع المفتوحة", "intro": "اسحب نهاية الشريط إلى اليمين للسماح لهذا المشروع بالتمدد؛ ثبّت المشروع الذي لا يجوز تحريكه. يكتب «تطبيق» الإزاحات في جميع المشاريع المعنية.", "blocked": { "uncounted": "احسب أولاً: {{docs}} لم يُحسب بعد، لذا لا يوجد ما يمكن توزيعه.", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 89e14b7f3..00bdae516 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -655,7 +655,7 @@ "open": "Verteilen…", "title": "Über Projekte verteilen", "back": "Zurück zur Belegung", - "subtitle": "{{item}} — Restkapazität über die geöffneten Projekte verteilen", + "subtitle": "Restkapazität über die geöffneten Projekte verteilen", "intro": "Ziehen Sie das Ende eines Balkens nach rechts, damit dieses Projekt überlaufen darf; fixieren Sie ein Projekt, das sich nicht bewegen darf. Übernehmen schreibt die Verschiebungen in alle beteiligten Projekte.", "blocked": { "uncounted": "Zuerst berechnen: {{docs}} ist noch nicht berechnet, daher gibt es nichts zu verteilen.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 8431e064b..7984e5806 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -655,7 +655,7 @@ "open": "Distribute…", "title": "Distribute over projects", "back": "Back to occupancy", - "subtitle": "{{item}} — distribute residual capacity over the open projects", + "subtitle": "Distribute residual capacity over the open projects", "intro": "Drag the end of a bar to the right to let that project run on; pin a project that must not move. Apply writes the shifts into every project involved.", "blocked": { "uncounted": "Calculate first: {{docs}} has not been calculated yet, so there is nothing to distribute.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2987a0515..d09dda3a0 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -667,7 +667,7 @@ "open": "Repartir…", "title": "Repartir entre proyectos", "back": "Volver a la ocupación", - "subtitle": "{{item}} — repartir la capacidad residual entre los proyectos abiertos", + "subtitle": "Repartir la capacidad residual entre los proyectos abiertos", "intro": "Arrastra el final de una barra hacia la derecha para dejar que ese proyecto se prolongue; fija un proyecto que no deba moverse. Aplicar escribe los desplazamientos en todos los proyectos implicados.", "blocked": { "uncounted": "Calcula primero: {{docs}} aún no está calculado, así que no hay nada que repartir.", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 0b7cca656..d17eaf396 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -655,7 +655,7 @@ "open": "توزیع…", "title": "توزیع بین پروژه‌ها", "back": "بازگشت به اشغال", - "subtitle": "{{item}} — توزیع ظرفیت باقی‌مانده بین پروژه‌های باز", + "subtitle": "توزیع ظرفیت باقی‌مانده بین پروژه‌های باز", "intro": "انتهای نوار را به راست بکشید تا آن پروژه تمدید شود؛ پروژه‌ای را که نباید جابه‌جا شود سنجاق کنید. «اعمال» جابه‌جایی‌ها را در همهٔ پروژه‌های مرتبط می‌نویسد.", "blocked": { "uncounted": "ابتدا محاسبه کنید: {{docs}} هنوز محاسبه نشده است، بنابراین چیزی برای توزیع وجود ندارد.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 5419beae2..79ce2cb19 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -667,7 +667,7 @@ "open": "Répartir…", "title": "Répartir entre les projets", "back": "Retour à l'occupation", - "subtitle": "{{item}} — répartir la capacité résiduelle entre les projets ouverts", + "subtitle": "Répartir la capacité résiduelle entre les projets ouverts", "intro": "Faites glisser la fin d'une barre vers la droite pour laisser ce projet déborder ; épinglez un projet qui ne doit pas bouger. Appliquer écrit les décalages dans tous les projets concernés.", "blocked": { "uncounted": "Calculez d'abord : {{docs}} n'est pas encore calculé, il n'y a donc rien à répartir.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index b40fefda4..b65132872 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -667,7 +667,7 @@ "open": "Distribuisci…", "title": "Distribuisci tra i progetti", "back": "Torna all'occupazione", - "subtitle": "{{item}} — distribuisci la capacità residua tra i progetti aperti", + "subtitle": "Distribuisci la capacità residua tra i progetti aperti", "intro": "Trascina la fine di una barra verso destra per lasciare che quel progetto sfori; blocca un progetto che non deve muoversi. Applica scrive gli slittamenti in tutti i progetti coinvolti.", "blocked": { "uncounted": "Calcola prima: {{docs}} non è ancora stato calcolato, quindi non c'è nulla da distribuire.", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 99a308b36..d5afc794b 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -643,7 +643,7 @@ "open": "配分…", "title": "プロジェクト間で配分", "back": "稼働状況に戻る", - "subtitle": "{{item}} — 残キャパシティを開いているプロジェクト間で配分", + "subtitle": "残キャパシティを開いているプロジェクト間で配分", "intro": "バーの末尾を右へドラッグすると、そのプロジェクトを延長できます。動かしたくないプロジェクトは固定してください。「適用」は関連するすべてのプロジェクトにずれを書き込みます。", "blocked": { "uncounted": "先に計算してください:{{docs}} はまだ計算されていないため、配分するものがありません。", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index b8ffe2598..6f897a268 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -643,7 +643,7 @@ "open": "분배…", "title": "프로젝트 간 분배", "back": "점유 현황으로 돌아가기", - "subtitle": "{{item}} — 열린 프로젝트 간에 잔여 용량 분배", + "subtitle": "열린 프로젝트 간에 잔여 용량 분배", "intro": "막대 끝을 오른쪽으로 끌면 해당 프로젝트를 연장할 수 있습니다. 움직이면 안 되는 프로젝트는 고정하세요. [적용]은 관련된 모든 프로젝트에 이동을 기록합니다.", "blocked": { "uncounted": "먼저 계산하세요: {{docs}} 이(가) 아직 계산되지 않아 분배할 것이 없습니다.", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0bae0ce4f..797a5b423 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -655,7 +655,7 @@ "open": "Verdelen…", "title": "Verdelen over projecten", "back": "Terug naar bezetting", - "subtitle": "{{item}} — restcapaciteit verdelen over de geopende projecten", + "subtitle": "Restcapaciteit verdelen over de geopende projecten", "intro": "Trek het einde van een balk naar rechts om dat project te laten uitlopen; zet een project vast als het niet mag bewegen. Toepassen schrijft de verschuivingen in alle betrokken projecten.", "blocked": { "uncounted": "Eerst doorrekenen: {{docs}} is nog niet berekend, dus er valt niets tegen te verdelen.", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 848baab12..a2ea9098c 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -679,7 +679,7 @@ "open": "Rozdziel…", "title": "Rozdziel między projekty", "back": "Powrót do obłożenia", - "subtitle": "{{item}} — rozdziel zdolność rezydualną między otwarte projekty", + "subtitle": "Rozdziel zdolność rezydualną między otwarte projekty", "intro": "Przeciągnij koniec paska w prawo, aby ten projekt mógł się przedłużyć; przypnij projekt, który nie może się przesuwać. Zastosuj zapisuje przesunięcia we wszystkich powiązanych projektach.", "blocked": { "uncounted": "Najpierw przelicz: {{docs}} nie jest jeszcze obliczony, więc nie ma czego rozdzielać.", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 7ed7eb1fe..af547ae94 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -667,7 +667,7 @@ "open": "Distribuir…", "title": "Distribuir entre projetos", "back": "Voltar à ocupação", - "subtitle": "{{item}} — distribuir a capacidade residual entre os projetos abertos", + "subtitle": "Distribuir a capacidade residual entre os projetos abertos", "intro": "Arraste o fim de uma barra para a direita para deixar esse projeto estender-se; fixe um projeto que não pode mover-se. Aplicar escreve os deslocamentos em todos os projetos envolvidos.", "blocked": { "uncounted": "Calcule primeiro: {{docs}} ainda não foi calculado, por isso não há nada para distribuir.", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 4e9722efe..94cc107fa 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -655,7 +655,7 @@ "open": "Dağıt…", "title": "Projeler arasında dağıt", "back": "Doluluğa dön", - "subtitle": "{{item}} — kalan kapasiteyi açık projeler arasında dağıt", + "subtitle": "Kalan kapasiteyi açık projeler arasında dağıt", "intro": "Bir projenin taşmasına izin vermek için çubuğun ucunu sağa sürükleyin; hareket etmemesi gereken projeyi sabitleyin. Uygula, kaymaları ilgili tüm projelere yazar.", "blocked": { "uncounted": "Önce hesapla: {{docs}} henüz hesaplanmadı, bu yüzden dağıtılacak bir şey yok.", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 55e1ec3f3..cbbc84c45 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -643,7 +643,7 @@ "open": "分配…", "title": "在项目间分配", "back": "返回占用情况", - "subtitle": "{{item}} — 在打开的项目之间分配剩余产能", + "subtitle": "在打开的项目之间分配剩余产能", "intro": "向右拖动条形的末端,让该项目延期;将不能移动的项目固定。应用会把偏移写入所有相关项目。", "blocked": { "uncounted": "请先计算:{{docs}} 尚未计算,因此没有可分配的内容。", From e46337ce86716bf0c8675ecbddc0d71950e25a88 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:50:33 +0200 Subject: [PATCH 087/108] feat(b1c): een project dat niet past toont zijn oude boeking als spook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Een tekort gaf een volkomen lege track — dat las als een weergavefout in plaats van als "dit past niet". De balk tekent nu waar dat werk vóór de verdeling stond als omlijnde blokjes in de projectkleur; een dag die wél geplaatst is krijgt geen spook. De rode pil en strook blijven onveranderd. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/PhaseStrip.tsx | 17 +++++++++ .../DistributionDialog/stripGeometry.ts | 26 ++++++++++++-- .../check-distribution-strip-geometry.ts | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index b6106c234..2951c2a25 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -140,6 +140,8 @@ export function PhaseStrip({ slackWorkdays: slackWorkdays ?? 0, ceilingWorkdays: displayCeiling, endShiftWorkdays, + // Bij een tekort tekent de rij ook waar het werk VÓÓR de verdeling stond — zie `ghostBlocks`. + showGhosts: shortfallCount > 0, }); const handleX = geometry?.handleX ?? AXIS.padLeft; @@ -382,6 +384,21 @@ export function PhaseStrip({ /> )} + {/* SPOOKBLOKJES bij een tekort: waar dit werk stond vóór de verdeling, omlijnd in de + projectkleur en zonder vulling. Vóór de polishronde van 2026-09-14 was de track van + een project dat nergens paste helemaal LEEG — nu zie je wát er niet past. Ze staan + ONDER de echte dagblokjes zodat een deels geplaatst project geen dubbel blokje geeft. */} + {(geometry?.ghostBlocks ?? []).map(block => ( + + ))} + {/* De dagblokjes. Een werkdag in de projectkleur met een 1 px witte scheiding rechts (`.pwork`), een pauzedag gearceerd met een dun grijs randje (`.ppause`). */} {(geometry?.blocks ?? []).map(block => (block.kind === 'work' ? ( diff --git a/src/components/dialogs/DistributionDialog/stripGeometry.ts b/src/components/dialogs/DistributionDialog/stripGeometry.ts index dd5413802..64934a59f 100644 --- a/src/components/dialogs/DistributionDialog/stripGeometry.ts +++ b/src/components/dialogs/DistributionDialog/stripGeometry.ts @@ -67,6 +67,13 @@ export interface StripBand { export interface StripGeometry { blocks: StripBlock[]; + /** + * SPOOKBLOKJES: waar het werk VÓÓR de verdeling stond, op de dagen waar de NA-stand niets meer + * heeft staan. Alleen gevuld wanneer de aanroeper `showGhosts` zet — dat doet hij bij een tekort. + * Zonder dit toont een project dat de motor nergens kwijt kon een volkomen LEGE track, en dat las + * als een weergavefout in plaats van als "dit past niet"; nu zie je wát er niet past. + */ + ghostBlocks: StripBlock[]; /** De vaste last van gepinde/#63-documenten, samengevoegd tot aaneengesloten banden. */ fixedBands: StripBand[]; /** "Toegestaan maar niet benut": van het fase-einde tot de handle. `null` ⇒ niets over. */ @@ -105,6 +112,9 @@ export interface StripGeometryInput { ceilingWorkdays: number | null; /** De benutte uitloop (`DistributionDocResult.endShiftWorkdays`). */ endShiftWorkdays: number; + /** Teken de VÓÓR-boeking als spookblokjes waar de NA-stand leeg is (zie `ghostBlocks`). De + * aanroeper zet dit bij een tekort; standaard uit, dus een bestaande aanroep verandert niets. */ + showGhosts?: boolean; } /** Alle kalenderdagen van de as met hun x, in tekenvolgorde. */ @@ -201,7 +211,7 @@ function advanceWorkdays( export function buildStripGeometry(input: StripGeometryInput): StripGeometry { const { axis, beforeLoadByDay, loadByDay, fixedLoadByDay, isWorkingDay, - slackWorkdays, ceilingWorkdays, endShiftWorkdays, + slackWorkdays, ceilingWorkdays, endShiftWorkdays, showGhosts = false, } = input; const dayWidth = axis.dayWidth; const days = axisDayXs(axis); @@ -227,6 +237,18 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // document niets te maken had. Het eerlijke anker is dan de VÓÓR-stand: daar stond dit werk, // en daar hoort de bediening bij. Alleen als ook die leeg is (niets vóór, niets ná) valt er // niets te ankeren en blijft `padLeft` over. + // (1b) De spookblokjes van de VÓÓR-stand op de dagen die de NA-stand niet meer bezet. Alleen + // WERKDAGEN met een echte boeking: een spook is "hier stond werk dat nu nergens staat", geen + // arcering van weekenden. + const ghostBlocks: StripBlock[] = []; + if (showGhosts) { + for (const day of days) { + if ((beforeLoadByDay[day.iso] ?? 0) <= 0) continue; + if ((loadByDay[day.iso] ?? 0) > 0) continue; + ghostBlocks.push({ iso: day.iso, x: day.x, w: dayWidth, kind: 'work' }); + } + } + const origEnd = lastBookedDay(beforeLoadByDay); const anchorEnd = last ?? origEnd; const phaseEndX = anchorEnd === null ? AXIS.padLeft : rightEdgeX(axis, anchorEnd); @@ -282,7 +304,7 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { } return { - blocks, fixedBands, freeBox, slackBar, overrunBar, + blocks, ghostBlocks, fixedBands, freeBox, slackBar, overrunBar, handleX, phaseEndX, ceilingEndIso, weekLines, monthLines, }; } diff --git a/tests/planning/check-distribution-strip-geometry.ts b/tests/planning/check-distribution-strip-geometry.ts index 6b4514104..8337d9687 100644 --- a/tests/planning/check-distribution-strip-geometry.ts +++ b/tests/planning/check-distribution-strip-geometry.ts @@ -254,6 +254,42 @@ ok(dw > 0, `de dagbreedte is positief (${dw})`); `7 — de plafonddatum blijft de ECHTE kalenderdatum, ook buiten de as (kreeg ${g.ceilingEndIso})`); } +// ── Geval 8: DE SPOOKBLOKJES BIJ EEN TEKORT (polishronde 2026-09-14, bevinding 6) ──────────── +// Een project dat de motor nergens kwijt kon boekt ná de verdeling niets meer. Zonder spoken is +// zijn track dan volkomen leeg en lijkt er iets stuk; mét spoken zie je precies wát er niet past. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const input = { + axis, + beforeLoadByDay: before, + loadByDay: {}, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 2, + ceilingWorkdays: 2, + endShiftWorkdays: 0, + }; + + const off = buildStripGeometry(input); + ok(off.ghostBlocks.length === 0, '8 — zonder `showGhosts` blijft de spooklijst leeg (bestaand pad)'); + + const on = buildStripGeometry({ ...input, showGhosts: true }); + ok(on.blocks.length === 0, '8 — er staat na de verdeling nog steeds geen enkel echt dagblokje'); + ok(on.ghostBlocks.length === 3, `8 — de drie VÓÓR-dagen komen terug als spook (kreeg ${on.ghostBlocks.length})`); + ok(on.ghostBlocks[0].x === xAt('2026-09-07'), '8 — het eerste spook staat op de oorspronkelijke startdag'); + ok(on.ghostBlocks[0].w === dw, '8 — een spook is precies één dagbreedte'); + + // Een DEELS geplaatst project: de dag die wél geplaatst is hoort géén spook te krijgen, anders + // staan er twee blokjes over elkaar heen. + const partly = buildStripGeometry({ + ...input, + loadByDay: load(['2026-09-08']), + showGhosts: true, + }); + ok(partly.ghostBlocks.length === 2, '8 — een dag die wél geplaatst is krijgt geen spook'); + ok(partly.ghostBlocks.every(b => b.iso !== '2026-09-08'), '8 — en dat is precies de geplaatste dag'); +} + // ── De verschil-prijs (§2.2) ───────────────────────────────────────────────────────────────── ok(savingsWorkdays(5, 2) === 3, 'prijs — uit 5, aan 2 ⇒ bespaart 3 werkdagen'); ok(savingsWorkdays(4, 4) === 0, 'prijs — even duur ⇒ bespaart niets'); From 4192dda9d9fbdb714bf6f50e0986e5f26e08642f Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:52:06 +0200 Subject: [PATCH 088/108] =?UTF-8?q?fix(b1c):=20de=20pin=20is=20een=20knop,?= =?UTF-8?q?=20en=20de=20statusstrook=20heeft=20=C3=A9=C3=A9n=20scheidingst?= =?UTF-8?q?eken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De pin was een onderstreept woordje tussen twee regels grijze tekst; hij krijgt de vorm van "Reset" onderin dezelfde dialoog, met het accent in gepinde stand. `aria-pressed` en het testanker blijven. De statusstrook plakte clausules aan elkaar met een spatie, waardoor er twee em-dashes achter elkaar stonden ("… niet meer actueel. — druk op Herbereken") en de tekortkop zijn "…" als scheidingsteken leek te gebruiken. Eén helper zet er nu " · " tussen. De legenda krijgt een breedtecap zodat hij wrapt in plaats van de scroll-container breder te duwen dan de balken. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 35 ++++++++++++++++--- .../dialogs/DistributionDialog/PhaseStrip.tsx | 15 +++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 0c04b3bd9..8e60a9bb4 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -69,6 +69,24 @@ const DIALOG_CONTENT_WIDTH = 960 - 32; * een handvol dagen die je moet kunnen aanwijzen en tellen. Boven ~28 px worden het luiken. */ const DIALOG_MAX_DAY_WIDTH = 28; +/** + * Twee losse clausules aan elkaar met ÉÉN scheidingsteken — de " · " die de balk-labels ook + * gebruiken (polishronde 2026-09-14, bevinding 8). + * + * De kale aaneenschakeling gaf twee soorten rommel. De stale-reden eindigt op een em-dash-zin + * ("… — dit voorstel is niet meer actueel.") en `compute.pressRecompute` BEGINT met een em-dash, + * dus las de strook als "… — niet meer actueel. — druk op Herbereken": twee streepjes in één + * regel. En de tekortkop eindigt op "…", waarna de opsomming van documenten er met een spatie + * tegenaan geplakt werd alsof die ellips het scheidingsteken was. Het afpellen van een leidend + * streepje is bewust op de TEKST en niet op de vertaalsleutel: de dertien vertalingen dragen dat + * streepje ook, en die staan buiten het bestek van deze ronde. + */ +function joinClauses(head: string, tail: string): string { + const trimmed = tail.replace(/^[\s\u2014\u2013-]+/, '').trim(); + if (trimmed === '') return head; + return `${head.trimEnd()} \u00b7 ${trimmed}`; +} + export function DistributionDialog() { const { t, i18n } = useTranslation('common'); const tune = useAppStore(s => s.ui.levelingDistribution); @@ -413,8 +431,10 @@ export function DistributionDialog() { if (staleReason !== null) { return { tone: 'neutral', - text: t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs }) - + (degraded || staleReason === 'edited' ? ` ${t('resource.distribution.compute.pressRecompute')}` : ''), + text: joinClauses( + t(`resource.distribution.stale.${staleReason}`, { docs: staleDocs }), + degraded || staleReason === 'edited' ? t('resource.distribution.compute.pressRecompute') : '', + ), stale: true, }; } @@ -432,7 +452,7 @@ export function DistributionDialog() { count: shortfallDays.length, days: shortfallDays.slice(0, 3).map(formatDay).join(', '), }); - return { tone: 'bad', text: named ? `${head} ${named}` : head, stale: false }; + return { tone: 'bad', text: joinClauses(head, named), stale: false }; } const worst = proposal.docs.reduce( (best, doc) => (doc.endShiftWorkdays > best.endShiftWorkdays ? doc : best), @@ -642,9 +662,16 @@ export function DistributionDialog() { })} {/* De legenda-regel (§4), ingesprongen tot waar de tracks beginnen. */} + {/* De legenda mag WRAPPEN (polishronde 2026-09-14, bevinding 9): zonder breedtecap + duwde hij als één lange regel de scroll-container breder dan de balken zelf, dus + verscheen er een horizontale schuifbalk die niets met de tijdas te maken had. Hij + krijgt precies de ruimte van de track plus het uitkomstlabel. */}
{t('resource.distribution.strip.legend')} diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 2951c2a25..92a38783e 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -300,7 +300,20 @@ export function PhaseStrip({ aria-pressed={pinned} title={t('resource.distribution.help.pin')} onClick={onTogglePin} - className="text-[10px] text-left underline underline-offset-2 text-text-secondary hover:text-text-primary" + // EEN ECHTE KNOP, GEEN ONDERSTREEPT WOORDJE (polishronde 2026-09-14, bevinding 7). Het + // vastzetten is naast de greep de tweede bediening van de rij; als kale link tussen + // twee regels grijze tekst las hij als voetnoot. Dezelfde vorm als "Reset" onderin de + // dialoog — rand, afgeronde hoek, hover-vlak — alleen compacter. In gepinde stand draagt + // hij het accent, zodat de stand ook zonder de tekst te lezen zichtbaar is; `aria-pressed` + // blijft de bron voor hulptechnologie en de browsertest. + className="mt-0.5 self-start max-w-full truncate rounded-[6px] border px-1.5 py-[1px] text-[10px] leading-4 hover:bg-surface-hover" + style={pinned + ? { + borderColor: 'var(--theme-accent)', + background: 'color-mix(in srgb, var(--theme-accent) 12%, transparent)', + color: 'var(--theme-accent)', + } + : { borderColor: 'var(--theme-border)', color: 'var(--color-text-secondary)' }} data-ops-distribution-pin > {pinned From 77277c5a6b04059df05aabe6937650955b63c42d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:55:26 +0200 Subject: [PATCH 089/108] test(b1c): browserasserties voor de vaste tijdas en de spookblokjes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twee regressies die de polishronde afdekt: een sleep in rij 2 laat de greep van rij 1 op dezelfde x staan (tijdens én na de herberekening) en verandert de dagbreedte niet, en een rij met een tekort toont spookblokjes in plaats van een lege balk terwijl de rij zonder tekort er geen krijgt. Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index f1541f823..8d8205eca 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -380,6 +380,85 @@ test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ p }, shiftedDocId)).toBe(5); }); +// --- Polishronde bevinding 3 — de tijdas groeit niet onder de muis weg --------------------------- + +test('plafond-handle: een sleep in rij 2 laat de as en de greep van rij 1 staan', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const first = page.locator('[data-ops-distribution-strip]').nth(0); + const second = page.locator('[data-ops-distribution-strip]').nth(1); + const anchorHandle = first.locator('[data-ops-distribution-handle]'); + const handle = second.locator('[data-ops-distribution-handle]'); + + const dayWidth = Number(await second.getAttribute('data-ops-distribution-day-width')); + expect(dayWidth).toBeGreaterThan(0); + + // Plafond 0 als deterministisch startpunt, net als de live-sleeptest hierboven. + await handle.focus(); + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await expect.poll(() => second.locator('[data-ops-distribution-effect]').textContent()) + .not.toContain('…'); + + // Het ankerpunt: de greep van de rij die NIET gesleept wordt. Vóór deze ronde fitte de as zich + // bij élke commit opnieuw op de beschikbare breedte; de dagenset groeit mee met het plafond, dus + // kromp de dagbreedte onderweg en sprong deze greep mee — terwijl er aan die rij niets gebeurde. + const anchorBefore = (await anchorHandle.boundingBox())!; + + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 4 * dayWidth, box.y + box.height / 2, { steps: 8 }); + await expect(handle).toHaveAttribute('aria-valuenow', '4'); + + // TIJDENS het slepen — dit is het moment waarop het misging. + const anchorDuring = (await anchorHandle.boundingBox())!; + expect(Math.abs(anchorDuring.x - anchorBefore.x)).toBeLessThan(1); + expect(Number(await second.getAttribute('data-ops-distribution-day-width'))).toBe(dayWidth); + + await page.mouse.move(box.x + box.width / 2 + 9 * dayWidth, box.y + box.height / 2, { steps: 8 }); + await expect(handle).toHaveAttribute('aria-valuenow', '9'); + await page.mouse.up(); + + // En ná het loslaten, wanneer de herberekening binnen is: de dagbreedte ligt vast voor de hele + // dialoogsessie, dus ook een veel ruimere dagenset verzet geen bestaande x. + await expect.poll(() => second.getAttribute('data-ops-distribution-day-width')).toBe(String(dayWidth)); + const anchorAfter = (await anchorHandle.boundingBox())!; + expect(Math.abs(anchorAfter.x - anchorBefore.x)).toBeLessThan(1); +}); + +// --- Polishronde bevinding 6 — een project dat niet past toont zijn oude boeking ----------------- + +test('een tekort tekent de VÓÓR-boeking als spookblokjes in plaats van een lege balk', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + // Plafond 0 op de rij die moet wijken: hij mag geen werkdag opschuiven, dus zijn ene taak kan + // nergens heen. Dat is de echte gebruikersroute naar een tekort — Home op de greep. + const handle = page.locator('[data-ops-distribution-strip]').nth(1) + .locator('[data-ops-distribution-handle]'); + await handle.focus(); + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + + // De rij met het tekort is de rij die zijn uitkomstpil rood heeft. + const shortStrip = page.locator('[data-ops-distribution-strip]') + .filter({ has: page.locator('[data-ops-distribution-effect-shortfall]') }); + await expect(shortStrip).toHaveCount(1); + + // Geen enkel echt dagblokje — en tóch iets te zien: de spoken van waar dit werk stond. + await expect(shortStrip.locator('[data-ops-distribution-day="work"]')).toHaveCount(0); + await expect(shortStrip.locator('[data-ops-distribution-day="unplaced"]').first()).toBeVisible(); + + // De rij die wél geplaatst is heeft geen spoken: dit is een tekort-markering, geen achtergrond. + const okStrip = page.locator('[data-ops-distribution-strip]') + .filter({ hasNot: page.locator('[data-ops-distribution-effect-shortfall]') }); + await expect(okStrip.locator('[data-ops-distribution-day="unplaced"]')).toHaveCount(0); +}); + // --- Fixronde bevinding B9 — de greep houdt de focus na een muisgebaar --------------------------- test('plafond-handle: na loslaten heeft de greep de focus en werkt een pijltje meteen', async ({ page, ops: _ops }) => { From 514829567b32e0cf6dc576feb02ed2c85db94d96 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:58:20 +0200 Subject: [PATCH 090/108] style(b1c): de "toegestaan maar niet benut"-doos staat achter de dagblokjes Bij een onbegrensd plafond staat de greep aan de rechterrand, dus die doos beslaat sinds de breedtefix de hele track. Op volle sterkte was hij daarmee het luidste element in de dialoog, terwijl hij ruimte aanduidt en geen boeking. Co-Authored-By: Claude Fable 5.1 --- src/components/dialogs/DistributionDialog/PhaseStrip.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 92a38783e..5a4d59091 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -393,6 +393,12 @@ export function PhaseStrip({ x={geometry.freeBox.x} y={STRIP.blockTop} width={Math.max(1, geometry.freeBox.w)} height={STRIP.blockHeight} fill="none" stroke="var(--theme-text-dim)" strokeWidth={1} strokeDasharray="3 3" rx={2} + // GEDEMPT (polishronde 2026-09-14). Bij een onbegrensd plafond staat de greep aan de + // rechterrand, dus deze doos beslaat sinds de breedtefix de HELE track in plaats van + // een paar dagen. Op volle sterkte was hij daarmee het luidste element in de dialoog, + // terwijl hij ruimte aanduidt en geen boeking. Hij blijft leesbaar, maar achter de + // dagblokjes. + opacity={0.5} data-ops-distribution-tail /> )} From 23126e79b050300917d63a72fd090353c49b6ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 17:47:49 +0000 Subject: [PATCH 091/108] =?UTF-8?q?Rapport:=20restpunten=20uit=20de=20revi?= =?UTF-8?q?ews=20op=20#138=20=E2=80=94=20gedeelde=20curvetoestand,=20kolom?= =?UTF-8?q?men=20laten=20vallen,=20weekendcijfers,=20getalnotatie,=20artif?= =?UTF-8?q?acts-beleid,=20middernacht-doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle zes vervolgpunten die na PR #138 waren blijven liggen, in één keer: - Eén weergaveregel voor de curvetoestand van een toewijzing (`src/engine/contour/curveState.ts`: `assignmentCurveState`, `contouredAssignmentIds`) die het eigenschappenpaneel én het resourcediagram delen; het verschil met de verdeelregel van `assignmentDayUnits` staat daar gedocumenteerd in plaats van op twee plekken herhaald. - De toewijzingskolommen vallen automatisch weg zodra de tabel minder dan `MIN_CHART_WIDTH_PX` (240 px) tijdlijn per papierbreedte overlaat (lange taaknamen met afkappen uit); `RenderReportResult.assignmentColumnsDropped`, melding in het overzichtsblok, sleutel in 14 locales. - Een tijdvenster zonder werkdag dat op de kalender-as terugvalt nummert nu ook de weekenddagen (anders stond er geen enkel dagcijfer). - Eén getalnotatie voor alle rapporten (`src/utils/reportNumber.ts`): hoogstens twee decimalen, geen duizendtalscheiding, decimaalteken van de app-taal — tabelrapporten via `dd.num`/`dd.signed`, de eenhedenkolom via `PrintOptions.numberLocale`; zonder taal de neutrale punt (tests ongewijzigd). - Artifacts-beleid vastgelegd: `artifacts//` = klein gecommit schermbewijs bij een PR, `qa/` = lokaal (CLAUDE.md, .gitignore). - Gids nl+en: presets zonder statusdatum schuiven na middernacht een dag op; de kolommen kunnen wegvallen bij een brede naamkolom. Regressie: check-print-report (weglaten bij 800 px naamkolom op A4 liggend, niet bij de gewone; weekendcijfers 10/11 op een weekendvenster; "1,5" bij numberLocale nl), check-reports (curvetoestand-helper, contourkoppeling, getalnotatie nl/en/de/plus). Browserspecs resourcediagram, tabelrapporten, contourdialoog en rapportopties groen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- .gitignore | 3 +- CLAUDE.md | 1 + public/docs/en/gids-rapporten-printen.md | 10 ++-- public/docs/nl/gids-rapporten-printen.md | 10 +++- src/components/panels/ReportPanel.tsx | 13 +++- .../panels/reports/useTableReportSpec.tsx | 59 +++++++++++-------- .../task-sections/TaskAssignmentsSection.tsx | 12 ++-- src/engine/contour/curveState.ts | 43 ++++++++++++++ src/engine/reports/resourceGantt.ts | 31 ++++------ src/i18n/locales/ar/report.json | 1 + src/i18n/locales/de/report.json | 1 + src/i18n/locales/en/report.json | 1 + src/i18n/locales/es/report.json | 1 + src/i18n/locales/fa/report.json | 1 + src/i18n/locales/fr/report.json | 1 + src/i18n/locales/it/report.json | 1 + src/i18n/locales/ja/report.json | 1 + src/i18n/locales/ko/report.json | 1 + src/i18n/locales/nl/report.json | 1 + src/i18n/locales/pl/report.json | 1 + src/i18n/locales/pt/report.json | 1 + src/i18n/locales/tr/report.json | 1 + src/i18n/locales/zh/report.json | 1 + src/services/print/printPreview.ts | 55 ++++++++++++----- src/utils/reportNumber.ts | 19 ++++++ tests/planning/check-print-report.ts | 23 ++++++++ tests/planning/check-reports.ts | 22 +++++++ 27 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 src/engine/contour/curveState.ts create mode 100644 src/utils/reportNumber.ts diff --git a/.gitignore b/.gitignore index f7fd68513..b6e8b8508 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,8 @@ test-results/ # Visual brainstorm companion mockups .superpowers/ -# Lokale QA-screenshots (niet committen) +# Lokale QA-screenshots (niet committen). Gecommit schermbewijs bij een PR hoort juist in +# artifacts// — klein houden (PNG, ≤ ~150 KB, een handvol per PR); zie CLAUDE.md. qa/ # Gegenereerde wiki (build-artefact van scripts/publish-wiki.mjs) diff --git a/CLAUDE.md b/CLAUDE.md index 4198c83b6..b316f2cb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,6 +349,7 @@ De artikelen worden gerenderd door `src/utils/miniMarkdown.tsx`, dat een **beper - [docs/extensions.md](docs/extensions.md) — handleiding voor extensie-auteurs (manifest, API, installeren). - [docs/library.md](docs/library.md) — resourcebibliotheken (B1/B1.1): bibliotheek als bron met projectinzet, herkomststempels, pool-IFC-export/-import, bekende beperkingen (geen sync tussen machines). - [docs/release-secrets.md](docs/release-secrets.md) — de sleutels achter de uitleverketen: wat elk secret doet, wat er stukgaat bij verlies, en het migratiepad voor de minisign-sleutel (de enige onherstelbare SPOF: zijn pubkey zit in elke uitgeleverde binary). +- `artifacts//` — **gecommit schermbewijs** bij een PR (bijv. `artifacts/resourcediagram/`), waar een PR-tekst of issue-reactie naar linkt via `raw.githubusercontent.com`. Klein houden: PNG, ≤ ~150 KB per bestand, een handvol per PR, en na de merge niet bijwerken (het is bewijs van dat moment, geen documentatie). Lokale QA-screenshots horen in `qa/` (in `.gitignore`). - [scripts/README.md](scripts/README.md) — wat elk script in `scripts/` doet en wie het aanroept (dev-serverpoorten, de verify-poorten, de generatoren, release-hulpjes). Regel: wat daar staat wordt aangeroepen — eenmalige klussen horen weg, niet "voor het geval dat". - [tests/planning/README.md](tests/planning/README.md) — hoe de CPM/kalender-regressiesuite werkt en hoe je cases toevoegt. - `public/docs//*.md` — de **in-app gidsen** achter Backstage → Help (viewer met taalkiezer, stale-waarschuwing en 14-taal-fallback). Brontalen zijn `nl` + `en`; die twee eist `npm run verify:docs` hard, de overige twaalf worden alleen gevalideerd wanneer ze bestaan (vertalingen volgen maandelijks, niet per release). diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index b797740ee..671da6639 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -120,9 +120,10 @@ resource on one task are added up, and a dash in the curve column means those as different curves. If the assignment has its own hour distribution the column says *Contour*; if the curve was imported from MS Project or P6 and is none of the eight built-in shapes it says *Imported curve* — the same as in the properties panel. The two columns make the table a good 130 px -wider and the time axis narrower; with long task names and *Truncate task names* off the time axis -can all but disappear. Turn **Show units/day and curve** off for the narrow table of the Gantt -print. +wider and the time axis narrower. If a wide name column (long task names with *Truncate task +names* off) would leave less than a quarter of the paper width for the timeline, the report drops +the two columns itself and says so in the summary block; truncate the names or choose a larger +paper size. Turn **Show units/day and curve** off for the narrow table of the Gantt print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from @@ -186,7 +187,8 @@ remembered setting per report (the resource diagram above offers the same contro - **Next / last week, 2, 4, 6, 8 or 12 weeks** and **next / last month** — counted from the project's status date (or today if none is set). A preset is inclusive on both ends: *next 4 weeks* on Thursday 10 September runs through Wednesday 7 October. Change the status date and the - window moves with it. + window moves with it. Without a status date a preset counts from today and is resolved again on + every recalculation: leave the app open overnight and the window shifts by a day after midnight. - **Project duration** — from the earliest start to the latest finish in the schedule. - **Custom** — two dates of your own. The *From* and *To* fields become editable (type or use the date picker); with a preset they show the calculated dates read-only. An end date before the diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index ae6040f10..8ded88b0b 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -123,8 +123,10 @@ opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillen Heeft de toewijzing een eigen urenverdeling, dan staat er *Contour*; is de curve uit MS Project of P6 geïmporteerd en is het geen van de acht eigen vormen, dan staat er *Geïmporteerde curve* — hetzelfde als in het eigenschappenpaneel. De twee kolommen maken de tabel ruim 130 px breder en de -tijdas dus smaller; bij lange taaknamen met *Taaknamen afkappen* uit kan de tijdas daardoor bijna -verdwijnen. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. +tijdas dus smaller. Blijft er door een brede naamkolom (lange taaknamen met *Taaknamen afkappen* +uit) minder dan een kwart papierbreedte tijdlijn over, dan laat het rapport de twee kolommen zelf +weg en meldt dat in het overzichtsblok; kap dan de namen af of kies groter papier. Zet **Eenheden/dag +en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het @@ -187,7 +189,9 @@ onthouden instelling per rapport (het resourcediagram hierboven kent dezelfde ke - **Volgende / afgelopen week, 2, 4, 6, 8 of 12 weken** en **volgende / afgelopen maand** — gerekend vanaf de statusdatum van het project (of vandaag als die ontbreekt). Een preset is inclusief aan beide kanten: *volgende 4 weken* op donderdag 10 september loopt t/m woensdag 7 oktober. Wijzig je - de statusdatum, dan schuift het venster automatisch mee. + de statusdatum, dan schuift het venster automatisch mee. Zonder statusdatum rekent een preset + vanaf vandaag en wordt hij bij elke herberekening opnieuw opgelost: blijft de app 's nachts open, + dan schuift het venster na middernacht een dag op. - **Hele project** — van de vroegste start tot het laatste einde in de planning. - **Aangepast** — twee eigen datums. De velden *Van* en *Tot* worden dan bewerkbaar (typen of de datumkiezer); bij een preset tonen ze alleen-lezen de berekende datums. Een einddatum vóór de diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index 39a0cbc06..9f6d087e5 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -263,6 +263,9 @@ export function ReportPanel() { }, []); // Resourcediagram (issue #113): blad per resource + taken zonder resource — samen bewaard met de rest. const [resourceGanttOptions, setResourceGanttOptions] = useState(DEFAULT_REPORT_SETTINGS.resourceGantt); + // Gezet door de preview-meting: de render liet de toewijzingskolommen vallen omdat de tabel + // anders geen tijdlijn overliet (zie `MIN_CHART_WIDTH_PX` in printPreview). + const [assignmentColumnsDropped, setAssignmentColumnsDropped] = useState(false); const patchResourceGanttOptions = useCallback((patch: Partial) => { setResourceGanttOptions(prev => ({ ...prev, ...patch })); }, []); @@ -633,6 +636,7 @@ export function ReportPanel() { assignmentColumns: reportType === 'resourceGantt' && resourceGanttOptions.showAssignmentColumns, rowAssignments: resourceGantt?.assignmentByRowKey, curveLabels, + numberLocale: i18n.language, barColorsLegendLabels: { criticalOutline: t('legend.criticalOutline', { defaultValue: 'Kritiek pad (rand)' }), categoriesMore: (n: number) => t('legend.categoriesMore', { count: n }), @@ -643,7 +647,7 @@ export function ReportPanel() { cpmResult, barColorSelection, fieldCtx.activityCodeTypes, fieldCtx.customFieldDefs, reportTaskTypeLabels, tTask, statusLine, statusDate, resources, assignments, baselineOverlay, reportRows, reportType, resourceGanttOptions.pageBreakPerResource, tasks.length, - resourceGantt, resourceGanttWindow, resourceGanttOptions.showAssignmentColumns, curveLabels]); + resourceGantt, resourceGanttWindow, resourceGanttOptions.showAssignmentColumns, curveLabels, i18n.language]); // `options` bevat afgeleide catalogus-/vertaalobjecten die bij een lokale preview-state-update // opnieuw kunnen worden aangemaakt zonder dat hun inhoud wijzigde. De rastertaak gebruikt deze // inhoudssignatuur als effectgrens: anders start `setPreviewPages` zelf opnieuw pagina 0 en 1. @@ -698,7 +702,9 @@ export function ReportPanel() { if (cancelled) return; const { width: logicalWidth, height: logicalHeight, tableWidth, headerHeight, footerHeight, breakOffsets, forcedBreakOffsets, + assignmentColumnsDropped: columnsDropped, } = measurePrintReport(tasks, sequences, calendar, projectName, options); + setAssignmentColumnsDropped(!!columnsDropped); const lowerPaper = options.paperSize.toLowerCase() as 'a4' | 'a3' | 'a2' | 'a1'; const cssPageWidth = previewCssWidth; const previewLimits = computePreviewRasterLimits( @@ -1236,6 +1242,11 @@ export function ReportPanel() { {resourceGantt.counts.outsidePeriod} )} + {assignmentColumnsDropped && resourceGanttOptions.showAssignmentColumns && ( + + {t('resourceGantt.columnsDropped')} + + )} ) : reportType === 'gantt' ? ( <> diff --git a/src/components/panels/reports/useTableReportSpec.tsx b/src/components/panels/reports/useTableReportSpec.tsx index dfe888ecd..11c01f53e 100644 --- a/src/components/panels/reports/useTableReportSpec.tsx +++ b/src/components/panels/reports/useTableReportSpec.tsx @@ -6,6 +6,7 @@ import { useDisplayDate } from '@/hooks/displayDate'; import type { ResourceType } from '@/types/resource'; import { formatDate } from '@/utils/dateUtils'; import { makeMonthLabeler } from '@/utils/monthLabel'; +import { formatReportNumber, formatSignedReportNumber } from '@/utils/reportNumber'; import type { ReportType, TableReportOptions } from '@/utils/reportSettings'; import type { ReportingPeriod } from '@/engine/reports'; import { isTableReportType } from '@/utils/reportSettings'; @@ -26,7 +27,8 @@ import { REPORT_COLORS, section, type ReportColumn, type ReportSummaryItem, type * met een F5/Bereken, een voortgangsinvoer of een baselinewissel. */ type T = TFunction<'report'>; -type DD = ReturnType; +/** Datum- én getalnotatie van het rapport, gebonden aan de app-instellingen en -taal. */ +type DD = ReturnType & { num: (n: number) => string; signed: (n: number) => string }; /** Zelfde sleutelmap als `ResourcePanel.tsx` — `t()` is strikt getypeerd, dus geen template-sleutel. */ const RESOURCE_TYPE_KEY = { @@ -38,8 +40,6 @@ const RESOURCE_TYPE_KEY = { } as const satisfies Record; const pct = (c: number) => `${Math.round(c * 100)}%`; -const num = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1)); -const signed = (n: number) => (n > 0 ? `+${num(n)}` : num(n)); const floatColor = (tf: number) => (tf < 0 ? REPORT_COLORS.error : tf === 0 ? REPORT_COLORS.warn : undefined); function useReportContext(): { ctx: ReportContext; stale: boolean } { @@ -74,16 +74,16 @@ function nameCol(t: T, width = 220): ReportColumn function dateCol(key: string, header: string, get: (r: R) => string | undefined, dd: DD, width = 95): ReportColumn { return { key, header, width, align: 'left', text: r => dd.date(get(r)) || '—' }; } -function remainingCol(t: T): ReportColumn { - return { key: 'remaining', header: t('tableReports.common.remaining'), width: 70, align: 'right', text: r => num(r.remainingDays) }; +function remainingCol(t: T, dd: DD): ReportColumn { + return { key: 'remaining', header: t('tableReports.common.remaining'), width: 70, align: 'right', text: r => dd.num(r.remainingDays) }; } function completionCol(t: T): ReportColumn { return { key: 'completion', header: t('tableReports.common.completion'), width: 60, align: 'right', text: r => pct(r.completion) }; } -function tfCol(t: T): ReportColumn { +function tfCol(t: T, dd: DD): ReportColumn { return { key: 'tf', header: t('tableReports.common.totalFloat'), width: 70, align: 'right', - text: r => num(r.totalFloat), color: r => floatColor(r.totalFloat), bold: r => r.totalFloat < 0, + text: r => dd.num(r.totalFloat), color: r => floatColor(r.totalFloat), bold: r => r.totalFloat < 0, }; } function statusCol(t: T, text: (r: R) => string, color: (r: R) => string | undefined, width = 110): ReportColumn { @@ -140,7 +140,7 @@ function buildLookAhead(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD, wbsCol(t), nameCol(t), dateCol('start', t('tableReports.common.start'), r => r.start, dd), dateCol('finish', t('tableReports.common.finish'), r => r.finish, dd), - remainingCol(t), completionCol(t), tfCol(t), criticalCol(t), + remainingCol(t, dd), completionCol(t), tfCol(t, dd), criticalCol(t), { key: 'resources', header: t('tableReports.common.resources'), width: 150, align: 'left', text: r => resourcesText(r.resources) }, statusCol(t, r => t(`tableReports.lookAhead.status_${r.status}`), r => LOOK_AHEAD_COLOR[r.status], 120), ]; @@ -169,8 +169,8 @@ function buildCritical(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD, wbsCol(t), nameCol(t, 260), dateCol('start', t('tableReports.common.start'), r => r.start, dd), dateCol('finish', t('tableReports.common.finish'), r => r.finish, dd), - remainingCol(t), tfCol(t), - { key: 'ff', header: t('tableReports.common.freeFloat'), width: 70, align: 'right', text: r => num(r.freeFloat) }, + remainingCol(t, dd), tfCol(t, dd), + { key: 'ff', header: t('tableReports.common.freeFloat'), width: 70, align: 'right', text: r => dd.num(r.freeFloat) }, { key: 'path', header: t(`${p}.floatPath`), width: 60, align: 'right', text: r => (r.floatPath === undefined ? '—' : String(r.floatPath)) }, statusCol(t, r => t(`tableReports.critical.status_${r.status}`), r => (r.status === 'critical' ? REPORT_COLORS.error : REPORT_COLORS.warn)), ]; @@ -197,7 +197,7 @@ function buildProgress(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD, wbsCol(t), nameCol(t), dateCol('baselineFinish', t(`${p}.baselineFinish`), r => r.baselineFinish, dd), dateCol('finish', t(`${p}.forecastFinish`), r => r.finish, dd), - completionCol(t), remainingCol(t), tfCol(t), criticalCol(t), + completionCol(t), remainingCol(t, dd), tfCol(t, dd), criticalCol(t), statusCol(t, r => t(`tableReports.progress.status_${r.status}`), r => PROGRESS_COLOR[r.status], 130), ]; const sec = (key: 'completed' | 'inProgress' | 'startingNext' | 'overdue' | 'critical', rows: ProgressRow[]) => @@ -210,9 +210,9 @@ function buildProgress(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD, { label: t(`${p}.lookAheadUntil`), value: dd.date(s.lookAheadTo) }, { label: t(`${p}.baselineFinish`), value: dd.date(s.baselineFinish) || '—' }, { label: t(`${p}.forecastFinish`), value: dd.date(s.forecastFinish) || '—' }, - { label: t(`${p}.finishVariance`), value: s.finishVarianceDays === undefined ? '—' : signed(s.finishVarianceDays), color: varianceColor }, - { label: `${t(`${p}.planned`)} ${t(`${p}.plannedBasis_${s.plannedBasis}`)}`, value: `${num(s.plannedPct)}%` }, - { label: t(`${p}.actual`), value: `${num(s.actualPct)}%`, color: s.actualPct + 0.05 < s.plannedPct ? REPORT_COLORS.error : REPORT_COLORS.ok }, + { label: t(`${p}.finishVariance`), value: s.finishVarianceDays === undefined ? '—' : dd.signed(s.finishVarianceDays), color: varianceColor }, + { label: `${t(`${p}.planned`)} ${t(`${p}.plannedBasis_${s.plannedBasis}`)}`, value: `${dd.num(s.plannedPct)}%` }, + { label: t(`${p}.actual`), value: `${dd.num(s.actualPct)}%`, color: s.actualPct + 0.05 < s.plannedPct ? REPORT_COLORS.error : REPORT_COLORS.ok }, { label: t(`${p}.status_complete`), value: `${s.counts.complete} / ${s.counts.total}` }, { label: t(`${p}.status_inProgress`), value: String(s.counts.inProgress) }, { label: t(`${p}.status_notStarted`), value: String(s.counts.notStarted) }, @@ -244,8 +244,8 @@ function healthDetailText(t: T, dd: DD, item: HealthItem): string { if (d.reason) parts.push(t(`tableReports.health.reason_${d.reason}`)); if (d.constraintType) parts.push(d.constraintType); if (d.lag) parts.push(t('tableReports.health.detail_lag', { value: d.lag })); - else if (d.days !== undefined) parts.push(t('tableReports.health.detail_days', { value: num(d.days) })); - if (d.float !== undefined) parts.push(t('tableReports.health.detail_float', { value: num(d.float) })); + else if (d.days !== undefined) parts.push(t('tableReports.health.detail_days', { value: dd.num(d.days) })); + if (d.float !== undefined) parts.push(t('tableReports.health.detail_float', { value: dd.num(d.float) })); if (d.date) parts.push(dd.date(d.date)); return parts.join(' · '); } @@ -321,10 +321,10 @@ function buildResourceLoading(ctx: ReportContext, o: TableReportOptions, t: T, t monthly ? { key: 'month', header: t(`${p}.month`), width: 95, align: 'left', text: row => monthLabel(row.bucketStart) } : dateCol('week', t(`${p}.week`), row => row.bucketStart, dd), - { key: 'required', header: t(`${p}.required`), width: 85, align: 'right', text: r => num(r.required) }, - { key: 'available', header: t(`${p}.available`), width: 90, align: 'right', text: r => num(r.available) }, - { key: 'variance', header: t(`${p}.variance`), width: 80, align: 'right', text: r => signed(r.variance), color: r => (r.variance < 0 ? REPORT_COLORS.error : undefined), bold: r => r.variance < 0 }, - { key: 'peak', header: t(`${p}.peak`), width: 80, align: 'right', text: r => num(r.peakDayLoad) }, + { key: 'required', header: t(`${p}.required`), width: 85, align: 'right', text: r => dd.num(r.required) }, + { key: 'available', header: t(`${p}.available`), width: 90, align: 'right', text: r => dd.num(r.available) }, + { key: 'variance', header: t(`${p}.variance`), width: 80, align: 'right', text: r => dd.signed(r.variance), color: r => (r.variance < 0 ? REPORT_COLORS.error : undefined), bold: r => r.variance < 0 }, + { key: 'peak', header: t(`${p}.peak`), width: 80, align: 'right', text: r => dd.num(r.peakDayLoad) }, // Aantal overbelaste dagen als getal: geen taalkundig meervoud nodig (Pools "dni" was fout bij 1). { key: 'overloaded', header: t(`${p}.overloaded`), width: 100, align: 'right', text: r => (r.overloaded ? String(r.overloadedDays) : ''), color: r => (r.overloaded ? REPORT_COLORS.error : undefined), bold: r => r.overloaded }, ]; @@ -360,8 +360,8 @@ function buildResourceAssignments(ctx: ReportContext, o: TableReportOptions, t: wbsCol(t), nameCol(t, 200), dateCol('start', t('tableReports.common.start'), r => r.start, dd), dateCol('finish', t('tableReports.common.finish'), r => r.finish, dd), - remainingCol(t), - { key: 'units', header: t(`${p}.units`), width: 75, align: 'right', text: r => num(r.unitsPerDay) }, + remainingCol(t, dd), + { key: 'units', header: t(`${p}.units`), width: 75, align: 'right', text: r => dd.num(r.unitsPerDay) }, completionCol(t), { key: 'critical', header: t('tableReports.common.critical'), width: 60, align: 'left', text: r => (r.isCritical ? t('tableReports.common.yes') : ''), color: r => (r.isCritical ? REPORT_COLORS.error : undefined), bold: r => r.isCritical }, statusCol(t, r => t(`tableReports.progress.status_${r.state}`), r => PROGRESS_COLOR[r.state], 110), @@ -390,10 +390,10 @@ function buildWbsSummary(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD dateCol('finish', t('tableReports.common.finish'), r => r.finish, dd), dateCol('baselineStart', t(`${p}.baselineStart`), r => r.baselineStart, dd), dateCol('baselineFinish', t(`${p}.baselineFinish`), r => r.baselineFinish, dd), - { key: 'duration', header: t(`${p}.duration`), width: 70, align: 'right', text: r => num(r.durationDays) }, + { key: 'duration', header: t(`${p}.duration`), width: 70, align: 'right', text: r => dd.num(r.durationDays) }, { key: 'completion', header: t('tableReports.common.completion'), width: 60, align: 'right', text: r => pct(r.completion) }, - { key: 'variance', header: t(`${p}.finishVariance`), width: 80, align: 'right', text: r => (r.finishVarianceDays === undefined ? '—' : signed(r.finishVarianceDays)), color: r => (r.finishVarianceDays !== undefined && r.finishVarianceDays > 0 ? REPORT_COLORS.error : undefined), bold: r => r.finishVarianceDays !== undefined && r.finishVarianceDays > 0 }, - { key: 'minFloat', header: t(`${p}.minFloat`), width: 70, align: 'right', text: r => num(r.minTotalFloat), color: r => floatColor(r.minTotalFloat), bold: r => r.minTotalFloat < 0 }, + { key: 'variance', header: t(`${p}.finishVariance`), width: 80, align: 'right', text: r => (r.finishVarianceDays === undefined ? '—' : dd.signed(r.finishVarianceDays)), color: r => (r.finishVarianceDays !== undefined && r.finishVarianceDays > 0 ? REPORT_COLORS.error : undefined), bold: r => r.finishVarianceDays !== undefined && r.finishVarianceDays > 0 }, + { key: 'minFloat', header: t(`${p}.minFloat`), width: 70, align: 'right', text: r => dd.num(r.minTotalFloat), color: r => floatColor(r.minTotalFloat), bold: r => r.minTotalFloat < 0 }, { key: 'activities', header: t(`${p}.activities`), width: 55, align: 'right', text: r => (r.isSummary ? String(r.counts.total) : '') }, { key: 'critical', header: t(`${p}.critical`), width: 55, align: 'right', text: r => (r.isSummary ? String(r.counts.critical) : ''), color: r => (r.counts.critical ? REPORT_COLORS.error : undefined) }, { key: 'inProgress', header: t(`${p}.inProgress`), width: 55, align: 'right', text: r => (r.isSummary ? String(r.counts.inProgress) : '') }, @@ -416,9 +416,16 @@ function buildWbsSummary(ctx: ReportContext, o: TableReportOptions, t: T, dd: DD export function useTableReportSpec(reportType: ReportType, options: TableReportOptions): TableReportSpec | null { const { t, i18n } = useTranslation('report'); const { t: tCommon } = useTranslation('common'); - const dd = useDisplayDate(); + const dates = useDisplayDate(); const { ctx, stale } = useReportContext(); const locale = i18n.language; + // Getallen in de app-taal (komma in nl/de/fr): dezelfde notatie als de toewijzingskolommen van het + // resourcediagram (`formatReportNumber`), zodat de rapporten in één paneel niet uiteenlopen. + const dd = useMemo
(() => ({ + ...dates, + num: n => formatReportNumber(n, locale), + signed: n => formatSignedReportNumber(n, locale), + }), [dates, locale]); return useMemo(() => { if (!isTableReportType(reportType)) return null; switch (reportType) { diff --git a/src/components/task-sections/TaskAssignmentsSection.tsx b/src/components/task-sections/TaskAssignmentsSection.tsx index b10bd77d7..6950ce73d 100644 --- a/src/components/task-sections/TaskAssignmentsSection.tsx +++ b/src/components/task-sections/TaskAssignmentsSection.tsx @@ -5,7 +5,7 @@ import type { ResourceCurve } from '@/types/resource'; import { UnitsInput } from '@/components/common/UnitsInput'; import { BarChart3, Trash2 } from 'lucide-react'; import { RESOURCE_CURVES, CURVE_KEY } from './shared'; -import { matchContoursToAssignments } from '@/engine/contour/contourEngine'; +import { assignmentCurveState, contouredAssignmentIds } from '@/engine/contour/curveState'; import { ContourDialog } from '@/components/dialogs/ContourDialog'; /** Pseudowaarden van de curve-dropdown voor de twee data-toestanden van de contour-engine @@ -40,7 +40,8 @@ export function TaskAssignmentsSection({ taskId }: { taskId: string }) { // Toewijzingen (fase 2.5, §6.3) — leaf-only, geen mijlpalen/samenvattingstaken. const taskAssignments = assignments.filter(a => a.taskId === taskId); - const contourOf = matchContoursToAssignments(task.timephasedContours, taskAssignments); + // Dezelfde weergaveregel als het resourcediagram (`curveState.ts`): contour > geïmporteerde curve > vorm. + const contouredIds = contouredAssignmentIds(task, taskAssignments); const assignmentsDisabled = task.isMilestone || task.childIds.length > 0; const assignedResourceIds = new Set(taskAssignments.map(a => a.resourceId)); const availableResources = resources.filter(r => !assignedResourceIds.has(r.id)); @@ -70,9 +71,10 @@ export function TaskAssignmentsSection({ taskId }: { taskId: string }) { {taskAssignments.map(a => { const res = resources.find(r => r.id === a.resourceId); const candidates = moveCandidates(a.resourceId); - const contoured = contourOf.has(a.id); - const importedCurve = !contoured && !a.curve && !!a.curveValues; - const curveValue = contoured ? CONTOURED : importedCurve ? IMPORTED_CURVE : (a.curve ?? 'UNIFORM'); + const curveState = assignmentCurveState(a, contouredIds.has(a.id)); + const contoured = curveState === 'contoured'; + const importedCurve = curveState === 'imported'; + const curveValue = contoured ? CONTOURED : importedCurve ? IMPORTED_CURVE : curveState; return (
{res?.name || '?'} diff --git a/src/engine/contour/curveState.ts b/src/engine/contour/curveState.ts new file mode 100644 index 000000000..a0b0f2701 --- /dev/null +++ b/src/engine/contour/curveState.ts @@ -0,0 +1,43 @@ +import type { ResourceAssignment, ResourceCurve } from '@/types/resource'; +import type { Task } from '@/types/task'; +import { matchContoursToAssignments } from './contourEngine'; + +/** + * De WEERGAVE-toestand van de curve van één toewijzing — de ene regel die het eigenschappenpaneel + * (`TaskAssignmentsSection`) en het resourcediagram (`computeResourceGanttRows`) delen, zodat een + * uitgedeeld vel nooit iets anders zegt dan het paneel (hyperkritische review op #138, ronde 2, + * bevinding 1): + * + * 1. een aan de toewijzing gekoppelde CONTOUR op de taak (`Task.timephasedContours` via + * `matchContoursToAssignments`) ⇒ `'contoured'` — de urenverdeling is dan data, de curve doet + * er niet toe; + * 2. anders exacte geïmporteerde curvewaarden ZONDER OPS-vorm (`curveValues` en geen `curve`, + * zoals een P6-/MSP-curve die geen van de acht eigen vormen is) ⇒ `'imported'`; + * 3. anders de OPS-vorm, `curve` afwezig = `'UNIFORM'`. + * + * Let op: dit is bewust NIET de verdeelregel van `ResourceLoad.ts`'s `assignmentDayUnits`. Die + * verdeelt met `curveValues` zodra die er zijn — óók naast een `curve` (het gewone P6-pad: + * naamterugval plús exacte waarden) — en negeert een contour zonder periodes. Weergave en + * verdeling mogen dus verschillen ("Vooraan belast" op het scherm, P6's exacte 21 waarden in het + * histogram); wat hier vastligt is dat alle WEERGAVEN onderling gelijk zijn. Wie de verdeelregel + * ooit gelijk wil trekken, doet dat in `assignmentDayUnits`, niet hier. + */ +export type AssignmentCurveState = ResourceCurve | 'contoured' | 'imported'; + +/** De ids van de toewijzingen van `task` waaraan een contour gekoppeld is. Geef ALLE records van + * de taak mee (ook naar een onbekende resource): de legacy-terugval in + * `matchContoursToAssignments` telt de lijstlengte, dus een gefilterde lijst valt anders uit dan + * het paneel en de lastverdeling. */ +export function contouredAssignmentIds( + task: Pick, + records: readonly ResourceAssignment[], +): Set { + if (!task.timephasedContours || task.timephasedContours.length === 0) return new Set(); + return new Set(matchContoursToAssignments(task.timephasedContours, records).keys()); +} + +export function assignmentCurveState(a: ResourceAssignment, contoured: boolean): AssignmentCurveState { + if (contoured) return 'contoured'; + if (!a.curve && a.curveValues) return 'imported'; + return a.curve ?? 'UNIFORM'; +} diff --git a/src/engine/reports/resourceGantt.ts b/src/engine/reports/resourceGantt.ts index e936eec1a..35c02c602 100644 --- a/src/engine/reports/resourceGantt.ts +++ b/src/engine/reports/resourceGantt.ts @@ -1,7 +1,7 @@ import type { Task } from '@/types/task'; -import type { Resource, ResourceAssignment, ResourceCurve, ResourceType } from '@/types/resource'; +import type { Resource, ResourceAssignment, ResourceType } from '@/types/resource'; import { encodeBandKey, encodeGroupedTaskRowKey, NONE_RAWKEY, type ViewRow } from '@/engine/view/visibleRows'; -import { matchContoursToAssignments } from '@/engine/contour/contourEngine'; +import { type AssignmentCurveState, assignmentCurveState, contouredAssignmentIds } from '@/engine/contour/curveState'; import { dayOf, taskFinish, taskStart } from './reportCommon'; import type { ResolvedPeriod } from './reportingPeriod'; @@ -46,17 +46,10 @@ import type { ResolvedPeriod } from './reportingPeriod'; * * Per taakrij onder een resourceband levert `assignmentByRowKey` de TOEWIJZING van die band op * die taak (punt 1): eenheden per dag en de verdeelcurve — de rij is een taak, maar wat de lezer - * wil weten is "hoe zwaar staat déze resource erop". De curveTOESTAND is exact de weergaveregel - * van het eigenschappenpaneel (`TaskAssignmentsSection`), zodat rapport en paneel nooit twee - * antwoorden geven: een aan de toewijzing gekoppelde CONTOUR op de taak ⇒ `'contoured'`, anders - * `curveValues` zonder OPS-vorm (`curve` afwezig) ⇒ `'imported'`, anders `curve` (afwezig = - * UNIFORM). Let op: dat is een WEERGAVEregel, niet de verdeelregel van `ResourceLoad.ts`'s - * `assignmentDayUnits` — die verdeelt met `curveValues` zodra die er zijn, óók naast een `curve` - * (het gewone P6-pad: naamterugval + exacte waarden), en negeert een contour zonder periodes. Het - * rapport toont dus wat het paneel toont ("Vooraan belast"), terwijl het histogram P6's exacte - * waarden gebruikt; een echt gedeelde toestandshelper is een vervolgstap (review ronde 2). Een - * P6-/MSP-import met een curve zonder OPS-vorm heet hier in elk geval nooit "Uniform" - * (ronde 1, bevinding 1). Twee records van dezelfde resource op één + * wil weten is "hoe zwaar staat déze resource erop". De curveTOESTAND komt uit de gedeelde + * weergaveregel `assignmentCurveState` (`src/engine/contour/curveState.ts`) — dezelfde als het + * eigenschappenpaneel, zodat rapport en paneel nooit twee antwoorden geven; het verschil met de + * verdeelregel van de lastverdeling staat dáár gedocumenteerd. Twee records van dezelfde resource op één * taak (één rij) worden opgeteld; de curve is alleen bekend als alle records dezelfde hebben * (anders `null`, de render toont een streepje). De "(geen)"-band heeft geen toewijzing en dus * geen entry. De render tekent dit als twee tabelkolommen (`PrintOptions.assignmentColumns`). @@ -107,8 +100,8 @@ export interface ResourceGanttRowsResult { }; } -/** De curvetoestand van een toewijzing: contour op de taak, geïmporteerde exacte curve, of de OPS-vorm. */ -export type RowCurve = ResourceCurve | 'contoured' | 'imported'; +/** De curvetoestand van een toewijzing — zie `assignmentCurveState`. */ +export type RowCurve = AssignmentCurveState; /** De toewijzing achter één taakrij van een resourceband (zie de moduledoc). */ export interface RowAssignment { @@ -214,13 +207,9 @@ export function computeResourceGanttRows( } const contouredIds = new Set(); for (const [taskId, list] of recordsByTask) { - const task = leafById.get(taskId)!; - if (task.timephasedContours && task.timephasedContours.length > 0) { - for (const id of matchContoursToAssignments(task.timephasedContours, list).keys()) contouredIds.add(id); - } + for (const id of contouredAssignmentIds(leafById.get(taskId)!, list)) contouredIds.add(id); } - const curveOf = (a: ResourceAssignment): RowCurve => - contouredIds.has(a.id) ? 'contoured' : (!a.curve && a.curveValues ? 'imported' : (a.curve ?? 'UNIFORM')); + const curveOf = (a: ResourceAssignment): RowCurve => assignmentCurveState(a, contouredIds.has(a.id)); let assignments = 0; for (const a of ctx.assignments) { const task = leafById.get(a.taskId); diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index 70f66eac8..f1a7bfc7c 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -150,6 +150,7 @@ "includeUnassigned": "تضمين المهام بدون مورد", "groupByType": "تجميع حسب نوع المورد", "showAssignmentColumns": "إظهار الوحدات/اليوم والمنحنى", + "columnsDropped": "تم حذف عمودي وحدة/يوم والمنحنى: مع عمود الاسم هذا لن يتبقى محور زمني. اقتطع أسماء المهام أو اختر ورقًا أكبر.", "emptyPeriod": "لا توجد مهام في فترة التقرير — اختر فترة أخرى أو المشروع بأكمله.", "outsidePeriod": "خارج الفترة:", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index 462542cb8..21d82a3c7 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -138,6 +138,7 @@ "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", "groupByType": "Nach Ressourcentyp gruppieren", "showAssignmentColumns": "Einheiten/Tag und Kurve anzeigen", + "columnsDropped": "Die Spalten Einh./T und Kurve wurden weggelassen: mit dieser Namensspalte bliebe keine Zeitachse übrig. Kürzen Sie die Vorgangsnamen oder wählen Sie ein größeres Papierformat.", "emptyPeriod": "Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt.", "outsidePeriod": "Außerhalb des Zeitraums:", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index 415157c9d..dd909ac20 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -138,6 +138,7 @@ "includeUnassigned": "Include tasks without a resource", "groupByType": "Group by resource type", "showAssignmentColumns": "Show units/day and curve", + "columnsDropped": "The Units/d and Curve columns were left out: with this name column no time axis would remain. Truncate task names or choose a larger paper size.", "emptyPeriod": "No tasks in the reporting period — choose another period or Whole project.", "outsidePeriod": "Outside the period:", "empty": "No resource assignments — assign resources to tasks first.", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index ee3509643..8621636b0 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -141,6 +141,7 @@ "includeUnassigned": "Incluir tareas sin recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/día y curva", + "columnsDropped": "Se omitieron las columnas Unid./d y Curva: con esta columna de nombre no quedaría eje de tiempo. Trunque los nombres de tarea o elija un papel más grande.", "emptyPeriod": "No hay tareas en el período del informe — elija otro período o Proyecto completo.", "outsidePeriod": "Fuera del período:", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 353c25aec..6e4bd0904 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -139,6 +139,7 @@ "includeUnassigned": "شامل کردن کارهای بدون منبع", "groupByType": "گروه‌بندی بر اساس نوع منبع", "showAssignmentColumns": "نمایش واحد/روز و منحنی", + "columnsDropped": "ستون‌های واحد/روز و منحنی حذف شدند: با این ستون نام، محور زمانی باقی نمی‌ماند. نام کارها را کوتاه کنید یا کاغذ بزرگ‌تری انتخاب کنید.", "emptyPeriod": "هیچ کاری در دوره گزارش وجود ندارد — دوره دیگری یا کل پروژه را انتخاب کنید.", "outsidePeriod": "خارج از دوره:", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index aad5aa386..b6208a947 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -141,6 +141,7 @@ "includeUnassigned": "Inclure les tâches sans ressource", "groupByType": "Grouper par type de ressource", "showAssignmentColumns": "Afficher unités/jour et courbe", + "columnsDropped": "Les colonnes Unités/j et Courbe ont été omises : avec cette colonne de nom, il ne resterait plus d’axe de temps. Tronquez les noms de tâches ou choisissez un papier plus grand.", "emptyPeriod": "Aucune tâche dans la période de rapport — choisissez une autre période ou Projet entier.", "outsidePeriod": "Hors période :", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index b5beec51d..200d3e36b 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -141,6 +141,7 @@ "includeUnassigned": "Includi le attività senza risorsa", "groupByType": "Raggruppa per tipo di risorsa", "showAssignmentColumns": "Mostra unità/giorno e curva", + "columnsDropped": "Le colonne Unità/g e Curva sono state omesse: con questa colonna del nome non resterebbe alcun asse temporale. Tronca i nomi delle attività o scegli una carta più grande.", "emptyPeriod": "Nessuna attività nel periodo del report — scegli un altro periodo o Intero progetto.", "outsidePeriod": "Fuori dal periodo:", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index 1813d42e9..947b89f23 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -135,6 +135,7 @@ "includeUnassigned": "リソース未割当のタスクを含める", "groupByType": "リソース種別でグループ化", "showAssignmentColumns": "単位/日とカーブを表示", + "columnsDropped": "「単位/日」と「カーブ」列は省略されました。この名前列では時間軸が残りません。タスク名を切り詰めるか、大きい用紙を選んでください。", "emptyPeriod": "レポート期間内にタスクがありません — 別の期間またはプロジェクト全体を選択してください。", "outsidePeriod": "期間外:", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 5d0036f1b..70a006708 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -135,6 +135,7 @@ "includeUnassigned": "자원이 없는 작업 포함", "groupByType": "자원 유형별로 그룹화", "showAssignmentColumns": "단위/일 및 곡선 표시", + "columnsDropped": "단위/일 및 곡선 열이 생략되었습니다. 이 이름 열로는 시간 축이 남지 않습니다. 작업 이름을 자르거나 더 큰 용지를 선택하세요.", "emptyPeriod": "보고 기간에 작업이 없습니다 — 다른 기간 또는 전체 프로젝트를 선택하세요.", "outsidePeriod": "기간 외:", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index 5d1f739fd..1ab799c7a 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -138,6 +138,7 @@ "includeUnassigned": "Taken zonder resource meenemen", "groupByType": "Groeperen op resourcetype", "showAssignmentColumns": "Eenheden/dag en curve tonen", + "columnsDropped": "De kolommen Eenh./d en Curve zijn weggelaten: met deze naamkolom bleef er geen tijdlijn over. Kap de taaknamen af of kies groter papier.", "emptyPeriod": "Geen taken in de rapportageperiode — kies een andere periode of Hele project.", "outsidePeriod": "Buiten de periode:", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 1f6429536..f0ba71b49 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -144,6 +144,7 @@ "includeUnassigned": "Uwzględnij zadania bez zasobu", "groupByType": "Grupuj według typu zasobu", "showAssignmentColumns": "Pokaż jednostki/dzień i krzywą", + "columnsDropped": "Pominięto kolumny Jedn./d i Krzywa: przy tej kolumnie nazwy nie zostałaby oś czasu. Skróć nazwy zadań lub wybierz większy papier.", "emptyPeriod": "Brak zadań w okresie raportu — wybierz inny okres lub Cały projekt.", "outsidePeriod": "Poza okresem:", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index 616c5811f..a8670678d 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -141,6 +141,7 @@ "includeUnassigned": "Incluir tarefas sem recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/dia e curva", + "columnsDropped": "As colunas Unid./d e Curva foram omitidas: com esta coluna de nome não sobraria eixo de tempo. Trunque os nomes das tarefas ou escolha um papel maior.", "emptyPeriod": "Nenhuma tarefa no período do relatório — escolha outro período ou Projeto inteiro.", "outsidePeriod": "Fora do período:", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index b76699da8..5f2da6b79 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -138,6 +138,7 @@ "includeUnassigned": "Kaynaksız görevleri dahil et", "groupByType": "Kaynak türüne göre grupla", "showAssignmentColumns": "Birim/gün ve eğriyi göster", + "columnsDropped": "Birim/g ve Eğri sütunları çıkarıldı: bu ad sütunuyla zaman ekseni kalmazdı. Görev adlarını kısaltın veya daha büyük bir kağıt seçin.", "emptyPeriod": "Rapor döneminde görev yok — başka bir dönem veya Tüm proje seçin.", "outsidePeriod": "Dönem dışında:", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 6bf763037..4cae1f685 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -135,6 +135,7 @@ "includeUnassigned": "包含未分配资源的任务", "groupByType": "按资源类型分组", "showAssignmentColumns": "显示单位/天和曲线", + "columnsDropped": "已省略“单位/天”和“曲线”列:使用此名称列宽度时将没有时间轴。请截断任务名称或选择更大的纸张。", "emptyPeriod": "报告期间内没有任务 — 请选择其他期间或整个项目。", "outsidePeriod": "期间之外:", "empty": "没有资源分配——请先为任务分配资源。", diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 6f96d303b..c0c66925b 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -31,6 +31,7 @@ import type { ActivityCodeType, CustomFieldDef } from '@/types/structure'; import type { BarColorSelection } from '@/types/barColor'; import type { ViewRow } from '@/engine/view/visibleRows'; import type { RowAssignment, RowCurve } from '@/engine/reports/resourceGantt'; +import { formatReportNumber } from '@/utils/reportNumber'; import type { BaselineOverlay } from '@/types/baseline'; // BASISmaten bij rapport-lettergrootte 100%. Niets tekent hier nog rechtstreeks mee: alle @@ -96,6 +97,14 @@ const COL = { curve: { w: 98 }, }; +/** + * Minimale chartbreedte (logische px, per paginabreedte) die de tabel moet overlaten. Komt de + * tabel mét toewijzingskolommen daaronder (lange taaknamen met afkappen uit), dan laat de render + * die twee kolommen vallen en meldt dat via `RenderReportResult.assignmentColumnsDropped` — beter + * een smallere tabel dan een tijdas van een paar pixels (review #138, ronde 2, bevinding 5). + */ +const MIN_CHART_WIDTH_PX = 240; + /** * Grenzen van de instelbare naamkolom (ongeschaalde px). `DEFAULT` is exact de breedte die de * kolom vóór deze instelling altijd had, zodat een verse installatie byte-identiek rendert. @@ -410,6 +419,8 @@ export interface PrintOptions { /** Vertaalde curvenamen (`common:resource.curve.*`, plus `contoured`/`imported` uit * `task:properties.assignments.*`); een ontbrekend label valt terug op de enum-/toestandsnaam. */ curveLabels?: Partial>; + /** BCP-47-taal voor getallen in de tabel (decimaalteken van de eenheden per dag); afwezig ⇒ punt. */ + numberLocale?: string; /** * Lettergrootte van het GEGENEREERDE RAPPORT als percentage (issue #25 punt 4). 100 (of * ontbrekend) = het oude gedrag, byte-identiek. Werkt bewust RELATIEF: tekst, rijhoogtes, @@ -516,11 +527,6 @@ function wrapWords(d2d: Draw2D, text: string, maxWidth: number): string[] { return lines.length > 0 ? lines : ['']; } -/** Eenheden per dag: geheel als "2", anders tot twee decimalen zonder nullen ("0.5", "1.25"). */ -function formatUnitsPerDay(units: number): string { - return String(Math.round(units * 100) / 100); -} - /** Format completion as "75%" */ function formatCompletion(completion: number): string { return `${Math.round(completion * 100)}%`; @@ -675,6 +681,12 @@ export interface RenderReportResult { * toegestane positie uit `breakOffsets` (een bandrij begint waar de vorige rij eindigt). */ forcedBreakOffsets?: number[]; + /** + * OPTIONEEL — `true` wanneer `PrintOptions.assignmentColumns` gevraagd was maar de tabel daarmee + * minder dan {@link MIN_CHART_WIDTH_PX} chart per paginabreedte overliet en de twee kolommen + * daarom zijn weggelaten. Het paneel meldt dat naast de optie. + */ + assignmentColumnsDropped?: boolean; } /** @@ -696,11 +708,24 @@ export function renderReport( ): RenderReportResult { // Alle maatvoering loopt via dit object — de tekenhelpers lezen de module-constanten niet meer // rechtstreeks (zie {@link ReportMetrics} voor het waarom van relatief-schalen). - const m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, !!options.assignmentColumns); + // Bronbreedte van één papierbreedte (zie de uitleg bij `availableChartWidth` verderop); hier al + // nodig om te beslissen of de toewijzingskolommen erbij passen. + const printableWidth = printableWidthLogicalPx( + options.paperSize.toLowerCase() as 'a4' | 'a3' | 'a2' | 'a1', + options.orientation, + ); + let assignmentColumns = !!options.assignmentColumns; + let m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, assignmentColumns); + let assignmentColumnsDropped = false; + if (assignmentColumns && m.tableWidth > printableWidth - MIN_CHART_WIDTH_PX) { + assignmentColumns = false; + assignmentColumnsDropped = true; + m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, false); + } // Rijen-bron: zie {@link buildPrintRows} — taakrijen mét diepte plus groepsband-rijen (#54) die // als samenvattings-strook tekenen. - const printRows = buildPrintRows(tasks, options.rows, options.rowAssignments); + const printRows = buildPrintRows(tasks, options.rows, assignmentColumns ? options.rowAssignments : undefined); const flatTasks: PrintTask[] = printRows .filter((r): r is PrintRow & { kind: 'task'; task: Task } => r.kind === 'task') .map(r => ({ ...r.task, _depth: r.depth })); @@ -762,8 +787,10 @@ export function renderReport( }); // Tijdvenster zonder één werkdag (b.v. een weekend) op de gecomprimeerde as: de as zou dan naar // de eerstvolgende werkdag búiten het venster kleven. Val voor dít venster terug op de kalender-as. + let windowOnCalendarAxis = false; if (options.timeWindow && compressed && measureAxis.daySpan(minDate, maxDate) < 1) { compressed = false; + windowOnCalendarAxis = true; measureAxis = resolveGanttAxis({ calendar: calEngine, compressNonWorkdays: false, origin: minDate, chartOriginX: 0, zoom: 1, scrollX: 0, @@ -787,10 +814,6 @@ export function renderReport( // De tabel behoudt zo op A4, A3, A2 én A1 dezelfde fysieke tekengrootte; uitsluitend de tijdas krijgt // meer of minder pixels per dag. De oude ondergrens van 5 px/dag maakte een meerjarenplanning // alsnog veel te breed, waarna de pagineerder juist de héle tabel mee verkleinde. - const printableWidth = printableWidthLogicalPx( - options.paperSize.toLowerCase() as 'a4' | 'a3' | 'a2' | 'a1', - options.orientation, - ); const availableChartWidth = Math.max(1, printableWidth - m.tableWidth) * timelineColumns; let zoom: number; @@ -1335,7 +1358,7 @@ export function renderReport( // ---- TIMELINE HEADER ---- drawTimelineHeader( d2d, m, canvasWidth, minDate, calendarDays, timelineDates, compressed, zoom, dateToX, options, - todayVisible ? todayX : null, statusLineX, + todayVisible ? todayX : null, statusLineX, windowOnCalendarAxis, ); // ---- TASK TABLE ---- @@ -1360,6 +1383,7 @@ export function renderReport( width: canvasWidth, height: canvasHeight, tableWidth: m.tableWidth, headerHeight: m.totalHeaderHeight, footerHeight: m.footerHeight, breakOffsets, ...(forcedBreakOffsets && forcedBreakOffsets.length > 0 ? { forcedBreakOffsets } : {}), + ...(assignmentColumnsDropped ? { assignmentColumnsDropped } : {}), }; } @@ -1735,6 +1759,9 @@ function drawTimelineHeader( options: PrintOptions, todayX: number | null, statusLineX: number | null = null, + /** Tijdvenster dat op de kalender-as is teruggevallen (geen werkdag erin): dan óók de + * weekenddagen nummeren, anders staat er geen enkel dagcijfer (review #138, bevinding 8). */ + showWeekendDayNumbers = false, ) { const top = m.projectHeaderHeight; const h = m.timelineHeaderHeight; @@ -1874,7 +1901,7 @@ function drawTimelineHeader( const x = dateToX(date); const dow = isoDayOfWeek(date); const dayNum = date.getUTCDate(); - if (compressed || (dow !== 6 && dow !== 7)) { // Weekenddagen staan alleen op de gewone kalender-as. + if (compressed || showWeekendDayNumbers || (dow !== 6 && dow !== 7)) { // Weekenddagen staan alleen op de gewone kalender-as. d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(7); d2d.textAlign = 'center'; @@ -2054,7 +2081,7 @@ function drawTaskTable( d2d.fillStyle = PRINT_COLORS.textSecondary; d2d.font = m.font(8); d2d.textAlign = 'right'; - d2d.fillText(formatUnitsPerDay(row.assignment.unitsPerDay), cols.units.x + cols.units.w - cellPad, textY); + d2d.fillText(formatReportNumber(row.assignment.unitsPerDay, options.numberLocale), cols.units.x + cols.units.w - cellPad, textY); d2d.textAlign = 'left'; const curveText = row.assignment.curve === null ? '—' : (options.curveLabels?.[row.assignment.curve] ?? row.assignment.curve); d2d.fillText(fitText(d2d, curveText, cols.curve.w - 2 * cellPad), cols.curve.x + cellPad, textY); diff --git a/src/utils/reportNumber.ts b/src/utils/reportNumber.ts new file mode 100644 index 000000000..c4c88d4c5 --- /dev/null +++ b/src/utils/reportNumber.ts @@ -0,0 +1,19 @@ +/** + * Getallen in rapporten (tabelrapporten én de toewijzingskolommen van het resourcediagram): één + * notatie voor DOM, raster-PDF en vector-PDF — hoogstens twee decimalen, geen overbodige nullen, + * geen duizendtalscheiding (het zijn dagen en eenheden, geen bedragen), en het decimaalteken van + * de app-taal ("0,5" in nl/de/fr, "0.5" in en). Zonder `locale` (headless tests, oude aanroepen) + * de neutrale punt-notatie. Eén functie, zodat twee rapporten in hetzelfde paneel niet met een + * verschillende precisie of een verschillend decimaalteken kunnen uitkomen (review #138). + */ +export function formatReportNumber(n: number, locale?: string): string { + if (!Number.isFinite(n)) return ''; + if (!locale) return String(Math.round(n * 100) / 100); + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2, useGrouping: false }).format(n); +} + +/** Zoals `formatReportNumber`, met een expliciete plus voor een positief getal (afwijkingen). */ +export function formatSignedReportNumber(n: number, locale?: string): string { + const text = formatReportNumber(n, locale); + return n > 0 ? `+${text}` : text; +} diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 8f1de7378..24a624fdf 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -586,6 +586,29 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(!plainA.texts.some(t => t.text === 'Eenh./d' || t.text === 'Vooraan belast'), 'zonder optie geen kolommen'); ok(JSON.stringify(record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, rowAssignments }))) === JSON.stringify(plainA), 'rowAssignments zonder assignmentColumns ⇒ byte-identiek'); + + // Restpunt review #138 (ronde 2, bevinding 5): laat de tabel mét kolommen minder dan + // MIN_CHART_WIDTH_PX (240) tijdlijn over op één papierbreedte, dan laat de render de twee + // kolommen vallen en meldt dat — op A4 liggend (1058 px printbreedte) is een naamkolom van + // 800 px daarvoor genoeg; de gewone naamkolom niet. + const wideOpts = baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'landscape', taskNameColumnWidth: 800 }); + const wide = record([T_NORM, T_CRIT], [], cal, wideOpts); + const wideNoCols = record([T_NORM, T_CRIT], [], cal, { ...wideOpts, assignmentColumns: false, rowAssignments: undefined }); + ok(wide.dims.assignmentColumnsDropped === true, 'brede naamkolom ⇒ toewijzingskolommen weggelaten en gemeld'); + ok(wide.dims.tableWidth === wideNoCols.dims.tableWidth && !wide.texts.some(t => t.text === 'Eenh./d'), + 'weggelaten ⇒ dezelfde tabel als zonder de optie, geen kolomkop'); + ok(withCols.dims.assignmentColumnsDropped === undefined, 'gewone naamkolom ⇒ kolommen blijven, geen melding'); + + // Restpunt bevinding 8: een venster zonder werkdag valt op de kalender-as terug — en nummert + // dan óók de weekenddagen, anders staat er geen enkel dagcijfer in de kop. + const weekend = record([T_CRIT], [], cal, baseOptions({ compressNonWorkdays: true, autoFit: false, customZoom: 20, timeWindow: { from: '2026-01-10', to: '2026-01-11' } })); + const headTexts = weekend.texts.filter(t => t.y <= weekend.dims.headerHeight).map(t => t.text); + ok(headTexts.includes('10') && headTexts.includes('11'), `weekendvenster op de kalender-as toont de dagcijfers 10 en 11 (got ${JSON.stringify(headTexts)})`); + + // Restpunt bevinding 10: het decimaalteken volgt de app-taal via `numberLocale`. + const nlUnits = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, numberLocale: 'nl' })); + ok(nlUnits.texts.some(t => t.text === '1,5' && inCol(t, 180, 225)), 'numberLocale nl ⇒ "1,5" in de eenhedenkolom'); + ok(withCols.texts.some(t => t.text === '1.5' && inCol(t, 180, 225)), 'zonder numberLocale ⇒ "1.5"'); } // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 6f2089fd8..806e299e4 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -14,6 +14,10 @@ import { computeLookAhead, computeCriticalReport, computeProgressReport, computeScheduleHealth, computeResourceLoading, computeResourceAssignments, computeResourceGanttRows, computeWbsSummary, progressState, remainingDays, resourceBandLabels, taskDepths, taskFinish, taskStart, +} from '@/engine/reports'; +import { assignmentCurveState, contouredAssignmentIds } from '@/engine/contour/curveState'; +import { formatReportNumber, formatSignedReportNumber } from '@/utils/reportNumber'; +import { isValidReportingPeriod, periodDays, projectSpan, resolveReportingPeriod, weeksToPreset, } from '@/engine/reports'; import { addCalendarMonths, formatDate, parseDate } from '@/utils/dateUtils'; @@ -628,6 +632,24 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); }, opts); eq('resourceGantt/curve: legacy-contour telt niet bij twee records (ook als er één naar een onbekende resource wijst)', legacy.assignmentByRowKey.get(legacy.rows.find(x => x.kind === 'task' && x.task.id === D)!.rowKey)?.curve, 'UNIFORM'); + // Restpunt review #138: de gedeelde weergaveregel zelf (paneel én rapport lezen deze ene functie). + const bare = asgFull('s1', A, kraan.id, 1); + eq('curveState: contour wint', assignmentCurveState({ ...bare, curve: 'BELL', curveValues }, true), 'contoured'); + eq('curveState: curveValues zonder vorm ⇒ imported', assignmentCurveState({ ...bare, curveValues }, false), 'imported'); + eq('curveState: curveValues mét vorm ⇒ de vorm', assignmentCurveState({ ...bare, curve: 'FRONT_LOADED', curveValues }, false), 'FRONT_LOADED'); + eq('curveState: niets ⇒ UNIFORM', assignmentCurveState(bare, false), 'UNIFORM'); + eq('curveState: contouredAssignmentIds koppelt op resourceId', [...contouredAssignmentIds(dMetContour, [asgFull('k1', D, jan1.id, 1), asgFull('k2', D, kraan.id, 1)])], ['k1']); + eq('curveState: zonder contouren een lege set', contouredAssignmentIds(byId(D), [bare]).size, 0); + + // Restpunt review #138: één getalnotatie voor alle rapporten — app-taal bepaalt het decimaalteken. + eq('reportNumber: nl ⇒ komma', formatReportNumber(0.5, 'nl'), '0,5'); + eq('reportNumber: en ⇒ punt', formatReportNumber(0.5, 'en'), '0.5'); + eq('reportNumber: zonder taal ⇒ punt', formatReportNumber(0.5), '0.5'); + eq('reportNumber: geheel zonder decimalen', formatReportNumber(2, 'nl'), '2'); + eq('reportNumber: hoogstens twee decimalen, geen duizendtalscheiding', formatReportNumber(1234.567, 'de'), '1234,57'); + eq('reportNumber: plus bij positief', formatSignedReportNumber(1.25, 'nl'), '+1,25'); + eq('reportNumber: geen plus bij nul of negatief', [formatSignedReportNumber(0, 'nl'), formatSignedReportNumber(-2, 'nl')], ['0', '-2']); + // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). const dubbel = computeResourceGanttRows({ From deb509cc34ebbc3169f9def05f179781b74e8936 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 18:35:44 +0000 Subject: [PATCH 092/108] =?UTF-8?q?Rapport:=20reviewbevindingen=20op=20#13?= =?UTF-8?q?9=20=E2=80=94=20kolomdrop=20relatief=20en=20alleen=20als=20het?= =?UTF-8?q?=20helpt,=20=C3=A9=C3=A9n=20getalnotatie=20in=20alle=20rapporte?= =?UTF-8?q?n,=20docs=20en=20melding=20waarheidsgetrouw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyperkritische review op de restpunten-PR (twaalf bevindingen, alle opgepakt): - Toewijzingskolommen laten vallen (bevindingen 1, 2, 4): de grens is niet langer een vaste 240 px maar een vijfde van de printbreedte met een vloer van 160 px, en de render laat de kolommen alléén weg als de tabel zónder die kolommen wél past. A4 staand met verse instellingen houdt de kolommen dus; een naamkolom van 800 px op A4 liggend houdt ze óók (weglaten loste daar niets op: 8 px tijdlijn). De melding en beide gidsen noemen nu de echte hefbomen (papier/richting, lettergrootte, naamkolom, Voltooiing tonen) in plaats van "kap de namen af" — dat stond al aan (bevinding 3). - Getalnotatie (bevindingen 5–7): `formatReportNumber` dwingt Latijnse cijfers af (`-u-nu-latn`, fa koos anders arabext naast Latijnse tellingen), stript de bidi-markering U+200E die Inter in de vector-PDF als spatie zette, geeft "0" in plaats van "-0", en de plus komt uit `signDisplay: 'exceptZero'` in plaats van een ASCII-plus naast een typografische min. De Duur-cel van de Gantt-afdruk, `fmtDelta` in het variance-rapport (DOM én PDF) en de lag-tekst in het gezondheidsrapport (`localizeDecimalPoint`) volgen nu dezelfde notatie als de Eenh./d-cel en de tabelrapporten. - Middernacht-doc (bevinding 8): "vandaag" volgt de UTC-kalender en wordt bij elke weergave bepaald, niet "bij elke herberekening". - Artifacts-beleid (bevinding 9): benoemt dat `artifacts/tabel-overhaul/` van vóór de regel dateert. - Melding (bevindingen 10, 11): browserassertie op de melding (verschijnt op A4 staand 125 %, verdwijnt op 100 %); de vlag wordt gewist zodra er geen Gantt-render meer is. - `curveState` (bevinding 12): lege `curveValues` zijn geen geïmporteerde curve. Regressie: check-print-report (drop/geen drop/staand/lettergrootte, duurcel), check-reports (fa/ar/-0/NaN/onbekende taal/lag-tekst/lege curveValues), resource-gantt-report.spec.ts. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- CLAUDE.md | 2 +- public/docs/en/gids-rapporten-printen.md | 18 +++-- public/docs/nl/gids-rapporten-printen.md | 16 +++-- src/components/panels/ReportPanel.tsx | 15 ++-- src/components/panels/VarianceReport.tsx | 18 +++-- .../panels/reports/useTableReportSpec.tsx | 9 ++- src/engine/contour/curveState.ts | 2 +- src/i18n/locales/ar/report.json | 2 +- src/i18n/locales/de/report.json | 2 +- src/i18n/locales/en/report.json | 2 +- src/i18n/locales/es/report.json | 2 +- src/i18n/locales/fa/report.json | 2 +- src/i18n/locales/fr/report.json | 2 +- src/i18n/locales/it/report.json | 2 +- src/i18n/locales/ja/report.json | 2 +- src/i18n/locales/ko/report.json | 2 +- src/i18n/locales/nl/report.json | 2 +- src/i18n/locales/pl/report.json | 2 +- src/i18n/locales/pt/report.json | 2 +- src/i18n/locales/tr/report.json | 2 +- src/i18n/locales/zh/report.json | 2 +- src/services/print/printPreview.ts | 46 +++++++++---- src/utils/reportNumber.ts | 68 ++++++++++++++++--- tests/browser/resource-gantt-report.spec.ts | 20 ++++++ tests/planning/check-print-report.ts | 29 ++++++-- tests/planning/check-reports.ts | 10 ++- 26 files changed, 207 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b316f2cb1..56aca8ce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,7 +349,7 @@ De artikelen worden gerenderd door `src/utils/miniMarkdown.tsx`, dat een **beper - [docs/extensions.md](docs/extensions.md) — handleiding voor extensie-auteurs (manifest, API, installeren). - [docs/library.md](docs/library.md) — resourcebibliotheken (B1/B1.1): bibliotheek als bron met projectinzet, herkomststempels, pool-IFC-export/-import, bekende beperkingen (geen sync tussen machines). - [docs/release-secrets.md](docs/release-secrets.md) — de sleutels achter de uitleverketen: wat elk secret doet, wat er stukgaat bij verlies, en het migratiepad voor de minisign-sleutel (de enige onherstelbare SPOF: zijn pubkey zit in elke uitgeleverde binary). -- `artifacts//` — **gecommit schermbewijs** bij een PR (bijv. `artifacts/resourcediagram/`), waar een PR-tekst of issue-reactie naar linkt via `raw.githubusercontent.com`. Klein houden: PNG, ≤ ~150 KB per bestand, een handvol per PR, en na de merge niet bijwerken (het is bewijs van dat moment, geen documentatie). Lokale QA-screenshots horen in `qa/` (in `.gitignore`). +- `artifacts//` — **gecommit schermbewijs** bij een PR (bijv. `artifacts/resourcediagram/`), waar een PR-tekst of issue-reactie naar linkt via `raw.githubusercontent.com`. Klein houden: PNG, ≤ ~150 KB per bestand, een handvol per PR, en na de merge niet bijwerken (het is bewijs van dat moment, geen documentatie). Die regel geldt vanaf 2026-09 voor nieuwe mappen; `artifacts/tabel-overhaul/` (25 JPG's, 2,3 MB) dateert van daarvóór en blijft bewust zoals hij is. Lokale QA-screenshots horen in `qa/` (in `.gitignore`). - [scripts/README.md](scripts/README.md) — wat elk script in `scripts/` doet en wie het aanroept (dev-serverpoorten, de verify-poorten, de generatoren, release-hulpjes). Regel: wat daar staat wordt aangeroepen — eenmalige klussen horen weg, niet "voor het geval dat". - [tests/planning/README.md](tests/planning/README.md) — hoe de CPM/kalender-regressiesuite werkt en hoe je cases toevoegt. - `public/docs//*.md` — de **in-app gidsen** achter Backstage → Help (viewer met taalkiezer, stale-waarschuwing en 14-taal-fallback). Brontalen zijn `nl` + `en`; die twee eist `npm run verify:docs` hard, de overige twaalf worden alleen gevalideerd wanneer ze bestaan (vertalingen volgen maandelijks, niet per release). diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index 671da6639..f3e67fdf6 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -120,10 +120,14 @@ resource on one task are added up, and a dash in the curve column means those as different curves. If the assignment has its own hour distribution the column says *Contour*; if the curve was imported from MS Project or P6 and is none of the eight built-in shapes it says *Imported curve* — the same as in the properties panel. The two columns make the table a good 130 px -wider and the time axis narrower. If a wide name column (long task names with *Truncate task -names* off) would leave less than a quarter of the paper width for the timeline, the report drops -the two columns itself and says so in the summary block; truncate the names or choose a larger -paper size. Turn **Show units/day and curve** off for the narrow table of the Gantt print. +wider and the time axis narrower. If less than about a fifth of the paper width would remain for +the timeline — because of a wide name column, a large font size (the table scales with it, the +timeline does not) or small or portrait paper — and leaving out the two columns fixes that, the +report drops them itself and says so in the summary block. If the table is too wide even without +the columns, they simply stay. On portrait A4 with the default settings they fit; when the notice +appears, choose a larger paper size or landscape, a smaller font size or a narrower name column, or +turn off *Show completion*. Turn **Show units/day and curve** off for the narrow table of the Gantt +print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from @@ -187,8 +191,10 @@ remembered setting per report (the resource diagram above offers the same contro - **Next / last week, 2, 4, 6, 8 or 12 weeks** and **next / last month** — counted from the project's status date (or today if none is set). A preset is inclusive on both ends: *next 4 weeks* on Thursday 10 September runs through Wednesday 7 October. Change the status date and the - window moves with it. Without a status date a preset counts from today and is resolved again on - every recalculation: leave the app open overnight and the window shifts by a day after midnight. + window moves with it. Without a status date a preset counts from today, and "today" is determined + again every time the panel renders — on the UTC calendar, not the local clock: in Amsterdam the day + turns over at 01:00 (winter time) or 02:00 (summer time), in New York at 19:00 or 20:00 the evening + before. Leave the app open overnight and the window shifts by a day after that moment. - **Project duration** — from the earliest start to the latest finish in the schedule. - **Custom** — two dates of your own. The *From* and *To* fields become editable (type or use the date picker); with a preset they show the calculated dates read-only. An end date before the diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 8ded88b0b..90676cbff 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -123,10 +123,13 @@ opgeteld, en een streepje bij de curve betekent dat die toewijzingen verschillen Heeft de toewijzing een eigen urenverdeling, dan staat er *Contour*; is de curve uit MS Project of P6 geïmporteerd en is het geen van de acht eigen vormen, dan staat er *Geïmporteerde curve* — hetzelfde als in het eigenschappenpaneel. De twee kolommen maken de tabel ruim 130 px breder en de -tijdas dus smaller. Blijft er door een brede naamkolom (lange taaknamen met *Taaknamen afkappen* -uit) minder dan een kwart papierbreedte tijdlijn over, dan laat het rapport de twee kolommen zelf -weg en meldt dat in het overzichtsblok; kap dan de namen af of kies groter papier. Zet **Eenheden/dag -en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. +tijdas dus smaller. Blijft er voor de tijdlijn minder over dan ongeveer een vijfde van de +papierbreedte — door een brede naamkolom, een grote lettergrootte (de tabel schaalt mee, de tijdlijn +niet) of klein of staand papier — én lost het weglaten van de twee kolommen dat op, dan laat het +rapport ze zelf weg en meldt dat in het overzichtsblok. Is de tabel ook zonder de kolommen te breed, +dan blijven ze gewoon staan. Op A4 staand met de standaardinstellingen passen ze; kies bij de melding +groter of liggend papier, een kleinere lettergrootte of een smallere naamkolom, of zet *Voltooiing +tonen* uit. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het @@ -190,8 +193,9 @@ onthouden instelling per rapport (het resourcediagram hierboven kent dezelfde ke vanaf de statusdatum van het project (of vandaag als die ontbreekt). Een preset is inclusief aan beide kanten: *volgende 4 weken* op donderdag 10 september loopt t/m woensdag 7 oktober. Wijzig je de statusdatum, dan schuift het venster automatisch mee. Zonder statusdatum rekent een preset - vanaf vandaag en wordt hij bij elke herberekening opnieuw opgelost: blijft de app 's nachts open, - dan schuift het venster na middernacht een dag op. + vanaf vandaag, en "vandaag" wordt bij elke weergave van het paneel opnieuw bepaald — op de + UTC-kalender, niet op de lokale klok: in Nederland kantelt de dag dus om 01:00 (wintertijd) of + 02:00 (zomertijd). Blijft de app 's nachts open, dan schuift het venster daarna een dag op. - **Hele project** — van de vroegste start tot het laatste einde in de planning. - **Aangepast** — twee eigen datums. De velden *Van* en *Tot* worden dan bewerkbaar (typen of de datumkiezer); bij een preset tonen ze alleen-lezen de berekende datums. Een einddatum vóór de diff --git a/src/components/panels/ReportPanel.tsx b/src/components/panels/ReportPanel.tsx index 9f6d087e5..fe6a79d04 100644 --- a/src/components/panels/ReportPanel.tsx +++ b/src/components/panels/ReportPanel.tsx @@ -92,7 +92,7 @@ function buildMilestoneColumns(t: TFunction<'report'>, dd: DisplayDate): PdfTabl * `VarianceReport.tsx`: zelfde `COLUMNS`-volgorde/headers, `fmtDelta`, deltaStart/deltaFinish `> 0` * rood+bold, en de `STATUS_COLOR`-badge (altijd bold). */ -function buildVarianceColumns(t: TFunction<'report'>, dd: DisplayDate): PdfTableColumn[] { +function buildVarianceColumns(t: TFunction<'report'>, dd: DisplayDate, locale: string): PdfTableColumn[] { return [ { header: t('milestoneReport.wbs'), width: 70, align: 'left', text: r => r.wbs }, { header: t('milestoneReport.name'), width: 220, align: 'left', text: r => r.name }, @@ -102,13 +102,13 @@ function buildVarianceColumns(t: TFunction<'report'>, dd: DisplayDate): PdfTable { header: t('variance.currentFinish'), width: 110, align: 'left', text: r => dd.date(r.currentFinish) || '—' }, { header: t('variance.deltaStart'), width: 90, align: 'right', - text: r => fmtDelta(r.deltaStart), + text: r => fmtDelta(r.deltaStart, locale), color: r => (r.deltaStart !== undefined && r.deltaStart > 0 ? '#DC2626' : undefined), bold: r => r.deltaStart !== undefined && r.deltaStart > 0, }, { header: t('variance.deltaFinish'), width: 90, align: 'right', - text: r => fmtDelta(r.deltaFinish), + text: r => fmtDelta(r.deltaFinish, locale), color: r => (r.deltaFinish !== undefined && r.deltaFinish > 0 ? '#DC2626' : undefined), bold: r => r.deltaFinish !== undefined && r.deltaFinish > 0, }, @@ -264,7 +264,7 @@ export function ReportPanel() { // Resourcediagram (issue #113): blad per resource + taken zonder resource — samen bewaard met de rest. const [resourceGanttOptions, setResourceGanttOptions] = useState(DEFAULT_REPORT_SETTINGS.resourceGantt); // Gezet door de preview-meting: de render liet de toewijzingskolommen vallen omdat de tabel - // anders geen tijdlijn overliet (zie `MIN_CHART_WIDTH_PX` in printPreview). + // anders geen tijdlijn overliet (zie `minChartWidthPx` in printPreview). const [assignmentColumnsDropped, setAssignmentColumnsDropped] = useState(false); const patchResourceGanttOptions = useCallback((patch: Partial) => { setResourceGanttOptions(prev => ({ ...prev, ...patch })); @@ -695,6 +695,9 @@ export function ReportPanel() { for (const page of previewPagesRef.current.values()) URL.revokeObjectURL(page.objectUrl); replacePreviewPages(new Map()); setPreviewLayout(previous => ({ ...previous, totalPages: 0 })); + // Geen Gantt-render ⇒ geen meting die de vlag zet; wis hem, anders blijft een oude "weggelaten" + // hangen tot de volgende Gantt-preview (review #139, bevinding 11). + setAssignmentColumnsDropped(false); return release; } @@ -1112,7 +1115,7 @@ export function ReportPanel() { tablePdfBytes = await paginateVectorToPdfBytes( makeTableRenderReport({ title: t('variance.title'), - columns: buildVarianceColumns(t, dd), + columns: buildVarianceColumns(t, dd, locale), rows: varianceResult.rows, emptyText: t('variance.noBaseline'), }), @@ -1128,7 +1131,7 @@ export function ReportPanel() { await writePdf(tablePdfBytes, `${fileBase}-${suffix}.pdf`); }, [reportType, isGanttLike, projectName, fileBase, tasks, sequences, calendar, options, paperSize, orientation, - autoFit, repeatHeader, repeatFooter, timelineColumns, writePdf, t, dd, milestoneRows, varianceResult, tableSpec]); + autoFit, repeatHeader, repeatFooter, timelineColumns, writePdf, t, dd, locale, milestoneRows, varianceResult, tableSpec]); // K7-guard: een stale planning eerst doorrekenen. NIET meteen daarna exporteren — `runExport` // leest `tasks`/`options`/`tableSpec` uit de closure van de HUIDIGE render, en die kent de diff --git a/src/components/panels/VarianceReport.tsx b/src/components/panels/VarianceReport.tsx index 291004921..d24413bf9 100644 --- a/src/components/panels/VarianceReport.tsx +++ b/src/components/panels/VarianceReport.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { CalendarEngine } from '@/engine/scheduler/CalendarEngine'; import { computeVariance, type VarianceResult, type VarianceStatus } from '@/engine/variance'; import { useDisplayDate } from '@/hooks/displayDate'; +import { formatSignedReportNumber } from '@/utils/reportNumber'; /** * Variance-rapport (fase 2.6, §7): vergelijkt de huidige (CPM-)datums met de actieve baseline. @@ -39,14 +40,19 @@ export const STATUS_COLOR: Record = { const COLUMNS = ['wbs', 'name', 'baselineStart', 'baselineFinish', 'currentStart', 'currentFinish', 'deltaStart', 'deltaFinish', 'status'] as const; -/** Geëxporteerd (fase 3) zodat de vector-PDF-tabel-export exact dezelfde delta-formattering gebruikt. */ -export function fmtDelta(v: number | undefined): string { +/** + * Geëxporteerd (fase 3) zodat de vector-PDF-tabel-export exact dezelfde delta-formattering gebruikt. + * Zelfde notatie als de andere rapporten (`formatSignedReportNumber`: plus bij positief, het + * decimaalteken van de app-taal — review #139 bevinding 5); de delta's zijn hele werkdagen, dus in + * de praktijk verandert alleen het teken mee. + */ +export function fmtDelta(v: number | undefined, locale?: string): string { if (v === undefined) return '—'; - return v > 0 ? `+${v}` : `${v}`; + return formatSignedReportNumber(v, locale); } export function VarianceReport() { - const { t } = useTranslation('report'); + const { t, i18n } = useTranslation('report'); const { rows } = useVarianceResult(); const dd = useDisplayDate(); @@ -74,10 +80,10 @@ export function VarianceReport() { {dd.date(r.currentStart) || '—'} {dd.date(r.currentFinish) || '—'} 0 ? { color: '#DC2626', fontWeight: 600 } : undefined}> - {fmtDelta(r.deltaStart)} + {fmtDelta(r.deltaStart, i18n.language)} 0 ? { color: '#DC2626', fontWeight: 600 } : undefined}> - {fmtDelta(r.deltaFinish)} + {fmtDelta(r.deltaFinish, i18n.language)} diff --git a/src/components/panels/reports/useTableReportSpec.tsx b/src/components/panels/reports/useTableReportSpec.tsx index 11c01f53e..0d6b5c070 100644 --- a/src/components/panels/reports/useTableReportSpec.tsx +++ b/src/components/panels/reports/useTableReportSpec.tsx @@ -6,7 +6,7 @@ import { useDisplayDate } from '@/hooks/displayDate'; import type { ResourceType } from '@/types/resource'; import { formatDate } from '@/utils/dateUtils'; import { makeMonthLabeler } from '@/utils/monthLabel'; -import { formatReportNumber, formatSignedReportNumber } from '@/utils/reportNumber'; +import { formatReportNumber, formatSignedReportNumber, localizeDecimalPoint } from '@/utils/reportNumber'; import type { ReportType, TableReportOptions } from '@/utils/reportSettings'; import type { ReportingPeriod } from '@/engine/reports'; import { isTableReportType } from '@/utils/reportSettings'; @@ -28,7 +28,7 @@ import { REPORT_COLORS, section, type ReportColumn, type ReportSummaryItem, type */ type T = TFunction<'report'>; /** Datum- én getalnotatie van het rapport, gebonden aan de app-instellingen en -taal. */ -type DD = ReturnType & { num: (n: number) => string; signed: (n: number) => string }; +type DD = ReturnType & { num: (n: number) => string; signed: (n: number) => string; lagText: (lag: string) => string }; /** Zelfde sleutelmap als `ResourcePanel.tsx` — `t()` is strikt getypeerd, dus geen template-sleutel. */ const RESOURCE_TYPE_KEY = { @@ -243,7 +243,9 @@ function healthDetailText(t: T, dd: DD, item: HealthItem): string { const parts: string[] = []; if (d.reason) parts.push(t(`tableReports.health.reason_${d.reason}`)); if (d.constraintType) parts.push(d.constraintType); - if (d.lag) parts.push(t('tableReports.health.detail_lag', { value: d.lag })); + // De engine levert de lag taalneutraal ("+1.5d", `formatLagShort`); hier krijgt hij hetzelfde + // decimaalteken als `dd.num(d.float)` verderop in dezelfde cel (review #139, bevinding 5). + if (d.lag) parts.push(t('tableReports.health.detail_lag', { value: dd.lagText(d.lag) })); else if (d.days !== undefined) parts.push(t('tableReports.health.detail_days', { value: dd.num(d.days) })); if (d.float !== undefined) parts.push(t('tableReports.health.detail_float', { value: dd.num(d.float) })); if (d.date) parts.push(dd.date(d.date)); @@ -425,6 +427,7 @@ export function useTableReportSpec(reportType: ReportType, options: TableReportO ...dates, num: n => formatReportNumber(n, locale), signed: n => formatSignedReportNumber(n, locale), + lagText: lag => localizeDecimalPoint(lag, locale), }), [dates, locale]); return useMemo(() => { if (!isTableReportType(reportType)) return null; diff --git a/src/engine/contour/curveState.ts b/src/engine/contour/curveState.ts index a0b0f2701..51a0476ef 100644 --- a/src/engine/contour/curveState.ts +++ b/src/engine/contour/curveState.ts @@ -38,6 +38,6 @@ export function contouredAssignmentIds( export function assignmentCurveState(a: ResourceAssignment, contoured: boolean): AssignmentCurveState { if (contoured) return 'contoured'; - if (!a.curve && a.curveValues) return 'imported'; + if (!a.curve && a.curveValues && a.curveValues.length > 0) return 'imported'; return a.curve ?? 'UNIFORM'; } diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index f1a7bfc7c..750c067be 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -150,7 +150,7 @@ "includeUnassigned": "تضمين المهام بدون مورد", "groupByType": "تجميع حسب نوع المورد", "showAssignmentColumns": "إظهار الوحدات/اليوم والمنحنى", - "columnsDropped": "تم حذف عمودي وحدة/يوم والمنحنى: مع عمود الاسم هذا لن يتبقى محور زمني. اقتطع أسماء المهام أو اختر ورقًا أكبر.", + "columnsDropped": "تم حذف عمودي وحدة/يوم والمنحنى: لم يترك الجدول مساحة كافية لمحور الوقت. اختر ورقًا أكبر أو اتجاهًا أفقيًا أو حجم خط أصغر أو عمود أسماء أضيق، أو أوقف إظهار نسبة الإنجاز.", "emptyPeriod": "لا توجد مهام في فترة التقرير — اختر فترة أخرى أو المشروع بأكمله.", "outsidePeriod": "خارج الفترة:", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index 21d82a3c7..d1928769a 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", "groupByType": "Nach Ressourcentyp gruppieren", "showAssignmentColumns": "Einheiten/Tag und Kurve anzeigen", - "columnsDropped": "Die Spalten Einh./T und Kurve wurden weggelassen: mit dieser Namensspalte bliebe keine Zeitachse übrig. Kürzen Sie die Vorgangsnamen oder wählen Sie ein größeres Papierformat.", + "columnsDropped": "Die Spalten Einh./T und Kurve wurden weggelassen: die Tabelle ließ zu wenig Platz für die Zeitachse. Wählen Sie ein größeres Papier oder Querformat, eine kleinere Schriftgröße oder eine schmalere Namensspalte, oder schalten Sie Fertigstellung anzeigen aus.", "emptyPeriod": "Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt.", "outsidePeriod": "Außerhalb des Zeitraums:", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index dd909ac20..dfe9f4d0a 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Include tasks without a resource", "groupByType": "Group by resource type", "showAssignmentColumns": "Show units/day and curve", - "columnsDropped": "The Units/d and Curve columns were left out: with this name column no time axis would remain. Truncate task names or choose a larger paper size.", + "columnsDropped": "The Units/d and Curve columns were left out: the table left too little room for the time axis. Choose a larger paper size or landscape, a smaller font size or a narrower name column, or turn off Show completion.", "emptyPeriod": "No tasks in the reporting period — choose another period or Whole project.", "outsidePeriod": "Outside the period:", "empty": "No resource assignments — assign resources to tasks first.", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index 8621636b0..e471c932e 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Incluir tareas sin recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/día y curva", - "columnsDropped": "Se omitieron las columnas Unid./d y Curva: con esta columna de nombre no quedaría eje de tiempo. Trunque los nombres de tarea o elija un papel más grande.", + "columnsDropped": "Se omitieron las columnas Unid./d y Curva: la tabla dejaba muy poco espacio para el eje de tiempo. Elija un papel más grande u horizontal, una letra más pequeña o una columna de nombres más estrecha, o desactive Mostrar avance.", "emptyPeriod": "No hay tareas en el período del informe — elija otro período o Proyecto completo.", "outsidePeriod": "Fuera del período:", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index 6e4bd0904..c151fd6ce 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -139,7 +139,7 @@ "includeUnassigned": "شامل کردن کارهای بدون منبع", "groupByType": "گروه‌بندی بر اساس نوع منبع", "showAssignmentColumns": "نمایش واحد/روز و منحنی", - "columnsDropped": "ستون‌های واحد/روز و منحنی حذف شدند: با این ستون نام، محور زمانی باقی نمی‌ماند. نام کارها را کوتاه کنید یا کاغذ بزرگ‌تری انتخاب کنید.", + "columnsDropped": "ستون‌های واحد/روز و منحنی حذف شدند: جدول فضای کافی برای محور زمان باقی نمی‌گذاشت. کاغذ بزرگ‌تر یا افقی، اندازه قلم کوچک‌تر یا ستون نام باریک‌تر انتخاب کنید، یا نمایش پیشرفت را خاموش کنید.", "emptyPeriod": "هیچ کاری در دوره گزارش وجود ندارد — دوره دیگری یا کل پروژه را انتخاب کنید.", "outsidePeriod": "خارج از دوره:", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index b6208a947..68726c94c 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Inclure les tâches sans ressource", "groupByType": "Grouper par type de ressource", "showAssignmentColumns": "Afficher unités/jour et courbe", - "columnsDropped": "Les colonnes Unités/j et Courbe ont été omises : avec cette colonne de nom, il ne resterait plus d’axe de temps. Tronquez les noms de tâches ou choisissez un papier plus grand.", + "columnsDropped": "Les colonnes Unités/j et Courbe ont été omises : le tableau ne laissait pas assez de place à l'axe du temps. Choisissez un papier plus grand ou en paysage, une police plus petite ou une colonne de noms plus étroite, ou désactivez Afficher l'avancement.", "emptyPeriod": "Aucune tâche dans la période de rapport — choisissez une autre période ou Projet entier.", "outsidePeriod": "Hors période :", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index 200d3e36b..db7bec6c1 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Includi le attività senza risorsa", "groupByType": "Raggruppa per tipo di risorsa", "showAssignmentColumns": "Mostra unità/giorno e curva", - "columnsDropped": "Le colonne Unità/g e Curva sono state omesse: con questa colonna del nome non resterebbe alcun asse temporale. Tronca i nomi delle attività o scegli una carta più grande.", + "columnsDropped": "Le colonne Unità/g e Curva sono state omesse: la tabella lasciava troppo poco spazio all'asse temporale. Scegli una carta più grande o orizzontale, un carattere più piccolo o una colonna dei nomi più stretta, oppure disattiva Mostra avanzamento.", "emptyPeriod": "Nessuna attività nel periodo del report — scegli un altro periodo o Intero progetto.", "outsidePeriod": "Fuori dal periodo:", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index 947b89f23..d6bb5d063 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "リソース未割当のタスクを含める", "groupByType": "リソース種別でグループ化", "showAssignmentColumns": "単位/日とカーブを表示", - "columnsDropped": "「単位/日」と「カーブ」列は省略されました。この名前列では時間軸が残りません。タスク名を切り詰めるか、大きい用紙を選んでください。", + "columnsDropped": "「単位/日」と「カーブ」の列は省略されました:表がタイムラインに残す幅が足りません。より大きい用紙か横向き、小さい文字サイズ、または狭い名前列を選ぶか、「進捗を表示」をオフにしてください。", "emptyPeriod": "レポート期間内にタスクがありません — 別の期間またはプロジェクト全体を選択してください。", "outsidePeriod": "期間外:", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index 70a006708..d9e149456 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "자원이 없는 작업 포함", "groupByType": "자원 유형별로 그룹화", "showAssignmentColumns": "단위/일 및 곡선 표시", - "columnsDropped": "단위/일 및 곡선 열이 생략되었습니다. 이 이름 열로는 시간 축이 남지 않습니다. 작업 이름을 자르거나 더 큰 용지를 선택하세요.", + "columnsDropped": "단위/일 및 곡선 열이 생략되었습니다: 표가 타임라인에 남긴 공간이 너무 적습니다. 더 큰 용지나 가로 방향, 더 작은 글자 크기 또는 더 좁은 이름 열을 선택하거나 진행률 표시를 끄세요.", "emptyPeriod": "보고 기간에 작업이 없습니다 — 다른 기간 또는 전체 프로젝트를 선택하세요.", "outsidePeriod": "기간 외:", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index 1ab799c7a..0f1d40898 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Taken zonder resource meenemen", "groupByType": "Groeperen op resourcetype", "showAssignmentColumns": "Eenheden/dag en curve tonen", - "columnsDropped": "De kolommen Eenh./d en Curve zijn weggelaten: met deze naamkolom bleef er geen tijdlijn over. Kap de taaknamen af of kies groter papier.", + "columnsDropped": "De kolommen Eenh./d en Curve zijn weggelaten: de tabel liet te weinig tijdlijn over. Kies groter of liggend papier, een kleinere lettergrootte of een smallere naamkolom, of zet Voltooiing tonen uit.", "emptyPeriod": "Geen taken in de rapportageperiode — kies een andere periode of Hele project.", "outsidePeriod": "Buiten de periode:", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index f0ba71b49..7a81b34d3 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -144,7 +144,7 @@ "includeUnassigned": "Uwzględnij zadania bez zasobu", "groupByType": "Grupuj według typu zasobu", "showAssignmentColumns": "Pokaż jednostki/dzień i krzywą", - "columnsDropped": "Pominięto kolumny Jedn./d i Krzywa: przy tej kolumnie nazwy nie zostałaby oś czasu. Skróć nazwy zadań lub wybierz większy papier.", + "columnsDropped": "Pominięto kolumny Jedn./d i Krzywa: tabela zostawiała za mało miejsca na oś czasu. Wybierz większy papier lub orientację poziomą, mniejszą czcionkę lub węższą kolumnę nazw, albo wyłącz Pokaż postęp.", "emptyPeriod": "Brak zadań w okresie raportu — wybierz inny okres lub Cały projekt.", "outsidePeriod": "Poza okresem:", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index a8670678d..49eb87116 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Incluir tarefas sem recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/dia e curva", - "columnsDropped": "As colunas Unid./d e Curva foram omitidas: com esta coluna de nome não sobraria eixo de tempo. Trunque os nomes das tarefas ou escolha um papel maior.", + "columnsDropped": "As colunas Unid./d e Curva foram omitidas: a tabela deixava pouco espaço para o eixo do tempo. Escolha um papel maior ou paisagem, uma fonte menor ou uma coluna de nomes mais estreita, ou desative Mostrar conclusão.", "emptyPeriod": "Nenhuma tarefa no período do relatório — escolha outro período ou Projeto inteiro.", "outsidePeriod": "Fora do período:", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index 5f2da6b79..82eba8333 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Kaynaksız görevleri dahil et", "groupByType": "Kaynak türüne göre grupla", "showAssignmentColumns": "Birim/gün ve eğriyi göster", - "columnsDropped": "Birim/g ve Eğri sütunları çıkarıldı: bu ad sütunuyla zaman ekseni kalmazdı. Görev adlarını kısaltın veya daha büyük bir kağıt seçin.", + "columnsDropped": "Birim/g ve Eğri sütunları çıkarıldı: tablo zaman ekseni için çok az yer bırakıyordu. Daha büyük veya yatay kâğıt, daha küçük yazı boyutu ya da daha dar bir ad sütunu seçin veya Tamamlanmayı göster seçeneğini kapatın.", "emptyPeriod": "Rapor döneminde görev yok — başka bir dönem veya Tüm proje seçin.", "outsidePeriod": "Dönem dışında:", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 4cae1f685..6b2cba69d 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "包含未分配资源的任务", "groupByType": "按资源类型分组", "showAssignmentColumns": "显示单位/天和曲线", - "columnsDropped": "已省略“单位/天”和“曲线”列:使用此名称列宽度时将没有时间轴。请截断任务名称或选择更大的纸张。", + "columnsDropped": "已省略“单位/天”和“曲线”列:表格给时间轴留下的空间太少。请选择更大或横向的纸张、更小的字号或更窄的名称列,或关闭“显示完成度”。", "emptyPeriod": "报告期间内没有任务 — 请选择其他期间或整个项目。", "outsidePeriod": "期间之外:", "empty": "没有资源分配——请先为任务分配资源。", diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index c0c66925b..802655976 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -98,12 +98,22 @@ const COL = { }; /** - * Minimale chartbreedte (logische px, per paginabreedte) die de tabel moet overlaten. Komt de - * tabel mét toewijzingskolommen daaronder (lange taaknamen met afkappen uit), dan laat de render - * die twee kolommen vallen en meldt dat via `RenderReportResult.assignmentColumnsDropped` — beter - * een smallere tabel dan een tijdas van een paar pixels (review #138, ronde 2, bevinding 5). + * Minimale chartbreedte die de tabel per paginabreedte moet overlaten vóór de render de twee + * toewijzingskolommen (Eenh./d + Curve) laat vallen: een vijfde van de printbreedte, met een vloer + * van 160 logische px voor klein papier (A4 staand: 730 px breed, dus 160 in plaats van 146). Komt de + * tabel mét kolommen daaronder — door een brede naamkolom, een grote rapportlettergrootte (de tabel + * schaalt mee, deze grens niet) of klein/staand papier — én lost weglaten dat op (zonder de kolommen + * blijft er wél genoeg chart over), dan vallen de twee kolommen en meldt de render dat via + * `RenderReportResult.assignmentColumnsDropped`. Lost weglaten niets op, dan blijven ze staan: de + * gebruiker koos ze, en een tijdas van een paar pixels heeft hij dan toch al. Review #138 ronde 2 + * bevinding 5; de vaste 240 px van de eerste versie gooide op A4 staand de kolommen al bij verse + * instellingen weg (review #139, bevindingen 1, 2 en 4). */ -const MIN_CHART_WIDTH_PX = 240; +const MIN_CHART_WIDTH_FRACTION = 0.2; +const MIN_CHART_WIDTH_FLOOR_PX = 160; +function minChartWidthPx(printableWidth: number): number { + return Math.max(MIN_CHART_WIDTH_FLOOR_PX, printableWidth * MIN_CHART_WIDTH_FRACTION); +} /** * Grenzen van de instelbare naamkolom (ongeschaalde px). `DEFAULT` is exact de breedte die de @@ -505,9 +515,13 @@ function formatDutchDate(d: Date, notation: DateNotation = 'dmy'): string { } } -/** Format duration as "15d" */ -function formatDuration(days: number): string { - return `${days}d`; +/** + * Duur-cel: "15d", "1,5d" in nl — hetzelfde getal en decimaalteken als de Eenh./d-cel en de + * tabelrapporten (`formatReportNumber`; review #139 bevinding 5: één tabel, één notatie). Zonder + * `numberLocale` de neutrale punt, op twee decimalen afgerond. + */ +function formatDuration(days: number, locale: string | undefined): string { + return `${formatReportNumber(days, locale)}d`; } /** @@ -683,7 +697,7 @@ export interface RenderReportResult { forcedBreakOffsets?: number[]; /** * OPTIONEEL — `true` wanneer `PrintOptions.assignmentColumns` gevraagd was maar de tabel daarmee - * minder dan {@link MIN_CHART_WIDTH_PX} chart per paginabreedte overliet en de twee kolommen + * minder dan {@link minChartWidthPx} chart per paginabreedte overliet en de twee kolommen * daarom zijn weggelaten. Het paneel meldt dat naast de optie. */ assignmentColumnsDropped?: boolean; @@ -717,10 +731,14 @@ export function renderReport( let assignmentColumns = !!options.assignmentColumns; let m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, assignmentColumns); let assignmentColumnsDropped = false; - if (assignmentColumns && m.tableWidth > printableWidth - MIN_CHART_WIDTH_PX) { - assignmentColumns = false; - assignmentColumnsDropped = true; - m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, false); + const maxTableWidth = printableWidth - minChartWidthPx(printableWidth); + if (assignmentColumns && m.tableWidth > maxTableWidth) { + const without = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, false); + if (without.tableWidth <= maxTableWidth) { + assignmentColumns = false; + assignmentColumnsDropped = true; + m = without; + } } // Rijen-bron: zie {@link buildPrintRows} — taakrijen mét diepte plus groepsband-rijen (#54) die @@ -2092,7 +2110,7 @@ function drawTaskTable( d2d.font = m.font(8); d2d.textAlign = 'right'; d2d.textBaseline = 'middle'; - d2d.fillText(formatDuration(task.time.scheduleDuration), cols.duration.x + cols.duration.w - cellPad, textY); + d2d.fillText(formatDuration(task.time.scheduleDuration, options.numberLocale), cols.duration.x + cols.duration.w - cellPad, textY); // Start date const startStr = task.time.earlyStart || task.time.scheduleStart; diff --git a/src/utils/reportNumber.ts b/src/utils/reportNumber.ts index c4c88d4c5..ad10aac2d 100644 --- a/src/utils/reportNumber.ts +++ b/src/utils/reportNumber.ts @@ -1,19 +1,65 @@ /** - * Getallen in rapporten (tabelrapporten én de toewijzingskolommen van het resourcediagram): één - * notatie voor DOM, raster-PDF en vector-PDF — hoogstens twee decimalen, geen overbodige nullen, - * geen duizendtalscheiding (het zijn dagen en eenheden, geen bedragen), en het decimaalteken van - * de app-taal ("0,5" in nl/de/fr, "0.5" in en). Zonder `locale` (headless tests, oude aanroepen) - * de neutrale punt-notatie. Eén functie, zodat twee rapporten in hetzelfde paneel niet met een - * verschillende precisie of een verschillend decimaalteken kunnen uitkomen (review #138). + * Getallen in rapporten (tabelrapporten, het variance-rapport én de tabel van de Gantt-afdruk): + * één notatie voor DOM, raster-PDF en vector-PDF — hoogstens twee decimalen, geen overbodige + * nullen, geen duizendtalscheiding (het zijn dagen en eenheden, geen bedragen), en het + * decimaalteken van de app-taal ("0,5" in nl/de/fr, "0.5" in en). Zonder `locale` (headless + * tests, oude aanroepen) de neutrale punt-notatie. Eén functie, zodat twee rapporten in hetzelfde + * paneel niet met een verschillende precisie of een verschillend decimaalteken kunnen uitkomen + * (review #138). + * + * Bewust altijd **Latijnse cijfers** (`-u-nu-latn`): datums, tellingen en percentages in dezelfde + * rapporten zijn Latijns, en een tabel met "۱٫۵" naast "3" en "75%" is geen lokalisatie maar een + * gemengd cijfersysteem (review #139, bevinding 5 — `fa` kiest anders `arabext`). De bidi-markering + * (U+200E) die `Intl` in `ar`/`fa` vóór een teken zet gaat eruit: in de DOM is hij onzichtbaar, + * maar in de vector-PDF legt Inter hem als een spatie van 0,28 em (bevinding 6). En een waarde die + * op twee decimalen tot nul afrondt heet "0", niet "-0" (bevinding 7). */ +const BIDI_MARKS = /[\u200E\u200F]/g; +const NEGATIVE_ZERO = /^[-\u2212]0$/; + +function intlFormatter(locale: string, signed: boolean): Intl.NumberFormat | null { + try { + return new Intl.NumberFormat(`${locale}-u-nu-latn`, { + maximumFractionDigits: 2, + useGrouping: false, + ...(signed ? { signDisplay: 'exceptZero' as const } : {}), + }); + } catch { + return null; // onbekende taalcode ⇒ de neutrale notatie hieronder + } +} + export function formatReportNumber(n: number, locale?: string): string { if (!Number.isFinite(n)) return ''; - if (!locale) return String(Math.round(n * 100) / 100); - return new Intl.NumberFormat(locale, { maximumFractionDigits: 2, useGrouping: false }).format(n); + const fmt = locale ? intlFormatter(locale, false) : null; + if (!fmt) return String(Math.round(n * 100) / 100 + 0); + const text = fmt.format(n).replace(BIDI_MARKS, ''); + return NEGATIVE_ZERO.test(text) ? '0' : text; } -/** Zoals `formatReportNumber`, met een expliciete plus voor een positief getal (afwijkingen). */ +/** + * Zoals `formatReportNumber`, met een expliciete plus voor een positief getal (afwijkingen). Plus + * én min komen uit dezelfde `Intl`-aanroep (`signDisplay: 'exceptZero'`), dus nooit een ASCII-plus + * naast een typografische min; nul — ook een negatieve nul — krijgt geen teken. + */ export function formatSignedReportNumber(n: number, locale?: string): string { - const text = formatReportNumber(n, locale); - return n > 0 ? `+${text}` : text; + if (!Number.isFinite(n)) return ''; + const fmt = locale ? intlFormatter(locale, true) : null; + if (!fmt) { + const text = formatReportNumber(n); + return text !== '0' && n > 0 ? `+${text}` : text; + } + return fmt.format(n).replace(BIDI_MARKS, ''); +} + +/** + * Zet in een door de engine geleverde, taalneutrale tekst met ASCII-decimaalpunten (zoals de + * lag-notatie "+1.5d" van `formatLagShort`) de punt om naar het decimaalteken van `locale`. Alleen + * een punt tússen twee cijfers telt; zonder `locale` ongewijzigd. + */ +export function localizeDecimalPoint(text: string, locale?: string): string { + if (!locale) return text; + const fmt = intlFormatter(locale, false); + const sep = fmt?.formatToParts(1.1).find(p => p.type === 'decimal')?.value ?? '.'; + return sep === '.' ? text : text.replace(/(\d)\.(\d)/g, `$1${sep}$2`); } diff --git a/tests/browser/resource-gantt-report.spec.ts b/tests/browser/resource-gantt-report.spec.ts index 2e691411d..7cbc4d4c9 100644 --- a/tests/browser/resource-gantt-report.spec.ts +++ b/tests/browser/resource-gantt-report.spec.ts @@ -72,6 +72,26 @@ test('resourcediagram: rapporttype rendert per resource, opties sturen samenvatt await expect.poll(() => firstImg.getAttribute('src'), { timeout: 20_000 }).not.toBe(beforeCols); await colsToggle.check(); + // Passen de kolommen niet naast een bruikbare tijdlijn, dan laat de render ze vallen en zegt het + // overzichtsblok dat (review #139, bevinding 10): A4 staand op 125 % is te krap, 100 % past weer. + const droppedNote = page.locator('[data-ops-resource-gantt-note="columnsDropped"]'); + await expect(droppedNote).toHaveCount(0); + await page.getByLabel(/^(Paper:|Papier:)$/).click(); + await page.getByRole('option', { name: 'A4' }).click(); + await page.getByLabel(/^(Orientation:|Orientatie:)$/).click(); + await page.getByRole('option', { name: /^(Portrait|Staand)$/ }).click(); + const fontSize = page.getByLabel(/^(Font size:|Lettergrootte:)$/); + await fontSize.click(); + await page.getByRole('option', { name: '125%' }).click(); + await expect(droppedNote).toBeVisible({ timeout: 20_000 }); + await fontSize.click(); + await page.getByRole('option', { name: '100%' }).click(); + await expect(droppedNote).toHaveCount(0, { timeout: 20_000 }); + await page.getByLabel(/^(Paper:|Papier:)$/).click(); + await page.getByRole('option', { name: 'A3' }).click(); + await page.getByLabel(/^(Orientation:|Orientatie:)$/).click(); + await page.getByRole('option', { name: /^(Landscape|Liggend)$/ }).click(); + // Rapportageperiode (manuvarkey punt 3): een eigen bereik dat alleen Fundering raakt ⇒ alleen Ploeg A // blijft over; Casco (met Kraan) en Gevel vallen buiten de periode en de telling zegt dat. Terug naar // Hele project herstelt alles — de export hieronder telt dan weer drie pagina's. diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index 24a624fdf..b1248b683 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -587,17 +587,30 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(JSON.stringify(record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, rowAssignments }))) === JSON.stringify(plainA), 'rowAssignments zonder assignmentColumns ⇒ byte-identiek'); - // Restpunt review #138 (ronde 2, bevinding 5): laat de tabel mét kolommen minder dan - // MIN_CHART_WIDTH_PX (240) tijdlijn over op één papierbreedte, dan laat de render de twee - // kolommen vallen en meldt dat — op A4 liggend (1058 px printbreedte) is een naamkolom van - // 800 px daarvoor genoeg; de gewone naamkolom niet. - const wideOpts = baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'landscape', taskNameColumnWidth: 800 }); + // Restpunt review #138 (ronde 2, bevinding 5), aangescherpt na review #139 (bevindingen 1, 2 + // en 4): laat de tabel mét kolommen minder dan `minChartWidthPx` (een vijfde van de + // printbreedte, vloer 160 px) tijdlijn over op één papierbreedte ÉN lost weglaten dat op, dan + // laat de render de twee kolommen vallen en meldt dat. A4 liggend is 1058,5 px breed ⇒ de tabel + // mag 846,8 px zijn: een naamkolom van 500 px geeft 893 px mét en 750 px zónder de kolommen. + const a4 = { paperSize: 'A4' as const, orientation: 'landscape' as const }; + const wideOpts = baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, ...a4, taskNameColumnWidth: 500 }); const wide = record([T_NORM, T_CRIT], [], cal, wideOpts); const wideNoCols = record([T_NORM, T_CRIT], [], cal, { ...wideOpts, assignmentColumns: false, rowAssignments: undefined }); ok(wide.dims.assignmentColumnsDropped === true, 'brede naamkolom ⇒ toewijzingskolommen weggelaten en gemeld'); ok(wide.dims.tableWidth === wideNoCols.dims.tableWidth && !wide.texts.some(t => t.text === 'Eenh./d'), 'weggelaten ⇒ dezelfde tabel als zonder de optie, geen kolomkop'); ok(withCols.dims.assignmentColumnsDropped === undefined, 'gewone naamkolom ⇒ kolommen blijven, geen melding'); + // Lost weglaten niets op (800 px naamkolom: 1050 px zónder de kolommen), dan blijven ze staan. + const hopeless = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, ...a4, taskNameColumnWidth: 800 })); + ok(hopeless.dims.assignmentColumnsDropped === undefined && hopeless.texts.some(t => t.text === 'Eenh./d'), + 'tabel ook zonder de kolommen te breed ⇒ kolommen blijven, geen melding'); + // A4 staand (729,7 px, vloer 160 ⇒ 569,7 px tabel) met verse instellingen: 523 px past. + const portrait = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'portrait' })); + ok(portrait.dims.assignmentColumnsDropped === undefined && portrait.texts.some(t => t.text === 'Eenh./d'), + 'A4 staand met standaardinstellingen ⇒ kolommen blijven'); + // De lettergrootte schaalt de tabel mee, de grens niet: 125 % op A4 staand geeft 654 px ⇒ weg, 475 px zonder ⇒ past. + const bigFont = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'portrait', reportFontScale: 125 })); + ok(bigFont.dims.assignmentColumnsDropped === true, 'grote lettergrootte op A4 staand ⇒ kolommen weggelaten'); // Restpunt bevinding 8: een venster zonder werkdag valt op de kalender-as terug — en nummert // dan óók de weekenddagen, anders staat er geen enkel dagcijfer in de kop. @@ -609,6 +622,12 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ const nlUnits = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, numberLocale: 'nl' })); ok(nlUnits.texts.some(t => t.text === '1,5' && inCol(t, 180, 225)), 'numberLocale nl ⇒ "1,5" in de eenhedenkolom'); ok(withCols.texts.some(t => t.text === '1.5' && inCol(t, 180, 225)), 'zonder numberLocale ⇒ "1.5"'); + // Review #139 bevinding 5: de Duur-cel in dezelfde tabel volgt dezelfde notatie. + const T_HALF = mkTask('t-half', 'Halve dag', { time: mkTime({ scheduleDuration: 2.5 }) }); + const nlDur = record([T_HALF], [], cal, baseOptions({ numberLocale: 'nl' })); + const plainDur = record([T_HALF], [], cal, baseOptions()); + ok(nlDur.texts.some(t => t.text === '2,5d') && plainDur.texts.some(t => t.text === '2.5d'), + `duurcel volgt numberLocale ("2,5d" in nl, "2.5d" zonder; got ${JSON.stringify(nlDur.texts.filter(t => t.text.endsWith('d')).map(t => t.text))})`); } // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index 806e299e4..babd432a1 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -16,7 +16,7 @@ import { remainingDays, resourceBandLabels, taskDepths, taskFinish, taskStart, } from '@/engine/reports'; import { assignmentCurveState, contouredAssignmentIds } from '@/engine/contour/curveState'; -import { formatReportNumber, formatSignedReportNumber } from '@/utils/reportNumber'; +import { formatReportNumber, formatSignedReportNumber, localizeDecimalPoint } from '@/utils/reportNumber'; import { isValidReportingPeriod, periodDays, projectSpan, resolveReportingPeriod, weeksToPreset, } from '@/engine/reports'; @@ -649,6 +649,14 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); eq('reportNumber: hoogstens twee decimalen, geen duizendtalscheiding', formatReportNumber(1234.567, 'de'), '1234,57'); eq('reportNumber: plus bij positief', formatSignedReportNumber(1.25, 'nl'), '+1,25'); eq('reportNumber: geen plus bij nul of negatief', [formatSignedReportNumber(0, 'nl'), formatSignedReportNumber(-2, 'nl')], ['0', '-2']); + // Review #139, bevindingen 5–7: Latijnse cijfers in élke taal, geen bidi-markering, geen "-0". + eq('reportNumber: fa ⇒ Latijnse cijfers', formatReportNumber(1.5, 'fa'), '1.5'); + eq('reportNumber: ar negatief zonder U+200E', [...formatReportNumber(-2, 'ar')].map(c => c.charCodeAt(0)), [45, 50]); + eq('reportNumber: -0,001 rondt af op "0", niet "-0"', [formatReportNumber(-0.001, 'nl'), formatReportNumber(-0.001), formatSignedReportNumber(-0.001, 'nl'), formatSignedReportNumber(-0.001)], ['0', '0', '0', '0']); + eq('reportNumber: NaN/oneindig ⇒ leeg', [formatReportNumber(NaN, 'nl'), formatSignedReportNumber(Infinity, 'nl')], ['', '']); + eq('reportNumber: onbekende taalcode valt terug op de punt', formatReportNumber(0.5, 'zz-!!'), '0.5'); + eq('reportNumber: lag-tekst krijgt het decimaalteken van de taal', [localizeDecimalPoint('+1.5d', 'nl'), localizeDecimalPoint('+1.5d', 'en'), localizeDecimalPoint('+2d', 'nl'), localizeDecimalPoint('+1.5d')], ['+1,5d', '+1.5d', '+2d', '+1.5d']); + eq('curveState: lege curveValues zijn geen geïmporteerde curve', assignmentCurveState({ ...bare, curveValues: [] }, false), 'UNIFORM'); // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). From 1a34c6c1414e22af63927afffa8b0287ba2b4025 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 19:18:18 +0000 Subject: [PATCH 093/108] =?UTF-8?q?Rapport:=20tweede=20reviewronde=20op=20?= =?UTF-8?q?#139=20=E2=80=94=20bidi-markering=20blijft,=20kolomdrop=20monot?= =?UTF-8?q?oon,=20melding=20zonder=20loze=20hefbomen,=20formatters=20gecac?= =?UTF-8?q?het?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zeven bevindingen van de tweede hyperkritische review, alle opgepakt: - Bidi (bevinding 1, hoog): de U+200E die Intl in ar/fa vóór een teken zet wordt niet meer gestript — zonder staat "-2" in een RTL-alinea als "2-" (DOM én de bidi-js-levels van de vector-PDF). De tofu die Inter ervan maakte is een emissieprobleem en zit nu dáár: `bidiShape.stripBidiControls` haalt de stuurtekens (LRM/RLM/ALM/embeddings/isolaten) pas weg bij het shapen van elke run, ná de levelberekening; het Latijnse/CJK-snelpad en `measureText` strippen vóór het encoden; beide coverage-checks slaan ze over. De test die de strip eiste is omgekeerd, plus een headless `layoutRuns`-test met nepfonts. - Kolomdrop (bevinding 3): weer monotoon in de tabelbreedte — de tussen- variant "alleen als weglaten helpt" liet een dode zone open (A4 staand, naamkolom 350: tabel past de pagina niet, kolommen bleven, tijdas 1 px, geen melding) en een bredere naamkolom bracht de kolommen terug. Regressiecase op precies die zone; de 800-px-case laat nu wél vallen. - Melding + gidsen (bevinding 2): geen "zet Voltooiing tonen uit" en geen verwijzing naar de naamkolomslider (die in de automodus niet bestaat); alleen wat aantoonbaar ruimte geeft, zonder te beloven dat de kolommen terugkomen. 14 locales. - Formatters gecachet per taal (bevinding 4: ~35 µs → ~0,7 µs per cel), ook het decimaalteken voor `localizeDecimalPoint`. - Duurcel: niet-eindig ⇒ "—" in plaats van een losse "d" (bevinding 5). - WarningsPanel toont de lag met hetzelfde decimaalteken als het gezondheidsrapport (bevinding 6); bewerkbare cellen blijven ASCII. - `formatSignedReportNumber` past dezelfde "-0"-vangnet toe en een welgevormde maar onbekende taalcode valt expliciet terug (bevinding 7). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014zosxbSXvi3YSGmeN1MsQq --- public/docs/en/gids-rapporten-printen.md | 12 ++--- public/docs/nl/gids-rapporten-printen.md | 11 +++-- src/components/panels/WarningsPanel.tsx | 6 ++- src/i18n/locales/ar/report.json | 2 +- src/i18n/locales/de/report.json | 2 +- src/i18n/locales/en/report.json | 2 +- src/i18n/locales/es/report.json | 2 +- src/i18n/locales/fa/report.json | 2 +- src/i18n/locales/fr/report.json | 2 +- src/i18n/locales/it/report.json | 2 +- src/i18n/locales/ja/report.json | 2 +- src/i18n/locales/ko/report.json | 2 +- src/i18n/locales/nl/report.json | 2 +- src/i18n/locales/pl/report.json | 2 +- src/i18n/locales/pt/report.json | 2 +- src/i18n/locales/tr/report.json | 2 +- src/i18n/locales/zh/report.json | 2 +- src/services/pdf/bidiShape.ts | 25 +++++++++- src/services/pdf/pdfVectorDraw2d.ts | 12 ++++- src/services/print/printPreview.ts | 30 ++++++------ src/utils/reportNumber.ts | 60 +++++++++++++++++------- tests/planning/check-print-report.ts | 29 ++++++++---- tests/planning/check-reports.ts | 25 +++++++++- 23 files changed, 163 insertions(+), 75 deletions(-) diff --git a/public/docs/en/gids-rapporten-printen.md b/public/docs/en/gids-rapporten-printen.md index f3e67fdf6..e5fa1d3d0 100644 --- a/public/docs/en/gids-rapporten-printen.md +++ b/public/docs/en/gids-rapporten-printen.md @@ -122,12 +122,12 @@ the curve was imported from MS Project or P6 and is none of the eight built-in s *Imported curve* — the same as in the properties panel. The two columns make the table a good 130 px wider and the time axis narrower. If less than about a fifth of the paper width would remain for the timeline — because of a wide name column, a large font size (the table scales with it, the -timeline does not) or small or portrait paper — and leaving out the two columns fixes that, the -report drops them itself and says so in the summary block. If the table is too wide even without -the columns, they simply stay. On portrait A4 with the default settings they fit; when the notice -appears, choose a larger paper size or landscape, a smaller font size or a narrower name column, or -turn off *Show completion*. Turn **Show units/day and curve** off for the narrow table of the Gantt -print. +timeline does not) or small or portrait paper — the report drops the two columns itself and says so +in the summary block. On portrait A4 with the default settings they fit. A larger paper size or +landscape, a smaller font size or a narrower table (a narrower name column with *Truncate task +names*, or *Show completion* off) gives the timeline more room; as soon as the table leaves enough +room again the columns come back by themselves. Turn **Show units/day and curve** off for the narrow +table of the Gantt print. The settings of the Gantt print apply here too — critical path, float, bar colours, status line, paper, header repeat — with three exceptions: *Follow view* (this report doesn't take its rows from diff --git a/public/docs/nl/gids-rapporten-printen.md b/public/docs/nl/gids-rapporten-printen.md index 90676cbff..bc9ab5c3d 100644 --- a/public/docs/nl/gids-rapporten-printen.md +++ b/public/docs/nl/gids-rapporten-printen.md @@ -125,11 +125,12 @@ P6 geïmporteerd en is het geen van de acht eigen vormen, dan staat er *Geïmpor hetzelfde als in het eigenschappenpaneel. De twee kolommen maken de tabel ruim 130 px breder en de tijdas dus smaller. Blijft er voor de tijdlijn minder over dan ongeveer een vijfde van de papierbreedte — door een brede naamkolom, een grote lettergrootte (de tabel schaalt mee, de tijdlijn -niet) of klein of staand papier — én lost het weglaten van de twee kolommen dat op, dan laat het -rapport ze zelf weg en meldt dat in het overzichtsblok. Is de tabel ook zonder de kolommen te breed, -dan blijven ze gewoon staan. Op A4 staand met de standaardinstellingen passen ze; kies bij de melding -groter of liggend papier, een kleinere lettergrootte of een smallere naamkolom, of zet *Voltooiing -tonen* uit. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de Gantt-afdruk. +niet) of klein of staand papier — dan laat het rapport de twee kolommen zelf weg en meldt dat in het +overzichtsblok. Op A4 staand met de standaardinstellingen passen ze. Meer ruimte voor de tijdlijn +geeft groter of liggend papier, een kleinere lettergrootte of een smallere tabel (een smallere +naamkolom bij *Taaknamen afkappen*, of *Voltooiing tonen* uit); zodra de tabel weer ruimte laat komen +de kolommen vanzelf terug. Zet **Eenheden/dag en curve tonen** uit voor de smalle tabel van de +Gantt-afdruk. De instellingen van de Gantt-afdruk gelden hier ook — kritiek pad, speling, balkkleuren, statuslijn, papier, kopherhaling — op drie na: *Volg weergave* (de rijen komen bij dit rapport niet van het diff --git a/src/components/panels/WarningsPanel.tsx b/src/components/panels/WarningsPanel.tsx index 8ec1bdea1..59762cdc2 100644 --- a/src/components/panels/WarningsPanel.tsx +++ b/src/components/panels/WarningsPanel.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, OctagonAlert, CheckCircle2, RefreshCw } from 'lucide-rea import { useAppStore } from '@/state/appStore'; import { useDisplayDate } from '@/hooks/displayDate'; import { formatLagShort } from '@/utils/lagFormat'; +import { localizeDecimalPoint } from '@/utils/reportNumber'; import { SEQUENCE_TYPE_OPTIONS } from '@/types/sequence'; import { collectScheduleWarnings, summarizeScheduleWarnings, hasScheduleWarningTarget, @@ -26,7 +27,7 @@ import type { Task } from '@/types/task'; * verborgen maar benoemd, met de Bereken-knop ernaast — hetzelfde F5-pad als het lint. */ export function WarningsPanel() { - const { t } = useTranslation('common'); + const { t, i18n } = useTranslation('common'); const { t: tTask } = useTranslation('task'); const { t: tMenu } = useTranslation('menu'); const dd = useDisplayDate(); @@ -110,7 +111,8 @@ export function WarningsPanel() { case 'sequence': { const seq = seqById.get(tg.sequenceId); const type = seq ? (SEQUENCE_TYPE_OPTIONS.find(o => o.value === seq.type)?.label ?? seq.type) : ''; - const lag = seq ? formatLagShort(seq) : ''; + // Weergave-only: het decimaalteken van de app-taal, net als het gezondheidsrapport (review #139). + const lag = seq ? localizeDecimalPoint(formatLagShort(seq), i18n.language) : ''; const relation = t('warnings.target.relation', { predecessor: taskLabel(tg.predecessorId), successor: taskLabel(tg.successorId), }); diff --git a/src/i18n/locales/ar/report.json b/src/i18n/locales/ar/report.json index 750c067be..442ac7bbf 100644 --- a/src/i18n/locales/ar/report.json +++ b/src/i18n/locales/ar/report.json @@ -150,7 +150,7 @@ "includeUnassigned": "تضمين المهام بدون مورد", "groupByType": "تجميع حسب نوع المورد", "showAssignmentColumns": "إظهار الوحدات/اليوم والمنحنى", - "columnsDropped": "تم حذف عمودي وحدة/يوم والمنحنى: لم يترك الجدول مساحة كافية لمحور الوقت. اختر ورقًا أكبر أو اتجاهًا أفقيًا أو حجم خط أصغر أو عمود أسماء أضيق، أو أوقف إظهار نسبة الإنجاز.", + "columnsDropped": "تم حذف عمودي وحدة/يوم والمنحنى: لم يترك الجدول مساحة كافية لمحور الوقت. يوفر ورق أكبر أو اتجاه أفقي أو حجم خط أصغر أو جدول أضيق مساحة أكبر.", "emptyPeriod": "لا توجد مهام في فترة التقرير — اختر فترة أخرى أو المشروع بأكمله.", "outsidePeriod": "خارج الفترة:", "empty": "لا توجد تعيينات موارد — عيّن الموارد للمهام أولاً.", diff --git a/src/i18n/locales/de/report.json b/src/i18n/locales/de/report.json index d1928769a..afb262c3c 100644 --- a/src/i18n/locales/de/report.json +++ b/src/i18n/locales/de/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Aufgaben ohne Ressource einbeziehen", "groupByType": "Nach Ressourcentyp gruppieren", "showAssignmentColumns": "Einheiten/Tag und Kurve anzeigen", - "columnsDropped": "Die Spalten Einh./T und Kurve wurden weggelassen: die Tabelle ließ zu wenig Platz für die Zeitachse. Wählen Sie ein größeres Papier oder Querformat, eine kleinere Schriftgröße oder eine schmalere Namensspalte, oder schalten Sie Fertigstellung anzeigen aus.", + "columnsDropped": "Die Spalten Einh./T und Kurve wurden weggelassen: die Tabelle ließ zu wenig Platz für die Zeitachse. Mehr Platz geben ein größeres Papier oder Querformat, eine kleinere Schriftgröße oder eine schmalere Tabelle.", "emptyPeriod": "Keine Vorgänge im Berichtszeitraum — wählen Sie einen anderen Zeitraum oder Gesamtes Projekt.", "outsidePeriod": "Außerhalb des Zeitraums:", "empty": "Keine Ressourcenzuweisungen — weisen Sie zuerst Ressourcen zu Aufgaben zu.", diff --git a/src/i18n/locales/en/report.json b/src/i18n/locales/en/report.json index dfe9f4d0a..3c66f634b 100644 --- a/src/i18n/locales/en/report.json +++ b/src/i18n/locales/en/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Include tasks without a resource", "groupByType": "Group by resource type", "showAssignmentColumns": "Show units/day and curve", - "columnsDropped": "The Units/d and Curve columns were left out: the table left too little room for the time axis. Choose a larger paper size or landscape, a smaller font size or a narrower name column, or turn off Show completion.", + "columnsDropped": "The Units/d and Curve columns were left out: the table left too little room for the time axis. A larger paper size or landscape, a smaller font size or a narrower table gives more room.", "emptyPeriod": "No tasks in the reporting period — choose another period or Whole project.", "outsidePeriod": "Outside the period:", "empty": "No resource assignments — assign resources to tasks first.", diff --git a/src/i18n/locales/es/report.json b/src/i18n/locales/es/report.json index e471c932e..4be7bc665 100644 --- a/src/i18n/locales/es/report.json +++ b/src/i18n/locales/es/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Incluir tareas sin recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/día y curva", - "columnsDropped": "Se omitieron las columnas Unid./d y Curva: la tabla dejaba muy poco espacio para el eje de tiempo. Elija un papel más grande u horizontal, una letra más pequeña o una columna de nombres más estrecha, o desactive Mostrar avance.", + "columnsDropped": "Se omitieron las columnas Unid./d y Curva: la tabla dejaba muy poco espacio para el eje de tiempo. Un papel más grande u horizontal, una letra más pequeña o una tabla más estrecha dan más espacio.", "emptyPeriod": "No hay tareas en el período del informe — elija otro período o Proyecto completo.", "outsidePeriod": "Fuera del período:", "empty": "No hay asignaciones de recursos — asigne primero recursos a las tareas.", diff --git a/src/i18n/locales/fa/report.json b/src/i18n/locales/fa/report.json index c151fd6ce..d956ef0d9 100644 --- a/src/i18n/locales/fa/report.json +++ b/src/i18n/locales/fa/report.json @@ -139,7 +139,7 @@ "includeUnassigned": "شامل کردن کارهای بدون منبع", "groupByType": "گروه‌بندی بر اساس نوع منبع", "showAssignmentColumns": "نمایش واحد/روز و منحنی", - "columnsDropped": "ستون‌های واحد/روز و منحنی حذف شدند: جدول فضای کافی برای محور زمان باقی نمی‌گذاشت. کاغذ بزرگ‌تر یا افقی، اندازه قلم کوچک‌تر یا ستون نام باریک‌تر انتخاب کنید، یا نمایش پیشرفت را خاموش کنید.", + "columnsDropped": "ستون‌های واحد/روز و منحنی حذف شدند: جدول فضای کافی برای محور زمان باقی نمی‌گذاشت. کاغذ بزرگ‌تر یا افقی، اندازه قلم کوچک‌تر یا جدول باریک‌تر فضای بیشتری می‌دهد.", "emptyPeriod": "هیچ کاری در دوره گزارش وجود ندارد — دوره دیگری یا کل پروژه را انتخاب کنید.", "outsidePeriod": "خارج از دوره:", "empty": "هیچ تخصیص منبعی وجود ندارد — ابتدا منابع را به کارها تخصیص دهید.", diff --git a/src/i18n/locales/fr/report.json b/src/i18n/locales/fr/report.json index 68726c94c..5e08cf602 100644 --- a/src/i18n/locales/fr/report.json +++ b/src/i18n/locales/fr/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Inclure les tâches sans ressource", "groupByType": "Grouper par type de ressource", "showAssignmentColumns": "Afficher unités/jour et courbe", - "columnsDropped": "Les colonnes Unités/j et Courbe ont été omises : le tableau ne laissait pas assez de place à l'axe du temps. Choisissez un papier plus grand ou en paysage, une police plus petite ou une colonne de noms plus étroite, ou désactivez Afficher l'avancement.", + "columnsDropped": "Les colonnes Unités/j et Courbe ont été omises : le tableau ne laissait pas assez de place à l'axe du temps. Un papier plus grand ou en paysage, une police plus petite ou un tableau plus étroit donne plus de place.", "emptyPeriod": "Aucune tâche dans la période de rapport — choisissez une autre période ou Projet entier.", "outsidePeriod": "Hors période :", "empty": "Aucune affectation de ressource — affectez d'abord des ressources aux tâches.", diff --git a/src/i18n/locales/it/report.json b/src/i18n/locales/it/report.json index db7bec6c1..d7d845002 100644 --- a/src/i18n/locales/it/report.json +++ b/src/i18n/locales/it/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Includi le attività senza risorsa", "groupByType": "Raggruppa per tipo di risorsa", "showAssignmentColumns": "Mostra unità/giorno e curva", - "columnsDropped": "Le colonne Unità/g e Curva sono state omesse: la tabella lasciava troppo poco spazio all'asse temporale. Scegli una carta più grande o orizzontale, un carattere più piccolo o una colonna dei nomi più stretta, oppure disattiva Mostra avanzamento.", + "columnsDropped": "Le colonne Unità/g e Curva sono state omesse: la tabella lasciava troppo poco spazio all'asse temporale. Una carta più grande o orizzontale, un carattere più piccolo o una tabella più stretta danno più spazio.", "emptyPeriod": "Nessuna attività nel periodo del report — scegli un altro periodo o Intero progetto.", "outsidePeriod": "Fuori dal periodo:", "empty": "Nessuna assegnazione di risorse — assegna prima le risorse alle attività.", diff --git a/src/i18n/locales/ja/report.json b/src/i18n/locales/ja/report.json index d6bb5d063..b759864b7 100644 --- a/src/i18n/locales/ja/report.json +++ b/src/i18n/locales/ja/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "リソース未割当のタスクを含める", "groupByType": "リソース種別でグループ化", "showAssignmentColumns": "単位/日とカーブを表示", - "columnsDropped": "「単位/日」と「カーブ」の列は省略されました:表がタイムラインに残す幅が足りません。より大きい用紙か横向き、小さい文字サイズ、または狭い名前列を選ぶか、「進捗を表示」をオフにしてください。", + "columnsDropped": "「単位/日」と「カーブ」の列は省略されました:表がタイムラインに残す幅が足りません。より大きい用紙か横向き、小さい文字サイズ、または幅の狭い表にすると余裕ができます。", "emptyPeriod": "レポート期間内にタスクがありません — 別の期間またはプロジェクト全体を選択してください。", "outsidePeriod": "期間外:", "empty": "リソースの割当がありません — まずタスクにリソースを割り当ててください。", diff --git a/src/i18n/locales/ko/report.json b/src/i18n/locales/ko/report.json index d9e149456..6edee5645 100644 --- a/src/i18n/locales/ko/report.json +++ b/src/i18n/locales/ko/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "자원이 없는 작업 포함", "groupByType": "자원 유형별로 그룹화", "showAssignmentColumns": "단위/일 및 곡선 표시", - "columnsDropped": "단위/일 및 곡선 열이 생략되었습니다: 표가 타임라인에 남긴 공간이 너무 적습니다. 더 큰 용지나 가로 방향, 더 작은 글자 크기 또는 더 좁은 이름 열을 선택하거나 진행률 표시를 끄세요.", + "columnsDropped": "단위/일 및 곡선 열이 생략되었습니다: 표가 타임라인에 남긴 공간이 너무 적습니다. 더 큰 용지나 가로 방향, 더 작은 글자 크기 또는 더 좁은 표가 공간을 더 줍니다.", "emptyPeriod": "보고 기간에 작업이 없습니다 — 다른 기간 또는 전체 프로젝트를 선택하세요.", "outsidePeriod": "기간 외:", "empty": "자원 배정이 없습니다 — 먼저 작업에 자원을 배정하세요.", diff --git a/src/i18n/locales/nl/report.json b/src/i18n/locales/nl/report.json index 0f1d40898..6803c1457 100644 --- a/src/i18n/locales/nl/report.json +++ b/src/i18n/locales/nl/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Taken zonder resource meenemen", "groupByType": "Groeperen op resourcetype", "showAssignmentColumns": "Eenheden/dag en curve tonen", - "columnsDropped": "De kolommen Eenh./d en Curve zijn weggelaten: de tabel liet te weinig tijdlijn over. Kies groter of liggend papier, een kleinere lettergrootte of een smallere naamkolom, of zet Voltooiing tonen uit.", + "columnsDropped": "De kolommen Eenh./d en Curve zijn weggelaten: de tabel liet te weinig ruimte voor de tijdlijn over. Meer ruimte geeft groter of liggend papier, een kleinere lettergrootte of een smallere tabel.", "emptyPeriod": "Geen taken in de rapportageperiode — kies een andere periode of Hele project.", "outsidePeriod": "Buiten de periode:", "empty": "Geen resourcetoewijzingen — wijs eerst resources toe aan taken.", diff --git a/src/i18n/locales/pl/report.json b/src/i18n/locales/pl/report.json index 7a81b34d3..02aa86341 100644 --- a/src/i18n/locales/pl/report.json +++ b/src/i18n/locales/pl/report.json @@ -144,7 +144,7 @@ "includeUnassigned": "Uwzględnij zadania bez zasobu", "groupByType": "Grupuj według typu zasobu", "showAssignmentColumns": "Pokaż jednostki/dzień i krzywą", - "columnsDropped": "Pominięto kolumny Jedn./d i Krzywa: tabela zostawiała za mało miejsca na oś czasu. Wybierz większy papier lub orientację poziomą, mniejszą czcionkę lub węższą kolumnę nazw, albo wyłącz Pokaż postęp.", + "columnsDropped": "Pominięto kolumny Jedn./d i Krzywa: tabela zostawiała za mało miejsca na oś czasu. Więcej miejsca da większy papier lub orientacja pozioma, mniejsza czcionka albo węższa tabela.", "emptyPeriod": "Brak zadań w okresie raportu — wybierz inny okres lub Cały projekt.", "outsidePeriod": "Poza okresem:", "empty": "Brak przypisań zasobów — najpierw przypisz zasoby do zadań.", diff --git a/src/i18n/locales/pt/report.json b/src/i18n/locales/pt/report.json index 49eb87116..99bb88ca7 100644 --- a/src/i18n/locales/pt/report.json +++ b/src/i18n/locales/pt/report.json @@ -141,7 +141,7 @@ "includeUnassigned": "Incluir tarefas sem recurso", "groupByType": "Agrupar por tipo de recurso", "showAssignmentColumns": "Mostrar unidades/dia e curva", - "columnsDropped": "As colunas Unid./d e Curva foram omitidas: a tabela deixava pouco espaço para o eixo do tempo. Escolha um papel maior ou paisagem, uma fonte menor ou uma coluna de nomes mais estreita, ou desative Mostrar conclusão.", + "columnsDropped": "As colunas Unid./d e Curva foram omitidas: a tabela deixava pouco espaço para o eixo do tempo. Um papel maior ou paisagem, uma fonte menor ou uma tabela mais estreita dão mais espaço.", "emptyPeriod": "Nenhuma tarefa no período do relatório — escolha outro período ou Projeto inteiro.", "outsidePeriod": "Fora do período:", "empty": "Sem atribuições de recursos — atribua primeiro recursos às tarefas.", diff --git a/src/i18n/locales/tr/report.json b/src/i18n/locales/tr/report.json index 82eba8333..57d0b4125 100644 --- a/src/i18n/locales/tr/report.json +++ b/src/i18n/locales/tr/report.json @@ -138,7 +138,7 @@ "includeUnassigned": "Kaynaksız görevleri dahil et", "groupByType": "Kaynak türüne göre grupla", "showAssignmentColumns": "Birim/gün ve eğriyi göster", - "columnsDropped": "Birim/g ve Eğri sütunları çıkarıldı: tablo zaman ekseni için çok az yer bırakıyordu. Daha büyük veya yatay kâğıt, daha küçük yazı boyutu ya da daha dar bir ad sütunu seçin veya Tamamlanmayı göster seçeneğini kapatın.", + "columnsDropped": "Birim/g ve Eğri sütunları çıkarıldı: tablo zaman ekseni için çok az yer bırakıyordu. Daha büyük veya yatay kâğıt, daha küçük yazı boyutu ya da daha dar bir tablo daha fazla yer sağlar.", "emptyPeriod": "Rapor döneminde görev yok — başka bir dönem veya Tüm proje seçin.", "outsidePeriod": "Dönem dışında:", "empty": "Kaynak ataması yok — önce görevlere kaynak atayın.", diff --git a/src/i18n/locales/zh/report.json b/src/i18n/locales/zh/report.json index 6b2cba69d..39bf62a94 100644 --- a/src/i18n/locales/zh/report.json +++ b/src/i18n/locales/zh/report.json @@ -135,7 +135,7 @@ "includeUnassigned": "包含未分配资源的任务", "groupByType": "按资源类型分组", "showAssignmentColumns": "显示单位/天和曲线", - "columnsDropped": "已省略“单位/天”和“曲线”列:表格给时间轴留下的空间太少。请选择更大或横向的纸张、更小的字号或更窄的名称列,或关闭“显示完成度”。", + "columnsDropped": "已省略“单位/天”和“曲线”列:表格给时间轴留下的空间太少。更大或横向的纸张、更小的字号或更窄的表格可腾出更多空间。", "emptyPeriod": "报告期间内没有任务 — 请选择其他期间或整个项目。", "outsidePeriod": "期间之外:", "empty": "没有资源分配——请先为任务分配资源。", diff --git a/src/services/pdf/bidiShape.ts b/src/services/pdf/bidiShape.ts index bc7f9c794..630c1f766 100644 --- a/src/services/pdf/bidiShape.ts +++ b/src/services/pdf/bidiShape.ts @@ -161,6 +161,25 @@ export function isArabicScriptCp(cp: number): boolean { * erven de font-klasse van hun run-level (RTL → Arabisch, anders Latijn). Zo blijft een Arabische run * mét interne spaties op het Arabische font i.p.v. bij elke spatie te splitsen. */ +/** + * Bidi-stuurtekens (UAX #9, "default ignorable"): LRM/RLM, ALM, de embedding/override-tekens en de + * isolaten. Ze sturen de UBA-levelberekening en hebben geen glyph — een shaper laat ze weg. Inter + * heeft er geen glyph voor en zou ze als `.notdef` van 0,66 em tekenen (review #139, bevinding 1/6), + * terwijl `Intl.NumberFormat` in `ar`/`fa` een U+200E vóór een minteken zet en de DOM die nodig heeft. + * Vandaar: de tekens BLIJVEN in de tekst voor {@link segmentRuns} (levels) en verdwijnen pas bij het + * shapen van elke run ({@link layoutRuns}) en op het Latijnse/CJK-snelpad vóór het encoden. + */ +export function isBidiControlCp(cp: number): boolean { + return cp === 0x200e || cp === 0x200f || cp === 0x061c || (cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069); +} + +const BIDI_CONTROLS = /[\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069]/g; + +/** `text` zonder bidi-stuurtekens (zie {@link isBidiControlCp}); voor glyph-emissie en breedtemeting. */ +export function stripBidiControls(text: string): string { + return text.replace(BIDI_CONTROLS, ''); +} + export function isNeutralCp(cp: number): boolean { return ( cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || // whitespace @@ -370,10 +389,12 @@ export function layoutRuns( const visual = reorderL2(logical); const runs: RawRunSized[] = []; for (const lr of visual) { - const str = text.slice(lr.start, lr.end); + // De stuurtekens hebben hun werk (de levels) gedaan; een run die alleen daaruit bestaat wordt + // een lege run van breedte 0. + const str = stripBidiControls(text.slice(lr.start, lr.end)); const dir: 'ltr' | 'rtl' = (lr.level & 1) === 1 ? 'rtl' : 'ltr'; const { fk, fontKey } = pickFont(lr.cls, bold, fonts); - const gr = fk.layout(str, undefined, undefined, undefined, dir); + const gr = str ? fk.layout(str, undefined, undefined, undefined, dir) : { glyphs: [], positions: [] }; const glyphIds = gr.glyphs.map(g => g.id); const advances = gr.positions.map(p => p.xAdvance); const xOffsets = gr.positions.map(p => p.xOffset ?? 0); diff --git a/src/services/pdf/pdfVectorDraw2d.ts b/src/services/pdf/pdfVectorDraw2d.ts index d9abc3e5d..e59b2b338 100644 --- a/src/services/pdf/pdfVectorDraw2d.ts +++ b/src/services/pdf/pdfVectorDraw2d.ts @@ -24,6 +24,8 @@ import type { Draw2D, TextAlign, TextBaseline } from './draw2d'; import { shapeAndPlace, isArabicScriptCp, isNeutralCp, type ShapingFonts, type ShapedRun, type FontKey, + isBidiControlCp, + stripBidiControls, } from './bidiShape'; /** Bezier-benadering van een kwart-cirkel: controle-afstand = kappa × r. */ @@ -293,7 +295,7 @@ export class PdfVectorDraw2D implements Draw2D { private checkCoverage(text: string, fk: FontkitFont): void { for (const ch of text) { const cp = ch.codePointAt(0); - if (cp === undefined) continue; + if (cp === undefined || isBidiControlCp(cp)) continue; // stuurtekens worden nooit getekend if (isRtlCodepoint(cp)) this.hasRtl = true; let covered = this.coverageCache.get(cp); if (covered === undefined) { @@ -334,7 +336,7 @@ export class PdfVectorDraw2D implements Draw2D { const arabicHas = (cp: number) => this.fkArabic?.hasGlyphForCodePoint(cp) ?? false; for (const ch of text) { const cp = ch.codePointAt(0); - if (cp === undefined) continue; + if (cp === undefined || isBidiControlCp(cp)) continue; // sturen de levels, worden nooit getekend if (isRtlCodepoint(cp)) this.hasRtl = true; let covered = this.coverageCacheComplex.get(cp); if (covered === undefined) { @@ -489,6 +491,11 @@ export class PdfVectorDraw2D implements Draw2D { return; } + // LTR-paden kennen geen herordening, dus de bidi-stuurtekens (U+200E uit `Intl` vóór een minteken + // in ar/fa) doen hier niets — en pdf-lib zou ze als `.notdef`-tofu encoden. Weg ermee. + text = stripBidiControls(text); + if (!text) return; + // Niet-complex pad (Latijn/CJK, LTR): glyph-dekking checken vóór het encoden. Een ongedekte // codepoint mapt bij subset:false op `.notdef` (tofu) zónder fout; de pagineerder leest // `uncoveredCodepoints` uit en faalt bewust, zodat de raster-fallback aanslaat i.p.v. tofu. @@ -737,6 +744,7 @@ export class PdfVectorDraw2D implements Draw2D { if (this.shapingFonts && this.hasRtlText(text)) { return { width: this.shapeComplex(text, bold, size).width }; } + text = stripBidiControls(text); // zie `fillText`: nooit getekend, dus ook niet gemeten // CJK-pad: som van de run-breedtes (Inter + providerfonts), consistent tussen beide render-passes // (fontkit-breedtes zijn in beide passes beschikbaar) zodat de paginering niet tussen de passes schuift. if (this.textHasCjk(text)) { diff --git a/src/services/print/printPreview.ts b/src/services/print/printPreview.ts index 802655976..6297c9872 100644 --- a/src/services/print/printPreview.ts +++ b/src/services/print/printPreview.ts @@ -102,12 +102,15 @@ const COL = { * toewijzingskolommen (Eenh./d + Curve) laat vallen: een vijfde van de printbreedte, met een vloer * van 160 logische px voor klein papier (A4 staand: 730 px breed, dus 160 in plaats van 146). Komt de * tabel mét kolommen daaronder — door een brede naamkolom, een grote rapportlettergrootte (de tabel - * schaalt mee, deze grens niet) of klein/staand papier — én lost weglaten dat op (zonder de kolommen - * blijft er wél genoeg chart over), dan vallen de twee kolommen en meldt de render dat via - * `RenderReportResult.assignmentColumnsDropped`. Lost weglaten niets op, dan blijven ze staan: de - * gebruiker koos ze, en een tijdas van een paar pixels heeft hij dan toch al. Review #138 ronde 2 - * bevinding 5; de vaste 240 px van de eerste versie gooide op A4 staand de kolommen al bij verse - * instellingen weg (review #139, bevindingen 1, 2 en 4). + * schaalt mee, deze grens niet) of klein/staand papier — dan vallen de twee kolommen en meldt de + * render dat via `RenderReportResult.assignmentColumnsDropped`. De regel is bewust MONOTOON in de + * tabelbreedte: elke bredere tabel laat de kolommen óók vallen. Een tussenvariant "alleen weglaten + * als de tabel zónder de kolommen wél past" liet een dode zone open waarin een tabel die de pagina + * al niet paste zijn optionele kolommen hield en de tijdas op 1 px klemde, zonder melding; en een + * bredere naamkolom bracht de kolommen dan terug (review #139, ronde 2, bevinding 3). Weglaten maakt + * de tijdas nooit smaller — helpt het niet genoeg, dan zegt de melding wat wél ruimte geeft. Review + * #138 ronde 2 bevinding 5; de vaste 240 px van de eerste versie gooide op A4 staand de kolommen al + * bij verse instellingen weg (review #139, ronde 1, bevindingen 1 en 2). */ const MIN_CHART_WIDTH_FRACTION = 0.2; const MIN_CHART_WIDTH_FLOOR_PX = 160; @@ -521,7 +524,8 @@ function formatDutchDate(d: Date, notation: DateNotation = 'dmy'): string { * `numberLocale` de neutrale punt, op twee decimalen afgerond. */ function formatDuration(days: number, locale: string | undefined): string { - return `${formatReportNumber(days, locale)}d`; + const text = formatReportNumber(days, locale); + return text ? `${text}d` : '—'; // niet-eindig: een streepje, geen losse eenheid } /** @@ -731,14 +735,10 @@ export function renderReport( let assignmentColumns = !!options.assignmentColumns; let m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, assignmentColumns); let assignmentColumnsDropped = false; - const maxTableWidth = printableWidth - minChartWidthPx(printableWidth); - if (assignmentColumns && m.tableWidth > maxTableWidth) { - const without = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, false); - if (without.tableWidth <= maxTableWidth) { - assignmentColumns = false; - assignmentColumnsDropped = true; - m = without; - } + if (assignmentColumns && m.tableWidth > printableWidth - minChartWidthPx(printableWidth)) { + assignmentColumns = false; + assignmentColumnsDropped = true; + m = makeMetrics(options.reportFontScale, options.showCompletion, options.taskNameColumnWidth, false); } // Rijen-bron: zie {@link buildPrintRows} — taakrijen mét diepte plus groepsband-rijen (#54) die diff --git a/src/utils/reportNumber.ts b/src/utils/reportNumber.ts index ad10aac2d..4314ec2ee 100644 --- a/src/utils/reportNumber.ts +++ b/src/utils/reportNumber.ts @@ -10,30 +10,47 @@ * Bewust altijd **Latijnse cijfers** (`-u-nu-latn`): datums, tellingen en percentages in dezelfde * rapporten zijn Latijns, en een tabel met "۱٫۵" naast "3" en "75%" is geen lokalisatie maar een * gemengd cijfersysteem (review #139, bevinding 5 — `fa` kiest anders `arabext`). De bidi-markering - * (U+200E) die `Intl` in `ar`/`fa` vóór een teken zet gaat eruit: in de DOM is hij onzichtbaar, - * maar in de vector-PDF legt Inter hem als een spatie van 0,28 em (bevinding 6). En een waarde die - * op twee decimalen tot nul afrondt heet "0", niet "-0" (bevinding 7). + * U+200E die `Intl` in `ar`/`fa` vóór een teken zet blijft bewust STAAN: in een RTL-alinea is hij + * de enige reden dat het minteken links van zijn cijfer blijft ("-2" wordt anders "2-", gemeten in + * Chromium én in de `bidi-js`-levels van de vector-PDF). Dat Inter er in de PDF een tofu van maakte + * is een emissieprobleem en is dáár opgelost (`bidiShape.stripBidiControls`), niet hier. Een waarde + * die op twee decimalen tot nul afrondt heet "0", niet "-0" (bevinding 7). De `Intl`-formatters + * zijn duur om te bouwen (~35 µs tegen ~0,7 µs hergebruik) en worden per taal gecachet. */ -const BIDI_MARKS = /[\u200E\u200F]/g; -const NEGATIVE_ZERO = /^[-\u2212]0$/; +const NEGATIVE_ZERO = /^\u200E?[-\u2212]0$/; +const formatters = new Map(); function intlFormatter(locale: string, signed: boolean): Intl.NumberFormat | null { + const key = `${locale}|${signed ? 's' : 'u'}`; + const cached = formatters.get(key); + if (cached !== undefined) return cached; + let fmt: Intl.NumberFormat | null = null; try { - return new Intl.NumberFormat(`${locale}-u-nu-latn`, { - maximumFractionDigits: 2, - useGrouping: false, - ...(signed ? { signDisplay: 'exceptZero' as const } : {}), - }); + // Onbekend-maar-welgevormd ("zz") gooit niet maar valt stil op de systeemtaal terug; dat is hier + // óók een terugval naar de neutrale notatie, dus expliciet uitsluiten. + if (Intl.NumberFormat.supportedLocalesOf([locale]).length > 0) { + fmt = new Intl.NumberFormat(`${locale}-u-nu-latn`, { + maximumFractionDigits: 2, + useGrouping: false, + ...(signed ? { signDisplay: 'exceptZero' as const } : {}), + }); + } } catch { - return null; // onbekende taalcode ⇒ de neutrale notatie hieronder + fmt = null; // misvormde taalcode ⇒ de neutrale notatie } + formatters.set(key, fmt); + return fmt; +} + +function neutral(n: number): string { + return String(Math.round(n * 100) / 100 + 0); } export function formatReportNumber(n: number, locale?: string): string { if (!Number.isFinite(n)) return ''; const fmt = locale ? intlFormatter(locale, false) : null; - if (!fmt) return String(Math.round(n * 100) / 100 + 0); - const text = fmt.format(n).replace(BIDI_MARKS, ''); + if (!fmt) return neutral(n); + const text = fmt.format(n); return NEGATIVE_ZERO.test(text) ? '0' : text; } @@ -46,20 +63,27 @@ export function formatSignedReportNumber(n: number, locale?: string): string { if (!Number.isFinite(n)) return ''; const fmt = locale ? intlFormatter(locale, true) : null; if (!fmt) { - const text = formatReportNumber(n); + const text = neutral(n); return text !== '0' && n > 0 ? `+${text}` : text; } - return fmt.format(n).replace(BIDI_MARKS, ''); + const text = fmt.format(n); + return NEGATIVE_ZERO.test(text) ? '0' : text; } +const decimalSeparators = new Map(); + /** * Zet in een door de engine geleverde, taalneutrale tekst met ASCII-decimaalpunten (zoals de * lag-notatie "+1.5d" van `formatLagShort`) de punt om naar het decimaalteken van `locale`. Alleen - * een punt tússen twee cijfers telt; zonder `locale` ongewijzigd. + * een punt tússen twee cijfers telt; zonder `locale` ongewijzigd. Voor weergave — bewerkbare cellen + * (de relatiekolom van het taakgrid) blijven ASCII, want `parseLagInput` leest die terug. */ export function localizeDecimalPoint(text: string, locale?: string): string { if (!locale) return text; - const fmt = intlFormatter(locale, false); - const sep = fmt?.formatToParts(1.1).find(p => p.type === 'decimal')?.value ?? '.'; + let sep = decimalSeparators.get(locale); + if (sep === undefined) { + sep = intlFormatter(locale, false)?.formatToParts(1.1).find(p => p.type === 'decimal')?.value ?? '.'; + decimalSeparators.set(locale, sep); + } return sep === '.' ? text : text.replace(/(\d)\.(\d)/g, `$1${sep}$2`); } diff --git a/tests/planning/check-print-report.ts b/tests/planning/check-print-report.ts index b1248b683..b9753a3ae 100644 --- a/tests/planning/check-print-report.ts +++ b/tests/planning/check-print-report.ts @@ -587,11 +587,11 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(JSON.stringify(record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, rowAssignments }))) === JSON.stringify(plainA), 'rowAssignments zonder assignmentColumns ⇒ byte-identiek'); - // Restpunt review #138 (ronde 2, bevinding 5), aangescherpt na review #139 (bevindingen 1, 2 - // en 4): laat de tabel mét kolommen minder dan `minChartWidthPx` (een vijfde van de - // printbreedte, vloer 160 px) tijdlijn over op één papierbreedte ÉN lost weglaten dat op, dan - // laat de render de twee kolommen vallen en meldt dat. A4 liggend is 1058,5 px breed ⇒ de tabel - // mag 846,8 px zijn: een naamkolom van 500 px geeft 893 px mét en 750 px zónder de kolommen. + // Restpunt review #138 (ronde 2, bevinding 5), aangescherpt na review #139 (twee rondes): laat + // de tabel mét kolommen minder dan `minChartWidthPx` (een vijfde van de printbreedte, vloer + // 160 px) tijdlijn over op één papierbreedte, dan laat de render de twee kolommen vallen en + // meldt dat — monotoon in de tabelbreedte. A4 liggend is 1058,5 px breed ⇒ de tabel mag 846,8 + // px zijn: een naamkolom van 500 px geeft 893 px mét de kolommen. const a4 = { paperSize: 'A4' as const, orientation: 'landscape' as const }; const wideOpts = baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, ...a4, taskNameColumnWidth: 500 }); const wide = record([T_NORM, T_CRIT], [], cal, wideOpts); @@ -600,15 +600,21 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ ok(wide.dims.tableWidth === wideNoCols.dims.tableWidth && !wide.texts.some(t => t.text === 'Eenh./d'), 'weggelaten ⇒ dezelfde tabel als zonder de optie, geen kolomkop'); ok(withCols.dims.assignmentColumnsDropped === undefined, 'gewone naamkolom ⇒ kolommen blijven, geen melding'); - // Lost weglaten niets op (800 px naamkolom: 1050 px zónder de kolommen), dan blijven ze staan. - const hopeless = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, ...a4, taskNameColumnWidth: 800 })); - ok(hopeless.dims.assignmentColumnsDropped === undefined && hopeless.texts.some(t => t.text === 'Eenh./d'), - 'tabel ook zonder de kolommen te breed ⇒ kolommen blijven, geen melding'); + // Monotoon: ook een tabel die zónder de kolommen te breed blijft (800 px naamkolom: 1050 px) + // laat ze vallen — weglaten maakt de tijdas nooit smaller (review #139 ronde 2, bevinding 3). + const wider = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, ...a4, taskNameColumnWidth: 800 })); + ok(wider.dims.assignmentColumnsDropped === true && !wider.texts.some(t => t.text === 'Eenh./d'), + 'tabel ook zonder de kolommen te breed ⇒ kolommen tóch weggelaten (monotoon)'); // A4 staand (729,7 px, vloer 160 ⇒ 569,7 px tabel) met verse instellingen: 523 px past. const portrait = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'portrait' })); ok(portrait.dims.assignmentColumnsDropped === undefined && portrait.texts.some(t => t.text === 'Eenh./d'), 'A4 staand met standaardinstellingen ⇒ kolommen blijven'); - // De lettergrootte schaalt de tabel mee, de grens niet: 125 % op A4 staand geeft 654 px ⇒ weg, 475 px zonder ⇒ past. + // De dode zone van de tussenvariant: A4 staand, naamkolom 350 ⇒ 743 px mét (past de pagina niet + // eens), 600 px zonder (past de pagina, maar niet de grens). Kolommen weg, tijdas 129,7 px. + const dead = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'portrait', taskNameColumnWidth: 350 })); + ok(dead.dims.assignmentColumnsDropped === true && dead.dims.tableWidth === 600, + `A4 staand met naamkolom 350 ⇒ kolommen weggelaten, tabel 600 px (got ${dead.dims.tableWidth})`); + // De lettergrootte schaalt de tabel mee, de grens niet: 125 % op A4 staand geeft 654 px ⇒ weg. const bigFont = record([T_NORM, T_CRIT], [], cal, baseOptions({ rows: aBands, assignmentColumns: true, rowAssignments, paperSize: 'A4', orientation: 'portrait', reportFontScale: 125 })); ok(bigFont.dims.assignmentColumnsDropped === true, 'grote lettergrootte op A4 staand ⇒ kolommen weggelaten'); @@ -628,6 +634,9 @@ const baseOptions = (over: Partial = {}): PrintOptions => ({ const plainDur = record([T_HALF], [], cal, baseOptions()); ok(nlDur.texts.some(t => t.text === '2,5d') && plainDur.texts.some(t => t.text === '2.5d'), `duurcel volgt numberLocale ("2,5d" in nl, "2.5d" zonder; got ${JSON.stringify(nlDur.texts.filter(t => t.text.endsWith('d')).map(t => t.text))})`); + const T_NAN = mkTask('t-nan', 'Onbekende duur', { time: mkTime({ scheduleDuration: NaN }) }); + const nanDur = record([T_NAN], [], cal, baseOptions()); + ok(nanDur.texts.some(t => t.text === '—') && !nanDur.texts.some(t => t.text === 'd'), 'niet-eindige duur ⇒ streepje, geen losse "d"'); } // Dezelfde drie banden in de overige pagineermodi (review op #132, bevinding 9): zonder // kopherhaling, in 'actual' (1 pt = 1 px, horizontaal getegeld) en met de tijdlijn over twee diff --git a/tests/planning/check-reports.ts b/tests/planning/check-reports.ts index babd432a1..94fe52c28 100644 --- a/tests/planning/check-reports.ts +++ b/tests/planning/check-reports.ts @@ -17,6 +17,7 @@ import { } from '@/engine/reports'; import { assignmentCurveState, contouredAssignmentIds } from '@/engine/contour/curveState'; import { formatReportNumber, formatSignedReportNumber, localizeDecimalPoint } from '@/utils/reportNumber'; +import { layoutRuns, stripBidiControls, type ShapeFontkitFont, type ShapingFonts } from '@/services/pdf/bidiShape'; import { isValidReportingPeriod, periodDays, projectSpan, resolveReportingPeriod, weeksToPreset, } from '@/engine/reports'; @@ -651,13 +652,35 @@ eq('scenario: B rest = 6 wd (10 × 60%)', remainingDays(ctx, byId(B)), 6); eq('reportNumber: geen plus bij nul of negatief', [formatSignedReportNumber(0, 'nl'), formatSignedReportNumber(-2, 'nl')], ['0', '-2']); // Review #139, bevindingen 5–7: Latijnse cijfers in élke taal, geen bidi-markering, geen "-0". eq('reportNumber: fa ⇒ Latijnse cijfers', formatReportNumber(1.5, 'fa'), '1.5'); - eq('reportNumber: ar negatief zonder U+200E', [...formatReportNumber(-2, 'ar')].map(c => c.charCodeAt(0)), [45, 50]); + // De U+200E die Intl vóór het teken zet BLIJFT: zonder staat "-2" in een RTL-alinea als "2-" (ronde 2, bevinding 1). + eq('reportNumber: ar negatief houdt de bidi-markering', [...formatReportNumber(-2, 'ar')].map(c => c.charCodeAt(0)), [0x200e, 45, 50]); + eq('reportNumber: fa signed positief houdt de bidi-markering', [...formatSignedReportNumber(2, 'fa')].map(c => c.charCodeAt(0)), [0x200e, 43, 50]); + eq('reportNumber: welgevormde maar onbekende taalcode valt terug op de punt', formatReportNumber(0.5, 'zz'), '0.5'); + eq('reportNumber: gecachete formatter geeft dezelfde uitkomst', [formatReportNumber(1.25, 'de'), formatReportNumber(1.25, 'de')], ['1,25', '1,25']); eq('reportNumber: -0,001 rondt af op "0", niet "-0"', [formatReportNumber(-0.001, 'nl'), formatReportNumber(-0.001), formatSignedReportNumber(-0.001, 'nl'), formatSignedReportNumber(-0.001)], ['0', '0', '0', '0']); eq('reportNumber: NaN/oneindig ⇒ leeg', [formatReportNumber(NaN, 'nl'), formatSignedReportNumber(Infinity, 'nl')], ['', '']); eq('reportNumber: onbekende taalcode valt terug op de punt', formatReportNumber(0.5, 'zz-!!'), '0.5'); eq('reportNumber: lag-tekst krijgt het decimaalteken van de taal', [localizeDecimalPoint('+1.5d', 'nl'), localizeDecimalPoint('+1.5d', 'en'), localizeDecimalPoint('+2d', 'nl'), localizeDecimalPoint('+1.5d')], ['+1,5d', '+1.5d', '+2d', '+1.5d']); eq('curveState: lege curveValues zijn geen geïmporteerde curve', assignmentCurveState({ ...bare, curveValues: [] }, false), 'UNIFORM'); + // Vector-PDF: de bidi-markering stuurt de levels (min links van het cijfer in een RTL-alinea) maar + // krijgt nooit een glyph — anders tekent Inter een `.notdef` van 0,66 em (ronde 2, bevinding 1/6). + eq('bidi: stripBidiControls haalt LRM/RLM/ALM/isolaten weg', stripBidiControls('\u200E-2 \u202Bx\u202C \u2067y\u2069 \u061Cz'), '-2 x y z'); + { + const seen: string[] = []; + const fakeFont = (tag: string): ShapeFontkitFont => ({ + unitsPerEm: 1000, + layout: (str: string) => { seen.push(`${tag}:${str}`); return { glyphs: [...str].map((_, i) => ({ id: i + 1 })), positions: [...str].map(() => ({ xAdvance: 500 })) }; }, + hasGlyphForCodePoint: (cp: number) => cp !== 0x200e, + }); + const fonts: ShapingFonts = { latinRegular: fakeFont('L'), latinBold: fakeFont('LB'), arabicRegular: fakeFont('A'), arabicBold: fakeFont('AB') }; + const runs = layoutRuns('\u0645 \u200E-2', 'rtl', fonts, false, 10); + ok('bidi: geen run bevat nog de LRM bij het shapen', !seen.some(str => str.includes('\u200E')), JSON.stringify(seen)); + ok('bidi: glyphs = tekens zonder stuurtekens', runs.every(r => r.glyphIds.length === stripBidiControls(r.text).length)); + const minus = runs.find(r => r.text.includes('-')); + ok('bidi: "-2" blijft één LTR-run met de min vooraan', !!minus && minus.dir === 'ltr' && minus.text.startsWith('-'), JSON.stringify(runs.map(r => [r.text, r.dir]))); + } + // Twee toewijzingen van dezelfde resource op één taak zijn één rij; een toewijzing aan een // onbekende resource telt niet (die taak is dan "zonder resource", zoals op het scherm). const dubbel = computeResourceGanttRows({ From d25a4c4c7629fa462565a5b36c35530268f419fc Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:28:51 +0200 Subject: [PATCH 094/108] fix(b1c): de label- en uitkomstkolom staan vast, alleen de tijdas scrollt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rekt een opgerekt plafond de tijdas voorbij de dialoogbreedte, dan schoven de projectnaam links en de uitkomstpil rechts mee uit beeld — de eigenaar zag "eind +5 dage" en "max onbegrensd · benu" afgekapt tegen de rand. Beide kolommen zijn nu bevroren; legenda en einddatum-badges horen bij de rijen en schuiven evenmin mee. Het histogram schuift wél mee (kolom-op-kolom onder de dagblokjes) en krijgt aan weerszijden een even brede afdekstrook, zodat er geen staaf zichtbaar blijft voor een dag waarvan het blokje erboven achter het label zit. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 101 ++++++++++++------ .../dialogs/DistributionDialog/PhaseStrip.tsx | 24 ++++- tests/browser/leveling-distribution.spec.ts | 52 +++++++++ 3 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 8e60a9bb4..9d010c6b0 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -41,6 +41,16 @@ // links `STRIP.labelWidth + STRIP.gap` en rechts `STRIP.endWidth + STRIP.gap` marge, zodat hun // plotgebied exact samenvalt met de tracks; de `AXIS.padLeft` van 34 px zit ín de as en geldt dus // voor beide. +// +// …MAAR NIET ALLES SCHUIFT MEE (gebruikstest eigenaar 2026-09-14). Alleen de TIJDAS hoort te +// scrollen. De labelkolom links en de uitkomstkolom rechts van elke balk stonden ín diezelfde +// scroller, dus zodra een opgerekt plafond de as breder maakte dan de dialoog schoven ze mee en +// liepen ze de rand uit: de eigenaar zag "eind +5 dage" en "max onbegrensd · benu" afgekapt. Die +// twee kolommen zijn nu BEVROREN (`position: sticky`, links respectievelijk rechts), net als de +// legenda en de einddatum-badges — die horen bij de rijen, niet bij de as. Het histogram schuift +// wél mee (het moet kolom-op-kolom onder de dagblokjes blijven) en krijgt daarom aan beide kanten +// een even brede, ondoorzichtige vastgezette strook, zodat er geen staaf zichtbaar blijft voor een +// dag waarvan het blokje erboven al achter de labelkolom verdwenen is. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; @@ -624,7 +634,13 @@ export function DistributionDialog() { {/* (3)(4) DE BALKEN EN DE UITKOMST, in ÉÉN horizontale scroll-container zodat ze kolom-op-kolom uitgelijnd blijven — ook tijdens het scrollen (spec §3.4). */} -
+
{(stripView?.docs ?? []).map(doc => { const recorded = doc.pinnedReason === 'dates-as-recorded'; @@ -666,10 +682,15 @@ export function DistributionDialog() { duwde hij als één lange regel de scroll-container breder dan de balken zelf, dus verscheen er een horizontale schuifbalk die niets met de tijdas te maken had. Hij krijgt precies de ruimte van de track plus het uitkomstlabel. */} + {/* MEEBEVROREN met de labelkolom (gebruikstest 2026-09-14): de legenda hoort niet + bij de tijdas maar bij de rijen erboven, dus hij hoort ook niet mee te schuiven + wanneer de as breder is dan de dialoog. `sticky` op exact zijn eigen + inspringing houdt hem staan zonder zijn plek te veranderen. */}
{(stripView?.docs ?? []).map(doc => { @@ -720,32 +747,46 @@ export function DistributionDialog() { van het histogram moet exact op de tracks vallen. Met `px-2` stond elke histogramkolom 8 px rechts van zijn dagblokje — gemeten 2026-09-14, blokje op x=428 tegen staaf op x=436. */} -
- {poolItem ? ( - ({ docId: doc.docId, title: doc.title }))} - docColors={docColors} - bookingByDay={proposal?.bookingByDay ?? {}} - afterLoadByDay={proposal?.afterLoadByDay ?? {}} - afterIncomplete={proposal?.afterIncomplete ?? false} - shortfallDocs={shortfallDocs} - /> - ) : ( - - {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} - - )} -
+ {/* HET HISTOGRAM SCHUIFT WÉL MEE — het is as-gebonden en moet kolom-op-kolom onder + de dagblokjes blijven staan. Maar dan moet het aan beide kanten net zo bedekt + worden als de tracks, anders zie je hier staven voor dagen waarvan het blokje + erboven achter de labelkolom verdwenen is. Vandaar twee ondoorzichtige, + vastgezette stroken links en rechts, precies zo breed als de bevroren kolommen + (min de 1 px rand van de sectie, net als de oude marges). */} +
+
+
+ {poolItem ? ( + ({ docId: doc.docId, title: doc.title }))} + docColors={docColors} + bookingByDay={proposal?.bookingByDay ?? {}} + afterLoadByDay={proposal?.afterLoadByDay ?? {}} + afterIncomplete={proposal?.afterIncomplete ?? false} + shortfallDocs={shortfallDocs} + /> + ) : ( + + {t('resource.distribution.preview.before')} / {t('resource.distribution.preview.after')} / {t('resource.distribution.preview.capacity')} + + )} +
+
+
diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 5a4d59091..35ddb5fb0 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -264,10 +264,22 @@ export function PhaseStrip({ data-ops-distribution-day-width={dayWidth} {...(pinned ? { 'data-ops-distribution-pinned': 'true' } : {})} > - {/* (a) LABEL — kleurstip, projectnaam, speling, pin-tekstknop (§4/§6). */} + {/* (a) LABEL — kleurstip, projectnaam, speling, pin-tekstknop (§4/§6). + + VASTGEZETTE KOLOM (gebruikstest eigenaar 2026-09-14). De rij staat in één horizontale + scroll-container samen met de tracks; rekt het plafond de tijdas voorbij de dialoogbreedte, + dan schoof deze kolom mee naar links en verdween de projectnaam uit beeld — precies + wanneer je 'm nodig hebt, want je bent dán aan het vergelijken. `position: sticky` is hier + de bevriezing en niet een aparte kolom-buiten-de-scroller: alleen zo blijven de rijhoogtes + van label, track en uitkomst per constructie gelijk (ze zitten in dezelfde flexrij) en + blijft de sleepstate LOKAAL in deze component. Zou de kolom een eigen stapel buiten de + scroller zijn, dan moest `dragValue` naar de dialoog — en dan hertekent elke sleepstap de + hele dialoog, inclusief het histogram, wat reparatie 2 hieronder juist wegneemt. + Ondoorzichtige achtergrond: de track schuift eronderdoor. */}
- {/* (c) UITKOMSTLABEL — vaste breedte, dus een toestandswissel verandert de maat niet (§7). */} + {/* (c) UITKOMSTLABEL — vaste breedte, dus een toestandswissel verandert de maat niet (§7). + VASTGEZET aan de RECHTERrand om dezelfde reden als het label links: de eigenaar zag + "eind +5 dage" en "max onbegrensd · benu" halverwege afgekapt tegen de dialoogrand zodra + de as voorbij de zichtbare breedte gerekt was. De uitkomst is de reden dat je sleept; die + mag nooit uit beeld schuiven. */}
diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 8d8205eca..42e179e9a 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -863,3 +863,55 @@ test('van document wisselen sluit de dialoog', async ({ page, ops: _ops }) => { // Het bezettingsoverzicht blijft gewoon staan; de gebruiker opent opnieuw op de conflictregel. await expect(page.locator('[data-ops-occupancy-view]')).toBeVisible(); }); + +// --- Gebruikstest eigenaar 2026-09-14 — de vaste kolommen blijven in beeld ----------------------- + +test('een opgerekt plafond schuift de label- en uitkomstkolom niet uit de dialoog', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const strip = page.locator('[data-ops-distribution-strip]').nth(1); + const handle = strip.locator('[data-ops-distribution-handle]'); + const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); + expect(dayWidth).toBeGreaterThan(0); + + // Een ECHTE sleep, zo ver naar rechts als het venster toelaat: de dagenset groeit mee met het + // plafond, dus de tijdas wordt hierdoor een veelvoud breder dan de dialoog zelf. Dat is precies + // de stand waarin de eigenaar "eind +5 dage" en "max onbegrensd · benu" afgekapt zag. + const viewport = page.viewportSize()!; + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(viewport.width - 8, box.y + box.height / 2, { steps: 10 }); + await page.mouse.up(); + + // De as is echt buiten de dialoog gegroeid — zonder die overloop bewijst deze test niets. + const scroller = page.locator('[data-ops-distribution-scroller]'); + await expect.poll(() => scroller.evaluate(el => el.scrollWidth - el.clientWidth)) + .toBeGreaterThan(100); + + const dialogBox = (await page.locator('[data-ops-distribution-dialog]').boundingBox())!; + const within = async (selector: string) => { + const cells = page.locator(selector); + await expect(cells).toHaveCount(2); + for (let i = 0; i < 2; i++) { + const cell = (await cells.nth(i).boundingBox())!; + expect(cell.width).toBeGreaterThan(0); + expect(cell.x).toBeGreaterThanOrEqual(dialogBox.x - 1); + expect(cell.x + cell.width).toBeLessThanOrEqual(dialogBox.x + dialogBox.width + 1); + } + }; + + // (1) Op de niet-gescrolde stand: de uitkomstkolom is aan de rechterrand vastgezet en staat dus + // niet meer achter de as aan, ver buiten het paneel. + await within('[data-ops-distribution-effect]'); + await within('[data-ops-distribution-label]'); + + // (2) En met de as helemaal naar rechts gescrold blijven beide kolommen staan — dat is wat + // "bevroren" betekent, en het is de enige manier om de projectnaam bij de uitkomst te houden. + await scroller.evaluate(el => { el.scrollLeft = el.scrollWidth; }); + await within('[data-ops-distribution-effect]'); + await within('[data-ops-distribution-label]'); + await expect(page.locator('[data-ops-distribution-label]').first()).toContainText(/Eendaags project/); +}); From abf64eb24e9089f7059b9c7e822617b2221f7650 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:35:31 +0200 Subject: [PATCH 095/108] fix(b1c): de verdeeldialoog herberekent pas bij het loslaten van de greep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigenaarsbesluit 2026-09-14, dat het "live meerekenen per gesnapte werkdag" uit spec §5 vervangt: "het flikkert enorm omdat het elke keer herberekent; het moet pas herberekenen en de nieuwe statusmelding tonen wanneer ik de muis loslaat." Tijdens een pointer-sleep is alles lokaal — alleen de greep, de gestippelde rest en de plafondtekst van de eigen rij bewegen mee. Er gaat geen onCeilingChange uit, dus geen computeDistribution en geen hertekening van pil, badges, histogram, validatiestrook of prijskaartje. Het pointerup commit één keer, dus de bezig-toestand gaat precies één keer aan en uit. Toetsenbordstappen blijven wél discrete rekenmomenten (spec §3.4). De liveCommit-prop is daarmee weg; de in-flight-bewaking in de hook blijft, maar niet langer als sleep-throttle. Spec §5 krijgt een gedateerde eigenaarsnoot, de gidsen nl+en de nieuwe zin. Co-Authored-By: Claude Fable 5.1 --- ...12-b1c-verdeeldialoog-herontwerp-design.md | 11 +++++ .../docs/en/gids-verdelen-restcapaciteit.md | 10 +++-- .../docs/nl/gids-verdelen-restcapaciteit.md | 10 +++-- .../DistributionDialog/DistributionDialog.tsx | 1 - .../dialogs/DistributionDialog/PhaseStrip.tsx | 44 +++++++++++++------ .../useDistributionProposal.ts | 12 +++-- tests/browser/leveling-distribution.spec.ts | 27 ++++++++---- 7 files changed, 80 insertions(+), 35 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md index 10264ef26..2a58233a2 100644 --- a/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md +++ b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md @@ -82,6 +82,17 @@ grijs = speling, rood = einddatum-verschuiving · gestippeld = toegestaan maar n ## 5. Het slepen +> **Eigenaarsbesluit 2026-09-14: geen herberekening tijdens het slepen; alleen bij loslaten (en per +> toetsenbordstap).** Het "live meerekenen" hieronder is in de gebruikstest afgevallen — elke +> gesnapte werkdag gaf een nieuwe run, en daarmee een knipperende bezig-toestand over pil, badges, +> histogram, validatiestrook en prijskaartje ("het flikkert enorm"). Tijdens een pointer-sleep +> bewegen nu alleen de greep, de gestippelde rest en de plafondtekst van de eigen rij mee; het +> `pointerup` commit één keer en levert één run. `pointercancel` breekt af zonder te committen. +> De toetsenbordstappen blijven wél discrete rekenmomenten, zoals §3.4 oorspronkelijk voorschreef. +> De twee opsommingsregels over "live meerekenen" en over het gedrag "boven de schaal" gelden +> daarmee niet meer als twee standen: er is nog één stand, en die is de oude +> boven-de-schaal-stand. + - Alleen de **handle** aan het einde van de fase is te pakken: een knop van 15×30 px met drie grijpstreepjes, `cursor: ew-resize`, `role="slider"`, `aria-valuemin=0`, `aria-valuemax=CEILING_MAX_WORKDAYS`, `aria-valuetext` met de echte datum ("einde uiterlijk diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index a6412d6eb..f357433bc 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -87,11 +87,13 @@ beyond that — real end-date shift. A bar without red costs the project nothing To the right of the last block sits a **grip** with three strokes. Drag it to the right to allow this project more overrun; the distributor then decides for itself which days to pause on and how much of that room it actually needs. Whatever you allowed but turned out not to be needed shows up as a -**dotted box** behind the bar. While you drag, the app recalculates: the other bars, the chart and -the outcomes move under your hand. On a very large overview it does not — there the calculation -follows when you release. +**dotted box** behind the bar. While you drag, only the grip and the dotted box of this one bar +move; the calculation follows when you release. Only then do the other bars, the chart and the +outcomes change. That is deliberate: recalculating on every workday made the whole window flicker +under your hand. -The same grip works from the keyboard: the arrow keys move one workday, PageUp and PageDown three, +The same grip works from the keyboard, and there every key press *is* a calculation moment right +away: the arrow keys move one workday, PageUp and PageDown three, Home sets the ceiling to zero and End makes it unlimited. To the right of the bar sits the outcome: a coloured pill with what happens to the end date (green at diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index f27606c52..9e2efdc2c 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -91,11 +91,13 @@ project dus niets. Rechts van het laatste blokje staat een **greep** met drie streepjes. Trek die naar rechts om dit project meer uitloop toe te staan; de verdeler bepaalt vervolgens zelf op welke dagen hij pauzeert en hoeveel van die ruimte hij echt nodig heeft. Wat je toestond maar niet nodig bleek, staat als een -**gestippeld kadertje** achter de balk. Terwijl je sleept rekent de app mee: de andere balken, de -grafiek en de uitkomsten veranderen onder je hand. Op een heel groot overzicht doet hij dat niet — -daar volgt de berekening zodra je loslaat. +**gestippeld kadertje** achter de balk. Terwijl je sleept bewegen alleen de greep en het kadertje +van deze ene balk mee; de berekening volgt zodra je loslaat. Pas dan veranderen de andere balken, +de grafiek en de uitkomsten. Dat is met opzet: zou hij bij elke werkdag opnieuw rekenen, dan +knippert het hele venster onder je hand. -Met het toetsenbord werkt dezelfde greep: pijltjes verzetten één werkdag, PageUp en PageDown drie, +Met het toetsenbord werkt dezelfde greep, en daar is elke toets wél meteen een rekenmoment: +pijltjes verzetten één werkdag, PageUp en PageDown drie, Home zet het plafond op nul en End maakt het onbegrensd. Rechts van de balk staat de uitkomst: een gekleurde pil met wat er met de einddatum gebeurt (groen diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 9d010c6b0..4ec08bc77 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -664,7 +664,6 @@ export function DistributionDialog() { pinned={pinnedNow} recorded={recorded} cannotMove={doc.cannotMove} - liveCommit={!degraded} busy={busy} shortfallCount={short?.count ?? 0} {...(short diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 35ddb5fb0..1fd65dff8 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -26,13 +26,24 @@ // hebben; de arcering van een pauzedag leest dan de projectkalender, niet die van de taak. Voor de // bedoeling van de balk — "hier zit een ingevoegde onderbreking" — is dat de juiste grofheid. // -// LIVE MEEREKENEN TIJDENS HET SLEPEN (§5). Onder de ondersteunde schaal gaat élke gesnapte -// werkdagverandering meteen naar `onCeilingChange` — de hook coalesceert die runs zelf (één in -// vlucht, de laatste stand wint), dus er is hier GEEN eigen timer nodig en er mag er ook geen -// bijkomen: een tweede throttle zou de laatste stand kunnen inslikken. Boven de schaal -// (`liveCommit === false`) commit alleen het loslaten, en beweegt tijdens het slepen dus alleen de -// handle en de plafondtekst. Een zuivere klik (pointerdown/-up zonder move) commit nooit iets: -// anders zou een klik op een onbegrensde handle 'm stiekem op een concreet getal zetten. +// HERREKENEN PAS BIJ LOSLATEN (eigenaarsbesluit 2026-09-14, vervangt het "live per gesnapte +// werkdag" uit spec §5). De vorige stand commit élke gesnapte werkdag tijdens het slepen; de hook +// coalesceerde die runs wel, maar elke binnenkomende run wisselde de bezig-toestand om en tekende +// pil, badges, histogram, validatiestrook en prijskaartje opnieuw. De eigenaar in de gebruikstest: +// "het flikkert enorm omdat het elke keer herberekent; het moet pas herberekenen en de nieuwe +// statusmelding tonen wanneer ik de muis loslaat." +// +// Dus: tijdens een pointer-sleep is ALLES lokaal. `dragValue` stuurt de greep, de gestippelde rest +// (`freeBox`) en de plafondtekst van DEZE rij; er gaat geen `onCeilingChange` uit, dus er is geen +// `computeDistribution`, geen store-mutatie en geen hertekening van de rest van de dialoog. Pas +// `pointerup` commit één keer — één run, dus de bezig-toestand gaat precies één keer aan en uit in +// plaats van te knipperen. `pointercancel` breekt af zonder te committen. +// +// HET TOETSENBORD BLIJFT WÉL EEN DISCREET REKENMOMENT (spec §3.4): één toets = één stand = één run. +// Daar is niets aan te flikkeren, en zonder run zou een pijltje niets lijken te doen. +// +// Een zuivere klik (pointerdown/-up zonder move) commit nooit iets: anders zou een klik op een +// onbegrensde handle 'm stiekem op een concreet getal zetten. import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AXIS, type OccupancyAxis } from '@/components/panels/occupancyAxis'; @@ -91,8 +102,6 @@ export interface PhaseStripProps { recorded: boolean; /** Alle betrokken taken staan vast (priority 1000) — het document KAN niet wijken. */ cannotMove: boolean; - /** Onder de ondersteunde schaal ⇒ élke gesnapte werkdag commit meteen (§5). */ - liveCommit: boolean; /** Er loopt een berekening: de uitkomstpil toont "Bezig…" ZONDER van maat te veranderen (§7). */ busy: boolean; /** @@ -111,7 +120,7 @@ export interface PhaseStripProps { export function PhaseStrip({ docId, title, axis, beforeLoadByDay, afterLoadByDay, fixedLoadByDay, isWorkingDay, color, - slackWorkdays, endShiftWorkdays, ceiling, pinned, recorded, cannotMove, liveCommit, busy, + slackWorkdays, endShiftWorkdays, ceiling, pinned, recorded, cannotMove, busy, shortfallCount, shortfallTitle, formatDay, onTogglePin, onCeilingChange, }: PhaseStripProps) { const { t } = useTranslation('common'); @@ -229,9 +238,9 @@ export function PhaseStrip({ const next = clamp(drag.startCeiling + Math.round((event.clientX - drag.startX) / dayWidth)); if (next === drag.value) return; drag.value = next; + // ALLEEN lokale state — géén `commit` hier. Zie de kop van dit bestand: het herrekenen hoort + // bij het loslaten, zodat de dialoog tijdens het slepen niet staat te knipperen. setDragValue(next); - // Onder de schaal is ELKE gesnapte werkdag een rekenmoment (§5). De hook coalesceert. - if (liveCommit) commit(next); }; const onHandlePointerUp = (event: React.PointerEvent) => { @@ -239,9 +248,12 @@ export function PhaseStrip({ if (!drag || drag.pointerId !== event.pointerId) return; dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); + // HET ENIGE REKENMOMENT VAN EEN SLEEP. `commit` vóór `setDragValue(null)`, en allebei binnen + // dezelfde eventhandler: React batcht ze, dus de volgende render heeft `dragValue === null` + // én het nieuwe `ceiling` al. Andersom zou de greep één frame terugspringen naar zijn oude + // stand voordat de nieuwe prop binnenkomt. Een zuivere klik (niet bewogen) commit niets. + if (drag.moved) commit(drag.value); setDragValue(null); - // Een zuivere klik commit niets; in de live-stand is de waarde al onderweg. - if (drag.moved && !liveCommit) commit(drag.value); }; const onHandlePointerCancel = (event: React.PointerEvent) => { @@ -533,6 +545,10 @@ export function PhaseStrip({ > 0 ? { 'data-ops-distribution-effect-shortfall': 'true', ...(pillTitle ? { title: pillTitle } : {}) } : {})} diff --git a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts index e07143499..63d6c375e 100644 --- a/src/components/dialogs/DistributionDialog/useDistributionProposal.ts +++ b/src/components/dialogs/DistributionDialog/useDistributionProposal.ts @@ -15,10 +15,14 @@ // DE BEZIG-TOESTAND IS ECHT, GEEN COSMETICA. `setBusy(true)` gebeurt synchroon, het rekenen pas in // een `setTimeout(…, 0)` daarna — anders blokkeert de synchrone solve de paint en ziet de gebruiker // nooit dat er iets gebeurt. Er loopt er precies één tegelijk; een verzoek dat tijdens een run -// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer). DÍT MECHANISME IS DE THROTTLE -// DIE SPEC §5 VRAAGT: onder de ondersteunde schaal commit de balk élke gesnapte werkdag tijdens het -// slepen, en deze in-flight-bewaking zorgt dat er maximaal één run loopt en de laatste stand wint. -// Er hoort hier GEEN tweede timer bij te komen — die zou de laatste stand kunnen inslikken. +// binnenkomt wordt daarna precies ÉÉN keer ingehaald (niet N keer). +// +// DEZE IN-FLIGHT-BEWAKING IS GEEN SLEEP-THROTTLE MEER (eigenaarsbesluit 2026-09-14). Tot die datum +// commit de balk élke gesnapte werkdag tijdens het slepen en vormde deze bewaking de throttle die +// spec §5 vroeg; sindsdien commit een sleep pas bij LOSLATEN, dus een sleep is nog maar één run. +// Wat de bewaking hier houdt is de rest: toetsherhaling op de greep (←/→ ingedrukt houden), de +// pin-knop, de gereedschapsschakelaar en de labelpas die achter een hoofdrun aan komt. Er hoort +// nog steeds GEEN timer bij te komen — die zou de laatste stand kunnen inslikken. // // SCHAAL-DEGRADATIE (§3.4). Boven `MAX_TASKS_AUTO` taken in één deelnemend document of // `MAX_BOOKING_TASKS_AUTO` boekende taken op dít poolitem is automatisch doorrekenen bij elke diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 42e179e9a..f90673c63 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -333,9 +333,9 @@ test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', await expect(page.locator('[data-ops-distribution-legend]')).toBeVisible(); }); -// --- B1c-plan4 taak 6 — pointer-slepen op de plafond-handle, nu met LIVE meerekenen --------------- +// --- Eigenaarsbesluit 2026-09-14 — slepen is stil, loslaten rekent ------------------------------ -test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ page, ops: _ops }) => { +test('plafond-handle: tijdens het slepen beweegt alleen de greep; bij loslaten wordt herberekend', async ({ page, ops: _ops }) => { await seedTwoSingleDayDocuments(page); await openDistributionFromConflictRow(page); await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); @@ -355,24 +355,34 @@ test('plafond-handle: tijdens het slepen rekent de dialoog live mee', async ({ p const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); expect(dayWidth).toBeGreaterThan(0); - const effectBefore = await strip.locator('[data-ops-distribution-effect]').textContent(); + const pill = strip.locator('[data-ops-distribution-effect-pill]'); + const status = page.locator('[data-ops-distribution-status]'); + const pillBefore = await pill.textContent(); + const statusBefore = await status.textContent(); const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); - // Spec §5, het eigenaarsbesluit dat §3.4 van de oude spec bijstelt: tijdens het slepen wordt er - // ECHT gerekend. Zowel de waarde als het uitkomstlabel moeten VÓÓR `mouse.up()` meebewegen. + // Eigenaarsbesluit 2026-09-14 (spec §5-noot): tijdens het slepen beweegt ALLEEN de greep. De + // gesnapte waarde loopt dus wél mee... await page.mouse.move(box.x + box.width / 2 + 3 * dayWidth, box.y + box.height / 2, { steps: 6 }); await expect(handle).toHaveAttribute('aria-valuenow', '3'); - await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) - .not.toBe(effectBefore); // De muis verlaat het element en het slepen loopt door — `setPointerCapture` op de handle, geen // document-brede listener nodig. await page.mouse.move(box.x + box.width / 2 + 5 * dayWidth, box.y - 200, { steps: 4 }); await expect(handle).toHaveAttribute('aria-valuenow', '5'); + + // ...maar de UITKOMST niet: geen herberekening, dus de pil en de validatiestrook staan er nog + // precies zoals vóór het gebaar. Dít is de flikkering die de eigenaar wegwilde. + expect(await pill.textContent()).toBe(pillBefore); + expect(await status.textContent()).toBe(statusBefore); + await page.mouse.up(); + // Pas het loslaten is het rekenmoment — en dan verandert de uitkomst wél. + await expect.poll(() => pill.textContent()).not.toBe(pillBefore); + const shiftedDocId = await strip.getAttribute('data-ops-doc-id') ?? ''; await expect.poll(() => page.evaluate(docId => { const ui = window.__OPS__!.store.getState().ui.levelingDistribution!; @@ -672,7 +682,8 @@ test('de dialoog flitst niet: dezelfde boundingBox over een herberekening en een await expect(page.locator('[data-ops-distribution-status]')).toBeVisible(); const after = (await dialog.boundingBox())!; - // (2) Een sleep, inclusief de live herberekening die eronder loopt. + // (2) Een sleep plus het loslaten — het loslaten is sinds 2026-09-14 het enige rekenmoment van + // een muisgebaar, dus `dragging` toetst de stille sleep en `released` de run die erop volgt. const strip = page.locator('[data-ops-distribution-strip]').nth(1); const handle = strip.locator('[data-ops-distribution-handle]'); const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); From 3ce4c8ef865d0cfe947ebdecfb1fd27db88d26e1 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:35:46 +0200 Subject: [PATCH 096/108] feat(examples): onderbrekingen toestaan maakt in de showcases zichtbaar verschil (Masonry crew) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KLEIN's ankerschuif gaat van 63 naar 66 kalenderdagen. Daardoor begint "Build masonry walls" op 2027-06-03 in plaats van 2027-05-31 en heeft MIDDEL's "Ground floor masonry — House 6" VIER vrije werkdagen vóór de blokkade in plaats van één. Waarom dat het verschil is: de schakelaar "Onderbrekingen toestaan" is in ResourceLeveler.findSlot geen tweede zoekstrategie maar een terugval — eerst de aaneengesloten scan, en pas als díé binnen het venster faalt komt scatterSlot met de pauzedagen. Het venster bestaat alleen met een plafond ("maximale uitloop"), en het bruikbare plafondbereik is precies zo breed als het aantal vrije werkdagen vóór de blokkade. Bij 63 was dat bereik één enkel getal (plafond 14); bij 66 is het 14 t/m 17, met het ronde "15 werkdagen" in het midden. Gemeten op de drie showcases samen, poolitem Masonry crew, plafond 15 werkdagen op De Akkers: onderbrekingen UIT ⇒ 1 tekort, geen uitloop, Toepassen geblokkeerd; onderbrekingen AAN ⇒ geen tekort, 6 werkdagen uitloop, één taak met leveling-gaten. Zonder plafond blijven beide standen gelijk (10/10) — dat is engine-gedrag, geen data-eigenschap. Beide kruisconflicten blijven staan; alleen de overlapstrook van de Masonry crew wordt korter (2 dagen i.p.v. 5). Co-Authored-By: Claude Fable 5.1 --- .../showcase-verbouwing-eengezinswoning.ifc | 384 +++++++++--------- .../showcase-verbouwing-eengezinswoning.ifc | 384 +++++++++--------- scripts/showcases.ts | 22 +- 3 files changed, 405 insertions(+), 385 deletions(-) diff --git a/examples/showcase-verbouwing-eengezinswoning.ifc b/examples/showcase-verbouwing-eengezinswoning.ifc index d143e9547..6a7dc9b97 100644 --- a/examples/showcase-verbouwing-eengezinswoning.ifc +++ b/examples/showcase-verbouwing-eengezinswoning.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [SchedulingView]'),'2;1'); -FILE_NAME('Refurbishment & Extension of a Family Home.ifc','2026-09-11T17:47:39',('Contractor'),('Van Dijk Construction BV'),'Open Planner Studio 0.1','Open Planner Studio',''); +FILE_NAME('Refurbishment & Extension of a Family Home.ifc','2026-09-14T19:32:53',('Contractor'),('Van Dijk Construction BV'),'Open Planner Studio 0.1','Open Planner Studio',''); FILE_SCHEMA(('IFC4X3')); ENDSEC; DATA; @@ -10,14 +10,14 @@ DATA; #3=IFCPERSONANDORGANIZATION(#1,#2,$); #4=IFCORGANIZATION($,'OpenAEC Foundation',$,$,$); #5=IFCAPPLICATION(#4,'0.1','Open Planner Studio','OPS'); -#6=IFCOWNERHISTORY(#3,#5,$,.NOCHANGE.,$,$,$,1789148859); +#6=IFCOWNERHISTORY(#3,#5,$,.NOCHANGE.,$,$,$,1789414373); #7=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); #8=IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); #9=IFCUNITASSIGNMENT((#7,#8)); #10=IFCCARTESIANPOINT((0.,0.,0.)); #11=IFCAXIS2PLACEMENT3D(#10,$,$); #12=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',3,1.0E-05,#11,$); -#13=IFCPROJECT('6nfJP9xFmDYfn7Alm1zfYn',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,$,(#12),#9); +#13=IFCPROJECT('c5CpzPr$8$ILMLAli1lzhJ',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,$,(#12),#9); #14=IFCTIMEPERIOD('07:00:00','16:00:00'); #15=IFCRECURRENCEPATTERN(.WEEKLY.,$,(1,2,3,4,5),$,$,$,$,(#14)); #16=IFCWORKTIME('Standard work week',.PREDICTED.,$,#15,$,$); @@ -43,225 +43,225 @@ DATA; #36=IFCPROPERTYSINGLEVALUE('HoursPerDay',$,IFCREAL(8),$); #37=IFCPROPERTYSET('qL5XmPUFgdlJ6X9$WtAtOZ',#6,'OPS_Calendar',$,(#36)); #38=IFCRELDEFINESBYPROPERTIES('bXh12hy$qvOdOP_VbbYntl',#6,$,$,(#35),#37); -#39=IFCWORKPLAN('EvEnLfedVdfxl981cTrFvf',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,'2026-09-11T17:47:39',$,$,$,$,$,'2027-05-03T07:00:00','2027-05-10T07:00:00',.PLANNED.); -#40=IFCWORKSCHEDULE('hd8nDjjFSrrnxHlVrjVpOL',#6,'Construction schedule v1.0',$,$,$,'2026-09-11T17:47:39',$,$,$,$,$,'2027-05-03T07:00:00','2027-05-10T07:00:00',.PLANNED.); -#41=IFCWORKSCHEDULE('DdwDR5tf4FNx5bynXFhVyB',#6,'Baseline plan',$,$,$,'2026-09-11T17:47:39.041Z',$,$,$,$,$,$,'2027-07-02T07:00:00',.BASELINE.); +#39=IFCWORKPLAN('U3iR4V_F4bTPOd2VXj_Hzv',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,'2026-09-14T19:32:53',$,$,$,$,$,'2027-05-06T07:00:00','2027-05-13T07:00:00',.PLANNED.); +#40=IFCWORKSCHEDULE('1n3tMRB$OP1THt8$nNIxR1',#6,'Construction schedule v1.0',$,$,$,'2026-09-14T19:32:53',$,$,$,$,$,'2027-05-06T07:00:00','2027-05-13T07:00:00',.PLANNED.); +#41=IFCWORKSCHEDULE('Gtib8LyHxHbVcTN1Z3mfQH',#6,'Baseline plan',$,$,$,'2026-09-14T19:32:53.070Z',$,$,$,$,$,$,'2027-07-07T07:00:00',.BASELINE.); #42=IFCRELAGGREGATES('X$Ph9TPtbVDd8te$ftI1sz',#6,$,$,#39,(#40,#41)); -#43=IFCTASKTIME('Start of refurbishment Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','P0Y0M0D','P0Y0M2D',.F.,$,$,$,$,$,0.00); -#44=IFCTASK('IfmPdbs$uNDP8FBlzpgTlr',#6,'Start of refurbishment',$,$,'1',$,$,$,.T.,$,#43,.CONSTRUCTION.); -#45=IFCTASKTIME('1. Preparation Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-03T07:00:00','2027-05-13T07:00:00','2027-05-07T07:00:00','2027-05-13T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#46=IFCTASK('k5FpLJ2F0BNZDlxnk3GNgt',#6,'1. Preparation',$,$,'2',$,$,$,.F.,$,#45,.LOGISTIC.); -#47=IFCTASKTIME('Set up site Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M2D','2027-05-03T07:00:00','2027-05-04T07:00:00','2027-05-03T07:00:00','2027-05-04T07:00:00','2027-05-07T07:00:00','2027-05-10T07:00:00','P0Y0M2D','P0Y0M2D',.F.,$,$,$,$,$,0.00); -#48=IFCTASK('Q$9xEXg$QJ4p8NyHdrqpOr',#6,'Set up site',$,$,'2.1',$,$,$,.F.,$,#47,.LOGISTIC.); -#49=IFCTASKTIME('Demolish existing extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-11T07:00:00','2027-05-13T07:00:00','2027-05-11T07:00:00','2027-05-13T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#50=IFCTASK('WXJ7VJ_FKN61avUnxlJvbR',#6,'Demolish existing extension',$,$,'2.2',$,$,$,.F.,$,#49,.DEMOLITION.); -#51=IFCTASKTIME('2. Foundations & structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-14T07:00:00','2027-06-07T07:00:00','2027-05-14T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#52=IFCTASK('kRzt_hiVpPPrWpzF4dunX3',#6,'2. Foundations & structure',$,$,'3',$,$,$,.F.,$,#51,.CONSTRUCTION.); -#53=IFCTASKTIME('Earthworks for extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#54=IFCTASK('R1lJnr7txx1ZlfCH6pDPBd',#6,'Earthworks for extension',$,$,'3.1',$,$,$,.F.,$,#53,.CONSTRUCTION.); -#55=IFCTASKTIME('Extension foundations Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-05-20T07:00:00','2027-05-25T07:00:00','2027-05-20T07:00:00','2027-05-25T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#56=IFCTASK('zFrnvv$dT1kLY981sxobT9',#6,'Extension foundations',$,$,'3.2',$,$,$,.F.,$,#55,.CONSTRUCTION.); -#57=IFCTASKTIME('Pour ground floor slab Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-26T07:00:00','2027-05-28T07:00:00','2027-05-26T07:00:00','2027-05-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#58=IFCTASK('SZeH5rV7u$2b1Bw$Kjk1sP',#6,'Pour ground floor slab',$,$,'3.3',$,$,$,.F.,$,#57,.CONSTRUCTION.); -#59=IFCTASKTIME('Build masonry walls Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M6D','2027-05-03T07:00:00','2027-05-10T07:00:00','2027-05-31T07:00:00','2027-06-07T07:00:00','2027-05-31T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#60=IFCTASK('Kx7r9XklhP4LMNWX71dxlB',#6,'Build masonry walls',$,$,'3.4',$,$,$,.F.,$,#59,.CONSTRUCTION.); -#61=IFCTASKTIME('Install roof structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-02T07:00:00','2027-06-07T07:00:00','2027-06-02T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#62=IFCTASK('dR8XaDc7KNMFjDpnhTpP1p',#6,'Install roof structure',$,$,'3.5',$,$,$,.F.,$,#61,.CONSTRUCTION.); -#63=IFCTASKTIME('3. Fit-out Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-06-08T07:00:00','2027-06-30T07:00:00','2027-06-08T07:00:00','2027-06-30T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#64=IFCTASK('7nVL3PpF2tkLUTbHA5Eb5L',#6,'3. Fit-out',$,$,'4',$,$,$,.F.,$,#63,.CONSTRUCTION.); -#65=IFCTASKTIME('Apply roofing Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-06-08T07:00:00','2027-06-10T07:00:00','2027-06-08T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#66=IFCTASK('UTO1sFDVZV29jjN1oDMTJz',#6,'Apply roofing',$,$,'4.1',$,$,$,.F.,$,#65,.CONSTRUCTION.); -#67=IFCTASKTIME('Install window frames Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-11T07:00:00','2027-06-16T07:00:00','2027-06-11T07:00:00','2027-06-16T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#68=IFCTASK('7BY$Arvtdd4L2J5FgLG9O3',#6,'Install window frames',$,$,'4.2',$,$,$,.F.,$,#67,.INSTALLATION.); -#69=IFCTASKTIME('Plastering Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-06-17T07:00:00','2027-06-23T07:00:00','2027-06-17T07:00:00','2027-06-23T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#70=IFCTASK('0nLHxLT12twjYt81BNwj_3',#6,'Plastering',$,$,'4.3',$,$,$,.F.,$,#69,.CONSTRUCTION.); -#71=IFCTASKTIME('Tiling Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-24T07:00:00','2027-06-29T07:00:00','2027-06-24T07:00:00','2027-06-29T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#72=IFCTASK('8pdzctqnWbjfzPEVqHpdj3',#6,'Tiling',$,$,'4.4',$,$,$,.F.,$,#71,.INSTALLATION.); -#73=IFCTASKTIME('Painting Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-06-28T07:00:00','2027-06-30T07:00:00','2027-06-28T07:00:00','2027-06-30T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#74=IFCTASK('vHZfy3SlJf5VohYn$$BFZb',#6,'Painting',$,$,'4.5',$,$,$,.F.,$,#73,.CONSTRUCTION.); -#75=IFCTASKTIME('4. Handover Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-07-01T07:00:00','2027-07-02T07:00:00','2027-07-01T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#76=IFCTASK('ZXh1gbpFwZQDe$N$jRtBml',#6,'4. Handover',$,$,'5',$,$,$,.F.,$,#75,.ATTENDANCE.); -#77=IFCTASKTIME('Final cleaning Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#78=IFCTASK('OxahfJL12zGX_PIVoDszF$',#6,'Final cleaning',$,$,'5.1',$,$,$,.F.,$,#77,.LOGISTIC.); -#79=IFCTASKTIME('Handover inspection Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#80=IFCTASK('qr$B9VM1MvD935yHhvS3TN',#6,'Handover inspection','Mandatory handover inspection; the deadline has ample margin (no conflict).',$,'5.2',$,$,$,.T.,$,#79,.CONSTRUCTION.); -#81=IFCTASKTIME('Final handover with the occupant Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#82=IFCTASK('mdsTGfW9f5$TnvIF47ijYJ',#6,'Final handover with the occupant',$,$,'5.3',$,$,$,.F.,$,#81,.ATTENDANCE.); -#83=IFCRELNESTS('PVbHrZ0t79AZRhEFopk5zL',#6,'WBS 1. Preparation',$,#46,( #48,#50)); -#84=IFCRELNESTS('P1Vz2La1wzsH9hsH4dOlv5',#6,'WBS 2. Foundations & structure',$,#52,( #54,#56,#58,#60,#62)); -#85=IFCRELNESTS('0HVLrZOvedvLELUnzpcDa5',#6,'WBS 3. Fit-out',$,#64,( #66,#68,#70,#72,#74)); -#86=IFCRELNESTS('azSh17I97T4DyTTn6f2B85',#6,'WBS 4. Handover',$,#76,( #78,#80,#82)); +#43=IFCTASKTIME('Start of refurbishment Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','2027-05-12T07:00:00','2027-05-12T07:00:00','P0Y0M0D','P0Y0M3D',.F.,$,$,$,$,$,0.00); +#44=IFCTASK('ANsZeTd9hvePK$rlb1fhkv',#6,'Start of refurbishment',$,$,'1',$,$,$,.T.,$,#43,.CONSTRUCTION.); +#45=IFCTASKTIME('1. Preparation Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-05-07T07:00:00','2027-05-19T07:00:00','2027-05-12T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#46=IFCTASK('rjTNSledrtcp4f0X93tzHH',#6,'1. Preparation',$,$,'2',$,$,$,.F.,$,#45,.LOGISTIC.); +#47=IFCTASKTIME('Set up site Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M2D','2027-05-06T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','2027-05-10T07:00:00','2027-05-12T07:00:00','2027-05-13T07:00:00','P0Y0M3D','P0Y0M3D',.F.,$,$,$,$,$,0.00); +#48=IFCTASK('g7CpTNNN8$ATZfWXXDgTXz',#6,'Set up site',$,$,'2.1',$,$,$,.F.,$,#47,.LOGISTIC.); +#49=IFCTASKTIME('Demolish existing extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#50=IFCTASK('V3KRqPzHhdCTJ1znKJI1YH',#6,'Demolish existing extension',$,$,'2.2',$,$,$,.F.,$,#49,.DEMOLITION.); +#51=IFCTASKTIME('2. Foundations & structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-05-20T07:00:00','2027-06-10T07:00:00','2027-05-20T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#52=IFCTASK('W$NRqdTl9VDdeXRn4L1x7f',#6,'2. Foundations & structure',$,$,'3',$,$,$,.F.,$,#51,.CONSTRUCTION.); +#53=IFCTASKTIME('Earthworks for extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-20T07:00:00','2027-05-24T07:00:00','2027-05-20T07:00:00','2027-05-24T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#54=IFCTASK('5LIxAllPJLHrqnP$atEV0N',#6,'Earthworks for extension',$,$,'3.1',$,$,$,.F.,$,#53,.CONSTRUCTION.); +#55=IFCTASKTIME('Extension foundations Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-05-25T07:00:00','2027-05-28T07:00:00','2027-05-25T07:00:00','2027-05-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#56=IFCTASK('0n5XujxV9Lb$ads1rJFTzB',#6,'Extension foundations',$,$,'3.2',$,$,$,.F.,$,#55,.CONSTRUCTION.); +#57=IFCTASKTIME('Pour ground floor slab Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-31T07:00:00','2027-06-02T07:00:00','2027-05-31T07:00:00','2027-06-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#58=IFCTASK('MPelBzqVjLtzt9qFuTLlJV',#6,'Pour ground floor slab',$,$,'3.3',$,$,$,.F.,$,#57,.CONSTRUCTION.); +#59=IFCTASKTIME('Build masonry walls Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M6D','2027-05-06T07:00:00','2027-05-13T07:00:00','2027-06-03T07:00:00','2027-06-10T07:00:00','2027-06-03T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#60=IFCTASK('odkxIpfNEnlJoLYFfpxHvx',#6,'Build masonry walls',$,$,'3.4',$,$,$,.F.,$,#59,.CONSTRUCTION.); +#61=IFCTASKTIME('Install roof structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-07T07:00:00','2027-06-10T07:00:00','2027-06-07T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#62=IFCTASK('sDXhNjZHhDEvoLUn9ZBF9F',#6,'Install roof structure',$,$,'3.5',$,$,$,.F.,$,#61,.CONSTRUCTION.); +#63=IFCTASKTIME('3. Fit-out Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-06-11T07:00:00','2027-07-05T07:00:00','2027-06-11T07:00:00','2027-07-05T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#64=IFCTASK('wHb$oZfNq7WHf5ClEHvbV7',#6,'3. Fit-out',$,$,'4',$,$,$,.F.,$,#63,.CONSTRUCTION.); +#65=IFCTASKTIME('Apply roofing Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-06-11T07:00:00','2027-06-15T07:00:00','2027-06-11T07:00:00','2027-06-15T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#66=IFCTASK('q9gPvLqF8fTtXbKHOJ$Jot',#6,'Apply roofing',$,$,'4.1',$,$,$,.F.,$,#65,.CONSTRUCTION.); +#67=IFCTASKTIME('Install window frames Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-16T07:00:00','2027-06-21T07:00:00','2027-06-16T07:00:00','2027-06-21T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#68=IFCTASK('SZelfJTHbLBnJDbHUdSz$9',#6,'Install window frames',$,$,'4.2',$,$,$,.F.,$,#67,.INSTALLATION.); +#69=IFCTASKTIME('Plastering Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-06-22T07:00:00','2027-06-28T07:00:00','2027-06-22T07:00:00','2027-06-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#70=IFCTASK('3l9D3PRXhD61e72npdKjcl',#6,'Plastering',$,$,'4.3',$,$,$,.F.,$,#69,.CONSTRUCTION.); +#71=IFCTASKTIME('Tiling Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-29T07:00:00','2027-07-02T07:00:00','2027-06-29T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#72=IFCTASK('dX5nPthldvO91rEXCZyhsZ',#6,'Tiling',$,$,'4.4',$,$,$,.F.,$,#71,.INSTALLATION.); +#73=IFCTASKTIME('Painting Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-07-01T07:00:00','2027-07-05T07:00:00','2027-07-01T07:00:00','2027-07-05T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#74=IFCTASK('MZL$E3DVtXzNfRu$VFTfEb',#6,'Painting',$,$,'4.5',$,$,$,.F.,$,#73,.CONSTRUCTION.); +#75=IFCTASKTIME('4. Handover Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-07-06T07:00:00','2027-07-07T07:00:00','2027-07-06T07:00:00','2027-07-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#76=IFCTASK('uJLl$vQ7OlGXwjt$uBaz5r',#6,'4. Handover',$,$,'5',$,$,$,.F.,$,#75,.ATTENDANCE.); +#77=IFCTASKTIME('Final cleaning Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#78=IFCTASK('cRGddx_fI1NZVHt1ql5V4B',#6,'Final cleaning',$,$,'5.1',$,$,$,.F.,$,#77,.LOGISTIC.); +#79=IFCTASKTIME('Handover inspection Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#80=IFCTASK('wLcJxFxn4n9bV5GVWfoRZN',#6,'Handover inspection','Mandatory handover inspection; the deadline has ample margin (no conflict).',$,'5.2',$,$,$,.T.,$,#79,.CONSTRUCTION.); +#81=IFCTASKTIME('Final handover with the occupant Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#82=IFCTASK('dVTNeZHPNRTdO$VVORUnJz',#6,'Final handover with the occupant',$,$,'5.3',$,$,$,.F.,$,#81,.ATTENDANCE.); +#83=IFCRELNESTS('4HxXC7XPfxtbR$9$1Zqpm1',#6,'WBS 1. Preparation',$,#46,( #48,#50)); +#84=IFCRELNESTS('d5S3sBlP1fJ7Q3jHR1fhUl',#6,'WBS 2. Foundations & structure',$,#52,( #54,#56,#58,#60,#62)); +#85=IFCRELNESTS('$J6phjs$OHedmbO1epbFBP',#6,'WBS 3. Fit-out',$,#64,( #66,#68,#70,#72,#74)); +#86=IFCRELNESTS('$n3tUfndx5np03TlaR7b9j',#6,'WBS 4. Handover',$,#76,( #78,#80,#82)); #87=IFCRELNESTS('tjjP5r1vgdnD4TBHzDgTrv',#6,'WBS Hoofd',$,#40,(#44,#46,#52,#64,#76)); -#88=IFCRELSEQUENCE('TTO1URcXhPiRb1f$XnZNI7',#6,$,$,#44,#48,$,.FINISH_START.,$); -#89=IFCRELSEQUENCE('urU1wd_FGJsZf5aHxryh2T',#6,$,$,#48,#50,$,.FINISH_START.,$); -#90=IFCRELSEQUENCE('WVZf4VwXjNBFt9CHcvS3dh',#6,$,$,#50,#54,$,.FINISH_START.,$); -#91=IFCRELSEQUENCE('qNk5ozz1uX_9r7MlMHBnvN',#6,$,$,#54,#56,$,.FINISH_START.,$); -#92=IFCRELSEQUENCE('bXDdDny1mDIPuFpFBpJvTN',#6,$,$,#56,#58,$,.FINISH_START.,$); -#93=IFCRELSEQUENCE('sNYLxL31yntbTLMHYrjvnV',#6,$,$,#58,#60,$,.FINISH_START.,$); +#88=IFCRELSEQUENCE('Ed_BNFCn8V2tr7slqHBF5F',#6,$,$,#44,#48,$,.FINISH_START.,$); +#89=IFCRELSEQUENCE('6h_rjZNNylhlNRWVHLnhIL',#6,$,$,#48,#50,$,.FINISH_START.,$); +#90=IFCRELSEQUENCE('4nXhfjpFEbrnnJpH3nz9S9',#6,$,$,#50,#54,$,.FINISH_START.,$); +#91=IFCRELSEQUENCE('HRDdLpQveHG$qnnVHv8F$J',#6,$,$,#54,#56,$,.FINISH_START.,$); +#92=IFCRELSEQUENCE('yjobe3DXRBXbBvcHxlhlll',#6,$,$,#56,#58,$,.FINISH_START.,$); +#93=IFCRELSEQUENCE('mt4ZSJM$AfR$e$1XMlzNI7',#6,$,$,#58,#60,$,.FINISH_START.,$); #94=IFCLAGTIME('Lag',.PREDICTED.,$,IFCDURATION('P2D'),.WORKTIME.); -#95=IFCRELSEQUENCE('0FLHrZmNOnajcZd$07OVK$',#6,$,$,#60,#62,#94,.START_START.,$); -#96=IFCRELSEQUENCE('yHp7ZbA10LZt99KHmlP5xn',#6,$,$,#62,#66,$,.FINISH_START.,$); -#97=IFCRELSEQUENCE('tNEbOb_ned7h_PEVqlJvlH',#6,$,$,#66,#68,$,.FINISH_START.,$); -#98=IFCRELSEQUENCE('Px4rlRr$aV3NPTlV7V5HXd',#6,$,$,#68,#70,$,.FINISH_START.,$); -#99=IFCRELSEQUENCE('oPM3cN87Y7m1KR4lLd81IL',#6,$,$,#70,#72,$,.FINISH_START.,$); -#100=IFCRELSEQUENCE('Rx4Tupnv_9ATnPw1KjMNUn',#6,$,$,#70,#74,$,.FINISH_START.,$); +#95=IFCRELSEQUENCE('XVTf4z4HNxV3b1N1PLun3L',#6,$,$,#60,#62,#94,.START_START.,$); +#96=IFCRELSEQUENCE('W$9xE1Yf4bhlBjplZ15V0f',#6,$,$,#62,#66,$,.FINISH_START.,$); +#97=IFCRELSEQUENCE('B9gdb5FvIXLHtB2VDPqDCZ',#6,$,$,#66,#68,$,.FINISH_START.,$); +#98=IFCRELSEQUENCE('zlvT4ZGNobD9fPw1qlrFhN',#6,$,$,#68,#70,$,.FINISH_START.,$); +#99=IFCRELSEQUENCE('Z$Xzw7MPzHGXg9iFEHVDqF',#6,$,$,#70,#72,$,.FINISH_START.,$); +#100=IFCRELSEQUENCE('bxaz2dB$81sZxbiFgLGfkP',#6,$,$,#70,#74,$,.FINISH_START.,$); #101=IFCLAGTIME('Lag',.PREDICTED.,$,IFCDURATION('P1D'),.WORKTIME.); -#102=IFCRELSEQUENCE('cxSLND79pPFDO$P1qzeHbx',#6,$,$,#72,#74,#101,.FINISH_FINISH.,$); -#103=IFCRELSEQUENCE('stAvPtjXh7m$OP_V97SrOz',#6,$,$,#74,#78,$,.FINISH_START.,$); -#104=IFCRELSEQUENCE('kRh1srnPPP4peXjHldKJot',#6,$,$,#78,#80,$,.FINISH_START.,$); -#105=IFCRELSEQUENCE('0nb163qFyFXzfdIVkTCDax',#6,$,$,#80,#82,$,.FINISH_START.,$); -#106=IFCCREWRESOURCE('oB1rtXA$CdyhclzHoHnZJ3',#6,'Masonry crew','The same masonry crew as on the terraced houses — it comes over for the extension walls',$,$,$,$,.USERDEFINED.); -#107=IFCLABORRESOURCE('U34B5lHdfxtbxlf1kZS3hJ',#6,'Bricklayers',$,$,$,$,$,.USERDEFINED.); -#108=IFCLABORRESOURCE('T3CpDn4lVP$ZhZzFqZqR37',#6,'Carpenters',$,$,$,$,$,.USERDEFINED.); -#109=IFCLABORRESOURCE('nLpvDDF7ExPBOV5FUdqRR5',#6,'Plasterers',$,$,$,$,$,.USERDEFINED.); -#110=IFCLABORRESOURCE('6rs7xllP3361aPA1tPGtKJ',#6,'Tilers',$,$,$,$,$,.USERDEFINED.); -#111=IFCLABORRESOURCE('yp2X0FLl9XR1SPs$zFRndv',#6,'Painters',$,$,$,$,$,.USERDEFINED.); +#102=IFCRELSEQUENCE('eTh$67c7KDet5nRlsx2Byh',#6,$,$,#72,#74,#101,.FINISH_FINISH.,$); +#103=IFCRELSEQUENCE('XX39UfNNOdHJs7slS5DPzB',#6,$,$,#74,#78,$,.FINISH_START.,$); +#104=IFCRELSEQUENCE('eZbl3PTHjtFLkF5lqHR1ER',#6,$,$,#78,#80,$,.FINISH_START.,$); +#105=IFCRELSEQUENCE('RRO1_7F7kVrn3xCnKjAt0N',#6,$,$,#80,#82,$,.FINISH_START.,$); +#106=IFCCREWRESOURCE('kf0tY9z1KD8v_7oFglzfw1',#6,'Masonry crew','The same masonry crew as on the terraced houses — it comes over for the extension walls',$,$,$,$,.USERDEFINED.); +#107=IFCLABORRESOURCE('uLIxgXDngd9RtN0Xj5hVOT',#6,'Bricklayers',$,$,$,$,$,.USERDEFINED.); +#108=IFCLABORRESOURCE('KvqD9zjXRXDdm5inQ3lB09',#6,'Carpenters',$,$,$,$,$,.USERDEFINED.); +#109=IFCLABORRESOURCE('ltCDvhCnK9gzLte$8Lu$sv',#6,'Plasterers',$,$,$,$,$,.USERDEFINED.); +#110=IFCLABORRESOURCE('zDcfW$Hvi9oB8l1XiDsz3P',#6,'Tilers',$,$,$,$,$,.USERDEFINED.); +#111=IFCLABORRESOURCE('hZ5FhNGfElx1mLglsRUFpJ',#6,'Painters',$,$,$,$,$,.USERDEFINED.); #112=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#1E293B'),$); -#113=IFCPROPERTYSET('6hwpJ1Q7S$NTbrM$1VVbrZ',#6,'OPS_Resource',$,(#112)); -#114=IFCRELDEFINESBYPROPERTIES('jN8VeZ$7olzvs7wHBDhVax',#6,$,$,(#106),#113); +#113=IFCPROPERTYSET('2DEFdNfNCzId4TRH_9Ataf',#6,'OPS_Resource',$,(#112)); +#114=IFCRELDEFINESBYPROPERTIES('fvq5o1cv0hBFThYFlPqpSf',#6,$,$,(#106),#113); #115=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #116=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(46),$); #117=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#B91C1C'),$); -#118=IFCPROPERTYSINGLEVALUE('ParentGuid',$,IFCTEXT('oB1rtXA$CdyhclzHoHnZJ3'),$); -#119=IFCPROPERTYSET('ApF5_BW95Ffxl9WXdH5XO3',#6,'OPS_Resource',$,(#115,#116,#117,#118)); -#120=IFCRELDEFINESBYPROPERTIES('Z$$Texw7gRjNhZpFbbwPtn',#6,$,$,(#107),#119); +#118=IFCPROPERTYSINGLEVALUE('ParentGuid',$,IFCTEXT('kf0tY9z1KD8v_7oFglzfw1'),$); +#119=IFCPROPERTYSET('SVxnlRL12tM3VRCFuBKjUp',#6,'OPS_Resource',$,(#115,#116,#117,#118)); +#120=IFCRELDEFINESBYPROPERTIES('3JAZyZ0tpP7LQlpHL3VRfT',#6,$,$,(#107),#119); #121=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #122=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(45),$); #123=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#DB2777'),$); -#124=IFCPROPERTYSET('9DvZKtrHXvp9Jj71dhGfEf',#6,'OPS_Resource',$,(#121,#122,#123)); -#125=IFCRELDEFINESBYPROPERTIES('YVJvjZ$vMv5$CrkVITCpGX',#6,$,$,(#108),#124); +#124=IFCPROPERTYSET('0HvZupF7QJ4pGR0VdhmNI7',#6,'OPS_Resource',$,(#121,#122,#123)); +#125=IFCRELDEFINESBYPROPERTIES('Pbel$PUn6Z6j6DrlDrpPzr',#6,$,$,(#108),#124); #126=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #127=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(42),$); #128=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#C2410C'),$); -#129=IFCPROPERTYSET('5hwD9Z7tZVMFVxWX3FLVK1',#6,'OPS_Resource',$,(#126,#127,#128)); -#130=IFCRELDEFINESBYPROPERTIES('i74ZqR3lpbhHNxylJR9BO3',#6,$,$,(#109),#129); +#129=IFCPROPERTYSET('3pU$c9iF_TypaPs1BjP5tF',#6,'OPS_Resource',$,(#126,#127,#128)); +#130=IFCRELDEFINESBYPROPERTIES('gPmdVf87Q1MzVxu1ghGfsT',#6,$,$,(#109),#129); #131=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #132=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(43),$); #133=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#6366F1'),$); -#134=IFCPROPERTYSET('Q1txg$tNCPqTZtCHw9oV0f',#6,'OPS_Resource',$,(#131,#132,#133)); -#135=IFCRELDEFINESBYPROPERTIES('$DY1sH172tAZbLIniXbFLN',#6,$,$,(#110),#134); +#134=IFCPROPERTYSET('f3vrjzpX9VZtfdM1FtAtmn',#6,'OPS_Resource',$,(#131,#132,#133)); +#135=IFCRELDEFINESBYPROPERTIES('2nLHR5dt19PJ2JZlyBZ9u5',#6,$,$,(#110),#134); #136=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #137=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(38),$); #138=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#0D9488'),$); -#139=IFCPROPERTYSET('e3lJbXpFAhFhc3Lnb5cDGV',#6,'OPS_Resource',$,(#136,#137,#138)); -#140=IFCRELDEFINESBYPROPERTIES('1Hnxg$fNUvWlVFZnsVRFh9',#6,$,$,(#111),#139); -#141=IFCRELNESTS('i7ahvru7wHm1yzd$HBR$oR',#6,'Ploeg Masonry crew',$,#106,(#107)); -#142=IFCRELASSIGNSTOPROCESS('aT8VKfHPrfMFt9qF0707tD',#6,$,$,(#106,#107),$,#60,$); -#143=IFCRELASSIGNSTOPROCESS('9p9ppH8dXRx$yt4Fl7WdbV',#6,$,$,(#108),$,#62,$); -#144=IFCRELASSIGNSTOPROCESS('EBlRC7vtLjIhEh_nvVr1of',#6,$,$,(#108),$,#66,$); -#145=IFCRELASSIGNSTOPROCESS('fZTfujB1GTqZP3ZHebcvf3',#6,$,$,(#108),$,#68,$); -#146=IFCRELASSIGNSTOPROCESS('GdM3gX5VTpWHbhwHZ$xVer',#6,$,$,(#109),$,#70,$); -#147=IFCRELASSIGNSTOPROCESS('8rcfmvYNuXo7wXN$gl5XW7',#6,$,$,(#110),$,#72,$); -#148=IFCRELASSIGNSTOPROCESS('9tkHxB7tJNZtxFblc3NTHh',#6,$,$,(#111),$,#74,$); -#149=IFCPROPERTYSINGLEVALUE('oB1rtXA$CdyhclzHoHnZJ3#0',$,IFCTEXT('1|UNIFORM'),$); -#150=IFCPROPERTYSINGLEVALUE('U34B5lHdfxtbxlf1kZS3hJ#1',$,IFCTEXT('2|UNIFORM'),$); -#151=IFCPROPERTYSET('jNWNK9LHrzede$XVip6jEx',#6,'OPS_Assignments',$,(#149,#150)); -#152=IFCRELDEFINESBYPROPERTIES('KRJ7L9KFAtXR3bmXWt2lPB',#6,$,$,(#60),#151); -#153=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#154=IFCPROPERTYSET('0HXLDFqVZJK3htu$5lLHFX',#6,'OPS_Assignments',$,(#153)); -#155=IFCRELDEFINESBYPROPERTIES('dxKbW1$PDdvL2JhnXp6jA3',#6,$,$,(#62),#154); -#156=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#157=IFCPROPERTYSET('5FNJBvEtXx1TTfO$p7unRV',#6,'OPS_Assignments',$,(#156)); -#158=IFCRELDEFINESBYPROPERTIES('Uza3Ar1vkXZt7$5lKJMN_T',#6,$,$,(#66),#157); -#159=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#160=IFCPROPERTYSET('W$lJ$T09fn1ZzvknOxERLp',#6,'OPS_Assignments',$,(#159)); -#161=IFCRELDEFINESBYPROPERTIES('7hEbOZdtXddrK5e1ClT9y7',#6,$,$,(#68),#160); -#162=IFCPROPERTYSINGLEVALUE('nLpvDDF7ExPBOV5FUdqRR5#0',$,IFCTEXT('2|UNIFORM'),$); -#163=IFCPROPERTYSET('P11TK9hHV57hQ3Zl07OlT9',#6,'OPS_Assignments',$,(#162)); -#164=IFCRELDEFINESBYPROPERTIES('0lVLnnz$mjAZNbe$07WPZb',#6,$,$,(#70),#163); -#165=IFCPROPERTYSINGLEVALUE('6rs7xllP3361aPA1tPGtKJ#0',$,IFCTEXT('2|UNIFORM'),$); -#166=IFCPROPERTYSET('lRbl1vEtLJ2R3xaHtvCRJX',#6,'OPS_Assignments',$,(#165)); -#167=IFCRELDEFINESBYPROPERTIES('8JR1UN$voFnpufKHeDJv75',#6,$,$,(#72),#166); -#168=IFCPROPERTYSINGLEVALUE('yp2X0FLl9XR1SPs$zFRndv#0',$,IFCTEXT('2|UNIFORM'),$); -#169=IFCPROPERTYSET('IRJ71T8vE5lJMfe14rLHJ$',#6,'OPS_Assignments',$,(#168)); -#170=IFCRELDEFINESBYPROPERTIES('vnlbe5k93zYNLZzFqHjvdB',#6,$,$,(#74),#169); +#139=IFCPROPERTYSET('N74BDJxXjh15PTlXjtwdTb',#6,'OPS_Resource',$,(#136,#137,#138)); +#140=IFCRELDEFINESBYPROPERTIES('mPYLhD2fA9fZNx4lF9stGn',#6,$,$,(#111),#139); +#141=IFCRELNESTS('ORO1QNePlXIPm5qlQ3FLcH',#6,'Ploeg Masonry crew',$,#106,(#107)); +#142=IFCRELASSIGNSTOPROCESS('U1NRyZ0tF3610TFV3pMTzh',#6,$,$,(#106,#107),$,#60,$); +#143=IFCRELASSIGNSTOPROCESS('6rU$gbzljB5XczRF4dOH$t',#6,$,$,(#108),$,#62,$); +#144=IFCRELASSIGNSTOPROCESS('aVh$2BmfIXDd8F3FG1BFN$',#6,$,$,(#108),$,#66,$); +#145=IFCRELASSIGNSTOPROCESS('CrP3oz5nOlm1KXVVYPS3Xv',#6,$,$,(#108),$,#68,$); +#146=IFCRELASSIGNSTOPROCESS('pPI5kpl7m3uN1XxFI$b1UJ',#6,$,$,(#109),$,#70,$); +#147=IFCRELASSIGNSTOPROCESS('fdGdNjpnMxnpmR0VxlBFtF',#6,$,$,(#110),$,#72,$); +#148=IFCRELASSIGNSTOPROCESS('c5KbuDvtvtwJkbyF7RwPJz',#6,$,$,(#111),$,#74,$); +#149=IFCPROPERTYSINGLEVALUE('kf0tY9z1KD8v_7oFglzfw1#0',$,IFCTEXT('1|UNIFORM'),$); +#150=IFCPROPERTYSINGLEVALUE('uLIxgXDngd9RtN0Xj5hVOT#1',$,IFCTEXT('2|UNIFORM'),$); +#151=IFCPROPERTYSET('P5CDpniXNlLHh391afAz9j',#6,'OPS_Assignments',$,(#149,#150)); +#152=IFCRELDEFINESBYPROPERTIES('o7Y$gx1dbhTt5HHXT$R1cr',#6,$,$,(#60),#151); +#153=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#154=IFCPROPERTYSET('Fd6D3PJF0hrn99anRxYhQh',#6,'OPS_Assignments',$,(#153)); +#155=IFCRELDEFINESBYPROPERTIES('sj5X0H9NUvOt7$rlDhXRfT',#6,$,$,(#62),#154); +#156=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#157=IFCPROPERTYSET('RbWNWXr1KjCZzdM$lvOHbR',#6,'OPS_Assignments',$,(#156)); +#158=IFCRELDEFINESBYPROPERTIES('qfQjbVZH3HuvAb812twPht',#6,$,$,(#66),#157); +#159=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#160=IFCPROPERTYSET('3lP32r0tbhzfJVf1YPaBer',#6,'OPS_Assignments',$,(#159)); +#161=IFCRELDEFINESBYPROPERTIES('S3ah3dDFEnXzTBoXqnnLsT',#6,$,$,(#68),#160); +#162=IFCPROPERTYSINGLEVALUE('ltCDvhCnK9gzLte$8Lu$sv#0',$,IFCTEXT('2|UNIFORM'),$); +#163=IFCPROPERTYSET('gz$ZSJQXdDUN1VLFVj2lB3',#6,'OPS_Assignments',$,(#162)); +#164=IFCRELDEFINESBYPROPERTIES('3Fjdl$tN0Bt399anRxoRbR',#6,$,$,(#70),#163); +#165=IFCPROPERTYSINGLEVALUE('zDcfW$Hvi9oB8l1XiDsz3P#0',$,IFCTEXT('2|UNIFORM'),$); +#166=IFCPROPERTYSET('EhR$o3BlVvjf$VJHHvOl1t',#6,'OPS_Assignments',$,(#165)); +#167=IFCRELDEFINESBYPROPERTIES('d1HjpJjFq78fZNu1kX1xBv',#6,$,$,(#72),#166); +#168=IFCPROPERTYSINGLEVALUE('hZ5FhNGfElx1mLglsRUFpJ#0',$,IFCTEXT('2|UNIFORM'),$); +#169=IFCPROPERTYSET('l9KJxF5nijSr8FhHXpgZJT',#6,'OPS_Assignments',$,(#168)); +#170=IFCRELDEFINESBYPROPERTIES('M3X32PxXh787wDjFJxZNAT',#6,$,$,(#74),#169); #171=IFCRELASSIGNSTOCONTROL('rDVhNjbXHjyDCF$VkTSruD',#6,$,$,(#44,#46,#48,#50,#52,#54,#56,#58,#60,#62,#64,#66,#68,#70,#72,#74,#76,#78,#80,#82),$,#40); -#172=IFCPROPERTYSINGLEVALUE('InternalProjectId',$,IFCTEXT('proj-mtx91aukh0t026'),$); +#172=IFCPROPERTYSINGLEVALUE('InternalProjectId',$,IFCTEXT('proj-mu1n46sx0r6n26'),$); #173=IFCPROPERTYSINGLEVALUE('wbsAutoNumber',$,IFCBOOLEAN(.T.),$); #174=IFCPROPERTYSINGLEVALUE('DefaultTaskDurationUnit',$,IFCLABEL('days'),$); -#175=IFCPROPERTYSINGLEVALUE('ProjectStartDate',$,IFCDATE('2027-05-03'),$); +#175=IFCPROPERTYSINGLEVALUE('ProjectStartDate',$,IFCDATE('2027-05-06'),$); #176=IFCPROPERTYSINGLEVALUE('ProjectEndDate',$,$,$); -#177=IFCPROPERTYSINGLEVALUE('CreatedAt',$,IFCTEXT('2026-09-11T17:47:38.924Z'),$); -#178=IFCPROPERTYSINGLEVALUE('ModifiedAt',$,IFCTEXT('2026-09-11T17:47:38.927Z'),$); +#177=IFCPROPERTYSINGLEVALUE('CreatedAt',$,IFCTEXT('2026-09-14T19:32:52.977Z'),$); +#178=IFCPROPERTYSINGLEVALUE('ModifiedAt',$,IFCTEXT('2026-09-14T19:32:52.980Z'),$); #179=IFCPROPERTYSET('e5a3ArlP$tNZvpDF1ZK5n5',#6,'OPS_ProjectSettings',$,(#172,#173,#174,#175,#176,#177,#178)); #180=IFCRELDEFINESBYPROPERTIES('0Hdzo1sPJhdjQlBF$1BF9l',#6,$,$,(#13),#179); -#181=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auwh6xz33'),$); -#182=IFCPROPERTYSET('rtQN0nLlbBPJclplPn7Dyr',#6,'OPS_TaskIdentity',$,(#181)); -#183=IFCRELDEFINESBYPROPERTIES('yDU$Q9KXB3uNvpjnH70vF$',#6,$,$,(#44),#182); -#184=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auy41vb34'),$); -#185=IFCPROPERTYSET('HNO$cNedxnf51VzHwfER$N',#6,'OPS_TaskIdentity',$,(#184)); -#186=IFCRELDEFINESBYPROPERTIES('O5yZCPpFgvD935iF6j$JEN',#6,$,$,(#46),#185); -#187=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auzt9uo35'),$); -#188=IFCPROPERTYSET('tJjd3PB$y59Rb11V_RXDOB',#6,'OPS_TaskIdentity',$,(#187)); -#189=IFCRELDEFINESBYPROPERTIES('G9SLj$gdLjsZFv2nn5DPZb',#6,$,$,(#48),#188); -#190=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av113u536'),$); -#191=IFCPROPERTYSET('zlFxwPkHHVQXwVlVxhmfMj',#6,'OPS_TaskIdentity',$,(#190)); -#192=IFCRELDEFINESBYPROPERTIES('sJTf0Hd91tvp0TP$8LWPzL',#6,$,$,(#50),#191); -#193=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av2czof37'),$); -#194=IFCPROPERTYSET('HtyrJZfNqZcpWJFVVFxHjX',#6,'OPS_TaskIdentity',$,(#193)); -#195=IFCRELDEFINESBYPROPERTIES('ebeHdx_NcJSriXlVlBLlJ$',#6,$,$,(#52),#194); -#196=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av4t47y38'),$); -#197=IFCPROPERTYSET('uDdzkD7tP7O95lN$6NMZ$j',#6,'OPS_TaskIdentity',$,(#196)); -#198=IFCRELDEFINESBYPROPERTIES('FLjd37lv0hLHVlf$wDZfcr',#6,$,$,(#54),#197); -#199=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av5fq8k39'),$); -#200=IFCPROPERTYSET('QVL1Yxw72TaJQT1X_RPxzr',#6,'OPS_TaskIdentity',$,(#199)); -#201=IFCRELDEFINESBYPROPERTIES('jbpdbbL$Mbx$qrIXYd4L2Z',#6,$,$,(#56),#200); -#202=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av6pocd40'),$); -#203=IFCPROPERTYSET('$D2XaFCX7ZCx9hYFzpMZbV',#6,'OPS_TaskIdentity',$,(#202)); -#204=IFCRELDEFINESBYPROPERTIES('6lxXaDc7mJkhknZFkV15d7',#6,$,$,(#58),#203); -#205=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av8nrxa41'),$); -#206=IFCPROPERTYSET('nhztUxsPJLnLErYX1$xX8D',#6,'OPS_TaskIdentity',$,(#205)); -#207=IFCRELDEFINESBYPROPERTIES('gva3wZePH$cpap71YPyTFx',#6,$,$,(#60),#206); -#208=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av9kkum42'),$); -#209=IFCPROPERTYSET('4jobG9y1CzcVQn9$13mtOB',#6,'OPS_TaskIdentity',$,(#208)); -#210=IFCRELDEFINESBYPROPERTIES('3FrnfFy$u$khM9ynPraBeT',#6,$,$,(#62),#209); -#211=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avbgkia43'),$); -#212=IFCPROPERTYSET('gzfRy1YfynN5PzhFzl7RDl',#6,'OPS_TaskIdentity',$,(#211)); -#213=IFCRELDEFINESBYPROPERTIES('nvcfKhJXdpYNpnlXPHZt4h',#6,$,$,(#64),#212); -#214=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avc4ymy44'),$); -#215=IFCPROPERTYSET('1lNJjZndJtdJoLYn3jI$AP',#6,'OPS_TaskIdentity',$,(#214)); -#216=IFCRELDEFINESBYPROPERTIES('8jztc9S1cp8voLMls$l3nV',#6,$,$,(#66),#215); -#217=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91averzu645'),$); -#218=IFCPROPERTYSET('a3qpH$FvUDgnzR0VF9EVi1',#6,'OPS_TaskIdentity',$,(#217)); -#219=IFCRELDEFINESBYPROPERTIES('Z$t5kH09HDm$8nRHkZa3$f',#6,$,$,(#68),#218); -#220=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avf51c546'),$); -#221=IFCPROPERTYSET('T11T43KnYdSJoV3lQznLMD',#6,'OPS_TaskIdentity',$,(#220)); -#222=IFCRELDEFINESBYPROPERTIES('M37r3revmzuN$$N$$1R16R',#6,$,$,(#70),#221); -#223=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avgzdak47'),$); -#224=IFCPROPERTYSET('hTRH7LaHP78vk5WVbxAzN3',#6,'OPS_TaskIdentity',$,(#223)); -#225=IFCRELDEFINESBYPROPERTIES('EP6DfDPt5n9R99CnWtk$Ex',#6,$,$,(#72),#224); -#226=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avii4ns48'),$); -#227=IFCPROPERTYSET('MTJ7tXolPdqx7p3n4hvp09',#6,'OPS_TaskIdentity',$,(#226)); -#228=IFCRELDEFINESBYPROPERTIES('FP_r$zKX1LHrqFVV0dqpiV',#6,$,$,(#74),#227); -#229=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avkc6mc49'),$); -#230=IFCPROPERTYSET('6HhHBZGfePX3p3Bn1$xV47',#6,'OPS_TaskIdentity',$,(#229)); -#231=IFCRELDEFINESBYPROPERTIES('DrlR0HlPjhVbLfqHMh81cF',#6,$,$,(#76),#230); -#232=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avlvj7j50'),$); -#233=IFCPROPERTYSET('xLQjb1l74RhH3PYXXHd5N$',#6,'OPS_TaskIdentity',$,(#232)); -#234=IFCRELDEFINESBYPROPERTIES('_H7Tmt4lJrzfF7oFEnLVWv',#6,$,$,(#78),#233); -#235=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avnbt3y51'),$); -#236=IFCPROPERTYSET('HR3twvUfKtwj2Pg$lvmtqF',#6,'OPS_TaskIdentity',$,(#235)); -#237=IFCRELDEFINESBYPROPERTIES('AN_rXpotfHx$O7clfJNhQL',#6,$,$,(#80),#236); -#238=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avoux0g52'),$); -#239=IFCPROPERTYSET('jtibKrNNC329ZhIFyPm9uJ',#6,'OPS_TaskIdentity',$,(#238)); -#240=IFCRELDEFINESBYPROPERTIES('QR0tg57tVxf5NRGVAbcvfZ',#6,$,$,(#82),#239); +#181=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tb108r33'),$); +#182=IFCPROPERTYSET('dxSLJ$cvmZSjMLUn7bwd9F',#6,'OPS_TaskIdentity',$,(#181)); +#183=IFCRELDEFINESBYPROPERTIES('WXRHbb5VXvhHxR4nT3elrT',#6,$,$,(#44),#182); +#184=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tdt7px34'),$); +#185=IFCPROPERTYSET('kRjvHbzlBt_bVRKnAxYL6X',#6,'OPS_TaskIdentity',$,(#184)); +#186=IFCRELDEFINESBYPROPERTIES('BfERuJ5Xdp2t3xCn8z$xP7',#6,$,$,(#46),#185); +#187=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tee0n735'),$); +#188=IFCPROPERTYSET('7Lk5Y5E9RLj7k5mV_5jvPB',#6,'OPS_TaskIdentity',$,(#187)); +#189=IFCRELDEFINESBYPROPERTIES('0l7ZCdbVJNxXYt81hd8nTx',#6,$,$,(#48),#188); +#190=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tf05uk36'),$); +#191=IFCPROPERTYSET('2FxVuj3l7ZyLw$pFdH5VWd',#6,'OPS_TaskIdentity',$,(#190)); +#192=IFCRELDEFINESBYPROPERTIES('tp$r9Xs$en4pmR0X$$rFdd',#6,$,$,(#50),#191); +#193=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46thh8k437'),$); +#194=IFCPROPERTYSET('3nDvfHGNsrJ7QFt1v$tr4h',#6,'OPS_TaskIdentity',$,(#193)); +#195=IFCRELDEFINESBYPROPERTIES('AD9DvLyHpbhH1XxF25hXOr',#6,$,$,(#52),#194); +#196=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tik39c38'),$); +#197=IFCPROPERTYSET('UT8Fhf09hjkLoBQ1gnfZPd',#6,'OPS_TaskIdentity',$,(#196)); +#198=IFCRELDEFINESBYPROPERTIES('RXTNOzjXXPtRjjXVQztz$N',#6,$,$,(#54),#197); +#199=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tj1mx139'),$); +#200=IFCPROPERTYSET('Z$HjRX3H7z0FPlRFyrrnvf',#6,'OPS_TaskIdentity',$,(#199)); +#201=IFCRELDEFINESBYPROPERTIES('Mztxw7sdFlOdKVf$_twdxF',#6,$,$,(#56),#200); +#202=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tlser340'),$); +#203=IFCPROPERTYSET('vr2VK95FK9Y5jXHVLTj7LJ',#6,'OPS_TaskIdentity',$,(#202)); +#204=IFCRELDEFINESBYPROPERTIES('0FJd7rW9djsZLzlXTXlrS9',#6,$,$,(#58),#203); +#205=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tmduok41'),$); +#206=IFCPROPERTYSET('L7CDLjoHNF9Rzx4lPrKJIB',#6,'OPS_TaskIdentity',$,(#205)); +#207=IFCRELDEFINESBYPROPERTIES('aZWNWXxVpxtbJJv1b$9rGX',#6,$,$,(#60),#206); +#208=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tnkbco42'),$); +#209=IFCPROPERTYSET('jRTf8BWt9LjvM9il8h07Lp',#6,'OPS_TaskIdentity',$,(#208)); +#210=IFCRELDEFINESBYPROPERTIES('CNUh3PRXhJEBunzFeDpd5H',#6,$,$,(#62),#209); +#211=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tobixn43'),$); +#212=IFCPROPERTYSET('N3FDXtyljLvpmB2XJ9cvV7',#6,'OPS_TaskIdentity',$,(#211)); +#213=IFCRELDEFINESBYPROPERTIES('Gv69M$9fifYfT9ClYhFLUJ',#6,$,$,(#64),#212); +#214=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tqyisk44'),$); +#215=IFCPROPERTYSET('NPWf8pP93TILQFFXvxUL6b',#6,'OPS_TaskIdentity',$,(#214)); +#216=IFCRELDEFINESBYPROPERTIES('U3Szg1l7ut$5bBkXUPaB8j',#6,$,$,(#66),#215); +#217=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46ts3dlh45'),$); +#218=IFCPROPERTYSET('$D2VW1$Phpo72Dd1G1rF9l',#6,'OPS_TaskIdentity',$,(#217)); +#219=IFCRELDEFINESBYPROPERTIES('6H5Xm9CFmjsT3Ps$pdiJMD',#6,$,$,(#68),#218); +#220=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tumgja46'),$); +#221=IFCPROPERTYSET('Q$$TSnCXvz87gNmVGHXpiX',#6,'OPS_TaskIdentity',$,(#220)); +#222=IFCRELDEFINESBYPROPERTIES('jxVzoZFPXx9RXRylZZCbjV',#6,$,$,(#70),#221); +#223=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tvxr4m47'),$); +#224=IFCPROPERTYSET('AjR$QtCF4RrnNvkFuTjvHH',#6,'OPS_TaskIdentity',$,(#223)); +#225=IFCRELDEFINESBYPROPERTIES('H9yB7vlvmTaj_7InuLOVGX',#6,$,$,(#72),#224); +#226=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46txg4ci48'),$); +#227=IFCPROPERTYSET('DBtDdxc74xvL2JrFphnhAv',#6,'OPS_TaskIdentity',$,(#226)); +#228=IFCRELDEFINESBYPROPERTIES('id0tMVRXljyhkHlXNtcvvR',#6,$,$,(#74),#227); +#229=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tyt9o949'),$); +#230=IFCPROPERTYSET('Lz1ZmPEH$BDd43v$LvCpOz',#6,'OPS_TaskIdentity',$,(#229)); +#231=IFCRELDEFINESBYPROPERTIES('ovMd5hrH9BXbFjZlSxB1kh',#6,$,$,(#76),#230); +#232=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tz9zl550'),$); +#233=IFCPROPERTYSET('3Dcf0FjVR1$5LzP$f92HxL',#6,'OPS_TaskIdentity',$,(#232)); +#234=IFCRELDEFINESBYPROPERTIES('yl5Xm94HFnCZzvsH8hmt8J',#6,$,$,(#78),#233); +#235=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46u1yoy651'),$); +#236=IFCPROPERTYSET('T34rl5VPnF4L2Jhnj9A9ip',#6,'OPS_TaskIdentity',$,(#235)); +#237=IFCRELDEFINESBYPROPERTIES('SVxFjzLlljSjMN0Vb$VpG1',#6,$,$,(#80),#236); +#238=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46u31o9x52'),$); +#239=IFCPROPERTYSET('AJpvh9KVPPSBmhEF6Dztqz',#6,'OPS_TaskIdentity',$,(#238)); +#240=IFCRELDEFINESBYPROPERTIES('ltiJP9RFo5dr4Ne107Gtqn',#6,$,$,(#82),#239); #241=IFCPROPERTYSINGLEVALUE('ConstraintType',$,IFCLABEL('SNET'),$); -#242=IFCPROPERTYSINGLEVALUE('ConstraintDate',$,IFCDATE('2027-05-11'),$); -#243=IFCPROPERTYSET('ebmvvvVPLvetLtWVBjPxT5',#6,'OPS_Constraints',$,(#241,#242)); -#244=IFCRELDEFINESBYPROPERTIES('V3qDbRh$obx$Kb4F9znLob',#6,$,$,(#50),#243); -#245=IFCPROPERTYSINGLEVALUE('Deadline',$,IFCDATE('2027-07-26'),$); -#246=IFCPROPERTYSET('yFXhfjZHVxN59rs13l$j6x',#6,'OPS_Constraints',$,(#245)); -#247=IFCRELDEFINESBYPROPERTIES('BJBnJZffaVZ9DT7$MlDdTR',#6,$,$,(#80),#246); +#242=IFCPROPERTYSINGLEVALUE('ConstraintDate',$,IFCDATE('2027-05-14'),$); +#243=IFCPROPERTYSET('f7qxwZW9rfkdSDd1CnFLgN',#6,'OPS_Constraints',$,(#241,#242)); +#244=IFCRELDEFINESBYPROPERTIES('YTWfur0tRL5XIxmXIXlByd',#6,$,$,(#50),#243); +#245=IFCPROPERTYSINGLEVALUE('Deadline',$,IFCDATE('2027-07-29'),$); +#246=IFCPROPERTYSET('oPwD93z$Sh7DGBc$A5cpm1',#6,'OPS_Constraints',$,(#245)); +#247=IFCRELDEFINESBYPROPERTIES('7Lkxc9Sl7fov6pZn_9s9yr',#6,$,$,(#80),#246); #248=IFCPROPERTYSINGLEVALUE('MilestoneKind',$,IFCLABEL('START'),$); -#249=IFCPROPERTYSET('Q1V3YR_FyRBFZLgHk1J7JX',#6,'OPS_Milestone',$,(#248)); -#250=IFCRELDEFINESBYPROPERTIES('1ps7ZR2F459bt9iF_RVpSf',#6,$,$,(#44),#249); +#249=IFCPROPERTYSET('Ajp7b1FvUdar4371FfwjAZ',#6,'OPS_Milestone',$,(#248)); +#250=IFCRELDEFINESBYPROPERTIES('ZVRH5BNfKNslLTJFTZijw1',#6,$,$,(#44),#249); #251=IFCPROPERTYSINGLEVALUE('MilestoneKind',$,IFCLABEL('FINISH'),$); #252=IFCPROPERTYSINGLEVALUE('Mandatory',$,IFCBOOLEAN(.T.),$); -#253=IFCPROPERTYSET('WVL1QDQ7CdSjoBkXcz$5XT',#6,'OPS_Milestone',$,(#251,#252)); -#254=IFCRELDEFINESBYPROPERTIES('vhMzALpXTVMFH3lVxBiJMp',#6,$,$,(#80),#253); -#255=IFCPROPERTYSINGLEVALUE('Baselines',$,IFCTEXT('[{"id":"baseline-mtx91axtu7xv77","name":"Baseline plan","createdAt":"2026-09-11T17:47:39.041Z","tasks":[{"taskId":"task-mtx91auwh6xz33","start":"2027-05-03","finish":"2027-05-03","duration":0,"isMilestone":true,"milestoneKind":"START"},{"taskId":"task-mtx91auzt9uo35","start":"2027-05-03","finish":"2027-05-04","duration":2,"isMilestone":false},{"taskId":"task-mtx91av113u536","start":"2027-05-11","finish":"2027-05-13","duration":3,"isMilestone":false},{"taskId":"task-mtx91av4t47y38","start":"2027-05-14","finish":"2027-05-19","duration":3,"isMilestone":false},{"taskId":"task-mtx91av5fq8k39","start":"2027-05-20","finish":"2027-05-25","duration":4,"isMilestone":false},{"taskId":"task-mtx91av6pocd40","start":"2027-05-26","finish":"2027-05-28","duration":3,"isMilestone":false},{"taskId":"task-mtx91av8nrxa41","start":"2027-05-31","finish":"2027-06-07","duration":6,"isMilestone":false},{"taskId":"task-mtx91av9kkum42","start":"2027-06-02","finish":"2027-06-07","duration":4,"isMilestone":false},{"taskId":"task-mtx91avc4ymy44","start":"2027-06-08","finish":"2027-06-10","duration":3,"isMilestone":false},{"taskId":"task-mtx91averzu645","start":"2027-06-11","finish":"2027-06-16","duration":4,"isMilestone":false},{"taskId":"task-mtx91avf51c546","start":"2027-06-17","finish":"2027-06-23","duration":5,"isMilestone":false},{"taskId":"task-mtx91avgzdak47","start":"2027-06-24","finish":"2027-06-29","duration":4,"isMilestone":false},{"taskId":"task-mtx91avii4ns48","start":"2027-06-28","finish":"2027-06-30","duration":3,"isMilestone":false},{"taskId":"task-mtx91avlvj7j50","start":"2027-07-01","finish":"2027-07-01","duration":1,"isMilestone":false},{"taskId":"task-mtx91avnbt3y51","start":"2027-07-01","finish":"2027-07-01","duration":0,"isMilestone":true,"milestoneKind":"FINISH"},{"taskId":"task-mtx91avoux0g52","start":"2027-07-02","finish":"2027-07-02","duration":1,"isMilestone":false}],"projectEnd":"2027-07-02","projectDuration":42}]'),$); -#256=IFCPROPERTYSINGLEVALUE('TaskGuids',$,IFCTEXT('{"task-mtx91auwh6xz33":"IfmPdbs$uNDP8FBlzpgTlr","task-mtx91auzt9uo35":"Q$9xEXg$QJ4p8NyHdrqpOr","task-mtx91av113u536":"WXJ7VJ_FKN61avUnxlJvbR","task-mtx91av4t47y38":"R1lJnr7txx1ZlfCH6pDPBd","task-mtx91av5fq8k39":"zFrnvv$dT1kLY981sxobT9","task-mtx91av6pocd40":"SZeH5rV7u$2b1Bw$Kjk1sP","task-mtx91av8nrxa41":"Kx7r9XklhP4LMNWX71dxlB","task-mtx91av9kkum42":"dR8XaDc7KNMFjDpnhTpP1p","task-mtx91avc4ymy44":"UTO1sFDVZV29jjN1oDMTJz","task-mtx91averzu645":"7BY$Arvtdd4L2J5FgLG9O3","task-mtx91avf51c546":"0nLHxLT12twjYt81BNwj_3","task-mtx91avgzdak47":"8pdzctqnWbjfzPEVqHpdj3","task-mtx91avii4ns48":"vHZfy3SlJf5VohYn$$BFZb","task-mtx91avlvj7j50":"OxahfJL12zGX_PIVoDszF$","task-mtx91avnbt3y51":"qr$B9VM1MvD935yHhvS3TN","task-mtx91avoux0g52":"mdsTGfW9f5$TnvIF47ijYJ"}'),$); -#257=IFCPROPERTYSINGLEVALUE('ActiveBaselineId',$,IFCTEXT('baseline-mtx91axtu7xv77'),$); +#253=IFCPROPERTYSET('EhjdnlHdPpYNtBoXKXLXKD',#6,'OPS_Milestone',$,(#251,#252)); +#254=IFCRELDEFINESBYPROPERTIES('dXxFbXRXXRD9RXrnjbXDmn',#6,$,$,(#80),#253); +#255=IFCPROPERTYSINGLEVALUE('Baselines',$,IFCTEXT('[{"id":"baseline-mu1n46vijqjz77","name":"Baseline plan","createdAt":"2026-09-14T19:32:53.070Z","tasks":[{"taskId":"task-mu1n46tb108r33","start":"2027-05-07","finish":"2027-05-07","duration":0,"isMilestone":true,"milestoneKind":"START"},{"taskId":"task-mu1n46tee0n735","start":"2027-05-07","finish":"2027-05-10","duration":2,"isMilestone":false},{"taskId":"task-mu1n46tf05uk36","start":"2027-05-14","finish":"2027-05-19","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tik39c38","start":"2027-05-20","finish":"2027-05-24","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tj1mx139","start":"2027-05-25","finish":"2027-05-28","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tlser340","start":"2027-05-31","finish":"2027-06-02","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tmduok41","start":"2027-06-03","finish":"2027-06-10","duration":6,"isMilestone":false},{"taskId":"task-mu1n46tnkbco42","start":"2027-06-07","finish":"2027-06-10","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tqyisk44","start":"2027-06-11","finish":"2027-06-15","duration":3,"isMilestone":false},{"taskId":"task-mu1n46ts3dlh45","start":"2027-06-16","finish":"2027-06-21","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tumgja46","start":"2027-06-22","finish":"2027-06-28","duration":5,"isMilestone":false},{"taskId":"task-mu1n46tvxr4m47","start":"2027-06-29","finish":"2027-07-02","duration":4,"isMilestone":false},{"taskId":"task-mu1n46txg4ci48","start":"2027-07-01","finish":"2027-07-05","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tz9zl550","start":"2027-07-06","finish":"2027-07-06","duration":1,"isMilestone":false},{"taskId":"task-mu1n46u1yoy651","start":"2027-07-06","finish":"2027-07-06","duration":0,"isMilestone":true,"milestoneKind":"FINISH"},{"taskId":"task-mu1n46u31o9x52","start":"2027-07-07","finish":"2027-07-07","duration":1,"isMilestone":false}],"projectEnd":"2027-07-07","projectDuration":43}]'),$); +#256=IFCPROPERTYSINGLEVALUE('TaskGuids',$,IFCTEXT('{"task-mu1n46tb108r33":"ANsZeTd9hvePK$rlb1fhkv","task-mu1n46tee0n735":"g7CpTNNN8$ATZfWXXDgTXz","task-mu1n46tf05uk36":"V3KRqPzHhdCTJ1znKJI1YH","task-mu1n46tik39c38":"5LIxAllPJLHrqnP$atEV0N","task-mu1n46tj1mx139":"0n5XujxV9Lb$ads1rJFTzB","task-mu1n46tlser340":"MPelBzqVjLtzt9qFuTLlJV","task-mu1n46tmduok41":"odkxIpfNEnlJoLYFfpxHvx","task-mu1n46tnkbco42":"sDXhNjZHhDEvoLUn9ZBF9F","task-mu1n46tqyisk44":"q9gPvLqF8fTtXbKHOJ$Jot","task-mu1n46ts3dlh45":"SZelfJTHbLBnJDbHUdSz$9","task-mu1n46tumgja46":"3l9D3PRXhD61e72npdKjcl","task-mu1n46tvxr4m47":"dX5nPthldvO91rEXCZyhsZ","task-mu1n46txg4ci48":"MZL$E3DVtXzNfRu$VFTfEb","task-mu1n46tz9zl550":"cRGddx_fI1NZVHt1ql5V4B","task-mu1n46u1yoy651":"wLcJxFxn4n9bV5GVWfoRZN","task-mu1n46u31o9x52":"dVTNeZHPNRTdO$VVORUnJz"}'),$); +#257=IFCPROPERTYSINGLEVALUE('ActiveBaselineId',$,IFCTEXT('baseline-mu1n46vijqjz77'),$); #258=IFCPROPERTYSET('XXh1w34HTJCTF7clPLWdrv',#6,'OPS_Baselines',$,(#255,#256,#257)); #259=IFCRELDEFINESBYPROPERTIES('Oz1Z43KV$BzN1$1VwJtr4B',#6,$,$,(#40),#258); ENDSEC; diff --git a/public/examples/showcase-verbouwing-eengezinswoning.ifc b/public/examples/showcase-verbouwing-eengezinswoning.ifc index d143e9547..6a7dc9b97 100644 --- a/public/examples/showcase-verbouwing-eengezinswoning.ifc +++ b/public/examples/showcase-verbouwing-eengezinswoning.ifc @@ -1,7 +1,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [SchedulingView]'),'2;1'); -FILE_NAME('Refurbishment & Extension of a Family Home.ifc','2026-09-11T17:47:39',('Contractor'),('Van Dijk Construction BV'),'Open Planner Studio 0.1','Open Planner Studio',''); +FILE_NAME('Refurbishment & Extension of a Family Home.ifc','2026-09-14T19:32:53',('Contractor'),('Van Dijk Construction BV'),'Open Planner Studio 0.1','Open Planner Studio',''); FILE_SCHEMA(('IFC4X3')); ENDSEC; DATA; @@ -10,14 +10,14 @@ DATA; #3=IFCPERSONANDORGANIZATION(#1,#2,$); #4=IFCORGANIZATION($,'OpenAEC Foundation',$,$,$); #5=IFCAPPLICATION(#4,'0.1','Open Planner Studio','OPS'); -#6=IFCOWNERHISTORY(#3,#5,$,.NOCHANGE.,$,$,$,1789148859); +#6=IFCOWNERHISTORY(#3,#5,$,.NOCHANGE.,$,$,$,1789414373); #7=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); #8=IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); #9=IFCUNITASSIGNMENT((#7,#8)); #10=IFCCARTESIANPOINT((0.,0.,0.)); #11=IFCAXIS2PLACEMENT3D(#10,$,$); #12=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',3,1.0E-05,#11,$); -#13=IFCPROJECT('6nfJP9xFmDYfn7Alm1zfYn',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,$,(#12),#9); +#13=IFCPROJECT('c5CpzPr$8$ILMLAli1lzhJ',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,$,(#12),#9); #14=IFCTIMEPERIOD('07:00:00','16:00:00'); #15=IFCRECURRENCEPATTERN(.WEEKLY.,$,(1,2,3,4,5),$,$,$,$,(#14)); #16=IFCWORKTIME('Standard work week',.PREDICTED.,$,#15,$,$); @@ -43,225 +43,225 @@ DATA; #36=IFCPROPERTYSINGLEVALUE('HoursPerDay',$,IFCREAL(8),$); #37=IFCPROPERTYSET('qL5XmPUFgdlJ6X9$WtAtOZ',#6,'OPS_Calendar',$,(#36)); #38=IFCRELDEFINESBYPROPERTIES('bXh12hy$qvOdOP_VbbYntl',#6,$,$,(#35),#37); -#39=IFCWORKPLAN('EvEnLfedVdfxl981cTrFvf',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,'2026-09-11T17:47:39',$,$,$,$,$,'2027-05-03T07:00:00','2027-05-10T07:00:00',.PLANNED.); -#40=IFCWORKSCHEDULE('hd8nDjjFSrrnxHlVrjVpOL',#6,'Construction schedule v1.0',$,$,$,'2026-09-11T17:47:39',$,$,$,$,$,'2027-05-03T07:00:00','2027-05-10T07:00:00',.PLANNED.); -#41=IFCWORKSCHEDULE('DdwDR5tf4FNx5bynXFhVyB',#6,'Baseline plan',$,$,$,'2026-09-11T17:47:39.041Z',$,$,$,$,$,$,'2027-07-02T07:00:00',.BASELINE.); +#39=IFCWORKPLAN('U3iR4V_F4bTPOd2VXj_Hzv',#6,'Refurbishment & Extension of a Family Home','Extension plus internal refurbishment of a family home — entry-level schedule.',$,$,'2026-09-14T19:32:53',$,$,$,$,$,'2027-05-06T07:00:00','2027-05-13T07:00:00',.PLANNED.); +#40=IFCWORKSCHEDULE('1n3tMRB$OP1THt8$nNIxR1',#6,'Construction schedule v1.0',$,$,$,'2026-09-14T19:32:53',$,$,$,$,$,'2027-05-06T07:00:00','2027-05-13T07:00:00',.PLANNED.); +#41=IFCWORKSCHEDULE('Gtib8LyHxHbVcTN1Z3mfQH',#6,'Baseline plan',$,$,$,'2026-09-14T19:32:53.070Z',$,$,$,$,$,$,'2027-07-07T07:00:00',.BASELINE.); #42=IFCRELAGGREGATES('X$Ph9TPtbVDd8te$ftI1sz',#6,$,$,#39,(#40,#41)); -#43=IFCTASKTIME('Start of refurbishment Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','P0Y0M0D','P0Y0M2D',.F.,$,$,$,$,$,0.00); -#44=IFCTASK('IfmPdbs$uNDP8FBlzpgTlr',#6,'Start of refurbishment',$,$,'1',$,$,$,.T.,$,#43,.CONSTRUCTION.); -#45=IFCTASKTIME('1. Preparation Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-03T07:00:00','2027-05-13T07:00:00','2027-05-07T07:00:00','2027-05-13T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#46=IFCTASK('k5FpLJ2F0BNZDlxnk3GNgt',#6,'1. Preparation',$,$,'2',$,$,$,.F.,$,#45,.LOGISTIC.); -#47=IFCTASKTIME('Set up site Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M2D','2027-05-03T07:00:00','2027-05-04T07:00:00','2027-05-03T07:00:00','2027-05-04T07:00:00','2027-05-07T07:00:00','2027-05-10T07:00:00','P0Y0M2D','P0Y0M2D',.F.,$,$,$,$,$,0.00); -#48=IFCTASK('Q$9xEXg$QJ4p8NyHdrqpOr',#6,'Set up site',$,$,'2.1',$,$,$,.F.,$,#47,.LOGISTIC.); -#49=IFCTASKTIME('Demolish existing extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-11T07:00:00','2027-05-13T07:00:00','2027-05-11T07:00:00','2027-05-13T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#50=IFCTASK('WXJ7VJ_FKN61avUnxlJvbR',#6,'Demolish existing extension',$,$,'2.2',$,$,$,.F.,$,#49,.DEMOLITION.); -#51=IFCTASKTIME('2. Foundations & structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-05-14T07:00:00','2027-06-07T07:00:00','2027-05-14T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#52=IFCTASK('kRzt_hiVpPPrWpzF4dunX3',#6,'2. Foundations & structure',$,$,'3',$,$,$,.F.,$,#51,.CONSTRUCTION.); -#53=IFCTASKTIME('Earthworks for extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#54=IFCTASK('R1lJnr7txx1ZlfCH6pDPBd',#6,'Earthworks for extension',$,$,'3.1',$,$,$,.F.,$,#53,.CONSTRUCTION.); -#55=IFCTASKTIME('Extension foundations Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-05-20T07:00:00','2027-05-25T07:00:00','2027-05-20T07:00:00','2027-05-25T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#56=IFCTASK('zFrnvv$dT1kLY981sxobT9',#6,'Extension foundations',$,$,'3.2',$,$,$,.F.,$,#55,.CONSTRUCTION.); -#57=IFCTASKTIME('Pour ground floor slab Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-05-26T07:00:00','2027-05-28T07:00:00','2027-05-26T07:00:00','2027-05-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#58=IFCTASK('SZeH5rV7u$2b1Bw$Kjk1sP',#6,'Pour ground floor slab',$,$,'3.3',$,$,$,.F.,$,#57,.CONSTRUCTION.); -#59=IFCTASKTIME('Build masonry walls Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M6D','2027-05-03T07:00:00','2027-05-10T07:00:00','2027-05-31T07:00:00','2027-06-07T07:00:00','2027-05-31T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#60=IFCTASK('Kx7r9XklhP4LMNWX71dxlB',#6,'Build masonry walls',$,$,'3.4',$,$,$,.F.,$,#59,.CONSTRUCTION.); -#61=IFCTASKTIME('Install roof structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-02T07:00:00','2027-06-07T07:00:00','2027-06-02T07:00:00','2027-06-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#62=IFCTASK('dR8XaDc7KNMFjDpnhTpP1p',#6,'Install roof structure',$,$,'3.5',$,$,$,.F.,$,#61,.CONSTRUCTION.); -#63=IFCTASKTIME('3. Fit-out Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-06-08T07:00:00','2027-06-30T07:00:00','2027-06-08T07:00:00','2027-06-30T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#64=IFCTASK('7nVL3PpF2tkLUTbHA5Eb5L',#6,'3. Fit-out',$,$,'4',$,$,$,.F.,$,#63,.CONSTRUCTION.); -#65=IFCTASKTIME('Apply roofing Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-06-08T07:00:00','2027-06-10T07:00:00','2027-06-08T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#66=IFCTASK('UTO1sFDVZV29jjN1oDMTJz',#6,'Apply roofing',$,$,'4.1',$,$,$,.F.,$,#65,.CONSTRUCTION.); -#67=IFCTASKTIME('Install window frames Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-11T07:00:00','2027-06-16T07:00:00','2027-06-11T07:00:00','2027-06-16T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#68=IFCTASK('7BY$Arvtdd4L2J5FgLG9O3',#6,'Install window frames',$,$,'4.2',$,$,$,.F.,$,#67,.INSTALLATION.); -#69=IFCTASKTIME('Plastering Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-06-17T07:00:00','2027-06-23T07:00:00','2027-06-17T07:00:00','2027-06-23T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#70=IFCTASK('0nLHxLT12twjYt81BNwj_3',#6,'Plastering',$,$,'4.3',$,$,$,.F.,$,#69,.CONSTRUCTION.); -#71=IFCTASKTIME('Tiling Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-03T07:00:00','2027-05-06T07:00:00','2027-06-24T07:00:00','2027-06-29T07:00:00','2027-06-24T07:00:00','2027-06-29T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#72=IFCTASK('8pdzctqnWbjfzPEVqHpdj3',#6,'Tiling',$,$,'4.4',$,$,$,.F.,$,#71,.INSTALLATION.); -#73=IFCTASKTIME('Painting Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-03T07:00:00','2027-05-05T07:00:00','2027-06-28T07:00:00','2027-06-30T07:00:00','2027-06-28T07:00:00','2027-06-30T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#74=IFCTASK('vHZfy3SlJf5VohYn$$BFZb',#6,'Painting',$,$,'4.5',$,$,$,.F.,$,#73,.CONSTRUCTION.); -#75=IFCTASKTIME('4. Handover Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-03T07:00:00','2027-05-07T07:00:00','2027-07-01T07:00:00','2027-07-02T07:00:00','2027-07-01T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#76=IFCTASK('ZXh1gbpFwZQDe$N$jRtBml',#6,'4. Handover',$,$,'5',$,$,$,.F.,$,#75,.ATTENDANCE.); -#77=IFCTASKTIME('Final cleaning Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#78=IFCTASK('OxahfJL12zGX_PIVoDszF$',#6,'Final cleaning',$,$,'5.1',$,$,$,.F.,$,#77,.LOGISTIC.); -#79=IFCTASKTIME('Handover inspection Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','2027-07-01T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#80=IFCTASK('qr$B9VM1MvD935yHhvS3TN',#6,'Handover inspection','Mandatory handover inspection; the deadline has ample margin (no conflict).',$,'5.2',$,$,$,.T.,$,#79,.CONSTRUCTION.); -#81=IFCTASKTIME('Final handover with the occupant Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-03T07:00:00','2027-05-03T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); -#82=IFCTASK('mdsTGfW9f5$TnvIF47ijYJ',#6,'Final handover with the occupant',$,$,'5.3',$,$,$,.F.,$,#81,.ATTENDANCE.); -#83=IFCRELNESTS('PVbHrZ0t79AZRhEFopk5zL',#6,'WBS 1. Preparation',$,#46,( #48,#50)); -#84=IFCRELNESTS('P1Vz2La1wzsH9hsH4dOlv5',#6,'WBS 2. Foundations & structure',$,#52,( #54,#56,#58,#60,#62)); -#85=IFCRELNESTS('0HVLrZOvedvLELUnzpcDa5',#6,'WBS 3. Fit-out',$,#64,( #66,#68,#70,#72,#74)); -#86=IFCRELNESTS('azSh17I97T4DyTTn6f2B85',#6,'WBS 4. Handover',$,#76,( #78,#80,#82)); +#43=IFCTASKTIME('Start of refurbishment Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','2027-05-12T07:00:00','2027-05-12T07:00:00','P0Y0M0D','P0Y0M3D',.F.,$,$,$,$,$,0.00); +#44=IFCTASK('ANsZeTd9hvePK$rlb1fhkv',#6,'Start of refurbishment',$,$,'1',$,$,$,.T.,$,#43,.CONSTRUCTION.); +#45=IFCTASKTIME('1. Preparation Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-05-07T07:00:00','2027-05-19T07:00:00','2027-05-12T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#46=IFCTASK('rjTNSledrtcp4f0X93tzHH',#6,'1. Preparation',$,$,'2',$,$,$,.F.,$,#45,.LOGISTIC.); +#47=IFCTASKTIME('Set up site Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M2D','2027-05-06T07:00:00','2027-05-07T07:00:00','2027-05-07T07:00:00','2027-05-10T07:00:00','2027-05-12T07:00:00','2027-05-13T07:00:00','P0Y0M3D','P0Y0M3D',.F.,$,$,$,$,$,0.00); +#48=IFCTASK('g7CpTNNN8$ATZfWXXDgTXz',#6,'Set up site',$,$,'2.1',$,$,$,.F.,$,#47,.LOGISTIC.); +#49=IFCTASKTIME('Demolish existing extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','2027-05-14T07:00:00','2027-05-19T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#50=IFCTASK('V3KRqPzHhdCTJ1znKJI1YH',#6,'Demolish existing extension',$,$,'2.2',$,$,$,.F.,$,#49,.DEMOLITION.); +#51=IFCTASKTIME('2. Foundations & structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-05-20T07:00:00','2027-06-10T07:00:00','2027-05-20T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#52=IFCTASK('W$NRqdTl9VDdeXRn4L1x7f',#6,'2. Foundations & structure',$,$,'3',$,$,$,.F.,$,#51,.CONSTRUCTION.); +#53=IFCTASKTIME('Earthworks for extension Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-20T07:00:00','2027-05-24T07:00:00','2027-05-20T07:00:00','2027-05-24T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#54=IFCTASK('5LIxAllPJLHrqnP$atEV0N',#6,'Earthworks for extension',$,$,'3.1',$,$,$,.F.,$,#53,.CONSTRUCTION.); +#55=IFCTASKTIME('Extension foundations Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-05-25T07:00:00','2027-05-28T07:00:00','2027-05-25T07:00:00','2027-05-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#56=IFCTASK('0n5XujxV9Lb$ads1rJFTzB',#6,'Extension foundations',$,$,'3.2',$,$,$,.F.,$,#55,.CONSTRUCTION.); +#57=IFCTASKTIME('Pour ground floor slab Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-05-31T07:00:00','2027-06-02T07:00:00','2027-05-31T07:00:00','2027-06-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#58=IFCTASK('MPelBzqVjLtzt9qFuTLlJV',#6,'Pour ground floor slab',$,$,'3.3',$,$,$,.F.,$,#57,.CONSTRUCTION.); +#59=IFCTASKTIME('Build masonry walls Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M6D','2027-05-06T07:00:00','2027-05-13T07:00:00','2027-06-03T07:00:00','2027-06-10T07:00:00','2027-06-03T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#60=IFCTASK('odkxIpfNEnlJoLYFfpxHvx',#6,'Build masonry walls',$,$,'3.4',$,$,$,.F.,$,#59,.CONSTRUCTION.); +#61=IFCTASKTIME('Install roof structure Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-07T07:00:00','2027-06-10T07:00:00','2027-06-07T07:00:00','2027-06-10T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#62=IFCTASK('sDXhNjZHhDEvoLUn9ZBF9F',#6,'Install roof structure',$,$,'3.5',$,$,$,.F.,$,#61,.CONSTRUCTION.); +#63=IFCTASKTIME('3. Fit-out Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-06-11T07:00:00','2027-07-05T07:00:00','2027-06-11T07:00:00','2027-07-05T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#64=IFCTASK('wHb$oZfNq7WHf5ClEHvbV7',#6,'3. Fit-out',$,$,'4',$,$,$,.F.,$,#63,.CONSTRUCTION.); +#65=IFCTASKTIME('Apply roofing Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-06-11T07:00:00','2027-06-15T07:00:00','2027-06-11T07:00:00','2027-06-15T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#66=IFCTASK('q9gPvLqF8fTtXbKHOJ$Jot',#6,'Apply roofing',$,$,'4.1',$,$,$,.F.,$,#65,.CONSTRUCTION.); +#67=IFCTASKTIME('Install window frames Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-16T07:00:00','2027-06-21T07:00:00','2027-06-16T07:00:00','2027-06-21T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#68=IFCTASK('SZelfJTHbLBnJDbHUdSz$9',#6,'Install window frames',$,$,'4.2',$,$,$,.F.,$,#67,.INSTALLATION.); +#69=IFCTASKTIME('Plastering Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-06-22T07:00:00','2027-06-28T07:00:00','2027-06-22T07:00:00','2027-06-28T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#70=IFCTASK('3l9D3PRXhD61e72npdKjcl',#6,'Plastering',$,$,'4.3',$,$,$,.F.,$,#69,.CONSTRUCTION.); +#71=IFCTASKTIME('Tiling Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M4D','2027-05-06T07:00:00','2027-05-11T07:00:00','2027-06-29T07:00:00','2027-07-02T07:00:00','2027-06-29T07:00:00','2027-07-02T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#72=IFCTASK('dX5nPthldvO91rEXCZyhsZ',#6,'Tiling',$,$,'4.4',$,$,$,.F.,$,#71,.INSTALLATION.); +#73=IFCTASKTIME('Painting Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M3D','2027-05-06T07:00:00','2027-05-10T07:00:00','2027-07-01T07:00:00','2027-07-05T07:00:00','2027-07-01T07:00:00','2027-07-05T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#74=IFCTASK('MZL$E3DVtXzNfRu$VFTfEb',#6,'Painting',$,$,'4.5',$,$,$,.F.,$,#73,.CONSTRUCTION.); +#75=IFCTASKTIME('4. Handover Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M5D','2027-05-06T07:00:00','2027-05-12T07:00:00','2027-07-06T07:00:00','2027-07-07T07:00:00','2027-07-06T07:00:00','2027-07-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#76=IFCTASK('uJLl$vQ7OlGXwjt$uBaz5r',#6,'4. Handover',$,$,'5',$,$,$,.F.,$,#75,.ATTENDANCE.); +#77=IFCTASKTIME('Final cleaning Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#78=IFCTASK('cRGddx_fI1NZVHt1ql5V4B',#6,'Final cleaning',$,$,'5.1',$,$,$,.F.,$,#77,.LOGISTIC.); +#79=IFCTASKTIME('Handover inspection Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M0D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','2027-07-06T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#80=IFCTASK('wLcJxFxn4n9bV5GVWfoRZN',#6,'Handover inspection','Mandatory handover inspection; the deadline has ample margin (no conflict).',$,'5.2',$,$,$,.T.,$,#79,.CONSTRUCTION.); +#81=IFCTASKTIME('Final handover with the occupant Time',.PREDICTED.,$,.WORKTIME.,'P0Y0M1D','2027-05-06T07:00:00','2027-05-06T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','2027-07-07T07:00:00','P0Y0M0D','P0Y0M0D',.T.,$,$,$,$,$,0.00); +#82=IFCTASK('dVTNeZHPNRTdO$VVORUnJz',#6,'Final handover with the occupant',$,$,'5.3',$,$,$,.F.,$,#81,.ATTENDANCE.); +#83=IFCRELNESTS('4HxXC7XPfxtbR$9$1Zqpm1',#6,'WBS 1. Preparation',$,#46,( #48,#50)); +#84=IFCRELNESTS('d5S3sBlP1fJ7Q3jHR1fhUl',#6,'WBS 2. Foundations & structure',$,#52,( #54,#56,#58,#60,#62)); +#85=IFCRELNESTS('$J6phjs$OHedmbO1epbFBP',#6,'WBS 3. Fit-out',$,#64,( #66,#68,#70,#72,#74)); +#86=IFCRELNESTS('$n3tUfndx5np03TlaR7b9j',#6,'WBS 4. Handover',$,#76,( #78,#80,#82)); #87=IFCRELNESTS('tjjP5r1vgdnD4TBHzDgTrv',#6,'WBS Hoofd',$,#40,(#44,#46,#52,#64,#76)); -#88=IFCRELSEQUENCE('TTO1URcXhPiRb1f$XnZNI7',#6,$,$,#44,#48,$,.FINISH_START.,$); -#89=IFCRELSEQUENCE('urU1wd_FGJsZf5aHxryh2T',#6,$,$,#48,#50,$,.FINISH_START.,$); -#90=IFCRELSEQUENCE('WVZf4VwXjNBFt9CHcvS3dh',#6,$,$,#50,#54,$,.FINISH_START.,$); -#91=IFCRELSEQUENCE('qNk5ozz1uX_9r7MlMHBnvN',#6,$,$,#54,#56,$,.FINISH_START.,$); -#92=IFCRELSEQUENCE('bXDdDny1mDIPuFpFBpJvTN',#6,$,$,#56,#58,$,.FINISH_START.,$); -#93=IFCRELSEQUENCE('sNYLxL31yntbTLMHYrjvnV',#6,$,$,#58,#60,$,.FINISH_START.,$); +#88=IFCRELSEQUENCE('Ed_BNFCn8V2tr7slqHBF5F',#6,$,$,#44,#48,$,.FINISH_START.,$); +#89=IFCRELSEQUENCE('6h_rjZNNylhlNRWVHLnhIL',#6,$,$,#48,#50,$,.FINISH_START.,$); +#90=IFCRELSEQUENCE('4nXhfjpFEbrnnJpH3nz9S9',#6,$,$,#50,#54,$,.FINISH_START.,$); +#91=IFCRELSEQUENCE('HRDdLpQveHG$qnnVHv8F$J',#6,$,$,#54,#56,$,.FINISH_START.,$); +#92=IFCRELSEQUENCE('yjobe3DXRBXbBvcHxlhlll',#6,$,$,#56,#58,$,.FINISH_START.,$); +#93=IFCRELSEQUENCE('mt4ZSJM$AfR$e$1XMlzNI7',#6,$,$,#58,#60,$,.FINISH_START.,$); #94=IFCLAGTIME('Lag',.PREDICTED.,$,IFCDURATION('P2D'),.WORKTIME.); -#95=IFCRELSEQUENCE('0FLHrZmNOnajcZd$07OVK$',#6,$,$,#60,#62,#94,.START_START.,$); -#96=IFCRELSEQUENCE('yHp7ZbA10LZt99KHmlP5xn',#6,$,$,#62,#66,$,.FINISH_START.,$); -#97=IFCRELSEQUENCE('tNEbOb_ned7h_PEVqlJvlH',#6,$,$,#66,#68,$,.FINISH_START.,$); -#98=IFCRELSEQUENCE('Px4rlRr$aV3NPTlV7V5HXd',#6,$,$,#68,#70,$,.FINISH_START.,$); -#99=IFCRELSEQUENCE('oPM3cN87Y7m1KR4lLd81IL',#6,$,$,#70,#72,$,.FINISH_START.,$); -#100=IFCRELSEQUENCE('Rx4Tupnv_9ATnPw1KjMNUn',#6,$,$,#70,#74,$,.FINISH_START.,$); +#95=IFCRELSEQUENCE('XVTf4z4HNxV3b1N1PLun3L',#6,$,$,#60,#62,#94,.START_START.,$); +#96=IFCRELSEQUENCE('W$9xE1Yf4bhlBjplZ15V0f',#6,$,$,#62,#66,$,.FINISH_START.,$); +#97=IFCRELSEQUENCE('B9gdb5FvIXLHtB2VDPqDCZ',#6,$,$,#66,#68,$,.FINISH_START.,$); +#98=IFCRELSEQUENCE('zlvT4ZGNobD9fPw1qlrFhN',#6,$,$,#68,#70,$,.FINISH_START.,$); +#99=IFCRELSEQUENCE('Z$Xzw7MPzHGXg9iFEHVDqF',#6,$,$,#70,#72,$,.FINISH_START.,$); +#100=IFCRELSEQUENCE('bxaz2dB$81sZxbiFgLGfkP',#6,$,$,#70,#74,$,.FINISH_START.,$); #101=IFCLAGTIME('Lag',.PREDICTED.,$,IFCDURATION('P1D'),.WORKTIME.); -#102=IFCRELSEQUENCE('cxSLND79pPFDO$P1qzeHbx',#6,$,$,#72,#74,#101,.FINISH_FINISH.,$); -#103=IFCRELSEQUENCE('stAvPtjXh7m$OP_V97SrOz',#6,$,$,#74,#78,$,.FINISH_START.,$); -#104=IFCRELSEQUENCE('kRh1srnPPP4peXjHldKJot',#6,$,$,#78,#80,$,.FINISH_START.,$); -#105=IFCRELSEQUENCE('0nb163qFyFXzfdIVkTCDax',#6,$,$,#80,#82,$,.FINISH_START.,$); -#106=IFCCREWRESOURCE('oB1rtXA$CdyhclzHoHnZJ3',#6,'Masonry crew','The same masonry crew as on the terraced houses — it comes over for the extension walls',$,$,$,$,.USERDEFINED.); -#107=IFCLABORRESOURCE('U34B5lHdfxtbxlf1kZS3hJ',#6,'Bricklayers',$,$,$,$,$,.USERDEFINED.); -#108=IFCLABORRESOURCE('T3CpDn4lVP$ZhZzFqZqR37',#6,'Carpenters',$,$,$,$,$,.USERDEFINED.); -#109=IFCLABORRESOURCE('nLpvDDF7ExPBOV5FUdqRR5',#6,'Plasterers',$,$,$,$,$,.USERDEFINED.); -#110=IFCLABORRESOURCE('6rs7xllP3361aPA1tPGtKJ',#6,'Tilers',$,$,$,$,$,.USERDEFINED.); -#111=IFCLABORRESOURCE('yp2X0FLl9XR1SPs$zFRndv',#6,'Painters',$,$,$,$,$,.USERDEFINED.); +#102=IFCRELSEQUENCE('eTh$67c7KDet5nRlsx2Byh',#6,$,$,#72,#74,#101,.FINISH_FINISH.,$); +#103=IFCRELSEQUENCE('XX39UfNNOdHJs7slS5DPzB',#6,$,$,#74,#78,$,.FINISH_START.,$); +#104=IFCRELSEQUENCE('eZbl3PTHjtFLkF5lqHR1ER',#6,$,$,#78,#80,$,.FINISH_START.,$); +#105=IFCRELSEQUENCE('RRO1_7F7kVrn3xCnKjAt0N',#6,$,$,#80,#82,$,.FINISH_START.,$); +#106=IFCCREWRESOURCE('kf0tY9z1KD8v_7oFglzfw1',#6,'Masonry crew','The same masonry crew as on the terraced houses — it comes over for the extension walls',$,$,$,$,.USERDEFINED.); +#107=IFCLABORRESOURCE('uLIxgXDngd9RtN0Xj5hVOT',#6,'Bricklayers',$,$,$,$,$,.USERDEFINED.); +#108=IFCLABORRESOURCE('KvqD9zjXRXDdm5inQ3lB09',#6,'Carpenters',$,$,$,$,$,.USERDEFINED.); +#109=IFCLABORRESOURCE('ltCDvhCnK9gzLte$8Lu$sv',#6,'Plasterers',$,$,$,$,$,.USERDEFINED.); +#110=IFCLABORRESOURCE('zDcfW$Hvi9oB8l1XiDsz3P',#6,'Tilers',$,$,$,$,$,.USERDEFINED.); +#111=IFCLABORRESOURCE('hZ5FhNGfElx1mLglsRUFpJ',#6,'Painters',$,$,$,$,$,.USERDEFINED.); #112=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#1E293B'),$); -#113=IFCPROPERTYSET('6hwpJ1Q7S$NTbrM$1VVbrZ',#6,'OPS_Resource',$,(#112)); -#114=IFCRELDEFINESBYPROPERTIES('jN8VeZ$7olzvs7wHBDhVax',#6,$,$,(#106),#113); +#113=IFCPROPERTYSET('2DEFdNfNCzId4TRH_9Ataf',#6,'OPS_Resource',$,(#112)); +#114=IFCRELDEFINESBYPROPERTIES('fvq5o1cv0hBFThYFlPqpSf',#6,$,$,(#106),#113); #115=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #116=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(46),$); #117=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#B91C1C'),$); -#118=IFCPROPERTYSINGLEVALUE('ParentGuid',$,IFCTEXT('oB1rtXA$CdyhclzHoHnZJ3'),$); -#119=IFCPROPERTYSET('ApF5_BW95Ffxl9WXdH5XO3',#6,'OPS_Resource',$,(#115,#116,#117,#118)); -#120=IFCRELDEFINESBYPROPERTIES('Z$$Texw7gRjNhZpFbbwPtn',#6,$,$,(#107),#119); +#118=IFCPROPERTYSINGLEVALUE('ParentGuid',$,IFCTEXT('kf0tY9z1KD8v_7oFglzfw1'),$); +#119=IFCPROPERTYSET('SVxnlRL12tM3VRCFuBKjUp',#6,'OPS_Resource',$,(#115,#116,#117,#118)); +#120=IFCRELDEFINESBYPROPERTIES('3JAZyZ0tpP7LQlpHL3VRfT',#6,$,$,(#107),#119); #121=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #122=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(45),$); #123=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#DB2777'),$); -#124=IFCPROPERTYSET('9DvZKtrHXvp9Jj71dhGfEf',#6,'OPS_Resource',$,(#121,#122,#123)); -#125=IFCRELDEFINESBYPROPERTIES('YVJvjZ$vMv5$CrkVITCpGX',#6,$,$,(#108),#124); +#124=IFCPROPERTYSET('0HvZupF7QJ4pGR0VdhmNI7',#6,'OPS_Resource',$,(#121,#122,#123)); +#125=IFCRELDEFINESBYPROPERTIES('Pbel$PUn6Z6j6DrlDrpPzr',#6,$,$,(#108),#124); #126=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #127=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(42),$); #128=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#C2410C'),$); -#129=IFCPROPERTYSET('5hwD9Z7tZVMFVxWX3FLVK1',#6,'OPS_Resource',$,(#126,#127,#128)); -#130=IFCRELDEFINESBYPROPERTIES('i74ZqR3lpbhHNxylJR9BO3',#6,$,$,(#109),#129); +#129=IFCPROPERTYSET('3pU$c9iF_TypaPs1BjP5tF',#6,'OPS_Resource',$,(#126,#127,#128)); +#130=IFCRELDEFINESBYPROPERTIES('gPmdVf87Q1MzVxu1ghGfsT',#6,$,$,(#109),#129); #131=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #132=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(43),$); #133=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#6366F1'),$); -#134=IFCPROPERTYSET('Q1txg$tNCPqTZtCHw9oV0f',#6,'OPS_Resource',$,(#131,#132,#133)); -#135=IFCRELDEFINESBYPROPERTIES('$DY1sH172tAZbLIniXbFLN',#6,$,$,(#110),#134); +#134=IFCPROPERTYSET('f3vrjzpX9VZtfdM1FtAtmn',#6,'OPS_Resource',$,(#131,#132,#133)); +#135=IFCRELDEFINESBYPROPERTIES('2nLHR5dt19PJ2JZlyBZ9u5',#6,$,$,(#110),#134); #136=IFCPROPERTYSINGLEVALUE('MaxUnits',$,IFCREAL(2),$); #137=IFCPROPERTYSINGLEVALUE('CostPerHour',$,IFCMONETARYMEASURE(38),$); #138=IFCPROPERTYSINGLEVALUE('Color',$,IFCTEXT('#0D9488'),$); -#139=IFCPROPERTYSET('e3lJbXpFAhFhc3Lnb5cDGV',#6,'OPS_Resource',$,(#136,#137,#138)); -#140=IFCRELDEFINESBYPROPERTIES('1Hnxg$fNUvWlVFZnsVRFh9',#6,$,$,(#111),#139); -#141=IFCRELNESTS('i7ahvru7wHm1yzd$HBR$oR',#6,'Ploeg Masonry crew',$,#106,(#107)); -#142=IFCRELASSIGNSTOPROCESS('aT8VKfHPrfMFt9qF0707tD',#6,$,$,(#106,#107),$,#60,$); -#143=IFCRELASSIGNSTOPROCESS('9p9ppH8dXRx$yt4Fl7WdbV',#6,$,$,(#108),$,#62,$); -#144=IFCRELASSIGNSTOPROCESS('EBlRC7vtLjIhEh_nvVr1of',#6,$,$,(#108),$,#66,$); -#145=IFCRELASSIGNSTOPROCESS('fZTfujB1GTqZP3ZHebcvf3',#6,$,$,(#108),$,#68,$); -#146=IFCRELASSIGNSTOPROCESS('GdM3gX5VTpWHbhwHZ$xVer',#6,$,$,(#109),$,#70,$); -#147=IFCRELASSIGNSTOPROCESS('8rcfmvYNuXo7wXN$gl5XW7',#6,$,$,(#110),$,#72,$); -#148=IFCRELASSIGNSTOPROCESS('9tkHxB7tJNZtxFblc3NTHh',#6,$,$,(#111),$,#74,$); -#149=IFCPROPERTYSINGLEVALUE('oB1rtXA$CdyhclzHoHnZJ3#0',$,IFCTEXT('1|UNIFORM'),$); -#150=IFCPROPERTYSINGLEVALUE('U34B5lHdfxtbxlf1kZS3hJ#1',$,IFCTEXT('2|UNIFORM'),$); -#151=IFCPROPERTYSET('jNWNK9LHrzede$XVip6jEx',#6,'OPS_Assignments',$,(#149,#150)); -#152=IFCRELDEFINESBYPROPERTIES('KRJ7L9KFAtXR3bmXWt2lPB',#6,$,$,(#60),#151); -#153=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#154=IFCPROPERTYSET('0HXLDFqVZJK3htu$5lLHFX',#6,'OPS_Assignments',$,(#153)); -#155=IFCRELDEFINESBYPROPERTIES('dxKbW1$PDdvL2JhnXp6jA3',#6,$,$,(#62),#154); -#156=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#157=IFCPROPERTYSET('5FNJBvEtXx1TTfO$p7unRV',#6,'OPS_Assignments',$,(#156)); -#158=IFCRELDEFINESBYPROPERTIES('Uza3Ar1vkXZt7$5lKJMN_T',#6,$,$,(#66),#157); -#159=IFCPROPERTYSINGLEVALUE('T3CpDn4lVP$ZhZzFqZqR37#0',$,IFCTEXT('2|UNIFORM'),$); -#160=IFCPROPERTYSET('W$lJ$T09fn1ZzvknOxERLp',#6,'OPS_Assignments',$,(#159)); -#161=IFCRELDEFINESBYPROPERTIES('7hEbOZdtXddrK5e1ClT9y7',#6,$,$,(#68),#160); -#162=IFCPROPERTYSINGLEVALUE('nLpvDDF7ExPBOV5FUdqRR5#0',$,IFCTEXT('2|UNIFORM'),$); -#163=IFCPROPERTYSET('P11TK9hHV57hQ3Zl07OlT9',#6,'OPS_Assignments',$,(#162)); -#164=IFCRELDEFINESBYPROPERTIES('0lVLnnz$mjAZNbe$07WPZb',#6,$,$,(#70),#163); -#165=IFCPROPERTYSINGLEVALUE('6rs7xllP3361aPA1tPGtKJ#0',$,IFCTEXT('2|UNIFORM'),$); -#166=IFCPROPERTYSET('lRbl1vEtLJ2R3xaHtvCRJX',#6,'OPS_Assignments',$,(#165)); -#167=IFCRELDEFINESBYPROPERTIES('8JR1UN$voFnpufKHeDJv75',#6,$,$,(#72),#166); -#168=IFCPROPERTYSINGLEVALUE('yp2X0FLl9XR1SPs$zFRndv#0',$,IFCTEXT('2|UNIFORM'),$); -#169=IFCPROPERTYSET('IRJ71T8vE5lJMfe14rLHJ$',#6,'OPS_Assignments',$,(#168)); -#170=IFCRELDEFINESBYPROPERTIES('vnlbe5k93zYNLZzFqHjvdB',#6,$,$,(#74),#169); +#139=IFCPROPERTYSET('N74BDJxXjh15PTlXjtwdTb',#6,'OPS_Resource',$,(#136,#137,#138)); +#140=IFCRELDEFINESBYPROPERTIES('mPYLhD2fA9fZNx4lF9stGn',#6,$,$,(#111),#139); +#141=IFCRELNESTS('ORO1QNePlXIPm5qlQ3FLcH',#6,'Ploeg Masonry crew',$,#106,(#107)); +#142=IFCRELASSIGNSTOPROCESS('U1NRyZ0tF3610TFV3pMTzh',#6,$,$,(#106,#107),$,#60,$); +#143=IFCRELASSIGNSTOPROCESS('6rU$gbzljB5XczRF4dOH$t',#6,$,$,(#108),$,#62,$); +#144=IFCRELASSIGNSTOPROCESS('aVh$2BmfIXDd8F3FG1BFN$',#6,$,$,(#108),$,#66,$); +#145=IFCRELASSIGNSTOPROCESS('CrP3oz5nOlm1KXVVYPS3Xv',#6,$,$,(#108),$,#68,$); +#146=IFCRELASSIGNSTOPROCESS('pPI5kpl7m3uN1XxFI$b1UJ',#6,$,$,(#109),$,#70,$); +#147=IFCRELASSIGNSTOPROCESS('fdGdNjpnMxnpmR0VxlBFtF',#6,$,$,(#110),$,#72,$); +#148=IFCRELASSIGNSTOPROCESS('c5KbuDvtvtwJkbyF7RwPJz',#6,$,$,(#111),$,#74,$); +#149=IFCPROPERTYSINGLEVALUE('kf0tY9z1KD8v_7oFglzfw1#0',$,IFCTEXT('1|UNIFORM'),$); +#150=IFCPROPERTYSINGLEVALUE('uLIxgXDngd9RtN0Xj5hVOT#1',$,IFCTEXT('2|UNIFORM'),$); +#151=IFCPROPERTYSET('P5CDpniXNlLHh391afAz9j',#6,'OPS_Assignments',$,(#149,#150)); +#152=IFCRELDEFINESBYPROPERTIES('o7Y$gx1dbhTt5HHXT$R1cr',#6,$,$,(#60),#151); +#153=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#154=IFCPROPERTYSET('Fd6D3PJF0hrn99anRxYhQh',#6,'OPS_Assignments',$,(#153)); +#155=IFCRELDEFINESBYPROPERTIES('sj5X0H9NUvOt7$rlDhXRfT',#6,$,$,(#62),#154); +#156=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#157=IFCPROPERTYSET('RbWNWXr1KjCZzdM$lvOHbR',#6,'OPS_Assignments',$,(#156)); +#158=IFCRELDEFINESBYPROPERTIES('qfQjbVZH3HuvAb812twPht',#6,$,$,(#66),#157); +#159=IFCPROPERTYSINGLEVALUE('KvqD9zjXRXDdm5inQ3lB09#0',$,IFCTEXT('2|UNIFORM'),$); +#160=IFCPROPERTYSET('3lP32r0tbhzfJVf1YPaBer',#6,'OPS_Assignments',$,(#159)); +#161=IFCRELDEFINESBYPROPERTIES('S3ah3dDFEnXzTBoXqnnLsT',#6,$,$,(#68),#160); +#162=IFCPROPERTYSINGLEVALUE('ltCDvhCnK9gzLte$8Lu$sv#0',$,IFCTEXT('2|UNIFORM'),$); +#163=IFCPROPERTYSET('gz$ZSJQXdDUN1VLFVj2lB3',#6,'OPS_Assignments',$,(#162)); +#164=IFCRELDEFINESBYPROPERTIES('3Fjdl$tN0Bt399anRxoRbR',#6,$,$,(#70),#163); +#165=IFCPROPERTYSINGLEVALUE('zDcfW$Hvi9oB8l1XiDsz3P#0',$,IFCTEXT('2|UNIFORM'),$); +#166=IFCPROPERTYSET('EhR$o3BlVvjf$VJHHvOl1t',#6,'OPS_Assignments',$,(#165)); +#167=IFCRELDEFINESBYPROPERTIES('d1HjpJjFq78fZNu1kX1xBv',#6,$,$,(#72),#166); +#168=IFCPROPERTYSINGLEVALUE('hZ5FhNGfElx1mLglsRUFpJ#0',$,IFCTEXT('2|UNIFORM'),$); +#169=IFCPROPERTYSET('l9KJxF5nijSr8FhHXpgZJT',#6,'OPS_Assignments',$,(#168)); +#170=IFCRELDEFINESBYPROPERTIES('M3X32PxXh787wDjFJxZNAT',#6,$,$,(#74),#169); #171=IFCRELASSIGNSTOCONTROL('rDVhNjbXHjyDCF$VkTSruD',#6,$,$,(#44,#46,#48,#50,#52,#54,#56,#58,#60,#62,#64,#66,#68,#70,#72,#74,#76,#78,#80,#82),$,#40); -#172=IFCPROPERTYSINGLEVALUE('InternalProjectId',$,IFCTEXT('proj-mtx91aukh0t026'),$); +#172=IFCPROPERTYSINGLEVALUE('InternalProjectId',$,IFCTEXT('proj-mu1n46sx0r6n26'),$); #173=IFCPROPERTYSINGLEVALUE('wbsAutoNumber',$,IFCBOOLEAN(.T.),$); #174=IFCPROPERTYSINGLEVALUE('DefaultTaskDurationUnit',$,IFCLABEL('days'),$); -#175=IFCPROPERTYSINGLEVALUE('ProjectStartDate',$,IFCDATE('2027-05-03'),$); +#175=IFCPROPERTYSINGLEVALUE('ProjectStartDate',$,IFCDATE('2027-05-06'),$); #176=IFCPROPERTYSINGLEVALUE('ProjectEndDate',$,$,$); -#177=IFCPROPERTYSINGLEVALUE('CreatedAt',$,IFCTEXT('2026-09-11T17:47:38.924Z'),$); -#178=IFCPROPERTYSINGLEVALUE('ModifiedAt',$,IFCTEXT('2026-09-11T17:47:38.927Z'),$); +#177=IFCPROPERTYSINGLEVALUE('CreatedAt',$,IFCTEXT('2026-09-14T19:32:52.977Z'),$); +#178=IFCPROPERTYSINGLEVALUE('ModifiedAt',$,IFCTEXT('2026-09-14T19:32:52.980Z'),$); #179=IFCPROPERTYSET('e5a3ArlP$tNZvpDF1ZK5n5',#6,'OPS_ProjectSettings',$,(#172,#173,#174,#175,#176,#177,#178)); #180=IFCRELDEFINESBYPROPERTIES('0Hdzo1sPJhdjQlBF$1BF9l',#6,$,$,(#13),#179); -#181=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auwh6xz33'),$); -#182=IFCPROPERTYSET('rtQN0nLlbBPJclplPn7Dyr',#6,'OPS_TaskIdentity',$,(#181)); -#183=IFCRELDEFINESBYPROPERTIES('yDU$Q9KXB3uNvpjnH70vF$',#6,$,$,(#44),#182); -#184=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auy41vb34'),$); -#185=IFCPROPERTYSET('HNO$cNedxnf51VzHwfER$N',#6,'OPS_TaskIdentity',$,(#184)); -#186=IFCRELDEFINESBYPROPERTIES('O5yZCPpFgvD935iF6j$JEN',#6,$,$,(#46),#185); -#187=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91auzt9uo35'),$); -#188=IFCPROPERTYSET('tJjd3PB$y59Rb11V_RXDOB',#6,'OPS_TaskIdentity',$,(#187)); -#189=IFCRELDEFINESBYPROPERTIES('G9SLj$gdLjsZFv2nn5DPZb',#6,$,$,(#48),#188); -#190=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av113u536'),$); -#191=IFCPROPERTYSET('zlFxwPkHHVQXwVlVxhmfMj',#6,'OPS_TaskIdentity',$,(#190)); -#192=IFCRELDEFINESBYPROPERTIES('sJTf0Hd91tvp0TP$8LWPzL',#6,$,$,(#50),#191); -#193=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av2czof37'),$); -#194=IFCPROPERTYSET('HtyrJZfNqZcpWJFVVFxHjX',#6,'OPS_TaskIdentity',$,(#193)); -#195=IFCRELDEFINESBYPROPERTIES('ebeHdx_NcJSriXlVlBLlJ$',#6,$,$,(#52),#194); -#196=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av4t47y38'),$); -#197=IFCPROPERTYSET('uDdzkD7tP7O95lN$6NMZ$j',#6,'OPS_TaskIdentity',$,(#196)); -#198=IFCRELDEFINESBYPROPERTIES('FLjd37lv0hLHVlf$wDZfcr',#6,$,$,(#54),#197); -#199=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av5fq8k39'),$); -#200=IFCPROPERTYSET('QVL1Yxw72TaJQT1X_RPxzr',#6,'OPS_TaskIdentity',$,(#199)); -#201=IFCRELDEFINESBYPROPERTIES('jbpdbbL$Mbx$qrIXYd4L2Z',#6,$,$,(#56),#200); -#202=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av6pocd40'),$); -#203=IFCPROPERTYSET('$D2XaFCX7ZCx9hYFzpMZbV',#6,'OPS_TaskIdentity',$,(#202)); -#204=IFCRELDEFINESBYPROPERTIES('6lxXaDc7mJkhknZFkV15d7',#6,$,$,(#58),#203); -#205=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av8nrxa41'),$); -#206=IFCPROPERTYSET('nhztUxsPJLnLErYX1$xX8D',#6,'OPS_TaskIdentity',$,(#205)); -#207=IFCRELDEFINESBYPROPERTIES('gva3wZePH$cpap71YPyTFx',#6,$,$,(#60),#206); -#208=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91av9kkum42'),$); -#209=IFCPROPERTYSET('4jobG9y1CzcVQn9$13mtOB',#6,'OPS_TaskIdentity',$,(#208)); -#210=IFCRELDEFINESBYPROPERTIES('3FrnfFy$u$khM9ynPraBeT',#6,$,$,(#62),#209); -#211=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avbgkia43'),$); -#212=IFCPROPERTYSET('gzfRy1YfynN5PzhFzl7RDl',#6,'OPS_TaskIdentity',$,(#211)); -#213=IFCRELDEFINESBYPROPERTIES('nvcfKhJXdpYNpnlXPHZt4h',#6,$,$,(#64),#212); -#214=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avc4ymy44'),$); -#215=IFCPROPERTYSET('1lNJjZndJtdJoLYn3jI$AP',#6,'OPS_TaskIdentity',$,(#214)); -#216=IFCRELDEFINESBYPROPERTIES('8jztc9S1cp8voLMls$l3nV',#6,$,$,(#66),#215); -#217=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91averzu645'),$); -#218=IFCPROPERTYSET('a3qpH$FvUDgnzR0VF9EVi1',#6,'OPS_TaskIdentity',$,(#217)); -#219=IFCRELDEFINESBYPROPERTIES('Z$t5kH09HDm$8nRHkZa3$f',#6,$,$,(#68),#218); -#220=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avf51c546'),$); -#221=IFCPROPERTYSET('T11T43KnYdSJoV3lQznLMD',#6,'OPS_TaskIdentity',$,(#220)); -#222=IFCRELDEFINESBYPROPERTIES('M37r3revmzuN$$N$$1R16R',#6,$,$,(#70),#221); -#223=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avgzdak47'),$); -#224=IFCPROPERTYSET('hTRH7LaHP78vk5WVbxAzN3',#6,'OPS_TaskIdentity',$,(#223)); -#225=IFCRELDEFINESBYPROPERTIES('EP6DfDPt5n9R99CnWtk$Ex',#6,$,$,(#72),#224); -#226=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avii4ns48'),$); -#227=IFCPROPERTYSET('MTJ7tXolPdqx7p3n4hvp09',#6,'OPS_TaskIdentity',$,(#226)); -#228=IFCRELDEFINESBYPROPERTIES('FP_r$zKX1LHrqFVV0dqpiV',#6,$,$,(#74),#227); -#229=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avkc6mc49'),$); -#230=IFCPROPERTYSET('6HhHBZGfePX3p3Bn1$xV47',#6,'OPS_TaskIdentity',$,(#229)); -#231=IFCRELDEFINESBYPROPERTIES('DrlR0HlPjhVbLfqHMh81cF',#6,$,$,(#76),#230); -#232=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avlvj7j50'),$); -#233=IFCPROPERTYSET('xLQjb1l74RhH3PYXXHd5N$',#6,'OPS_TaskIdentity',$,(#232)); -#234=IFCRELDEFINESBYPROPERTIES('_H7Tmt4lJrzfF7oFEnLVWv',#6,$,$,(#78),#233); -#235=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avnbt3y51'),$); -#236=IFCPROPERTYSET('HR3twvUfKtwj2Pg$lvmtqF',#6,'OPS_TaskIdentity',$,(#235)); -#237=IFCRELDEFINESBYPROPERTIES('AN_rXpotfHx$O7clfJNhQL',#6,$,$,(#80),#236); -#238=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mtx91avoux0g52'),$); -#239=IFCPROPERTYSET('jtibKrNNC329ZhIFyPm9uJ',#6,'OPS_TaskIdentity',$,(#238)); -#240=IFCRELDEFINESBYPROPERTIES('QR0tg57tVxf5NRGVAbcvfZ',#6,$,$,(#82),#239); +#181=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tb108r33'),$); +#182=IFCPROPERTYSET('dxSLJ$cvmZSjMLUn7bwd9F',#6,'OPS_TaskIdentity',$,(#181)); +#183=IFCRELDEFINESBYPROPERTIES('WXRHbb5VXvhHxR4nT3elrT',#6,$,$,(#44),#182); +#184=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tdt7px34'),$); +#185=IFCPROPERTYSET('kRjvHbzlBt_bVRKnAxYL6X',#6,'OPS_TaskIdentity',$,(#184)); +#186=IFCRELDEFINESBYPROPERTIES('BfERuJ5Xdp2t3xCn8z$xP7',#6,$,$,(#46),#185); +#187=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tee0n735'),$); +#188=IFCPROPERTYSET('7Lk5Y5E9RLj7k5mV_5jvPB',#6,'OPS_TaskIdentity',$,(#187)); +#189=IFCRELDEFINESBYPROPERTIES('0l7ZCdbVJNxXYt81hd8nTx',#6,$,$,(#48),#188); +#190=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tf05uk36'),$); +#191=IFCPROPERTYSET('2FxVuj3l7ZyLw$pFdH5VWd',#6,'OPS_TaskIdentity',$,(#190)); +#192=IFCRELDEFINESBYPROPERTIES('tp$r9Xs$en4pmR0X$$rFdd',#6,$,$,(#50),#191); +#193=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46thh8k437'),$); +#194=IFCPROPERTYSET('3nDvfHGNsrJ7QFt1v$tr4h',#6,'OPS_TaskIdentity',$,(#193)); +#195=IFCRELDEFINESBYPROPERTIES('AD9DvLyHpbhH1XxF25hXOr',#6,$,$,(#52),#194); +#196=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tik39c38'),$); +#197=IFCPROPERTYSET('UT8Fhf09hjkLoBQ1gnfZPd',#6,'OPS_TaskIdentity',$,(#196)); +#198=IFCRELDEFINESBYPROPERTIES('RXTNOzjXXPtRjjXVQztz$N',#6,$,$,(#54),#197); +#199=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tj1mx139'),$); +#200=IFCPROPERTYSET('Z$HjRX3H7z0FPlRFyrrnvf',#6,'OPS_TaskIdentity',$,(#199)); +#201=IFCRELDEFINESBYPROPERTIES('Mztxw7sdFlOdKVf$_twdxF',#6,$,$,(#56),#200); +#202=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tlser340'),$); +#203=IFCPROPERTYSET('vr2VK95FK9Y5jXHVLTj7LJ',#6,'OPS_TaskIdentity',$,(#202)); +#204=IFCRELDEFINESBYPROPERTIES('0FJd7rW9djsZLzlXTXlrS9',#6,$,$,(#58),#203); +#205=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tmduok41'),$); +#206=IFCPROPERTYSET('L7CDLjoHNF9Rzx4lPrKJIB',#6,'OPS_TaskIdentity',$,(#205)); +#207=IFCRELDEFINESBYPROPERTIES('aZWNWXxVpxtbJJv1b$9rGX',#6,$,$,(#60),#206); +#208=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tnkbco42'),$); +#209=IFCPROPERTYSET('jRTf8BWt9LjvM9il8h07Lp',#6,'OPS_TaskIdentity',$,(#208)); +#210=IFCRELDEFINESBYPROPERTIES('CNUh3PRXhJEBunzFeDpd5H',#6,$,$,(#62),#209); +#211=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tobixn43'),$); +#212=IFCPROPERTYSET('N3FDXtyljLvpmB2XJ9cvV7',#6,'OPS_TaskIdentity',$,(#211)); +#213=IFCRELDEFINESBYPROPERTIES('Gv69M$9fifYfT9ClYhFLUJ',#6,$,$,(#64),#212); +#214=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tqyisk44'),$); +#215=IFCPROPERTYSET('NPWf8pP93TILQFFXvxUL6b',#6,'OPS_TaskIdentity',$,(#214)); +#216=IFCRELDEFINESBYPROPERTIES('U3Szg1l7ut$5bBkXUPaB8j',#6,$,$,(#66),#215); +#217=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46ts3dlh45'),$); +#218=IFCPROPERTYSET('$D2VW1$Phpo72Dd1G1rF9l',#6,'OPS_TaskIdentity',$,(#217)); +#219=IFCRELDEFINESBYPROPERTIES('6H5Xm9CFmjsT3Ps$pdiJMD',#6,$,$,(#68),#218); +#220=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tumgja46'),$); +#221=IFCPROPERTYSET('Q$$TSnCXvz87gNmVGHXpiX',#6,'OPS_TaskIdentity',$,(#220)); +#222=IFCRELDEFINESBYPROPERTIES('jxVzoZFPXx9RXRylZZCbjV',#6,$,$,(#70),#221); +#223=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tvxr4m47'),$); +#224=IFCPROPERTYSET('AjR$QtCF4RrnNvkFuTjvHH',#6,'OPS_TaskIdentity',$,(#223)); +#225=IFCRELDEFINESBYPROPERTIES('H9yB7vlvmTaj_7InuLOVGX',#6,$,$,(#72),#224); +#226=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46txg4ci48'),$); +#227=IFCPROPERTYSET('DBtDdxc74xvL2JrFphnhAv',#6,'OPS_TaskIdentity',$,(#226)); +#228=IFCRELDEFINESBYPROPERTIES('id0tMVRXljyhkHlXNtcvvR',#6,$,$,(#74),#227); +#229=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tyt9o949'),$); +#230=IFCPROPERTYSET('Lz1ZmPEH$BDd43v$LvCpOz',#6,'OPS_TaskIdentity',$,(#229)); +#231=IFCRELDEFINESBYPROPERTIES('ovMd5hrH9BXbFjZlSxB1kh',#6,$,$,(#76),#230); +#232=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46tz9zl550'),$); +#233=IFCPROPERTYSET('3Dcf0FjVR1$5LzP$f92HxL',#6,'OPS_TaskIdentity',$,(#232)); +#234=IFCRELDEFINESBYPROPERTIES('yl5Xm94HFnCZzvsH8hmt8J',#6,$,$,(#78),#233); +#235=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46u1yoy651'),$); +#236=IFCPROPERTYSET('T34rl5VPnF4L2Jhnj9A9ip',#6,'OPS_TaskIdentity',$,(#235)); +#237=IFCRELDEFINESBYPROPERTIES('SVxFjzLlljSjMN0Vb$VpG1',#6,$,$,(#80),#236); +#238=IFCPROPERTYSINGLEVALUE('InternalTaskId',$,IFCTEXT('task-mu1n46u31o9x52'),$); +#239=IFCPROPERTYSET('AJpvh9KVPPSBmhEF6Dztqz',#6,'OPS_TaskIdentity',$,(#238)); +#240=IFCRELDEFINESBYPROPERTIES('ltiJP9RFo5dr4Ne107Gtqn',#6,$,$,(#82),#239); #241=IFCPROPERTYSINGLEVALUE('ConstraintType',$,IFCLABEL('SNET'),$); -#242=IFCPROPERTYSINGLEVALUE('ConstraintDate',$,IFCDATE('2027-05-11'),$); -#243=IFCPROPERTYSET('ebmvvvVPLvetLtWVBjPxT5',#6,'OPS_Constraints',$,(#241,#242)); -#244=IFCRELDEFINESBYPROPERTIES('V3qDbRh$obx$Kb4F9znLob',#6,$,$,(#50),#243); -#245=IFCPROPERTYSINGLEVALUE('Deadline',$,IFCDATE('2027-07-26'),$); -#246=IFCPROPERTYSET('yFXhfjZHVxN59rs13l$j6x',#6,'OPS_Constraints',$,(#245)); -#247=IFCRELDEFINESBYPROPERTIES('BJBnJZffaVZ9DT7$MlDdTR',#6,$,$,(#80),#246); +#242=IFCPROPERTYSINGLEVALUE('ConstraintDate',$,IFCDATE('2027-05-14'),$); +#243=IFCPROPERTYSET('f7qxwZW9rfkdSDd1CnFLgN',#6,'OPS_Constraints',$,(#241,#242)); +#244=IFCRELDEFINESBYPROPERTIES('YTWfur0tRL5XIxmXIXlByd',#6,$,$,(#50),#243); +#245=IFCPROPERTYSINGLEVALUE('Deadline',$,IFCDATE('2027-07-29'),$); +#246=IFCPROPERTYSET('oPwD93z$Sh7DGBc$A5cpm1',#6,'OPS_Constraints',$,(#245)); +#247=IFCRELDEFINESBYPROPERTIES('7Lkxc9Sl7fov6pZn_9s9yr',#6,$,$,(#80),#246); #248=IFCPROPERTYSINGLEVALUE('MilestoneKind',$,IFCLABEL('START'),$); -#249=IFCPROPERTYSET('Q1V3YR_FyRBFZLgHk1J7JX',#6,'OPS_Milestone',$,(#248)); -#250=IFCRELDEFINESBYPROPERTIES('1ps7ZR2F459bt9iF_RVpSf',#6,$,$,(#44),#249); +#249=IFCPROPERTYSET('Ajp7b1FvUdar4371FfwjAZ',#6,'OPS_Milestone',$,(#248)); +#250=IFCRELDEFINESBYPROPERTIES('ZVRH5BNfKNslLTJFTZijw1',#6,$,$,(#44),#249); #251=IFCPROPERTYSINGLEVALUE('MilestoneKind',$,IFCLABEL('FINISH'),$); #252=IFCPROPERTYSINGLEVALUE('Mandatory',$,IFCBOOLEAN(.T.),$); -#253=IFCPROPERTYSET('WVL1QDQ7CdSjoBkXcz$5XT',#6,'OPS_Milestone',$,(#251,#252)); -#254=IFCRELDEFINESBYPROPERTIES('vhMzALpXTVMFH3lVxBiJMp',#6,$,$,(#80),#253); -#255=IFCPROPERTYSINGLEVALUE('Baselines',$,IFCTEXT('[{"id":"baseline-mtx91axtu7xv77","name":"Baseline plan","createdAt":"2026-09-11T17:47:39.041Z","tasks":[{"taskId":"task-mtx91auwh6xz33","start":"2027-05-03","finish":"2027-05-03","duration":0,"isMilestone":true,"milestoneKind":"START"},{"taskId":"task-mtx91auzt9uo35","start":"2027-05-03","finish":"2027-05-04","duration":2,"isMilestone":false},{"taskId":"task-mtx91av113u536","start":"2027-05-11","finish":"2027-05-13","duration":3,"isMilestone":false},{"taskId":"task-mtx91av4t47y38","start":"2027-05-14","finish":"2027-05-19","duration":3,"isMilestone":false},{"taskId":"task-mtx91av5fq8k39","start":"2027-05-20","finish":"2027-05-25","duration":4,"isMilestone":false},{"taskId":"task-mtx91av6pocd40","start":"2027-05-26","finish":"2027-05-28","duration":3,"isMilestone":false},{"taskId":"task-mtx91av8nrxa41","start":"2027-05-31","finish":"2027-06-07","duration":6,"isMilestone":false},{"taskId":"task-mtx91av9kkum42","start":"2027-06-02","finish":"2027-06-07","duration":4,"isMilestone":false},{"taskId":"task-mtx91avc4ymy44","start":"2027-06-08","finish":"2027-06-10","duration":3,"isMilestone":false},{"taskId":"task-mtx91averzu645","start":"2027-06-11","finish":"2027-06-16","duration":4,"isMilestone":false},{"taskId":"task-mtx91avf51c546","start":"2027-06-17","finish":"2027-06-23","duration":5,"isMilestone":false},{"taskId":"task-mtx91avgzdak47","start":"2027-06-24","finish":"2027-06-29","duration":4,"isMilestone":false},{"taskId":"task-mtx91avii4ns48","start":"2027-06-28","finish":"2027-06-30","duration":3,"isMilestone":false},{"taskId":"task-mtx91avlvj7j50","start":"2027-07-01","finish":"2027-07-01","duration":1,"isMilestone":false},{"taskId":"task-mtx91avnbt3y51","start":"2027-07-01","finish":"2027-07-01","duration":0,"isMilestone":true,"milestoneKind":"FINISH"},{"taskId":"task-mtx91avoux0g52","start":"2027-07-02","finish":"2027-07-02","duration":1,"isMilestone":false}],"projectEnd":"2027-07-02","projectDuration":42}]'),$); -#256=IFCPROPERTYSINGLEVALUE('TaskGuids',$,IFCTEXT('{"task-mtx91auwh6xz33":"IfmPdbs$uNDP8FBlzpgTlr","task-mtx91auzt9uo35":"Q$9xEXg$QJ4p8NyHdrqpOr","task-mtx91av113u536":"WXJ7VJ_FKN61avUnxlJvbR","task-mtx91av4t47y38":"R1lJnr7txx1ZlfCH6pDPBd","task-mtx91av5fq8k39":"zFrnvv$dT1kLY981sxobT9","task-mtx91av6pocd40":"SZeH5rV7u$2b1Bw$Kjk1sP","task-mtx91av8nrxa41":"Kx7r9XklhP4LMNWX71dxlB","task-mtx91av9kkum42":"dR8XaDc7KNMFjDpnhTpP1p","task-mtx91avc4ymy44":"UTO1sFDVZV29jjN1oDMTJz","task-mtx91averzu645":"7BY$Arvtdd4L2J5FgLG9O3","task-mtx91avf51c546":"0nLHxLT12twjYt81BNwj_3","task-mtx91avgzdak47":"8pdzctqnWbjfzPEVqHpdj3","task-mtx91avii4ns48":"vHZfy3SlJf5VohYn$$BFZb","task-mtx91avlvj7j50":"OxahfJL12zGX_PIVoDszF$","task-mtx91avnbt3y51":"qr$B9VM1MvD935yHhvS3TN","task-mtx91avoux0g52":"mdsTGfW9f5$TnvIF47ijYJ"}'),$); -#257=IFCPROPERTYSINGLEVALUE('ActiveBaselineId',$,IFCTEXT('baseline-mtx91axtu7xv77'),$); +#253=IFCPROPERTYSET('EhjdnlHdPpYNtBoXKXLXKD',#6,'OPS_Milestone',$,(#251,#252)); +#254=IFCRELDEFINESBYPROPERTIES('dXxFbXRXXRD9RXrnjbXDmn',#6,$,$,(#80),#253); +#255=IFCPROPERTYSINGLEVALUE('Baselines',$,IFCTEXT('[{"id":"baseline-mu1n46vijqjz77","name":"Baseline plan","createdAt":"2026-09-14T19:32:53.070Z","tasks":[{"taskId":"task-mu1n46tb108r33","start":"2027-05-07","finish":"2027-05-07","duration":0,"isMilestone":true,"milestoneKind":"START"},{"taskId":"task-mu1n46tee0n735","start":"2027-05-07","finish":"2027-05-10","duration":2,"isMilestone":false},{"taskId":"task-mu1n46tf05uk36","start":"2027-05-14","finish":"2027-05-19","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tik39c38","start":"2027-05-20","finish":"2027-05-24","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tj1mx139","start":"2027-05-25","finish":"2027-05-28","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tlser340","start":"2027-05-31","finish":"2027-06-02","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tmduok41","start":"2027-06-03","finish":"2027-06-10","duration":6,"isMilestone":false},{"taskId":"task-mu1n46tnkbco42","start":"2027-06-07","finish":"2027-06-10","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tqyisk44","start":"2027-06-11","finish":"2027-06-15","duration":3,"isMilestone":false},{"taskId":"task-mu1n46ts3dlh45","start":"2027-06-16","finish":"2027-06-21","duration":4,"isMilestone":false},{"taskId":"task-mu1n46tumgja46","start":"2027-06-22","finish":"2027-06-28","duration":5,"isMilestone":false},{"taskId":"task-mu1n46tvxr4m47","start":"2027-06-29","finish":"2027-07-02","duration":4,"isMilestone":false},{"taskId":"task-mu1n46txg4ci48","start":"2027-07-01","finish":"2027-07-05","duration":3,"isMilestone":false},{"taskId":"task-mu1n46tz9zl550","start":"2027-07-06","finish":"2027-07-06","duration":1,"isMilestone":false},{"taskId":"task-mu1n46u1yoy651","start":"2027-07-06","finish":"2027-07-06","duration":0,"isMilestone":true,"milestoneKind":"FINISH"},{"taskId":"task-mu1n46u31o9x52","start":"2027-07-07","finish":"2027-07-07","duration":1,"isMilestone":false}],"projectEnd":"2027-07-07","projectDuration":43}]'),$); +#256=IFCPROPERTYSINGLEVALUE('TaskGuids',$,IFCTEXT('{"task-mu1n46tb108r33":"ANsZeTd9hvePK$rlb1fhkv","task-mu1n46tee0n735":"g7CpTNNN8$ATZfWXXDgTXz","task-mu1n46tf05uk36":"V3KRqPzHhdCTJ1znKJI1YH","task-mu1n46tik39c38":"5LIxAllPJLHrqnP$atEV0N","task-mu1n46tj1mx139":"0n5XujxV9Lb$ads1rJFTzB","task-mu1n46tlser340":"MPelBzqVjLtzt9qFuTLlJV","task-mu1n46tmduok41":"odkxIpfNEnlJoLYFfpxHvx","task-mu1n46tnkbco42":"sDXhNjZHhDEvoLUn9ZBF9F","task-mu1n46tqyisk44":"q9gPvLqF8fTtXbKHOJ$Jot","task-mu1n46ts3dlh45":"SZelfJTHbLBnJDbHUdSz$9","task-mu1n46tumgja46":"3l9D3PRXhD61e72npdKjcl","task-mu1n46tvxr4m47":"dX5nPthldvO91rEXCZyhsZ","task-mu1n46txg4ci48":"MZL$E3DVtXzNfRu$VFTfEb","task-mu1n46tz9zl550":"cRGddx_fI1NZVHt1ql5V4B","task-mu1n46u1yoy651":"wLcJxFxn4n9bV5GVWfoRZN","task-mu1n46u31o9x52":"dVTNeZHPNRTdO$VVORUnJz"}'),$); +#257=IFCPROPERTYSINGLEVALUE('ActiveBaselineId',$,IFCTEXT('baseline-mu1n46vijqjz77'),$); #258=IFCPROPERTYSET('XXh1w34HTJCTF7clPLWdrv',#6,'OPS_Baselines',$,(#255,#256,#257)); #259=IFCRELDEFINESBYPROPERTIES('Oz1Z43KV$BzN1$1VwJtr4B',#6,$,$,(#40),#258); ENDSEC; diff --git a/scripts/showcases.ts b/scripts/showcases.ts index 5205f0e38..922ac7b35 100644 --- a/scripts/showcases.ts +++ b/scripts/showcases.ts @@ -37,6 +37,26 @@ import type { ProjectSpec, TaskSpec, LinkSpec } from './spec'; // boeken de Masonry crew (bedrijfscapaciteit 1), beide liggen ná MIDDEL's statusdatum en zijn dus // écht verschuifbaar — een conflict dat "Verdeel automatisch" met een paar werkdagen oplost. // Verhaal: deze verbouwing loopt náást de rijwoningen, niet ervoor. +// +// WAAROM 66 EN NIET 63 (2026-09-14, "onderbrekingen toestaan moet verschil maken"). De schakelaar +// **Onderbrekingen toestaan** in de verdeeldialoog is in de nivelleerder géén tweede zoekstrategie +// maar een TERUGVAL: `ResourceLeveler.findSlot` scant eerst naar een AANEENGESLOTEN venster en komt +// pas bij `scatterSlot` (de pauzedagen) als díé scan binnen het venster faalt. Zonder venster +// (`constrainToFloat: false` + geen "maximale uitloop") is er geen bovengrens, vindt de +// aaneengesloten scan altijd iets, en is de schakelaar per constructie een no-op — precies wat het +// prijskaartje meldde ("zou niets besparen"). Hij doet dus alleen iets in combinatie met een +// PLAFOND, en het bruikbare plafondbereik is exact zo breed als het aantal VRIJE werkdagen dat de +// wijkende taak vóór de blokkade heeft: +// - bij 63 begon KLEIN's metselwerk op 2027-05-31 en had MIDDEL's "Ground floor masonry — +// House 6" (2027-05-28…06-04) precies ÉÉN vrije dag ervóór ⇒ alleen plafond 14 gaf verschil; +// één getal breed, dus in de praktijk onvindbaar voor wie het niet toevallig intikt. +// - bij 66 begint KLEIN's metselwerk op 2027-06-03 en heeft House 6 er VIER vrije dagen vóór +// (05-28, 05-31, 06-01, 06-02) ⇒ elk plafond van 14 t/m 17 werkdagen laat het verschil zien, +// met het ronde "15 werkdagen (drie weken)" in het midden. Zie de bewijsassert in +// tests/library/check-showcase-occupancy.ts en de gids `gids-verdelen-restcapaciteit.md`. +// De prijs van de schuif: de Masonry crew-conflictstrook wordt korter (2 dagen i.p.v. 5) en de +// Plasterers-kruisdagen verschuiven mee — beide kruisconflicten blijven bestaan (de exacte-set-poort +// in check-showcase-occupancy.ts bewaakt dat). const KLEIN: ProjectSpec = { slug: 'showcase-verbouwing-eengezinswoning', name: 'Refurbishment & Extension of a Family Home', @@ -53,7 +73,7 @@ const KLEIN: ProjectSpec = { 'week as the terraced houses showcase, so the occupancy overview shows a cross-project conflict ' + 'that "Distribute" can resolve.', tags: ['residential', 'small', 'entry-level', 'milestones', 'constraints', 'baseline', 'resources'], - anchorShiftDays: 63, + anchorShiftDays: 66, tasks: [ // 1. Voorbereiding { key: 'ms_start', name: 'Start of refurbishment', milestone: true, milestoneKind: 'START' }, From 73c3debc3731e53ddd40758ed849620bafed5f0c Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:36:21 +0200 Subject: [PATCH 097/108] fix(b1c): het sluitkruisje van de verdeeldialoog staat rechtsBOVEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De kop is in de gevulde stand drie regels hoog (itemnaam, ondertitel, introregel), en `items-center` centreerde het kruisje over die hele hoogte — het zweefde halverwege de kop. Met `items-start` lijnt hij uit op de titelregel, zoals in de eenregelige koppen elders. De kophoogte verandert niet (§7). Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 4ec08bc77..62f1e0c8b 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -554,8 +554,16 @@ export function DistributionDialog() { ...(lastStaleReason ? { 'data-ops-distribution-last-stale-reason': lastStaleReason } : {}), }} > - {/* (1) Kop */} -
+ {/* (1) Kop. + + `items-start`, NIET `items-center` (gebruikstest eigenaar 2026-09-14). De kop van déze + dialoog is in de gevulde stand DRIE regels hoog — itemnaam, ondertitel, introregel — + terwijl hij in bijvoorbeeld `ProjectInfoDialog` één regel is. Met `items-center` centreert + het sluitkruisje zich over die hele hoogte, en stond het dus halverwege de kop te zweven: + precies waar niemand een sluitknop zoekt. Uitgelijnd op de TITELregel staat hij weer waar + hij hoort. De hoogte van de kop verandert hier niet door (§7) — dit is alleen de verticale + uitlijning van twee bestaande kinderen, geen extra of weggehaald blok. */} +
{tune ? itemName : t('resource.distribution.title')} From e0606abc595e8fb6220ae65b7d55b08e3cb33fb6 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:37:53 +0200 Subject: [PATCH 098/108] test(examples): poort op het verschil dat "onderbrekingen toestaan" maakt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twee nieuwe blokken in check-showcase-occupancy.ts, plus de gidsalinea nl/en. (b3) bewijst het verschil zelf: met een plafond van 15 werkdagen op De Akkers geeft de uit-stand een tekort (Toepassen geblokkeerd) en de aan-stand een toepasbaar voorstel met zes werkdagen uitloop en minstens één taak met leveling-gaten. De eis is bewust NIET "kleinere uitloop bij gelijke tekorten". Dat is met deze nivelleerder onbereikbaar, niet ongemeten: scatterSlot is een terugval na de aaneengesloten scan, dus de twee standen kunnen alleen verschillen op precies het punt waar de uit-stand vastloopt — en daar levert die stand een tekort op, geen grotere uitloop. Het commentaarblok bij (b3) legt dat uit zodat niemand deze assert "strenger" maakt in een richting die niet bestaat. (b4) bewaakt de BREEDTE: het plafondbereik waarin de schakelaar verschil maakt moet minstens drie opeenvolgende waarden tellen en het ronde 15 bevatten. Gemeten bereik nu: 14 t/m 17. Vóór de ankerschuif was het alleen 14 — dat is de reden dat het verschil in de demo niet te vinden was. Co-Authored-By: Claude Fable 5.1 --- .../docs/en/gids-verdelen-restcapaciteit.md | 10 +++ .../docs/nl/gids-verdelen-restcapaciteit.md | 11 +++ tests/library/check-showcase-occupancy.ts | 73 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/public/docs/en/gids-verdelen-restcapaciteit.md b/public/docs/en/gids-verdelen-restcapaciteit.md index a6412d6eb..77be92d83 100644 --- a/public/docs/en/gids-verdelen-restcapaciteit.md +++ b/public/docs/en/gids-verdelen-restcapaciteit.md @@ -51,6 +51,16 @@ Click **Masonry crew**, then **Distribute…** on that row and let **Distribute automatically** build the proposal; then drag a project's grip to the right, or pin a project, and watch what happens to the proposed shift. +**Masonry crew** is also the item that shows **"Allow interruptions"** doing real work. That switch +only bites once there is a limit on the overrun as well — without one, the distributor always finds +an uninterrupted slot further along, so there is nothing to gain. So set the maximum overrun on +**6 New Terraced Houses, De Akkers** to **15 working days** (three weeks) and leave the switch off: +the proposal now reports a shortfall and **Apply** stays disabled — house 6's masonry no longer fits +inside that limit as one uninterrupted stretch. Switch it on and the proposal does work out: house 6 +keeps bricklaying on the days before the extension starts, pauses while the crew is at the family +home, and finishes afterwards — six working days of overrun, comfortably inside the three weeks. Any +limit between 14 and 17 working days shows the same thing. + A few situations block the distribution right away, with a clear reason: - One of the involved projects hasn't been calculated yet — calculate it first (F5) in that project. diff --git a/public/docs/nl/gids-verdelen-restcapaciteit.md b/public/docs/nl/gids-verdelen-restcapaciteit.md index f27606c52..57c48a54d 100644 --- a/public/docs/nl/gids-verdelen-restcapaciteit.md +++ b/public/docs/nl/gids-verdelen-restcapaciteit.md @@ -52,6 +52,17 @@ Klik op **Masonry crew** en dan op **Verdelen…**, en laat **Verdeel automatisch** het voorstel maken; trek daarna eens de greep van een project naar rechts of zet een project vast, en kijk wat er met de voorgestelde verschuiving gebeurt. +**Masonry crew** is ook het item waarop u **"Onderbrekingen toestaan"** zelf ziet werken. Die +schakelaar doet alleen iets zodra er ook een grens op de uitloop staat — zonder grens vindt de +verdeler altijd wel een aaneengesloten plek verderop, en is er niets te winnen. Zet daarom bij +**Nieuwbouw 6 Rijwoningen De Akkers** de maximale uitloop op **15 werkdagen** (drie weken) en laat +de schakelaar nog uit: het voorstel meldt dan een tekort en **Toepassen** blijft uit — het +metselwerk van woning 6 past in één aaneengesloten stuk niet meer binnen die grens. Zet de +schakelaar vervolgens aan en het voorstel klopt wél: woning 6 metselt de dagen vóór de uitbouw +gewoon door, pauzeert zolang de ploeg bij de eengezinswoning staat, en maakt daarna af — zes +werkdagen uitloop, ruim binnen de drie weken. Elke grens tussen 14 en 17 werkdagen laat hetzelfde +zien. + Een paar situaties blokkeren de verdeling meteen, met een duidelijke reden: - Een van de betrokken projecten is nog niet doorgerekend — reken het eerst door (F5) in dat diff --git a/tests/library/check-showcase-occupancy.ts b/tests/library/check-showcase-occupancy.ts index 9a6b43cf1..b57f627cd 100644 --- a/tests/library/check-showcase-occupancy.ts +++ b/tests/library/check-showcase-occupancy.ts @@ -200,6 +200,79 @@ if (poolItem) { } } +// ═══ (b3) "Onderbrekingen toestaan" maakt op de showcases ECHT verschil ══════════════════════════ +// De aanleiding (2026-09-14): met de drie showcases open meldde het prijskaartje van de schakelaar +// in beide standen exact hetzelfde getal — "zou niets besparen". De demo bevatte dus een knop die +// aantoonbaar niets deed. +// +// WAT ER GEMETEN IS, EN WAAROM DE EIS NIET "KLEINERE UITLOOP" IS. `allowSplits` is in +// `ResourceLeveler.findSlot` geen tweede zoekstrategie maar een TERUGVAL: de aaneengesloten +// kandidaatscan loopt eerst, en `scatterSlot` (de pauzedagen) komt pas aan bod als díé scan binnen +// het venster faalt. Twee gevolgen die je moet kennen voor je deze assert "strenger" maakt: +// 1. Zónder venster — en `computeDistribution` zet `constrainToFloat: false`, dus het venster +// bestaat alleen bij een expliciete "maximale uitloop" (`ceilingWorkdays`) — vindt de +// aaneengesloten scan ALTIJD een slot en is de schakelaar per constructie een no-op. Gemeten op +// deze drie showcases: 10 werkdagen uitloop in beide standen. Dat is engine-gedrag en met geen +// enkele spec-tweak aan de voorbeelden te veranderen. +// 2. Slaat de aaneengesloten scan WEL af (plafond bindt), dan blijft de taak in de uit-stand op +// haar PF liggen MET conflictdagen — een TEKORT, geen grotere uitloop. Een eis van de vorm +// "kleinere uitloop in beide standen, zonder tekort" is daarom niet alleen niet gehaald maar +// onbereikbaar: de twee standen kunnen per definitie alleen verschillen op het punt waar de +// uit-stand een tekort heeft. +// De eerlijke, wél demonstreerbare belofte is dus deze: mét een plafond verandert de schakelaar een +// ONOPLOSBAAR voorstel (tekort ⇒ Toepassen geblokkeerd) in een toepasbaar voorstel binnen datzelfde +// plafond. Dat is het verschil dat de gids "Uitproberen met de voorbeelden" beschrijft. +{ + const CEILING = 15; // werkdagen — het midden van het werkzame bereik 14 t/m 17 (zie hieronder) + const AKKERS = 1; // index van MIDDEL in SLUGS + const withCeiling: DistributionDocInput[] = distInputs.map((d, i) => ({ + ...d, ceilingWorkdays: i === AKKERS ? CEILING : null, + })); + if (poolItem) { + const off = computeDistribution(DEMO_COMPANY_ID, pool, poolItem.id, withCeiling, { allowSplits: false }); + const on = computeDistribution(DEMO_COMPANY_ID, pool, poolItem.id, withCeiling, { allowSplits: true }); + const shortfallsOf = (p: typeof off): number => p.docs.reduce((a, d) => a + d.shortfalls.length, 0); + const gapTasksOf = (p: typeof off): number => p.docs.reduce((a, d) => a + Object.keys(d.gaps).length, 0); + + assert(off.blocked === null && on.blocked === null, + `"${CONFLICT_ITEM}" met plafond ${CEILING}: de actie mag in geen van beide standen geblokkeerd zijn`); + assert(shortfallsOf(off) > 0, + `"${CONFLICT_ITEM}" met plafond ${CEILING} en onderbrekingen UIT: verwacht een tekort (kreeg ${shortfallsOf(off)}). ` + + 'Zonder dat tekort valt er niets te winnen en meldt het prijskaartje weer "zou niets besparen".'); + assert(shortfallsOf(on) === 0, + `"${CONFLICT_ITEM}" met plafond ${CEILING} en onderbrekingen AAN: verwacht GEEN tekort (kreeg ${shortfallsOf(on)})`); + assert(gapTasksOf(on) >= 1, + `"${CONFLICT_ITEM}" met onderbrekingen AAN: verwacht minstens één taak met leveling-gaten (kreeg ${gapTasksOf(on)})`); + const maxOn = Math.max(0, ...on.docs.map((d) => d.endShiftWorkdays)); + assert(maxOn > 0 && maxOn <= CEILING, + `"${CONFLICT_ITEM}" met onderbrekingen AAN: uitloop ${maxOn} werkdagen — verwacht > 0 en binnen het plafond ${CEILING}`); + } +} + +// ═══ (b4) Het werkzame plafondbereik is breed genoeg om te DEMONSTREREN ══════════════════════════ +// Waarom dit een eigen poort is: vóór de ankerschuif van KLEIN (63 → 66, zie `scripts/showcases.ts`) +// werkte exact ÉÉN plafondwaarde (14) — wie 13 of 15 intikte zag niets en concludeerde terecht dat +// de schakelaar niets doet. Het bereik is precies zo breed als het aantal VRIJE werkdagen dat de +// wijkende taak vóór de blokkade heeft; deze assert is dus de mechanische bewaking op dat aantal. +{ + if (poolItem) { + const werkt: number[] = []; + for (const c of [13, 14, 15, 16, 17, 18]) { + const inp: DistributionDocInput[] = distInputs.map((d, i) => ({ ...d, ceilingWorkdays: i === 1 ? c : null })); + const off = computeDistribution(DEMO_COMPANY_ID, pool, poolItem.id, inp, { allowSplits: false }); + const on = computeDistribution(DEMO_COMPANY_ID, pool, poolItem.id, inp, { allowSplits: true }); + const sf = (p: typeof off): number => p.docs.reduce((a, d) => a + d.shortfalls.length, 0); + if (sf(off) > 0 && sf(on) === 0) werkt.push(c); + } + assert(werkt.length >= 3, + `het plafondbereik waarin de schakelaar verschil maakt is [${werkt.join(', ')}] — verwacht ≥3 opeenvolgende ` + + 'waarden, anders is het verschil in de demo niet te vinden. Vergroot het aantal vrije werkdagen vóór de ' + + 'blokkade via KLEIN\'s `anchorShiftDays` in scripts/showcases.ts.'); + assert(werkt.includes(15), + `het ronde plafond van 15 werkdagen (drie weken) — dat de gids noemt — zit niet in het werkzame bereik [${werkt.join(', ')}]`); + } +} + // ═══ (c) Geen intrinsieke overvraag: geen showcase-taak vraagt op één dag meer dan de poolcapaciteit ══ // Zou één taak méér vragen dan het bedrijf heeft, dan is het conflict per definitie onoplosbaar en // levert "Verdeel automatisch" altijd een tekort — hoe de documenten ook geschoven worden. From 6c4a057dcc2481903705c842459fd8193862ce62 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:39:30 +0200 Subject: [PATCH 099/108] test(b1c): de vaste-kolommentest rekt de as vanaf plafond 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zonder Home vooraf staat de greep al tegen de rechterrand van de as (plafond onbegrensd), en levert één sleep tot de vensterrand maar een handvol werkdagen op — te weinig om de as buiten de dialoog te laten groeien, dus toetste de test zijn eigen aanname niet. Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index f90673c63..f28e71b47 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -887,6 +887,16 @@ test('een opgerekt plafond schuift de label- en uitkomstkolom niet uit de dialoo const dayWidth = Number(await strip.getAttribute('data-ops-distribution-day-width')); expect(dayWidth).toBeGreaterThan(0); + // Home eerst: het plafond staat standaard op ONBEGRENSD, en dan zit de greep al tegen de + // rechterrand van de as — er is dan nauwelijks sleepruimte over binnen het venster. Vanaf + // plafond 0 staat hij bij het fase-einde en levert één sleep naar de vensterrand tientallen + // werkdagen op. Zelfde deterministische startpunt als de sleeptests hierboven. + await handle.focus(); + await handle.press('Home'); + await expect(handle).toHaveAttribute('aria-valuenow', '0'); + await expect.poll(() => strip.locator('[data-ops-distribution-effect]').textContent()) + .not.toContain('…'); + // Een ECHTE sleep, zo ver naar rechts als het venster toelaat: de dagenset groeit mee met het // plafond, dus de tijdas wordt hierdoor een veelvoud breder dan de dialoog zelf. Dat is precies // de stand waarin de eigenaar "eind +5 dage" en "max onbegrensd · benu" afgekapt zag. From 2db8ece31c9d9d1a5514da84f6166306ad951f3a Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:45:03 +0200 Subject: [PATCH 100/108] fix(b1c): de greep volgt de muis ook nu de tijdas niet meer meegroeit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vervolg op het rekenen-bij-loslaten: zonder herberekening groeit de tijdas tijdens het slepen niet mee, en dan kan de geometrie de greep niet plaatsen — `advanceWorkdays` landt op een datum buiten de as en `rightEdgeX` klemt die terug op de rechterrand van het laatste assegment. Gemeten op de screenshot van deze ronde: het uitkomstlabel liep door naar "max 22 okt" terwijl de greep bij het fase-einde bleef plakken, dus je sleepte en er bewoog niets. De greep wordt tijdens een sleep daarom getekend op zijn anker plus het aantal gesnapte werkdagen × de dagbreedte — dezelfde omrekening die de waarde zelf bepaalt, dus greep en muis lopen per constructie synchroon. De gestippelde rest loopt mee tot diezelfde x. Het anker wordt pas op de EERSTE beweging vastgelegd, zodat een plafond dat van onbegrensd komt niet verspringt. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/PhaseStrip.tsx | 51 ++++++++++++++++--- tests/browser/leveling-distribution.spec.ts | 7 +++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 1fd65dff8..61ad287c8 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -127,8 +127,26 @@ export function PhaseStrip({ // Sleepstate: `dragRef` is de bron van waarheid TIJDENS het slepen (geen staleness over // event-grenzen heen), `dragValue` de renderbare afgeleide. Niet-`null` ⇒ er wordt nu gesleept. - const dragRef = useRef<{ pointerId: number; startX: number; startCeiling: number; moved: boolean; value: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; startX: number; startCeiling: number; moved: boolean; value: number; + /** De getekende x van de greep bij `startCeiling`, pas op de EERSTE beweging vastgelegd. */ + baseHandleX: number | null; + } | null>(null); const [dragValue, setDragValue] = useState(null); + // DE GETEKENDE x VAN DE GREEP TIJDENS HET SLEPEN (eigenaarsbesluit 2026-09-14, vervolg). + // + // Zonder herberekening groeit de TIJDAS tijdens het slepen niet mee, en dan kan de geometrie de + // greep niet meer plaatsen: `advanceWorkdays` landt op een datum die niet op de as staat en + // `rightEdgeX` klemt die terug op de rechterrand van het laatste assegment. Gemeten op de + // screenshot van deze ronde: het uitkomstlabel liep netjes door naar "max 22 okt" terwijl de + // greep bij het fase-einde bleef plakken — je sleepte en er bewoog niets. + // + // Vandaar deze afgeleide: zodra er echt bewogen wordt, tekenen we de greep op zijn ANKER plus het + // aantal gesnapte werkdagen × de dagbreedte. Dat is exact de omrekening die `onHandlePointerMove` + // óók gebruikt om de waarde te bepalen, dus greep en muis lopen per constructie synchroon. Blijft + // `null` tot de eerste beweging — dan staat de geometrie er nog gewoon goed op, ook bij een + // plafond dat van ONBEGRENSD komt (waar de greep aan de rechterrand van de as staat). + const [dragHandleX, setDragHandleX] = useState(null); // De greep moet ZICHTBAAR de focus hebben (spec §5): hij wordt met pijltjes bediend, en zonder // omranding weet je niet welke van de rijen die toets opvangt. Dat kan hier niet met // `:focus-visible` in CSS omdat de hele greep inline gestyled is (zijn x volgt de geometrie), dus @@ -153,7 +171,15 @@ export function PhaseStrip({ showGhosts: shortfallCount > 0, }); - const handleX = geometry?.handleX ?? AXIS.padLeft; + const handleX = dragHandleX ?? geometry?.handleX ?? AXIS.padLeft; + // De "toegestaan maar niet benut"-doos loopt van het fase-einde tot de greep (§4), dus zodra de + // greep tijdens het slepen geëxtrapoleerd wordt, moet de doos mee — anders zou hij achterblijven + // op de oude asrand en de greep los van de balk lijken te zweven. + const freeBox = dragHandleX !== null && geometry !== null + ? (dragHandleX > geometry.phaseEndX + ? { x: geometry.phaseEndX, w: dragHandleX - geometry.phaseEndX } + : null) + : geometry?.freeBox ?? null; const patternId = `ops-pause-${docId}`; const ceilingText = displayCeiling === null @@ -223,7 +249,10 @@ export function PhaseStrip({ // dus niets — terwijl de greep er wel "aangeraakt" uitzag. Focus dus zelf zetten, ná het // capturen, zodat toetsenbordbediening naadloos op de muis aansluit. event.currentTarget.focus(); - dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startCeiling: stepBase, moved: false, value: stepBase }; + dragRef.current = { + pointerId: event.pointerId, startX: event.clientX, startCeiling: stepBase, + moved: false, value: stepBase, baseHandleX: null, + }; setDragValue(stepBase); }; @@ -232,6 +261,10 @@ export function PhaseStrip({ if (!drag || drag.pointerId !== event.pointerId) return; drag.moved = true; if (dayWidth <= 0) return; + // Het anker: de x die de geometrie NU voor `startCeiling` tekent. Pas hier vastgelegd en niet + // bij `pointerdown`, want daar staat `displayCeiling` nog op de oude waarde — bij een plafond + // dat van ONBEGRENSD komt zou dat de rechterrand van de as zijn in plaats van de benutte stand. + if (drag.baseHandleX === null) drag.baseHandleX = geometry?.handleX ?? AXIS.padLeft; // Snappen op hele werkdagen: dezelfde dayWidth-per-werkdag-conventie die de tekenpositie van de // handle hierboven gebruikt. Een tweede, kalenderdaggetrouwe omrekening zou de handle tijdens // het slepen van zijn eigen getekende positie laten afwijken. @@ -241,6 +274,10 @@ export function PhaseStrip({ // ALLEEN lokale state — géén `commit` hier. Zie de kop van dit bestand: het herrekenen hoort // bij het loslaten, zodat de dialoog tijdens het slepen niet staat te knipperen. setDragValue(next); + setDragHandleX(Math.max(AXIS.padLeft, Math.min( + trackWidth - AXIS.padRight, + drag.baseHandleX + (next - drag.startCeiling) * dayWidth, + ))); }; const onHandlePointerUp = (event: React.PointerEvent) => { @@ -254,6 +291,7 @@ export function PhaseStrip({ // stand voordat de nieuwe prop binnenkomt. Een zuivere klik (niet bewogen) commit niets. if (drag.moved) commit(drag.value); setDragValue(null); + setDragHandleX(null); }; const onHandlePointerCancel = (event: React.PointerEvent) => { @@ -261,6 +299,7 @@ export function PhaseStrip({ if (!drag || drag.pointerId !== event.pointerId) return; dragRef.current = null; setDragValue(null); + setDragHandleX(null); }; // Een tekort weegt zwaarder dan de einddatum: past er een taak niet, dan is de rij rood, ook @@ -412,10 +451,10 @@ export function PhaseStrip({ ))} {/* "Toegestaan maar niet benut" — lege doos met gestippelde rand (§4). */} - {geometry?.freeBox && ( + {freeBox && ( Date: Mon, 14 Sep 2026 22:00:31 +0200 Subject: [PATCH 101/108] feat(b1c): begrippenlijst in de verdeeldialoog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De eigenaar vroeg in de gebruikstest wat "taken passen niet" betekent. De dialoog gebruikt een vocabulaire dat nergens anders in het programma voorkomt — speling, plafond, benut, vaste last, pauzedag, tekort, einddatum-verschuiving, vastzetten en het besparingsprijskaartje — en dat stond nergens uitgelegd op de plek waar je het leest. Onder de legenda staat nu een uitklapregel "Wat betekenen de termen?" met per term één zin. Ingeklapt kost hij één regel; uitklappen is een gebruikershandeling en mag dus de hoogte veranderen (spec §7 verbiedt alleen dat een REKENtoestand de maat laat verspringen). De open/dicht stand blijft lokale componentstate: in `ui.levelingDistribution` zou hij de fingerprint raken en een rekenronde uitlokken. Alleen niet-vanzelfsprekende termen; gewone app-begrippen als resource, capaciteit en project staan er bewust niet in. De veertien vertalingen nemen per taal de woorden over uit de labels die ze uitleggen. Co-Authored-By: Claude Fable 5.1 --- .../DistributionDialog/DistributionDialog.tsx | 16 ++++ .../DistributionGlossary.tsx | 77 +++++++++++++++++++ src/i18n/locales/ar/common.json | 39 ++++++++++ src/i18n/locales/de/common.json | 39 ++++++++++ src/i18n/locales/en/common.json | 39 ++++++++++ src/i18n/locales/es/common.json | 39 ++++++++++ src/i18n/locales/fa/common.json | 39 ++++++++++ src/i18n/locales/fr/common.json | 39 ++++++++++ src/i18n/locales/it/common.json | 39 ++++++++++ src/i18n/locales/ja/common.json | 39 ++++++++++ src/i18n/locales/ko/common.json | 39 ++++++++++ src/i18n/locales/nl/common.json | 39 ++++++++++ src/i18n/locales/pl/common.json | 39 ++++++++++ src/i18n/locales/pt/common.json | 39 ++++++++++ src/i18n/locales/tr/common.json | 39 ++++++++++ src/i18n/locales/zh/common.json | 39 ++++++++++ 16 files changed, 639 insertions(+) create mode 100644 src/components/dialogs/DistributionDialog/DistributionGlossary.tsx diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 62f1e0c8b..3e9e8577b 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -69,6 +69,7 @@ import { STRIP } from './stripGeometry'; import { PhaseStrip } from './PhaseStrip'; import { DistributionPicker } from './DistributionPicker'; import { BeforeAfterChart } from './BeforeAfterChart'; +import { DistributionGlossary } from './DistributionGlossary'; /** De inhoudsbreedte van het dialoogpaneel: `w-[960px]` min de `p-4`-padding aan weerszijden. * Alleen de terugval vóór de eerste ResizeObserver-meting; daarna telt de gemeten breedte. */ @@ -705,6 +706,21 @@ export function DistributionDialog() { {t('resource.distribution.strip.legend')}
+ {/* De begrippenlijst, direct onder de legenda (eigenaarsvraag 2026-09-14). Net als + de legenda hoort hij bij de rijen en niet bij de tijdas, dus hij is op precies + dezelfde manier MEEBEVROREN en krijgt dezelfde inspringing en breedtecap — + anders rekt een uitgeklapte definitie de scroll-container breder dan de as. */} +
+ +
+ {/* De einddatum-badges per project, boven het histogram (§3.4). Een project met een tekort krijgt hier zijn markering: zijn balk is dan (deels) LEEG omdat de motor zijn werk nergens kwijt kon, en dat moet bij die kleur staan en niet alleen diff --git a/src/components/dialogs/DistributionDialog/DistributionGlossary.tsx b/src/components/dialogs/DistributionDialog/DistributionGlossary.tsx new file mode 100644 index 000000000..43ba42c58 --- /dev/null +++ b/src/components/dialogs/DistributionDialog/DistributionGlossary.tsx @@ -0,0 +1,77 @@ +// De begrippenlijst van de verdeeldialoog (eigenaarsvraag 2026-09-14: "wat wordt er bedoeld met +// 'taken passen niet'?"). +// +// WAAROM UITKLAPBAAR EN NIET ALTIJD ZICHTBAAR. De dialoog is bedieningsoppervlak: balken, greep, +// pin. Negen definities permanent tussen de legenda en het histogram duwen precies dát oppervlak +// weg. Uitgeklapt is het een gebruikershandeling en mag de hoogte dus veranderen — spec §7 verbiedt +// alleen dat een REKENtoestand (bezig, stale, gedegradeerd, tekort) de maat verspringt, want dan +// beweegt de balk onder de muis tijdens het slepen. +// +// ALLEEN NIET-VANZELFSPREKENDE TERMEN. Gewone app-begrippen (resource, capaciteit, project, +// bibliotheek) staan er bewust NIET in: wie die niet kent, heeft aan deze dialoog sowieso niets. +// Wat er wél in staat is het vocabulaire dat alléén hier voorkomt en dat je nergens anders in het +// programma tegenkomt — speling, plafond, benut, vaste last, pauzedag, "past niet", de +// einddatum-verschuiving, vastzetten en het besparingsprijskaartje. +// +// LOKALE STATE, GEEN STOREVELD. Of de lijst open staat is geen documentdata en geen instelling; +// het hoort niet in `ui.levelingDistribution` (die state wordt gefingerprint voor het +// herrekenen — een uitgeklapte lijst zou dan een rekenronde uitlokken). +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight } from 'lucide-react'; + +/** De termen in leesvolgorde van de dialoog: eerst wat er op een balk staat (speling → plafond → + * benut), dan wat er omheen speelt (vaste last, pauzedag, tekort), dan de uitkomst en de knoppen. */ +const TERMS = [ + 'slack', + 'ceiling', + 'used', + 'fixedLoad', + 'pauseDay', + 'shortfall', + 'endShift', + 'pin', + 'savings', +] as const; + +export function DistributionGlossary() { + const { t } = useTranslation('common'); + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ {TERMS.map(key => ( +
+
+ {t(`resource.distribution.glossary.${key}.term`)} +
+
+ {t(`resource.distribution.glossary.${key}.text`)} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index c01ef6268..9eb8c5cb0 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -827,6 +827,45 @@ "shortfall_many": "ما زال ينقص {{count}} يوم فريق في {{days}}…", "shortfall_other": "ما زال ينقص {{count}} يوم فريق في {{days}}…", "allPinned": "جميع المشاريع مثبّتة — لا يوجد ما يُعاد توزيعه. فُكّ تثبيت أحدها." + }, + "glossary": { + "toggle": "ما معنى هذه المصطلحات؟", + "slack": { + "term": "المرونة", + "text": "عدد أيام العمل التي يجوز فيها لهذا المشروع أن يبدأ متأخرًا بهذا المورد دون أن يتحرّك تاريخ انتهائه. صاحب أقل مرونة يأتي في الأعلى ويُراعى أكثر من غيره." + }, + "ceiling": { + "term": "السقف · «بحد أقصى …»", + "text": "أقصى إزاحة لتاريخ الانتهاء تسمح بها لهذا المشروع، بأيام العمل؛ تضبطها بالمقبض عند طرف الشريط. «بحد أقصى <التاريخ>» هو اليوم الذي ينتهي إليه هذا السقف. السقف 0 يعني: تاريخ الانتهاء ثابت، لكن يبقى التحريك ممكنًا داخل المرونة." + }, + "used": { + "term": "المستخدم", + "text": "كم يوم عمل من السقف احتاجه التوزيع فعليًا. أقل من السقف أمر جيد: البقية المنقّطة مسموحة لكنها غير مستخدمة." + }, + "fixedLoad": { + "term": "الحمل الثابت", + "text": "الحجوزات على هذا المورد التي لا يجوز تحريكها: المشاريع التي ثبّتتها، والمشاريع المعروضة بالتواريخ كما سُجّلت. التوزيع يخطط حولها." + }, + "pauseDay": { + "term": "يوم توقّف · الانقطاعات", + "text": "يوم عمل داخل المدة لا يستخدم فيه هذا المشروع هذا المورد. مع تفعيل «السماح بالانقطاعات» يجوز للتوزيع إدراج مثل هذه الفترات حتى تعمل المهمة حول مشروع آخر بدل انتظاره بالكامل. والفجوة بين مهمتين تُحسب أيضًا يوم توقّف." + }, + "shortfall": { + "term": "«غير مناسب» · العجز", + "text": "لم يجد التوزيع لتلك المهمة أيامًا يكون فيها المورد متاحًا، ضمن السقف الذي منحته لهذا المشروع. لذلك لا يحجزها عمدًا فوق الطاقة. المخارج: امنح هذا المشروع مزيدًا من الإزاحة، أو ثبّت مشروعًا آخر أو فُكّ تثبيته، أو اسمح بالانقطاعات." + }, + "endShift": { + "term": "إزاحة تاريخ الانتهاء · «النهاية +N يوم»", + "text": "كم يوم عمل يتأخر تاريخ انتهاء المشروع بأكمله بسبب هذا التوزيع. «النهاية دون تغيير» تعني أن التحريك اتّسع له داخل المرونة." + }, + "pin": { + "term": "تثبيت", + "text": "هذا المشروع لا يشارك في التوزيع: يحتفظ بتاريخ انتهائه وبأيام عمله، ويُحسب للآخرين حملًا ثابتًا." + }, + "savings": { + "term": "«سيوفّر N يوم عمل» · «يوفّر»", + "text": "كم يوم عمل من الإزاحة سيوفّره الوضع الآخر لـ«السماح بالانقطاعات». «لن يوفّر شيئًا» تعني أنه لا يحدث فرقًا في هذا التعارض." + } } }, "color": "اللون" diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 00bdae516..cdd50bdcb 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -747,6 +747,45 @@ "shortfall_one": "Es fehlt noch {{count}} Schichttag am {{days}}…", "shortfall_other": "Es fehlen noch {{count}} Schichttage am {{days}}…", "allPinned": "Alle Projekte sind fixiert — es gibt nichts umzuverteilen. Lösen Sie eines." + }, + "glossary": { + "toggle": "Was bedeuten diese Begriffe?", + "slack": { + "term": "Puffer", + "text": "Die Anzahl Arbeitstage, um die dieses Projekt mit dieser Ressource später beginnen darf, ohne dass sein Endtermin verschoben wird. Wer den geringsten Puffer hat, steht oben und wird am meisten geschont." + }, + "ceiling": { + "term": "Obergrenze · „max. …“", + "text": "Die maximale Endterminverschiebung, die Sie diesem Projekt erlauben, in Arbeitstagen; Sie stellen sie mit dem Griff am Ende des Balkens ein. „max. “ ist der Tag, auf den diese Obergrenze hinausläuft. Obergrenze 0 bedeutet: Der Endtermin steht fest, aber innerhalb des Puffers darf noch verschoben werden." + }, + "used": { + "term": "genutzt", + "text": "Wie viele Arbeitstage der Obergrenze die Verteilung tatsächlich gebraucht hat. Weniger als die Obergrenze ist in Ordnung: Der gepunktete Rest ist erlaubt, aber ungenutzt." + }, + "fixedLoad": { + "term": "feste Last", + "text": "Buchungen auf dieser Ressource, die sich nicht bewegen dürfen: Projekte, die Sie fixiert haben, und Projekte mit Datumsangaben wie gespeichert. Die Verteilung plant darum herum." + }, + "pauseDay": { + "term": "Pausentag · Unterbrechungen", + "text": "Ein Arbeitstag innerhalb der Laufzeit, an dem dieses Projekt diese Ressource nicht nutzt. Ist „Unterbrechungen zulassen“ aktiv, darf die Verteilung solche Pausen einfügen, damit ein Vorgang um ein anderes Projekt herum arbeitet, statt vollständig dahinter zu warten. Auch eine Lücke zwischen zwei Vorgängen zählt als Pausentag." + }, + "shortfall": { + "term": "„passt nicht“ · Fehlmenge", + "text": "Für diesen Vorgang fand die Verteilung innerhalb der Obergrenze, die Sie diesem Projekt gegeben haben, keine Tage, an denen die Ressource noch frei ist. Sie bucht ihn dann bewusst nicht über die Kapazität hinaus. Auswege: Geben Sie diesem Projekt mehr Spielraum, fixieren Sie ein anderes Projekt oder lösen Sie es gerade, oder lassen Sie Unterbrechungen zu." + }, + "endShift": { + "term": "Endterminverschiebung · „Ende +N Tage“", + "text": "Um wie viele Arbeitstage der Endtermin des gesamten Projekts durch diese Verteilung später liegt. „Ende unverändert“ bedeutet, dass das Verschieben in den Puffer passte." + }, + "pin": { + "term": "fixieren", + "text": "Dieses Projekt nimmt an der Verteilung nicht teil: Es behält seinen Endtermin und seine Arbeitstage und zählt für die anderen als feste Last." + }, + "savings": { + "term": "„würde N Arbeitstage sparen“ · „spart“", + "text": "Wie viele Arbeitstage Verschiebung die andere Stellung von „Unterbrechungen zulassen“ ausmachen würde. „Würde nichts sparen“ bedeutet, dass es für diesen Konflikt keinen Unterschied macht." + } } }, "color": "Farbe" diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 7984e5806..cd4765f5c 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -747,6 +747,45 @@ "shortfall_one": "Still {{count}} crew day short on {{days}}…", "shortfall_other": "Still {{count}} crew days short on {{days}}…", "allPinned": "Every project is pinned — there is nothing to redistribute. Unpin one." + }, + "glossary": { + "toggle": "What do these terms mean?", + "slack": { + "term": "slack", + "text": "The number of workdays this project may start later with this resource without its end date moving. Whoever has the least slack is listed first and is spared the most." + }, + "ceiling": { + "term": "ceiling · \"max …\"", + "text": "The maximum end-date slip you allow this project, in workdays; you set it with the handle at the end of the bar. \"max \" is the day that ceiling works out to. Ceiling 0 means the end date is fixed, but there may still be movement within the slack." + }, + "used": { + "term": "used", + "text": "How many workdays of the ceiling the distribution actually needed. Less than the ceiling is fine: the dotted remainder is allowed but not used." + }, + "fixedLoad": { + "term": "fixed load", + "text": "Bookings on this resource that may not move: projects you have pinned, and projects showing dates as recorded. The distribution plans around them." + }, + "pauseDay": { + "term": "pause day · interruptions", + "text": "A workday within the run on which this project does not use this resource. With \"Allow interruptions\" on, the distribution may insert such pauses so a task works around another project instead of waiting fully behind it. A gap between two tasks also counts as a pause day." + }, + "shortfall": { + "term": "\"does not fit\" · shortfall", + "text": "For that task the distribution could find no days on which the resource is still free, within the ceiling you gave this project. It then deliberately does not book it over capacity. Ways out: give this project more slip, pin another project or unpin it, or allow interruptions." + }, + "endShift": { + "term": "end-date shift · \"end +N days\"", + "text": "How many workdays later the end date of the whole project lands because of this distribution. \"End unchanged\" means the shifting fitted within the slack." + }, + "pin": { + "term": "pin", + "text": "This project takes no part in the distribution: it keeps both its end date and its workdays, and counts as fixed load for the others." + }, + "savings": { + "term": "\"would save N workdays\" · \"saves\"", + "text": "How many workdays of slip the other setting of \"Allow interruptions\" would make. \"Would save nothing\" means it makes no difference for this conflict." + } } }, "color": "Color" diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index d09dda3a0..ec3944b72 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -767,6 +767,45 @@ "shortfall_many": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", "shortfall_other": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", "allPinned": "Todos los proyectos están fijados: no hay nada que redistribuir. Suelta uno." + }, + "glossary": { + "toggle": "¿Qué significan estos términos?", + "slack": { + "term": "holgura", + "text": "El número de días laborables que este proyecto puede empezar más tarde con este recurso sin que su fecha de fin se desplace. Quien tiene menos holgura aparece arriba y es el más respetado." + }, + "ceiling": { + "term": "techo · «máx. …»", + "text": "El desplazamiento máximo de la fecha de fin que permites a este proyecto, en días laborables; lo ajustas con el tirador al final de la barra. «máx. » es el día al que llega ese techo. Techo 0 significa: la fecha de fin es fija, pero dentro de la holgura todavía se puede mover." + }, + "used": { + "term": "usado", + "text": "Cuántos días laborables del techo ha necesitado realmente el reparto. Menos que el techo está bien: el resto punteado está permitido pero no se usa." + }, + "fixedLoad": { + "term": "carga fija", + "text": "Reservas sobre este recurso que no pueden moverse: proyectos que has fijado y proyectos con las fechas tal como se guardaron. El reparto planifica a su alrededor." + }, + "pauseDay": { + "term": "día de pausa · interrupciones", + "text": "Un día laborable dentro de la duración en el que este proyecto no usa este recurso. Con «Permitir interrupciones» activado, el reparto puede insertar esas pausas para que una tarea rodee a otro proyecto en lugar de esperar del todo detrás de él. Un hueco entre dos tareas también cuenta como día de pausa." + }, + "shortfall": { + "term": "«no cabe» · déficit", + "text": "Para esa tarea el reparto no encontró días en los que el recurso siga libre, dentro del techo que diste a este proyecto. Entonces, deliberadamente, no la reserva por encima de la capacidad. Salidas: da más margen a este proyecto, fija otro proyecto o suéltalo, o permite interrupciones." + }, + "endShift": { + "term": "desplazamiento de la fecha de fin · «fin +N días»", + "text": "Cuántos días laborables más tarde queda la fecha de fin de todo el proyecto por este reparto. «Fin sin cambios» significa que el desplazamiento cupo dentro de la holgura." + }, + "pin": { + "term": "fijar", + "text": "Este proyecto no participa en el reparto: conserva su fecha de fin y sus días laborables, y cuenta como carga fija para los demás." + }, + "savings": { + "term": "«ahorraría N días laborables» · «ahorra»", + "text": "Cuántos días laborables de desplazamiento supondría la otra posición de «Permitir interrupciones». «No ahorraría nada» significa que para este conflicto no cambia nada." + } } }, "color": "Color" diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index d17eaf396..09c64f092 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -747,6 +747,45 @@ "shortfall_one": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", "shortfall_other": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", "allPinned": "همهٔ پروژه‌ها سنجاق شده‌اند — چیزی برای توزیع مجدد نیست. یکی را باز کنید." + }, + "glossary": { + "toggle": "این اصطلاح‌ها چه معنایی دارند؟", + "slack": { + "term": "شناوری", + "text": "تعداد روزهای کاری که این پروژه می‌تواند با این منبع دیرتر شروع کند بدون آنکه تاریخ پایانش جابه‌جا شود. هر کس کمترین شناوری را داشته باشد بالاتر قرار می‌گیرد و بیشتر از همه محافظت می‌شود." + }, + "ceiling": { + "term": "سقف · «حداکثر …»", + "text": "بیشینه‌ی جابه‌جایی تاریخ پایان که به این پروژه اجازه می‌دهید، بر حسب روز کاری؛ آن را با دستگیره‌ی انتهای میله تنظیم می‌کنید. «حداکثر <تاریخ>» روزی است که این سقف به آن می‌رسد. سقف ۰ یعنی: تاریخ پایان ثابت است، اما درون شناوری هنوز می‌توان جابه‌جا کرد." + }, + "used": { + "term": "استفاده‌شده", + "text": "اینکه توزیع عملاً چند روز کاری از سقف را لازم داشته است. کمتر از سقف اشکالی ندارد: باقی‌مانده‌ی نقطه‌چین مجاز است اما استفاده نشده." + }, + "fixedLoad": { + "term": "بار ثابت", + "text": "رزروهایی روی این منبع که حق جابه‌جایی ندارند: پروژه‌هایی که سنجاق کرده‌اید و پروژه‌هایی که تاریخ‌هایشان همان‌طور که ثبت شده نمایش داده می‌شود. توزیع دور آن‌ها برنامه‌ریزی می‌کند." + }, + "pauseDay": { + "term": "روز توقف · وقفه‌ها", + "text": "یک روز کاری در طول مدت که این پروژه از این منبع استفاده نمی‌کند. با روشن بودن «اجازه دادن به وقفه‌ها»، توزیع می‌تواند چنین وقفه‌هایی را درج کند تا یک وظیفه به‌جای انتظار کامل پشت پروژه‌ی دیگر، از کنار آن رد شود. فاصله‌ی میان دو وظیفه نیز روز توقف به حساب می‌آید." + }, + "shortfall": { + "term": "«جا نمی‌شود» · کمبود", + "text": "توزیع برای آن وظیفه، در محدوده‌ی سقفی که به این پروژه داده‌اید، روزی نیافت که منبع هنوز آزاد باشد. آن‌گاه عامداً آن را بیش از ظرفیت رزرو نمی‌کند. راه‌حل‌ها: به این پروژه جابه‌جایی بیشتری بدهید، پروژه‌ی دیگری را سنجاق کنید یا برعکس آزاد کنید، یا وقفه‌ها را مجاز کنید." + }, + "endShift": { + "term": "جابه‌جایی تاریخ پایان · «پایان +N روز»", + "text": "اینکه تاریخ پایان کل پروژه به خاطر این توزیع چند روز کاری دیرتر می‌افتد. «پایان بدون تغییر» یعنی جابه‌جایی درون شناوری جا شده است." + }, + "pin": { + "term": "سنجاق", + "text": "این پروژه در توزیع شرکت نمی‌کند: هم تاریخ پایان و هم روزهای کاری خود را حفظ می‌کند و برای دیگران بار ثابت به حساب می‌آید." + }, + "savings": { + "term": "«N روز کاری صرفه‌جویی می‌کرد» · «صرفه‌جویی می‌کند»", + "text": "اینکه وضعیت دیگر «اجازه دادن به وقفه‌ها» چند روز کاری از جابه‌جایی را کم می‌کند. «چیزی صرفه‌جویی نمی‌کرد» یعنی برای این تعارض فرقی نمی‌کند." + } } }, "color": "رنگ" diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 79ce2cb19..ee2097a48 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -767,6 +767,45 @@ "shortfall_many": "Il manque encore {{count}} jours-équipe les {{days}}…", "shortfall_other": "Il manque encore {{count}} jours-équipe les {{days}}…", "allPinned": "Tous les projets sont épinglés — il n'y a rien à redistribuer. Détachez-en un." + }, + "glossary": { + "toggle": "Que signifient ces termes ?", + "slack": { + "term": "marge", + "text": "Le nombre de jours ouvrés dont ce projet peut commencer plus tard avec cette ressource sans que sa date de fin bouge. Celui qui a le moins de marge figure en haut et est le plus ménagé." + }, + "ceiling": { + "term": "plafond · « max … »", + "text": "Le décalage maximal de la date de fin que vous autorisez à ce projet, en jours ouvrés ; vous le réglez avec la poignée au bout de la barre. « max  » est le jour auquel ce plafond aboutit. Un plafond de 0 signifie : la date de fin est figée, mais on peut encore déplacer à l'intérieur de la marge." + }, + "used": { + "term": "utilisé", + "text": "Combien de jours ouvrés du plafond la répartition a réellement utilisés. Moins que le plafond, c'est très bien : le reste en pointillé est autorisé mais non utilisé." + }, + "fixedLoad": { + "term": "charge fixe", + "text": "Les réservations sur cette ressource qui ne peuvent pas bouger : les projets que vous avez épinglés et les projets affichés avec les dates telles qu'enregistrées. La répartition planifie autour d'elles." + }, + "pauseDay": { + "term": "jour de pause · interruptions", + "text": "Un jour ouvré de la durée pendant lequel ce projet n'utilise pas cette ressource. Avec « Autoriser les interruptions » activé, la répartition peut insérer de telles pauses pour qu'une tâche contourne un autre projet au lieu d'attendre entièrement derrière lui. Un écart entre deux tâches compte aussi comme jour de pause." + }, + "shortfall": { + "term": "« ne tient pas » · déficit", + "text": "Pour cette tâche, la répartition n'a trouvé aucun jour où la ressource est encore libre, dans le plafond que vous avez donné à ce projet. Elle évite alors délibérément de la réserver au-delà de la capacité. Issues : donnez plus de décalage à ce projet, épinglez un autre projet ou au contraire détachez-le, ou autorisez les interruptions." + }, + "endShift": { + "term": "décalage de la date de fin · « fin +N jours »", + "text": "De combien de jours ouvrés la date de fin de tout le projet se retrouve plus tard à cause de cette répartition. « Fin inchangée » signifie que le déplacement tenait dans la marge." + }, + "pin": { + "term": "épingler", + "text": "Ce projet ne participe pas à la répartition : il conserve sa date de fin et ses jours ouvrés, et compte comme charge fixe pour les autres." + }, + "savings": { + "term": "« économiserait N jours ouvrés » · « économise »", + "text": "Combien de jours ouvrés de décalage l'autre position de « Autoriser les interruptions » ferait gagner. « N'économiserait rien » signifie que cela ne change rien pour ce conflit." + } } }, "color": "Couleur" diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index b65132872..5503e5526 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -767,6 +767,45 @@ "shortfall_many": "Mancano ancora {{count}} giorni-squadra il {{days}}…", "shortfall_other": "Mancano ancora {{count}} giorni-squadra il {{days}}…", "allPinned": "Tutti i progetti sono bloccati: non c'è nulla da ridistribuire. Sbloccane uno." + }, + "glossary": { + "toggle": "Che cosa significano questi termini?", + "slack": { + "term": "scorrimento", + "text": "Il numero di giorni lavorativi di cui questo progetto può iniziare più tardi con questa risorsa senza che la sua data di fine si sposti. Chi ha meno scorrimento sta in alto ed è il più risparmiato." + }, + "ceiling": { + "term": "tetto · «max …»", + "text": "Lo slittamento massimo della data di fine che concedi a questo progetto, in giorni lavorativi; lo imposti con la maniglia alla fine della barra. «max » è il giorno a cui arriva quel tetto. Tetto 0 significa: la data di fine è fissa, ma entro lo scorrimento si può ancora spostare." + }, + "used": { + "term": "usato", + "text": "Quanti giorni lavorativi del tetto la distribuzione ha effettivamente usato. Meno del tetto va benissimo: il resto punteggiato è consentito ma non usato." + }, + "fixedLoad": { + "term": "carico fisso", + "text": "Prenotazioni su questa risorsa che non possono muoversi: i progetti che hai bloccato e i progetti con le date come registrate. La distribuzione pianifica intorno a essi." + }, + "pauseDay": { + "term": "giorno di pausa · interruzioni", + "text": "Un giorno lavorativo all'interno della durata in cui questo progetto non usa questa risorsa. Con «Consenti interruzioni» attivo, la distribuzione può inserire tali pause perché un'attività aggiri un altro progetto invece di attenderlo del tutto. Anche un vuoto tra due attività conta come giorno di pausa." + }, + "shortfall": { + "term": "«non entra» · carenza", + "text": "Per quell'attività la distribuzione non ha trovato giorni in cui la risorsa sia ancora libera, entro il tetto che hai dato a questo progetto. Non la prenota quindi deliberatamente oltre la capacità. Vie d'uscita: concedi più slittamento a questo progetto, blocca un altro progetto o sbloccalo, oppure consenti le interruzioni." + }, + "endShift": { + "term": "slittamento della data di fine · «fine +N giorni»", + "text": "Di quanti giorni lavorativi la data di fine dell'intero progetto arriva più tardi per effetto di questa distribuzione. «Fine invariata» significa che lo spostamento rientrava nello scorrimento." + }, + "pin": { + "term": "blocca", + "text": "Questo progetto non partecipa alla distribuzione: mantiene la sua data di fine e i suoi giorni lavorativi e conta come carico fisso per gli altri." + }, + "savings": { + "term": "«risparmierebbe N giorni lavorativi» · «risparmia»", + "text": "Quanti giorni lavorativi di slittamento farebbe l'altra posizione di «Consenti interruzioni». «Non risparmierebbe nulla» significa che per questo conflitto non fa differenza." + } } }, "color": "Colore" diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index d5afc794b..93d8b7715 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -727,6 +727,45 @@ "resolved": "競合を解消しました — 最大の終了日のずれ:{{shift}}({{doc}})", "shortfall_other": "{{days}} に {{count}} 班日ぶん不足しています…", "allPinned": "すべてのプロジェクトが固定されています。再配分するものがありません。1 つ解除してください。" + }, + "glossary": { + "toggle": "これらの用語の意味は?", + "slack": { + "term": "余裕", + "text": "このプロジェクトがこのリソースで終了日をずらさずに遅く開始できる稼働日数です。余裕が最も少ないものが一番上に並び、最も優先的に守られます。" + }, + "ceiling": { + "term": "上限 · 「最大 …」", + "text": "このプロジェクトに許す終了日の最大のずれ(稼働日)です。バー末端のハンドルで設定します。「最大 <日付>」はその上限が行き着く日です。上限 0 は、終了日は固定だが余裕の範囲内ではなお動かせる、という意味です。" + }, + "used": { + "term": "使用", + "text": "上限のうち割り当てが実際に必要とした稼働日数です。上限より少なくても問題ありません。点線の残りは、許可済みだが未使用の分です。" + }, + "fixedLoad": { + "term": "固定負荷", + "text": "このリソース上で動かせない予約です。固定したプロジェクトと、記録された日付のとおりに表示されているプロジェクトが含まれます。割り当てはそれを避けて計画します。" + }, + "pauseDay": { + "term": "休止日 · 中断", + "text": "期間内でこのプロジェクトがこのリソースを使わない稼働日です。「中断を許可」を入れると、タスクが他のプロジェクトの後ろで丸ごと待たずに、その周りを回り込めるようこうした休止を挿入できます。二つのタスクの間の隙間も休止日に数えます。" + }, + "shortfall": { + "term": "「収まりません」 · 不足", + "text": "このプロジェクトに与えた上限の中に、リソースがまだ空いている日を見つけられませんでした。その場合は意図的に能力を超えて割り当てません。対処策:このプロジェクトにもっとずれを許す、他のプロジェクトを固定または逆に解除する、中断を許可する。" + }, + "endShift": { + "term": "終了日のずれ · 「終了日 +N 日」", + "text": "この割り当てによってプロジェクト全体の終了日が何稼働日遅くなるかです。「終了日は変わりません」は、移動が余裕の範囲に収まったことを意味します。" + }, + "pin": { + "term": "固定", + "text": "このプロジェクトは割り当てに参加しません。終了日も稼働日もそのまま保ち、他のプロジェクトにとっては固定負荷として扱われます。" + }, + "savings": { + "term": "「N 稼働日を節約できます」 · 「節約」", + "text": "「中断を許可」を反対の状態にした場合に減らせるずれの稼働日数です。「何も節約できません」は、この競合には差が出ないという意味です。" + } } }, "color": "色" diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 6f897a268..08ec9874a 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -727,6 +727,45 @@ "resolved": "충돌 해결 — 최대 종료일 이동: {{shift}} ({{doc}})", "shortfall_other": "{{days}}에 작업조 {{count}}일이 부족합니다…", "allPinned": "모든 프로젝트가 고정되어 재분배할 것이 없습니다. 하나를 해제하세요." + }, + "glossary": { + "toggle": "이 용어들은 무슨 뜻인가요?", + "slack": { + "term": "여유", + "text": "이 프로젝트가 이 리소스로 종료일을 밀지 않고 늦게 시작할 수 있는 작업일 수입니다. 여유가 가장 적은 쪽이 맨 위에 오고 가장 많이 보호됩니다." + }, + "ceiling": { + "term": "상한 · “최대 …”", + "text": "이 프로젝트에 허용하는 최대 종료일 이동량으로, 작업일 단위입니다. 막대 끝의 핸들로 설정합니다. “최대 <날짜>”는 그 상한이 닿는 날입니다. 상한 0은 종료일이 고정되지만 여유 안에서는 여전히 움직일 수 있다는 뜻입니다." + }, + "used": { + "term": "사용", + "text": "배분이 상한 중 실제로 필요로 한 작업일 수입니다. 상한보다 적어도 괜찮습니다. 점선으로 남은 부분은 허용됐지만 쓰지 않은 분입니다." + }, + "fixedLoad": { + "term": "고정 부하", + "text": "이 리소스에서 움직일 수 없는 예약입니다. 직접 고정한 프로젝트와 기록된 날짜대로 표시된 프로젝트가 여기에 해당합니다. 배분은 그것들을 피해 계획합니다." + }, + "pauseDay": { + "term": "휴지일 · 중단", + "text": "기간 안에서 이 프로젝트가 이 리소스를 쓰지 않는 작업일입니다. “중단 허용”을 켜면 배분은 이러한 휴지를 넣어 작업이 다른 프로젝트 뒤에서 통째로 기다리는 대신 그것을 우회하게 할 수 있습니다. 두 작업 사이의 빈팂도 휴지일로 칩니다." + }, + "shortfall": { + "term": "“맞지 않음” · 부족", + "text": "이 프로젝트에 준 상한 안에서, 배분은 해당 작업에 대해 리소스가 아직 비어 있는 날을 찾지 못했습니다. 그럴 때는 의도적으로 용량을 넘겨 예약하지 않습니다. 해결책: 이 프로젝트에 여유를 더 주거나, 다른 프로젝트를 고정하거나 반대로 풀거나, 중단을 허용하세요." + }, + "endShift": { + "term": "종료일 이동 · “종료일 +N일”", + "text": "이 배분으로 전체 프로젝트의 종료일이 몇 작업일 늦춰지는지입니다. “종료일 변동 없음”은 이동이 여유 안에 들어맞았다는 뜻입니다." + }, + "pin": { + "term": "고정", + "text": "이 프로젝트는 배분에 참여하지 않습니다. 종료일과 작업일을 그대로 유지하며, 다른 프로젝트에게는 고정 부하로 계산됩니다." + }, + "savings": { + "term": "“작업일 N일을 절약할 수 있습니다” · “절약”", + "text": "“중단 허용”을 반대로 두면 이동이 몇 작업일 줄어드는지입니다. “절약할 수 있는 시간이 없습니다”는 이 충돌에는 차이가 없다는 뜻입니다." + } } }, "color": "색상" diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 797a5b423..2fb2d008b 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -747,6 +747,45 @@ "shortfall_one": "Nog {{count}} ploegdag tekort op {{days}}…", "shortfall_other": "Nog {{count}} ploegdagen tekort op {{days}}…", "allPinned": "Alle projecten staan vast — er is niets te herverdelen. Maak er één los." + }, + "glossary": { + "toggle": "Wat betekenen de termen?", + "slack": { + "term": "speling", + "text": "Het aantal werkdagen dat dit project later mag beginnen met deze resource zonder dat zijn einddatum opschuift. Wie de minste speling heeft, staat bovenaan en wordt het meest ontzien." + }, + "ceiling": { + "term": "plafond · \"max …\"", + "text": "De maximale uitloop van de einddatum die jij dit project toestaat, in werkdagen; je zet hem met de greep aan het einde van de balk. \"max \" is de dag waar dat plafond op uitkomt. Plafond 0 betekent: de einddatum staat vast, maar binnen de speling mag er nog geschoven worden." + }, + "used": { + "term": "benut", + "text": "Hoeveel werkdagen van het plafond de verdeler daadwerkelijk nodig had. Minder dan het plafond is prima: de gestippelde rest is toegestaan maar niet gebruikt." + }, + "fixedLoad": { + "term": "vaste last", + "text": "Boekingen op deze resource die niet mogen bewegen: projecten die je hebt vastgezet, en projecten in \"datums zoals opgeslagen\". De verdeler plant er omheen." + }, + "pauseDay": { + "term": "pauzedag · onderbrekingen", + "text": "Een werkdag binnen de looptijd waarop dit project deze resource niet gebruikt. Met \"Onderbrekingen toestaan\" aan mag de verdeler zulke pauzes invoegen zodat een taak om een ander project heen werkt in plaats van er volledig achter te wachten. Een gat tussen twee taken telt ook als pauzedag." + }, + "shortfall": { + "term": "\"past niet\" · tekort", + "text": "De verdeler kon voor die taak geen dagen vinden waarop de resource nog vrij is, binnen het plafond dat je dit project gaf. Hij boekt hem dan bewust niet over de capaciteit heen. Uitweg: geef dit project meer uitloop, zet een ander project vast of maak het juist los, of sta onderbrekingen toe." + }, + "endShift": { + "term": "einddatum-verschuiving · \"eind +N dagen\"", + "text": "Hoeveel werkdagen de einddatum van het hele project later komt te liggen door deze verdeling. \"Eind ongewijzigd\" betekent dat het verschuiven binnen de speling paste." + }, + "pin": { + "term": "vastzetten", + "text": "Dit project doet niet mee in de verdeling: het houdt zijn einddatum én zijn werkdagen, en telt voor de anderen als vaste last." + }, + "savings": { + "term": "\"zou N werkdagen besparen\" · \"bespaart\"", + "text": "Hoeveel werkdagen uitloop de andere stand van \"Onderbrekingen toestaan\" zou schelen. \"Zou niets besparen\" betekent dat het voor dit conflict geen verschil maakt." + } } }, "color": "Kleur" diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index a2ea9098c..5d20b7271 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -787,6 +787,45 @@ "shortfall_many": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", "shortfall_other": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", "allPinned": "Wszystkie projekty są przypięte — nie ma czego rozdzielać. Odepnij jeden." + }, + "glossary": { + "toggle": "Co oznaczają te pojęcia?", + "slack": { + "term": "zapas", + "text": "Liczba dni roboczych, o które ten projekt może rozpocząć się później z tym zasobem bez przesunięcia jego daty zakończenia. Kto ma najmniejszy zapas, stoi na górze i jest najbardziej oszczędzany." + }, + "ceiling": { + "term": "pułap · „maks. …”", + "text": "Maksymalne przesunięcie daty zakończenia, na które pozwalasz temu projektowi, w dniach roboczych; ustawiasz je uchwytem na końcu paska. „maks. ” to dzień, na który wypada ten pułap. Pułap 0 oznacza: data zakończenia jest stała, ale w ramach zapasu wciąż można przesuwać." + }, + "used": { + "term": "wykorzystano", + "text": "Ile dni roboczych pułapu rozłożenie faktycznie potrzebowało. Mniej niż pułap jest w porządku: kropkowana reszta jest dozwolona, ale niewykorzystana." + }, + "fixedLoad": { + "term": "stałe obciążenie", + "text": "Rezerwacje na tym zasobie, które nie mogą się ruszać: projekty przez ciebie przypięte oraz projekty z datami tak jak zapisane. Rozłożenie planuje wokół nich." + }, + "pauseDay": { + "term": "dzień przerwy · przerwy", + "text": "Dzień roboczy w czasie trwania, w którym ten projekt nie korzysta z tego zasobu. Przy włączonym „Zezwól na przerwy” rozłożenie może wstawiać takie przerwy, aby zadanie obeszło inny projekt, zamiast czekać w całości za nim. Luka między dwoma zadaniami też liczy się jako dzień przerwy." + }, + "shortfall": { + "term": "„nie mieści się” · niedobór", + "text": "Dla tego zadania rozłożenie nie znalazło dni, w których zasób jest jeszcze wolny, w ramach pułapu nadanego temu projektowi. Świadomie nie rezerwuje go wtedy ponad wydajność. Wyjścia: daj temu projektowi więcej przesunięcia, przypnij inny projekt albo go odepnij, albo zezwól na przerwy." + }, + "endShift": { + "term": "przesunięcie daty zakończenia · „koniec +N dni”", + "text": "O ile dni roboczych później wypada data zakończenia całego projektu z powodu tego rozłożenia. „Koniec bez zmian” oznacza, że przesunięcie zmieściło się w zapasie." + }, + "pin": { + "term": "przypnij", + "text": "Ten projekt nie bierze udziału w rozłożeniu: zachowuje datę zakończenia i swoje dni robocze, a dla pozostałych liczy się jako stałe obciążenie." + }, + "savings": { + "term": "„zaoszczędziłoby N dni roboczych” · „oszczędza”", + "text": "Ile dni roboczych przesunięcia dałoby drugie ustawienie „Zezwól na przerwy”. „Nic by nie zaoszczędziło” oznacza, że dla tego konfliktu nie robi to różnicy." + } } }, "color": "Kolor" diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index af547ae94..636fe1b26 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -767,6 +767,45 @@ "shortfall_many": "Ainda faltam {{count}} dias-equipa em {{days}}…", "shortfall_other": "Ainda faltam {{count}} dias-equipa em {{days}}…", "allPinned": "Todos os projetos estão fixados — não há nada para redistribuir. Solte um." + }, + "glossary": { + "toggle": "O que significam estes termos?", + "slack": { + "term": "folga", + "text": "O número de dias úteis que este projeto pode começar mais tarde com este recurso sem que a sua data de fim se desloque. Quem tem menos folga fica no topo e é o mais poupado." + }, + "ceiling": { + "term": "teto · «máx. …»", + "text": "O deslocamento máximo da data de fim que permite a este projeto, em dias úteis; define-o com a pega no fim da barra. «máx. » é o dia a que esse teto chega. Teto 0 significa: a data de fim está fixa, mas dentro da folga ainda se pode deslocar." + }, + "used": { + "term": "usado", + "text": "Quantos dias úteis do teto a distribuição precisou realmente. Menos do que o teto é bom: o resto pontilhado é permitido mas não usado." + }, + "fixedLoad": { + "term": "carga fixa", + "text": "Reservas neste recurso que não podem mover-se: projetos que fixou e projetos com as datas como registadas. A distribuição planeia à volta delas." + }, + "pauseDay": { + "term": "dia de pausa · interrupções", + "text": "Um dia útil dentro da duração em que este projeto não usa este recurso. Com «Permitir interrupções» ativo, a distribuição pode inserir essas pausas para que uma tarefa contorne outro projeto em vez de esperar totalmente atrás dele. Uma lacuna entre duas tarefas também conta como dia de pausa." + }, + "shortfall": { + "term": "«não cabe» · défice", + "text": "Para essa tarefa a distribuição não encontrou dias em que o recurso ainda esteja livre, dentro do teto que deu a este projeto. Assim, deliberadamente não a reserva acima da capacidade. Saídas: dê mais deslocação a este projeto, fixe outro projeto ou solte-o, ou permita interrupções." + }, + "endShift": { + "term": "deslocamento da data de fim · «fim +N dias»", + "text": "Quantos dias úteis mais tarde fica a data de fim de todo o projeto por causa desta distribuição. «Fim inalterado» significa que a deslocação coube dentro da folga." + }, + "pin": { + "term": "fixar", + "text": "Este projeto não participa na distribuição: mantém a sua data de fim e os seus dias úteis e conta como carga fixa para os outros." + }, + "savings": { + "term": "«pouparia N dias úteis» · «poupa»", + "text": "Quantos dias úteis de deslocação a outra posição de «Permitir interrupções» faria. «Não pouparia nada» significa que para este conflito não faz diferença." + } } }, "color": "Cor" diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 94cc107fa..532c7533f 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -747,6 +747,45 @@ "shortfall_one": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", "shortfall_other": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", "allPinned": "Tüm projeler sabit — yeniden dağıtılacak bir şey yok. Birini çözün." + }, + "glossary": { + "toggle": "Bu terimler ne anlama geliyor?", + "slack": { + "term": "bolluk", + "text": "Bu projenin, bitiş tarihi kaymadan bu kaynakla kaç iş günü daha geç başlayabileceği. En az bolluğu olan en üstte durur ve en çok o korunur." + }, + "ceiling": { + "term": "tavan · “en geç …”", + "text": "Bu projeye izin verdiğiniz azami bitiş tarihi kayması, iş günü olarak; çubuğun ucundaki tutamakla ayarlarsınız. “en geç ”, bu tavanın denk geldiği gündür. Tavan 0 şu demektir: bitiş tarihi sabittir, ama bolluk içinde hâlâ kaydırma yapılabilir." + }, + "used": { + "term": "kullanılan", + "text": "Dağıtımın tavandan gerçekte kaç iş günü kullandığı. Tavandan az olması sorun değildir: noktalı kalan kısım izinli ama kullanılmamıştır." + }, + "fixedLoad": { + "term": "sabit yük", + "text": "Bu kaynaktaki hareket edemeyen rezervasyonlar: sabitlediğiniz projeler ve kayıtlı tarihlerle gösterilen projeler. Dağıtım onların etrafından planlar." + }, + "pauseDay": { + "term": "mola günü · kesintiler", + "text": "Süre içinde bu projenin bu kaynağı kullanmadığı bir iş günü. “Kesintilere izin ver” açıkken dağıtım böyle molalar ekleyebilir; böylece bir görev başka bir projenin tamamen arkasında beklemek yerine onun etrafından çalışır. İki görev arasındaki boşluk da mola günü sayılır." + }, + "shortfall": { + "term": "“sığmıyor” · açık", + "text": "Bu projeye verdiğiniz tavan içinde, dağıtım o görev için kaynağın hâlâ boş olduğu gün bulamadı. O zaman görevi bilerek kapasitenin üstüne rezerve etmez. Çıkış yolları: bu projeye daha fazla kayma tanıyın, başka bir projeyi sabitleyin veya tam tersine serbest bırakın ya da kesintilere izin verin." + }, + "endShift": { + "term": "bitiş tarihi kayması · “bitiş +N gün”", + "text": "Bu dağıtım yüzünden tüm projenin bitiş tarihinin kaç iş günü geriye kaydığı. “Bitiş değişmedi”, kaydırmanın bolluk içine sığdığı anlamına gelir." + }, + "pin": { + "term": "sabitle", + "text": "Bu proje dağıtıma katılmaz: hem bitiş tarihini hem iş günlerini korur ve diğerleri için sabit yük sayılır." + }, + "savings": { + "term": "“N iş günü kazandırırdı” · “kazandırır”", + "text": "“Kesintilere izin ver” ayarının diğer konumunun kaç iş günü kayma kazandıracağı. “Hiçbir şey kazandırmazdı”, bu çakışma için fark yaratmadığı anlamına gelir." + } } }, "color": "Renk" diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index cbbc84c45..a152d5843 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -727,6 +727,45 @@ "resolved": "冲突已解决 — 最大完工日期偏移:{{shift}}({{doc}})", "shortfall_other": "{{days}} 仍缺 {{count}} 个班组日…", "allPinned": "所有项目都已固定 — 没有可重新分配的内容。请解除其中一个。" + }, + "glossary": { + "toggle": "这些术语是什么意思?", + "slack": { + "term": "浮时", + "text": "本项目在使用该资源时可以推迟开始的工作日数,且不会影响它的完工日期。浮时最少的项目排在最上方,也最受照顾。" + }, + "ceiling": { + "term": "上限 · “最迟 …”", + "text": "你允许本项目完工日期推迟的最大工作日数;用进度条末端的拖柄设定。“最迟<日期>”就是该上限对应的那一天。上限为 0 表示:完工日期固定,但在浮时范围内仍可移动。" + }, + "used": { + "term": "已用", + "text": "分配实际用掉了上限中的多少个工作日。少于上限完全没问题:虚线部分是已允许但未使用的余量。" + }, + "fixedLoad": { + "term": "固定负载", + "text": "该资源上不允许移动的占用:你已固定的项目,以及按记录的日期显示的项目。分配会绕开它们安排。" + }, + "pauseDay": { + "term": "暂停日 · 中断", + "text": "工期内本项目不使用该资源的某个工作日。开启“允许中断”后,分配可以插入这样的暂停,让任务绕开另一个项目,而不是完全排在它后面等。两项任务之间的空隙也算暂停日。" + }, + "shortfall": { + "term": "“无法安排” · 缺口", + "text": "在你为本项目设定的上限内,分配找不到该资源仍然空闲的日子。于是它有意不把任务安排到超出产能的位置。出路:给本项目更多推迟空间、固定或解除固定另一个项目,或者允许中断。" + }, + "endShift": { + "term": "完工日期偏移 · “结束日期 +N 天”", + "text": "由于本次分配,整个项目的完工日期推迟了多少个工作日。“结束日期不变”表示这次移动落在浮时范围内。" + }, + "pin": { + "term": "固定", + "text": "本项目不参与分配:它保留自己的完工日期和工作日,对其他项目而言算作固定负载。" + }, + "savings": { + "term": "“可节省 N 个工作日” · “节省”", + "text": "把“允许中断”切到另一个状态,可以少推迟多少个工作日。“不会节省时间”表示对这个冲突没有影响。" + } } }, "color": "颜色" From cfc502e395cce2462346e6c04640e6ad077343eb Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:02:16 +0200 Subject: [PATCH 102/108] test(b1c): de begrippenlijst klapt uit en geeft de dialoog zijn maat terug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bewaakt drie dingen die makkelijk stilletjes wegvallen: hij staat ingeklapt bij het openen, alle negen termen staan erin (poort op acht dt-regels), en inklappen brengt de boundingBox van de dialoog exact terug — de §7-belofte dat alleen een gebruikershandeling de hoogte verandert. Co-Authored-By: Claude Fable 5.1 --- tests/browser/leveling-distribution.spec.ts | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index 2a413f3dc..a2e7711ca 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -943,3 +943,39 @@ test('een opgerekt plafond schuift de label- en uitkomstkolom niet uit de dialoo await within('[data-ops-distribution-label]'); await expect(page.locator('[data-ops-distribution-label]').first()).toContainText(/Eendaags project/); }); + +// --- De begrippenlijst (eigenaarsvraag 2026-09-14: "wat betekent 'taken passen niet'?") ---------- + +test('begrippenlijst: uitklappen toont de uitleg, inklappen geeft de dialoog zijn maat terug', async ({ page, ops: _ops }) => { + await seedTwoSingleDayDocuments(page); + await openDistributionFromConflictRow(page); + await expect(page.locator('[data-ops-distribution-strip]')).toHaveCount(2); + + const dialog = page.locator('[data-ops-distribution-dialog]'); + const toggle = page.locator('[data-ops-distribution-glossary-toggle]'); + const glossary = page.locator('[data-ops-distribution-glossary]'); + + // Ingeklapt bij het openen: de dialoog is bediening, geen naslagwerk. + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(glossary).toHaveCount(0); + const closedBox = (await dialog.boundingBox())!; + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(glossary).toBeVisible(); + // Alle niet-vanzelfsprekende termen van deze dialoog staan erin; minder dan acht betekent dat er + // een term uit de lijst gevallen is. + expect(await glossary.locator('dt').count()).toBeGreaterThanOrEqual(8); + await expect(glossary).toContainText(/past niet|does not fit/i); + + // Uitklappen mag de hoogte veranderen — het is een gebruikershandeling, geen REKENtoestand. + // Maar inklappen moet de dialoog exact op zijn oude maat terugzetten (spec §7). + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(glossary).toHaveCount(0); + const reclosedBox = (await dialog.boundingBox())!; + expect(reclosedBox.x).toBeCloseTo(closedBox.x, 0); + expect(reclosedBox.y).toBeCloseTo(closedBox.y, 0); + expect(reclosedBox.width).toBeCloseTo(closedBox.width, 0); + expect(reclosedBox.height).toBeCloseTo(closedBox.height, 0); +}); From 5044dd34b0695d46fcb65e2a49d81a3cf818870d Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:05:31 +0200 Subject: [PATCH 103/108] fix(b1c): de begrippenlijst wrapt binnen de dialoog in plaats van de rand uit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De lijst erfde de breedtecap van de legenda — de AS-breedte. Dat werkt voor één korte regel, maar de definities zijn lopende tekst, dus bij een opgerekt plafond liepen ze mee de scroller in en las de eindscreenshot "… zonder dat zijn einddatum opschu". `trackSpace` is per constructie de breedte die naast de bevroren labelkolom zichtbaar blijft; daarmee wrapt de tekst waar hij hoort. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/DistributionDialog.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 3e9e8577b..8d817d863 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -715,7 +715,13 @@ export function DistributionDialog() { style={{ marginLeft: STRIP.labelWidth + STRIP.gap, left: STRIP.labelWidth + STRIP.gap, - maxWidth: (stripView?.axis?.width ?? trackSpace) + STRIP.gap + STRIP.endWidth, + // NIET de as-breedte zoals de legenda, maar de ZICHTBARE track (`trackSpace`). + // De legenda is één korte regel en past altijd; de definities zijn lopende + // tekst, dus met de as als cap liepen ze bij een opgerekt plafond gewoon de + // rand uit en stond er "… zonder dat zijn einddatum opschu" — gemeten op de + // eindscreenshot van 2026-09-14. `trackSpace` is per constructie de breedte + // die naast de bevroren labelkolom overblijft, dus hier wordt gewrapt. + maxWidth: trackSpace + STRIP.gap + STRIP.endWidth, }} > From 3777db00ba9de3b2247ddbf9fe97cf514b92bcdd Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:22:29 +0200 Subject: [PATCH 104/108] fix(b1c): afkapteken alleen als er meer tekortdata zijn dan getoond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Het "…" stond in de vertaalstring zelf, dus "Nog 1 ploegdag tekort op 7 sep…" beloofde ook bij één datum dat er meer volgden. Het teken hoort bij de lijst: de dialoog plakt het nu aan de dagenparameter zodra er meer dan drie data zijn, en de veertien locales laten het weg. Co-Authored-By: Claude Fable 5.1 --- .../dialogs/DistributionDialog/DistributionDialog.tsx | 6 +++++- src/i18n/locales/ar/common.json | 10 +++++----- src/i18n/locales/de/common.json | 4 ++-- src/i18n/locales/en/common.json | 4 ++-- src/i18n/locales/es/common.json | 6 +++--- src/i18n/locales/fa/common.json | 4 ++-- src/i18n/locales/fr/common.json | 6 +++--- src/i18n/locales/it/common.json | 6 +++--- src/i18n/locales/ja/common.json | 2 +- src/i18n/locales/ko/common.json | 2 +- src/i18n/locales/nl/common.json | 4 ++-- src/i18n/locales/pl/common.json | 8 ++++---- src/i18n/locales/pt/common.json | 6 +++--- src/i18n/locales/tr/common.json | 4 ++-- src/i18n/locales/zh/common.json | 2 +- 15 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx index 8d817d863..fdd7fbce3 100644 --- a/src/components/dialogs/DistributionDialog/DistributionDialog.tsx +++ b/src/components/dialogs/DistributionDialog/DistributionDialog.tsx @@ -459,9 +459,13 @@ export function DistributionDialog() { const named = shortfallDocs .map(doc => t('resource.distribution.shortfall.doc', { doc: doc.title, count: doc.count })) .join(' · '); + // Alleen de eerste drie data in de zin, met een AFKAPTEKEN als er meer zijn. Het teken + // hoort bij de LIJST en niet bij de zin: stond het in de vertaalstring (zoals eerst), dan + // beloofde "kort op 7 sep…" ook bij precies één datum dat er nog meer kwamen. + const shownDays = shortfallDays.slice(0, 3).map(formatDay).join(', '); const head = t('resource.distribution.status.shortfall', { count: shortfallDays.length, - days: shortfallDays.slice(0, 3).map(formatDay).join(', '), + days: shortfallDays.length > 3 ? `${shownDays}…` : shownDays, }); return { tone: 'bad', text: joinClauses(head, named), stale: false }; } diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 9eb8c5cb0..9324a9fc1 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -821,11 +821,11 @@ "status": { "resolved": "تم حل التعارض — أكبر إزاحة للانتهاء: {{shift}} ({{doc}})", "shortfall_zero": "لا يوجد نقص", - "shortfall_one": "ما زال ينقص يوم فريق واحد في {{days}}…", - "shortfall_two": "ما زال ينقص يوما فريق في {{days}}…", - "shortfall_few": "ما زال ينقص {{count}} أيام فريق في {{days}}…", - "shortfall_many": "ما زال ينقص {{count}} يوم فريق في {{days}}…", - "shortfall_other": "ما زال ينقص {{count}} يوم فريق في {{days}}…", + "shortfall_one": "ما زال ينقص يوم فريق واحد في {{days}}", + "shortfall_two": "ما زال ينقص يوما فريق في {{days}}", + "shortfall_few": "ما زال ينقص {{count}} أيام فريق في {{days}}", + "shortfall_many": "ما زال ينقص {{count}} يوم فريق في {{days}}", + "shortfall_other": "ما زال ينقص {{count}} يوم فريق في {{days}}", "allPinned": "جميع المشاريع مثبّتة — لا يوجد ما يُعاد توزيعه. فُكّ تثبيت أحدها." }, "glossary": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index cdd50bdcb..5eaed7c54 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -744,8 +744,8 @@ "reset": "Zurücksetzen", "status": { "resolved": "Konflikt gelöst — größte Endterminverschiebung: {{shift}} ({{doc}})", - "shortfall_one": "Es fehlt noch {{count}} Schichttag am {{days}}…", - "shortfall_other": "Es fehlen noch {{count}} Schichttage am {{days}}…", + "shortfall_one": "Es fehlt noch {{count}} Schichttag am {{days}}", + "shortfall_other": "Es fehlen noch {{count}} Schichttage am {{days}}", "allPinned": "Alle Projekte sind fixiert — es gibt nichts umzuverteilen. Lösen Sie eines." }, "glossary": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index cd4765f5c..e722805bc 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -744,8 +744,8 @@ "reset": "Reset", "status": { "resolved": "Conflict resolved — largest end-date shift: {{shift}} ({{doc}})", - "shortfall_one": "Still {{count}} crew day short on {{days}}…", - "shortfall_other": "Still {{count}} crew days short on {{days}}…", + "shortfall_one": "Still {{count}} crew day short on {{days}}", + "shortfall_other": "Still {{count}} crew days short on {{days}}", "allPinned": "Every project is pinned — there is nothing to redistribute. Unpin one." }, "glossary": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index ec3944b72..0038aff90 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -763,9 +763,9 @@ "reset": "Restablecer", "status": { "resolved": "Conflicto resuelto — mayor desplazamiento de fin: {{shift}} ({{doc}})", - "shortfall_one": "Todavía falta {{count}} día-cuadrilla el {{days}}…", - "shortfall_many": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", - "shortfall_other": "Todavía faltan {{count}} días-cuadrilla el {{days}}…", + "shortfall_one": "Todavía falta {{count}} día-cuadrilla el {{days}}", + "shortfall_many": "Todavía faltan {{count}} días-cuadrilla el {{days}}", + "shortfall_other": "Todavía faltan {{count}} días-cuadrilla el {{days}}", "allPinned": "Todos los proyectos están fijados: no hay nada que redistribuir. Suelta uno." }, "glossary": { diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 09c64f092..4db60f460 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -744,8 +744,8 @@ "reset": "بازنشانی", "status": { "resolved": "تعارض حل شد — بیشترین جابه‌جایی پایان: {{shift}} ({{doc}})", - "shortfall_one": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", - "shortfall_other": "هنوز {{count}} روزِ اکیپ در {{days}} کم است…", + "shortfall_one": "هنوز {{count}} روزِ اکیپ در {{days}} کم است", + "shortfall_other": "هنوز {{count}} روزِ اکیپ در {{days}} کم است", "allPinned": "همهٔ پروژه‌ها سنجاق شده‌اند — چیزی برای توزیع مجدد نیست. یکی را باز کنید." }, "glossary": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ee2097a48..3a43f5412 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -763,9 +763,9 @@ "reset": "Réinitialiser", "status": { "resolved": "Conflit résolu — plus grand décalage de fin : {{shift}} ({{doc}})", - "shortfall_one": "Il manque encore {{count}} jour-équipe le {{days}}…", - "shortfall_many": "Il manque encore {{count}} jours-équipe les {{days}}…", - "shortfall_other": "Il manque encore {{count}} jours-équipe les {{days}}…", + "shortfall_one": "Il manque encore {{count}} jour-équipe le {{days}}", + "shortfall_many": "Il manque encore {{count}} jours-équipe les {{days}}", + "shortfall_other": "Il manque encore {{count}} jours-équipe les {{days}}", "allPinned": "Tous les projets sont épinglés — il n'y a rien à redistribuer. Détachez-en un." }, "glossary": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 5503e5526..7b6b27db5 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -763,9 +763,9 @@ "reset": "Reimposta", "status": { "resolved": "Conflitto risolto — slittamento di fine maggiore: {{shift}} ({{doc}})", - "shortfall_one": "Manca ancora {{count}} giorno-squadra il {{days}}…", - "shortfall_many": "Mancano ancora {{count}} giorni-squadra il {{days}}…", - "shortfall_other": "Mancano ancora {{count}} giorni-squadra il {{days}}…", + "shortfall_one": "Manca ancora {{count}} giorno-squadra il {{days}}", + "shortfall_many": "Mancano ancora {{count}} giorni-squadra il {{days}}", + "shortfall_other": "Mancano ancora {{count}} giorni-squadra il {{days}}", "allPinned": "Tutti i progetti sono bloccati: non c'è nulla da ridistribuire. Sbloccane uno." }, "glossary": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 93d8b7715..16d32e6b7 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -725,7 +725,7 @@ "reset": "リセット", "status": { "resolved": "競合を解消しました — 最大の終了日のずれ:{{shift}}({{doc}})", - "shortfall_other": "{{days}} に {{count}} 班日ぶん不足しています…", + "shortfall_other": "{{days}} に {{count}} 班日ぶん不足しています", "allPinned": "すべてのプロジェクトが固定されています。再配分するものがありません。1 つ解除してください。" }, "glossary": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 08ec9874a..2dfa15abf 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -725,7 +725,7 @@ "reset": "초기화", "status": { "resolved": "충돌 해결 — 최대 종료일 이동: {{shift}} ({{doc}})", - "shortfall_other": "{{days}}에 작업조 {{count}}일이 부족합니다…", + "shortfall_other": "{{days}}에 작업조 {{count}}일이 부족합니다", "allPinned": "모든 프로젝트가 고정되어 재분배할 것이 없습니다. 하나를 해제하세요." }, "glossary": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 2fb2d008b..4cb22ff9c 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -744,8 +744,8 @@ "reset": "Reset", "status": { "resolved": "Conflict opgelost — grootste einddatum-verschuiving: {{shift}} ({{doc}})", - "shortfall_one": "Nog {{count}} ploegdag tekort op {{days}}…", - "shortfall_other": "Nog {{count}} ploegdagen tekort op {{days}}…", + "shortfall_one": "Nog {{count}} ploegdag tekort op {{days}}", + "shortfall_other": "Nog {{count}} ploegdagen tekort op {{days}}", "allPinned": "Alle projecten staan vast — er is niets te herverdelen. Maak er één los." }, "glossary": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 5d20b7271..78f82a019 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -782,10 +782,10 @@ "reset": "Resetuj", "status": { "resolved": "Konflikt rozwiązany — największe przesunięcie zakończenia: {{shift}} ({{doc}})", - "shortfall_one": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", - "shortfall_few": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", - "shortfall_many": "Brakuje jeszcze {{count}} dni brygady w {{days}}…", - "shortfall_other": "Brakuje jeszcze {{count}} dnia brygady w {{days}}…", + "shortfall_one": "Brakuje jeszcze {{count}} dnia brygady w {{days}}", + "shortfall_few": "Brakuje jeszcze {{count}} dni brygady w {{days}}", + "shortfall_many": "Brakuje jeszcze {{count}} dni brygady w {{days}}", + "shortfall_other": "Brakuje jeszcze {{count}} dnia brygady w {{days}}", "allPinned": "Wszystkie projekty są przypięte — nie ma czego rozdzielać. Odepnij jeden." }, "glossary": { diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 636fe1b26..37dd701a2 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -763,9 +763,9 @@ "reset": "Repor", "status": { "resolved": "Conflito resolvido — maior deslocamento de fim: {{shift}} ({{doc}})", - "shortfall_one": "Ainda falta {{count}} dia-equipa em {{days}}…", - "shortfall_many": "Ainda faltam {{count}} dias-equipa em {{days}}…", - "shortfall_other": "Ainda faltam {{count}} dias-equipa em {{days}}…", + "shortfall_one": "Ainda falta {{count}} dia-equipa em {{days}}", + "shortfall_many": "Ainda faltam {{count}} dias-equipa em {{days}}", + "shortfall_other": "Ainda faltam {{count}} dias-equipa em {{days}}", "allPinned": "Todos os projetos estão fixados — não há nada para redistribuir. Solte um." }, "glossary": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 532c7533f..79a2fab66 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -744,8 +744,8 @@ "reset": "Sıfırla", "status": { "resolved": "Çakışma çözüldü — en büyük bitiş kayması: {{shift}} ({{doc}})", - "shortfall_one": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", - "shortfall_other": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik…", + "shortfall_one": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik", + "shortfall_other": "{{days}} tarihinde hâlâ {{count}} ekip günü eksik", "allPinned": "Tüm projeler sabit — yeniden dağıtılacak bir şey yok. Birini çözün." }, "glossary": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index a152d5843..c0d646f31 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -725,7 +725,7 @@ "reset": "重置", "status": { "resolved": "冲突已解决 — 最大完工日期偏移:{{shift}}({{doc}})", - "shortfall_other": "{{days}} 仍缺 {{count}} 个班组日…", + "shortfall_other": "{{days}} 仍缺 {{count}} 个班组日", "allPinned": "所有项目都已固定 — 没有可重新分配的内容。请解除其中一个。" }, "glossary": { From ccf03ba9f9de257655be39bbfc155ae65c1e68e8 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:23:04 +0200 Subject: [PATCH 105/108] =?UTF-8?q?fix(b1c):=20de=20greep=20krijgt=20een?= =?UTF-8?q?=20tooltip=20van=20=C3=A9=C3=A9n=20regel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De native title van vier regels dekte na het loslaten de legenda en de knop naar de begrippenlijst af. De lange uitleg staat inmiddels in die begrippenlijst, dus de tooltip is in alle veertien locales ingekort tot één regel. aria-valuetext blijft ongewijzigd. Co-Authored-By: Claude Fable 5.1 --- src/i18n/locales/ar/common.json | 2 +- src/i18n/locales/de/common.json | 2 +- src/i18n/locales/en/common.json | 2 +- src/i18n/locales/es/common.json | 2 +- src/i18n/locales/fa/common.json | 2 +- src/i18n/locales/fr/common.json | 2 +- src/i18n/locales/it/common.json | 2 +- src/i18n/locales/ja/common.json | 2 +- src/i18n/locales/ko/common.json | 2 +- src/i18n/locales/nl/common.json | 2 +- src/i18n/locales/pl/common.json | 2 +- src/i18n/locales/pt/common.json | 2 +- src/i18n/locales/tr/common.json | 2 +- src/i18n/locales/zh/common.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 9324a9fc1..e7c49a283 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -733,7 +733,7 @@ "allowSplits": "يسمح بأن يتخلل العمل أيام توقف بدلًا من إزاحته قطعةً واحدة؛ يمنح ذلك التوزيع مجالًا أوسع، لكنه ينتج مهامًا مجزّأة.", "price": "تعرض بطاقة السعر، لكل وضع، عدد أيام العمل التي يتأخرها تاريخ انتهاء المشروع الأكثر تضررًا؛ ووجود سعر واحد مع \"في كلتا الحالتين\" يعني أن السماح بالتوقف لا يغيّر شيئًا هنا.", "pin": "يثبّت هذا المشروع: يحتفظ بتاريخ انتهائه وبأيام عمله، ويُحتسب حِملًا ثابتًا يتعين على المشاريع الأخرى الالتفاف حوله.", - "ceiling": "أقصى عدد من أيام العمل يمكن أن يتأخره تاريخ انتهاء هذا المشروع. اسحب المقبض، أو استخدم مفاتيح الأسهم وHome (بلا تجاوز) وEnd (بلا حد). تُظهر التسمية الأثر على تاريخ الانتهاء، لا مسافة السحب.", + "ceiling": "السقف: أقصى تجاوز لتاريخ الانتهاء — اسحب، أو الأسهم/Home/End", "chart": "الآن = الحجز الحالي لجميع المشاريع المفتوحة مجتمعة؛ بعد التوزيع = ما يتبقى بعد التوزيع. الأحمر هو ما يتجاوز السعة.", "recompute": "يعيد حساب الاقتراح بالترتيب والتثبيتات والسقوف الحالية.", "apply": "يكتب الإزاحات في كل المشاريع المعنية؛ ويحصل كل مشروع على خطوة تراجع خاصة به.", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 5eaed7c54..023aa40c5 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -677,7 +677,7 @@ "allowSplits": "Erlaubt, dass die Arbeit Pausentage erhält, statt am Stück zu verschieben; das gibt der Verteilung mehr Spielraum, erzeugt aber unterbrochene Vorgänge.", "price": "Das Preisschild zeigt je Stellung, um wie viele Arbeitstage sich das Enddatum des am stärksten betroffenen Projekts verschiebt; ein einzelner Preis mit „in beiden Stellungen“ heißt, dass Unterbrechen hier nichts ändert.", "pin": "Fixiert dieses Projekt: Es behält sein Enddatum und seine Arbeitstage und zählt als feste Last, um die die anderen Projekte herum müssen.", - "ceiling": "Um wie viele Arbeitstage sich das Enddatum dieses Projekts höchstens verschieben darf. Ziehen Sie den Griff, oder nutzen Sie die Pfeiltasten, Pos1 (kein Überlauf) und Ende (unbegrenzt). Die Beschriftung zeigt die Wirkung auf das Enddatum, nicht die Ziehdistanz.", + "ceiling": "Obergrenze: maximaler Überlauf des Enddatums — ziehen, oder Pfeiltasten/Pos1/Ende", "chart": "Jetzt = die aktuelle Buchung aller geöffneten Projekte zusammen; Nach dem Verteilen = was nach der Verteilung übrig bleibt. Rot liegt über der Kapazität.", "recompute": "Berechnet den Vorschlag mit der aktuellen Reihenfolge, den Fixierungen und Obergrenzen neu.", "apply": "Schreibt die Verschiebungen in alle beteiligten Projekte; jedes Projekt erhält einen eigenen Rückgängig-Schritt.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e722805bc..ab56fcaca 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -677,7 +677,7 @@ "allowSplits": "Allows the work to take pause days instead of shifting in one piece; this gives the distribution more room, but produces split tasks.", "price": "The price tag shows, per setting, how many working days the end date of the worst-affected project shifts; a single price with \"in both settings\" means interrupting makes no difference here.", "pin": "Pin this project: it keeps its end date and its working days, and counts as a fixed load the other projects have to work around.", - "ceiling": "How many working days this project's end date may shift at most. Drag the handle, or use the arrow keys, Home (no overrun) and End (unlimited). The label shows the effect on the end date, not how far you dragged.", + "ceiling": "Ceiling: maximum end-date overrun — drag, or arrow keys/Home/End", "chart": "Now = the current booking of all open projects together; After distributing = what remains once the work is spread. Red is whatever exceeds the capacity.", "recompute": "Recalculates the proposal with the current order, pins and caps.", "apply": "Writes the shifts into every project involved; each project gets its own undo step.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 0038aff90..01611e385 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -691,7 +691,7 @@ "allowSplits": "Permite que el trabajo tenga días de pausa en lugar de desplazarse de una pieza; da más margen al reparto, pero genera tareas divididas.", "price": "La etiqueta de precio muestra, por posición, cuántos días laborables se desplaza la fecha de fin del proyecto más afectado; un único precio con «en ambos casos» significa que interrumpir no cambia nada aquí.", "pin": "Fija este proyecto: conserva su fecha de fin y sus días laborables, y cuenta como carga fija que los demás proyectos deben rodear.", - "ceiling": "Cuántos días laborables puede desplazarse como máximo la fecha de fin de este proyecto. Arrastre el asa o use las flechas, Inicio (sin desbordamiento) y Fin (ilimitado). La etiqueta muestra el efecto en la fecha de fin, no cuánto ha arrastrado.", + "ceiling": "Techo: desbordamiento máximo de la fecha de fin — arrastre, o flechas/Inicio/Fin", "chart": "Ahora = la reserva actual de todos los proyectos abiertos juntos; Después de repartir = lo que queda tras el reparto. El rojo supera la capacidad.", "recompute": "Vuelve a calcular la propuesta con el orden, las fijaciones y los límites actuales.", "apply": "Escribe los desplazamientos en todos los proyectos implicados; cada proyecto recibe su propio paso de deshacer.", diff --git a/src/i18n/locales/fa/common.json b/src/i18n/locales/fa/common.json index 4db60f460..efdbf7dee 100644 --- a/src/i18n/locales/fa/common.json +++ b/src/i18n/locales/fa/common.json @@ -677,7 +677,7 @@ "allowSplits": "اجازه می‌دهد کار روزهای وقفه داشته باشد به‌جای آنکه یکپارچه جابه‌جا شود؛ این کار فضای بیشتری به توزیع می‌دهد، اما وظایف را تکه‌تکه می‌کند.", "price": "برچسب قیمت برای هر حالت نشان می‌دهد تاریخ پایان آسیب‌دیده‌ترین پروژه چند روز کاری جابه‌جا می‌شود؛ اگر تنها یک قیمت با «در هر دو حالت» دیده شود، یعنی وقفه‌دادن اینجا تفاوتی ایجاد نمی‌کند.", "pin": "این پروژه را پین می‌کند: تاریخ پایان و روزهای کاری‌اش را نگه می‌دارد و به‌عنوان بار ثابتی شمرده می‌شود که پروژه‌های دیگر باید از کنارش بگذرند.", - "ceiling": "تاریخ پایان این پروژه حداکثر چند روز کاری می‌تواند جابه‌جا شود. دستگیره را بکشید یا از کلیدهای پیکان، Home (بدون سرریز) و End (بی‌حد) استفاده کنید. برچسب اثر روی تاریخ پایان را نشان می‌دهد، نه مسافت کشیدن را.", + "ceiling": "سقف: بیشترین سرریز تاریخ پایان — بکشید، یا کلیدهای پیکان/Home/End", "chart": "اکنون = رزرو فعلی همهٔ پروژه‌های باز روی هم؛ پس از توزیع = آنچه پس از توزیع می‌ماند. قرمز یعنی فراتر از ظرفیت.", "recompute": "پیشنهاد را با ترتیب، پین‌ها و سقف‌های فعلی دوباره محاسبه می‌کند.", "apply": "جابه‌جایی‌ها را در همهٔ پروژه‌های درگیر می‌نویسد؛ هر پروژه گام واگرد خودش را می‌گیرد.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 3a43f5412..db5ca47c9 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -691,7 +691,7 @@ "allowSplits": "Autorise le travail à comporter des jours de pause au lieu de se décaler d'un seul bloc ; cela donne plus de marge à la répartition, mais découpe les tâches.", "price": "L'étiquette de prix indique, pour chaque position, de combien de jours ouvrés la date de fin du projet le plus touché se décale ; un prix unique avec « dans les deux cas » signifie qu'interrompre ne change rien ici.", "pin": "Épingle ce projet : il conserve sa date de fin et ses jours ouvrés, et compte comme charge fixe que les autres projets doivent contourner.", - "ceiling": "De combien de jours ouvrés la date de fin de ce projet peut se décaler au maximum. Faites glisser la poignée, ou utilisez les flèches, Début (aucun dépassement) et Fin (illimité). L'étiquette montre l'effet sur la date de fin, pas la distance parcourue.", + "ceiling": "Plafond : dépassement maximal de la date de fin — faites glisser, ou flèches/Début/Fin", "chart": "Maintenant = la réservation actuelle de tous les projets ouverts réunis ; Après répartition = ce qu'il reste une fois le travail réparti. Le rouge dépasse la capacité.", "recompute": "Recalcule la proposition avec l'ordre, les épingles et les plafonds actuels.", "apply": "Écrit les décalages dans tous les projets concernés ; chaque projet reçoit sa propre étape d'annulation.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 7b6b27db5..fbadf3763 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -691,7 +691,7 @@ "allowSplits": "Consente al lavoro di avere giorni di pausa invece di spostarsi in un unico blocco; dà più margine alla distribuzione, ma produce attività spezzate.", "price": "Il cartellino mostra, per ciascuna posizione, di quanti giorni lavorativi si sposta la data di fine del progetto più colpito; un unico prezzo con «in entrambi i casi» significa che interrompere qui non cambia nulla.", "pin": "Fissa questo progetto: mantiene la data di fine e i suoi giorni lavorativi e conta come carico fisso attorno al quale gli altri progetti devono muoversi.", - "ceiling": "Di quanti giorni lavorativi può spostarsi al massimo la data di fine di questo progetto. Trascina la maniglia oppure usa i tasti freccia, Home (nessuno sforamento) e Fine (illimitato). L'etichetta mostra l'effetto sulla data di fine, non quanto hai trascinato.", + "ceiling": "Tetto: sforamento massimo della data di fine — trascina, o frecce/Home/Fine", "chart": "Ora = la prenotazione attuale di tutti i progetti aperti insieme; Dopo la distribuzione = ciò che resta dopo aver distribuito. Il rosso supera la capacità.", "recompute": "Ricalcola la proposta con l'ordine, i fissaggi e i limiti attuali.", "apply": "Scrive gli spostamenti in tutti i progetti coinvolti; ogni progetto riceve il proprio passo di annullamento.", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 16d32e6b7..bac7c6cd2 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -663,7 +663,7 @@ "allowSplits": "作業を一続きでずらすのではなく、中断日を挟むことを許可します。配分の余地は広がりますが、タスクは分割されます。", "price": "価格表示は、スイッチの各状態について、最も影響を受けるプロジェクトの終了日が何営業日ずれるかを示します。「どちらの設定でも同じ」と 1 つだけ表示される場合、ここでは中断しても違いはありません。", "pin": "このプロジェクトを固定します。終了日と営業日を保ち、ほかのプロジェクトが避けて通るべき固定負荷として数えられます。", - "ceiling": "このプロジェクトの終了日が最大で何営業日ずれてよいか。ハンドルをドラッグするか、矢印キー、Home(超過なし)、End(無制限)を使います。ラベルはドラッグ距離ではなく終了日への影響を示します。", + "ceiling": "上限:終了日の最大超過 — ドラッグ、または矢印キー/Home/End", "chart": "現在 = 開いているすべてのプロジェクトの合計予約。配分後 = 配分したあとに残る分。赤は容量を超えた部分です。", "recompute": "現在の順序、固定、上限で提案を計算し直します。", "apply": "関係するすべてのプロジェクトにずれを書き込みます。各プロジェクトには個別の元に戻す操作が用意されます。", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 2dfa15abf..0424c683e 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -663,7 +663,7 @@ "allowSplits": "작업이 한 덩어리로 밀리는 대신 중단일을 가질 수 있게 합니다. 분배에 여유가 생기지만 작업이 쪼개집니다.", "price": "가격표는 스위치의 각 상태별로 가장 큰 영향을 받는 프로젝트의 종료일이 며칠(작업일) 밀리는지 보여 줍니다. \"두 설정 모두 동일\"과 함께 하나의 가격만 있다면, 여기서는 중단해도 차이가 없습니다.", "pin": "이 프로젝트를 고정합니다. 종료일과 작업일을 유지하며, 다른 프로젝트가 피해 가야 하는 고정 부하로 계산됩니다.", - "ceiling": "이 프로젝트의 종료일이 최대 며칠(작업일)까지 밀릴 수 있는지입니다. 손잡이를 끌거나 화살표 키, Home(초과 없음), End(무제한)를 사용하세요. 라벨은 끈 거리가 아니라 종료일에 미치는 영향을 보여 줍니다.", + "ceiling": "상한: 종료일의 최대 초과 — 끌거나 화살표 키/Home/End", "chart": "현재 = 열려 있는 모든 프로젝트의 현재 예약 합계, 분배 후 = 분배한 뒤 남는 양. 빨간색은 용량을 초과한 부분입니다.", "recompute": "현재 순서와 고정, 상한으로 제안을 다시 계산합니다.", "apply": "관련된 모든 프로젝트에 이동을 기록합니다. 프로젝트마다 별도의 실행 취소 단계가 생깁니다.", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 4cb22ff9c..d068035a8 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -677,7 +677,7 @@ "allowSplits": "Laat toe dat het werk pauzedagen krijgt in plaats van in één stuk op te schuiven; dat geeft de verdeling meer ruimte, maar levert opgeknipte taken op.", "price": "Het prijskaartje toont per stand hoeveel werkdagen de einddatum van het zwaarst getroffen project opschuift; staat er één prijs met \"in beide standen\", dan maakt onderbreken hier geen verschil.", "pin": "Zet dit project vast: het houdt zijn einddatum én zijn werkdagen, en telt mee als vaste last waar de andere projecten omheen moeten.", - "ceiling": "Hoeveel werkdagen de einddatum van dit project maximaal mag opschuiven. Sleep de greep, of gebruik de pijltjestoetsen, Home (geen uitloop) en End (onbegrensd). Het label toont het effect op de einddatum, niet hoe ver je gesleept hebt.", + "ceiling": "Plafond: maximale uitloop van de einddatum — sleep, of pijltjes/Home/End", "chart": "Nu = de huidige boeking van alle geopende projecten samen; Na verdelen = wat er na de verdeling overblijft. Rood is wat boven de capaciteit uitkomt.", "recompute": "Rekent het voorstel opnieuw door met de huidige volgorde, vastzettingen en plafonds.", "apply": "Schrijft de verschuivingen weg in alle betrokken projecten; elk project krijgt een eigen ongedaan-maken-stap.", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 78f82a019..bc947345a 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -705,7 +705,7 @@ "allowSplits": "Pozwala, aby praca miała dni przerwy zamiast przesuwać się w jednym kawałku; daje to rozdziałowi więcej miejsca, ale powstają zadania pocięte.", "price": "Metka ceny pokazuje dla każdego ustawienia, o ile dni roboczych przesuwa się data zakończenia najbardziej dotkniętego projektu; jedna cena z „w obu ustawieniach” oznacza, że przerywanie niczego tu nie zmienia.", "pin": "Przypina ten projekt: zachowuje swoją datę zakończenia i swoje dni robocze oraz liczy się jako stałe obciążenie, które pozostałe projekty muszą ominąć.", - "ceiling": "O ile dni roboczych data zakończenia tego projektu może się najwyżej przesunąć. Przeciągnij uchwyt albo użyj strzałek, Home (bez przekroczenia) i End (bez ograniczenia). Etykieta pokazuje skutek dla daty zakończenia, a nie odległość przeciągnięcia.", + "ceiling": "Pułap: maksymalne przekroczenie daty zakończenia — przeciągnij lub strzałki/Home/End", "chart": "Teraz = bieżąca rezerwacja wszystkich otwartych projektów razem; Po rozdzieleniu = to, co zostaje po rozdzieleniu. Czerwień wykracza ponad wydajność.", "recompute": "Przelicza propozycję z bieżącą kolejnością, przypięciami i limitami.", "apply": "Zapisuje przesunięcia we wszystkich objętych projektach; każdy projekt otrzymuje własny krok cofnięcia.", diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index 37dd701a2..0cbbc7145 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -691,7 +691,7 @@ "allowSplits": "Permite que o trabalho tenha dias de pausa em vez de se deslocar de uma só vez; dá mais folga à distribuição, mas gera tarefas divididas.", "price": "A etiqueta de preço mostra, por posição, quantos dias úteis a data de fim do projeto mais afetado se desloca; um único preço com «em ambos os casos» significa que interromper não muda nada aqui.", "pin": "Fixa este projeto: mantém a data de fim e os seus dias úteis e conta como carga fixa que os outros projetos têm de contornar.", - "ceiling": "Quantos dias úteis a data de fim deste projeto pode deslocar-se no máximo. Arraste a alça ou use as setas, Home (sem excesso) e End (ilimitado). A etiqueta mostra o efeito na data de fim, não a distância arrastada.", + "ceiling": "Teto: excesso máximo da data de fim — arraste, ou setas/Home/End", "chart": "Agora = a reserva atual de todos os projetos abertos em conjunto; Depois de distribuir = o que resta após a distribuição. O vermelho ultrapassa a capacidade.", "recompute": "Recalcula a proposta com a ordem, as fixações e os limites atuais.", "apply": "Escreve os deslocamentos em todos os projetos envolvidos; cada projeto recebe o seu próprio passo de anulação.", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 79a2fab66..f0b01d46b 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -677,7 +677,7 @@ "allowSplits": "İşin tek parça hâlinde kaymak yerine ara günler almasına izin verir; dağıtıma daha çok alan tanır, ancak bölünmüş görevler oluşturur.", "price": "Fiyat etiketi, her konum için en çok etkilenen projenin bitiş tarihinin kaç iş günü kaydığını gösterir; «her iki durumda da» yazan tek bir fiyat, bölmenin burada bir fark yaratmadığı anlamına gelir.", "pin": "Bu projeyi sabitler: bitiş tarihini ve iş günlerini korur ve diğer projelerin etrafından dolaşması gereken sabit bir yük olarak sayılır.", - "ceiling": "Bu projenin bitiş tarihinin en fazla kaç iş günü kayabileceği. Tutamacı sürükleyin veya ok tuşlarını, Home (taşma yok) ve End (sınırsız) tuşlarını kullanın. Etiket, ne kadar sürüklediğinizi değil, bitiş tarihine etkisini gösterir.", + "ceiling": "Tavan: bitiş tarihinin azami taşması — sürükleyin veya ok tuşları/Home/End", "chart": "Şimdi = açık tüm projelerin şu anki toplam rezervasyonu; Dağıtımdan sonra = dağıtımdan sonra geriye kalan. Kırmızı, kapasitenin üzerine çıkan kısımdır.", "recompute": "Öneriyi mevcut sıra, sabitlemeler ve tavanlarla yeniden hesaplar.", "apply": "Kaymaları ilgili tüm projelere yazar; her proje kendi geri alma adımını alır.", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index c0d646f31..8b03c8a25 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -663,7 +663,7 @@ "allowSplits": "允许工作出现暂停日,而不是整块顺延;这为分配留出更多余地,但会产生被拆分的任务。", "price": "价格标签按开关的每种状态显示受影响最严重项目的完成日期顺延多少个工作日;若只显示一个价格并标注“两种设置下相同”,说明在此处允许中断没有区别。", "pin": "固定该项目:它保留完成日期和工作日,并作为固定负荷计入,其他项目必须绕开它。", - "ceiling": "本项目的完成日期最多可顺延多少个工作日。拖动手柄,或使用方向键、Home(不顺延)和 End(不限)。标签显示的是对完成日期的影响,而不是拖动的距离。", + "ceiling": "上限:完成日期的最大顺延 — 拖动,或方向键/Home/End", "chart": "现在 = 所有已打开项目当前的合计占用;分配后 = 分配之后剩下的情况。红色表示超出容量的部分。", "recompute": "按当前的顺序、固定项和上限重新计算方案。", "apply": "把位移写入所有相关项目;每个项目都会获得自己的撤销步骤。", From 8fecca99454741859b7c269f65b73ba235da4d12 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:25:07 +0200 Subject: [PATCH 106/108] fix(b1c): geen gestippelde doos bij een onbegrensd plafond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Toegestaan maar niet benut" is een maat; onbegrensd is dat niet, dus de doos vulde de hele track tot de rand van de as en beloofde ruimte die de as niet uitdrukt. buildStripGeometry geeft bij ceiling null nu freeBox null, ook tijdens een sleep. Vastgelegd als noot bij spec §4 (2026-09-14), met een geometriecase en een browserassertie erbij. Co-Authored-By: Claude Fable 5.1 --- ...12-b1c-verdeeldialoog-herontwerp-design.md | 7 +++++++ .../dialogs/DistributionDialog/PhaseStrip.tsx | 4 +++- .../DistributionDialog/stripGeometry.ts | 11 ++++++++-- tests/browser/leveling-distribution.spec.ts | 3 +++ .../check-distribution-strip-geometry.ts | 21 +++++++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md index 2a58233a2..b4145770e 100644 --- a/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md +++ b/docs/superpowers/specs/2026-09-12-b1c-verdeeldialoog-herontwerp-design.md @@ -68,6 +68,13 @@ In de track, op de gedeelde as: (`repeating-linear-gradient(135deg, …)`, dun grijs randje); - **"toegestaan maar niet benut"** als lege doos met gestippelde rand, van het nieuwe fase-einde tot de handle-stand; + + > **Eigenaarsbesluit 2026-09-14: bij een ONBEGRENSD plafond géén gestippelde doos.** De doos is + > een maat, en een onbegrensde maat vulde simpelweg de hele track tot de rand van de as — dat las + > als een belofte over ruimte die de as niet uitdrukt. Onbegrensd toont zich nu alleen nog in de + > greep aan de rechterrand en in de plafondtekst "onbegrensd"; `buildStripGeometry` geeft in dat + > geval `freeBox: null`. + - onderin een **meetlat** van 3 px: grijs gestippeld = de eigen speling (vanaf de oorspronkelijke einddatum), massief rood = alles voorbij de speling, dus echte einddatum-verschuiving; - de **vaste last** van gepinde/#63-documenten als lichte achtergrondband (bestaand). diff --git a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx index 61ad287c8..b519d1346 100644 --- a/src/components/dialogs/DistributionDialog/PhaseStrip.tsx +++ b/src/components/dialogs/DistributionDialog/PhaseStrip.tsx @@ -175,7 +175,9 @@ export function PhaseStrip({ // De "toegestaan maar niet benut"-doos loopt van het fase-einde tot de greep (§4), dus zodra de // greep tijdens het slepen geëxtrapoleerd wordt, moet de doos mee — anders zou hij achterblijven // op de oude asrand en de greep los van de balk lijken te zweven. - const freeBox = dragHandleX !== null && geometry !== null + // Bij een ONBEGRENSD plafond is er geen gestippelde doos (§4-noot 2026-09-14) — ook niet tijdens + // een sleep, waar de geëxtrapoleerde greep anders alsnog een doos zou opspannen. + const freeBox = displayCeiling !== null && dragHandleX !== null && geometry !== null ? (dragHandleX > geometry.phaseEndX ? { x: geometry.phaseEndX, w: dragHandleX - geometry.phaseEndX } : null) diff --git a/src/components/dialogs/DistributionDialog/stripGeometry.ts b/src/components/dialogs/DistributionDialog/stripGeometry.ts index 64934a59f..be2fe6308 100644 --- a/src/components/dialogs/DistributionDialog/stripGeometry.ts +++ b/src/components/dialogs/DistributionDialog/stripGeometry.ts @@ -76,7 +76,8 @@ export interface StripGeometry { ghostBlocks: StripBlock[]; /** De vaste last van gepinde/#63-documenten, samengevoegd tot aaneengesloten banden. */ fixedBands: StripBand[]; - /** "Toegestaan maar niet benut": van het fase-einde tot de handle. `null` ⇒ niets over. */ + /** "Toegestaan maar niet benut": van het fase-einde tot de handle. `null` ⇒ niets over — en ook + * altijd `null` bij een ONBEGRENSD plafond (§4-noot 2026-09-14): een oneindige maat tekent niet. */ freeBox: StripBand | null; /** De beschikbare eigen speling, vanaf het OORSPRONKELIJKE fase-einde. */ slackBar: StripBand | null; @@ -275,6 +276,12 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { // (4) Het plafond: de handle en de gestippelde rest. Onbegrensd ⇒ aan het einde van de as (§5). let handleX: number; let ceilingEndIso: string | null; + // GEEN GESTIPPELDE DOOS BIJ EEN ONBEGRENSD PLAFOND (eigenaarsbesluit 2026-09-14, noot bij §4). + // "Toegestaan maar niet benut" is een MAAT; bij een onbegrensd plafond is die maat oneindig en + // vulde de doos domweg de hele track tot de rechterrand van de as — dat las als "hier mag nog + // een berg werk bij tot ergens in de toekomst" terwijl de as-rand niets betekent. Onbegrensd + // spreekt nu alleen nog uit de greep aan de rechterrand en de plafondtekst "onbegrensd". + let freeBox: StripBand | null = null; if (ceilingWorkdays === null) { handleX = trackRight; ceilingEndIso = null; @@ -292,8 +299,8 @@ export function buildStripGeometry(input: StripGeometryInput): StripGeometry { const ceiling = advanceWorkdays(axis, anchorEnd, remaining, isWorkingDay); handleX = Math.max(AXIS.padLeft, Math.min(trackRight, ceiling.xEnd)); ceilingEndIso = ceiling.iso; + freeBox = handleX > phaseEndX ? { x: phaseEndX, w: handleX - phaseEndX } : null; } - const freeBox = handleX > phaseEndX ? { x: phaseEndX, w: handleX - phaseEndX } : null; // (5) Week- en maandlijnen. const weekLines: number[] = []; diff --git a/tests/browser/leveling-distribution.spec.ts b/tests/browser/leveling-distribution.spec.ts index a2e7711ca..de2dbc939 100644 --- a/tests/browser/leveling-distribution.spec.ts +++ b/tests/browser/leveling-distribution.spec.ts @@ -303,6 +303,9 @@ test('fasestroken: pin en plafond zijn met het toetsenbord te bedienen', async ( const handleBox = (await handle.boundingBox())!; const lastDay = (await strip.locator('[data-ops-distribution-day="work"]').last().boundingBox())!; expect(handleBox.x).toBeGreaterThan(lastDay.x + lastDay.width); + // ... maar de gestippelde "toegestaan maar niet benut"-doos hoort er NIET te zijn: die is een + // maat, en onbegrensd is geen maat (§4-noot 2026-09-14). Eerder vulde hij de hele track. + await expect(strip.locator('[data-ops-distribution-tail]')).toHaveCount(0); }); test('fasestroken: het label toont het EINDDATUM-effect, niet de sleepafstand', async ({ page, ops: _ops }) => { diff --git a/tests/planning/check-distribution-strip-geometry.ts b/tests/planning/check-distribution-strip-geometry.ts index 8337d9687..501e58197 100644 --- a/tests/planning/check-distribution-strip-geometry.ts +++ b/tests/planning/check-distribution-strip-geometry.ts @@ -290,6 +290,27 @@ ok(dw > 0, `de dagbreedte is positief (${dw})`); ok(partly.ghostBlocks.every(b => b.iso !== '2026-09-08'), '8 — en dat is precies de geplaatste dag'); } +// ── Geval 9 (§4-noot 2026-09-14): ONBEGRENSD PLAFOND ⇒ GEEN GESTIPPELDE DOOS ──────────────── +// Een onbegrensde maat tekent niet: de doos zou de hele track tot de asrand vullen. De greep +// staat wél aan de rechterrand, en er is geen plafonddatum. +{ + const before = load(['2026-09-07', '2026-09-08', '2026-09-09']); + const g = buildStripGeometry({ + axis, + beforeLoadByDay: before, + loadByDay: before, + fixedLoadByDay: {}, + isWorkingDay, + slackWorkdays: 2, + ceilingWorkdays: null, + endShiftWorkdays: 0, + }); + + ok(g.freeBox === null, '9 — bij een onbegrensd plafond is er geen gestippelde doos'); + ok(g.ceilingEndIso === null, '9 — en dus ook geen plafonddatum'); + ok(g.handleX > g.phaseEndX, '9 — de greep staat wel degelijk rechts van het fase-einde'); +} + // ── De verschil-prijs (§2.2) ───────────────────────────────────────────────────────────────── ok(savingsWorkdays(5, 2) === 3, 'prijs — uit 5, aan 2 ⇒ bespaart 3 werkdagen'); ok(savingsWorkdays(4, 4) === 0, 'prijs — even duur ⇒ bespaart niets'); From 14e3645b12c3a06c6a824780f588acde8552c090 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:32:19 +0200 Subject: [PATCH 107/108] fix(b1c): leesbare rood/groen/amber-tekst in donker en high-contrast De semantische tokens waren op wit gekozen: als tekst op een donkere kaart haalde de validatiestrook van de verdeeldialoog 2,55:1. Donker en high-contrast krijgen nu lichtere varianten (gemeten 7,3-8,3:1 resp. 10,5-15,1:1); het lichte thema zet de verzadigde waarden terug, want het gedeelde blok is :root plus dark. De handvol plekken waar zo'n token juist een ACHTERGROND onder witte tekst is (meldingsballon, gevaar-knoppen) krijgt in die twee thema's het verzadigde rood terug - een lichte tint is een tekstkleur, geen vlak. Co-Authored-By: Claude Fable 5.1 --- src/styles/globals.css | 3963 ++++++++++++++++++++-------------------- 1 file changed, 2001 insertions(+), 1962 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index fb1272015..510361d64 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,1348 +1,1359 @@ -@import "tailwindcss"; - -/* De gedeelde taakgrid is een dicht planninginstrument: één DOM-boom, vaste rijhoogte en één - scherpe actieve-celrand. Alle kleurkeuzes blijven via de bestaande thematokens lopen. */ -.task-grid-core { - position: relative; - overflow: auto; - background: var(--theme-surface); - color: var(--theme-text); - font-family: var(--font-body); - font-size: 11px; - outline: none; -} - -.task-grid-header-row, -.task-grid-data-row { - display: grid; -} - -/* De vaste plusknop staat buiten de scrollende kolommen. Geef de header en body daarom een echte - * afsluitende strook; padding op de scrollcontainer zelf schoof uitsluitend het maximale - * scrollbereik op en liet de knop bij scrollLeft=0 alsnog bovenop de laatste kolomtitel liggen. */ -.task-grid-header-row, -.task-grid-body { - box-sizing: content-box; - padding-inline-end: var(--task-grid-chooser-strip, 16px); -} - -/* De gridheader is sticky binnen dezelfde scroller als de gevirtualiseerde rijen. Laat de verticale - * scrollbaan pas onder die header beginnen, zodat duim en track nooit door de kolomkop lopen. */ -.task-grid-core::-webkit-scrollbar-track:vertical { - margin-block-start: var(--task-grid-header-height); -} - -.task-grid-header-row { - position: sticky; - top: 0; - z-index: var(--z-sticky); - background: var(--theme-surface-alt); - box-shadow: inset 0 -1px 0 var(--theme-border); -} - -.task-grid-column-header, -.task-grid-cell { - min-width: 0; - box-sizing: border-box; - display: flex; - align-items: center; - border-inline-end: 1px solid var(--theme-border-light); - border-bottom: 1px solid var(--theme-border-light); - background: var(--theme-surface); -} - -.task-grid-column-header { - padding-inline: 8px 10px; - overflow: visible; - background: var(--theme-surface-alt); - color: var(--theme-text-muted); - font-family: var(--font-heading); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - user-select: none; -} - -.task-grid-column-label, -.task-grid-cell-content, -.task-grid-group-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Een editorpop-over is bewust breder/hoger dan zijn 28px-cel. De gewone celinhoud blijft - * ellipsized, maar de actieve editor mag niet door die wrapper worden afgeknipt: anders zijn - * dropdowns en chipknoppen wel in de DOM aanwezig maar fysiek niet aanklikbaar. - * Issue #89: de contentspan is een flex-item van de cel en kromp zonder `flex: 1 1 auto` tot de - * intrinsieke breedte van het invoerveld (~20 tekens), zodat de naameditor maar een deel van de - * kolom vulde. De editor hoort de volle resterende kolombreedte te krijgen; de naameditor staat - * daarbij genest in de inspringrij (`.full-task-grid-name`), vandaar de afstammelingsselector. */ -.task-grid-cell-content:has(.task-grid-editor-host) { - flex: 1 1 auto; - overflow: visible; -} - -.task-grid-cell { - position: relative; - padding-inline: 7px; - outline: none; -} - -.task-grid-cell[data-grid-selected="true"] { - background: color-mix(in srgb, var(--theme-accent) 12%, var(--theme-surface)); - box-shadow: inset 0 -1px 0 var(--theme-accent); -} - -.task-grid-cell[data-grid-active="true"] { - z-index: 4 !important; - box-shadow: inset 0 0 0 2px var(--theme-accent); -} - -.task-grid-cell[data-grid-readonly="true"]::after { - content: ""; - position: absolute; - inset-block-start: 3px; - inset-inline-end: 3px; - width: 4px; - height: 4px; - border-block-start: 1px solid var(--theme-text-muted); - border-inline-end: 1px solid var(--theme-text-muted); -} - -.task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { - text-decoration-line: underline; - text-decoration-style: dotted; - text-underline-offset: 3px; -} - -.task-grid-cell[aria-invalid="true"] { - box-shadow: inset 0 0 0 2px var(--error); -} - -.task-grid-data-row[data-grid-dimmed="true"] .task-grid-cell-content { - opacity: 0.58; -} - -/* Browserreview, observatie 4: de rij-indicators (selectiebalk, drop-zone tijdens rijsleep) waren - * tot nu toe een `box-shadow: inset` op de RIJ zelf — maar een box-shadow van een element schildert - * ALTIJD vóór zijn eigen kinderen (de schilderorde is achtergrond/rand/box-shadow, dan pas - * children), en elke `.task-grid-cell` heeft een eigen ONdoorzichtige achtergrond. Onder elke kolom - * met inhoud (dus praktisch de hele tabelbreedte) werd de balk/lijn daardoor volledig overschilderd - * door de cellen erboven — zichtbaar bleef alleen het smalle restje rijbreedte VOORBIJ de laatste - * kolom. Twee bijna-onzichtbare lijnen die daar toevallig samenvallen (bv. "after" van rij N en - * "before" van rij N+1, beide maar 2px uit elkaar) konden zo als "twee oranje lijnen" ogen, en de - * selectiebalk op de gesleepte rij zelf was al net zo onzichtbaar. - * - * Fix: de indicator is nu een `::before`-pseudo-element, `position:absolute` op de rij (die - * daarvoor `position:relative` krijgt) met `z-index` boven de hoogste celwaarde (actieve cel = 4 - * `!important`) — een pseudo-element ligt zo als een eigen laag BOVEN alle cellen, ongeacht wat - * daaronder staat. `pointer-events:none` houdt 'm buiten alle hit-testing (klikken/slepen blijven - * op de cellen zelf werken). */ -.task-grid-data-row { - position: relative; -} - -.task-grid-data-row[data-grid-row-selected="true"] .task-grid-cell { - background: color-mix(in srgb, var(--theme-accent) 7%, var(--theme-surface)); -} - -.task-grid-data-row[data-grid-dragging="true"] { - opacity: 0.56; -} - -.task-grid-data-row[data-grid-row-selected="true"]::before, -.task-grid-data-row[data-grid-drop-zone]::before { - content: ""; - position: absolute; - inset: 0; - z-index: 6; - pointer-events: none; -} - -.task-grid-data-row[data-grid-row-selected="true"]::before { - box-shadow: inset 3px 0 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="before"]::before { - box-shadow: inset 0 2px 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="after"]::before { - box-shadow: inset 0 -2px 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="nest"]::before { - box-shadow: inset 0 0 0 2px var(--theme-accent); -} - -.task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { - opacity: 0.25; -} - -.task-grid-data-row.task-grid-trace-focus { - outline: 2px solid var(--theme-accent); - outline-offset: -2px; -} - -.task-grid-data-row.task-grid-trace-predecessor, -.task-grid-data-row.task-grid-trace-predecessor-driving { - border-inline-start: 3px solid var(--trace-predecessor, #F59E0B); -} - -.task-grid-data-row.task-grid-trace-successor, -.task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start: 3px dashed var(--trace-successor, #A78BFA); -} - -.task-grid-data-row.task-grid-trace-predecessor-driving, -.task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start-width: 5px; - font-weight: 600; -} - -@media (forced-colors: active) { - .task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { - opacity: 1; - text-decoration: line-through; - text-decoration-style: dotted; - } - - .task-grid-data-row.task-grid-trace-dimmed { - border-inline-start: 2px dotted GrayText; - } - - .task-grid-data-row.task-grid-trace-focus { - outline-color: Highlight; - } - - .task-grid-data-row.task-grid-trace-predecessor, - .task-grid-data-row.task-grid-trace-predecessor-driving { - border-inline-start-color: Highlight; - border-inline-start-style: solid; - } - - .task-grid-data-row.task-grid-trace-successor, - .task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start-color: Highlight; - border-inline-start-style: dashed; - } - - .task-grid-cell[data-grid-selected="true"] { - background: Highlight; - color: HighlightText; - box-shadow: inset 0 -2px 0 HighlightText; - } - - .task-grid-cell[data-grid-active="true"] { - outline: 2px solid Highlight; - outline-offset: -2px; - } - - .task-grid-cell[data-grid-readonly="true"]::after { - border-color: GrayText; - border-style: dotted; - } - - .task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { - text-decoration: underline dotted; - } - - .task-grid-cell[aria-invalid="true"] { - outline: 3px double Mark; - outline-offset: -3px; - } - - .task-grid-relation-chip--driving { - font-weight: 700; - text-decoration: underline double; - } - - .task-grid-relation-chip--warning { - text-decoration: underline wavy; - } -} - -.task-grid-group-row, -.task-grid-group-cell { - box-sizing: border-box; - background: var(--theme-surface-alt); - border-bottom: 1px solid var(--theme-border); -} - -.full-task-grid { - position: relative; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.full-task-grid > .task-grid-shell { - width: 100%; - height: 100%; -} - -.gantt-workspace { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr) auto auto; - flex: 1 1 auto; - width: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-workspace-grid, -.gantt-workspace-timeline { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-workspace-grid { - display: flex; - grid-column: 1; - grid-row: 1; -} - -.gantt-workspace-timeline { - display: flex; - grid-column: 2; - grid-row: 1; -} - -.gantt-workspace-histogram { - display: flex; - flex-direction: column; - grid-column: 1 / -1; - grid-row: 2; - min-width: 0; -} - -.gantt-workspace-minimap { - display: flex; - grid-column: 2; - grid-row: 3; - min-width: 0; -} - -.gantt-workspace-splitter { - grid-column: 1; - grid-row: 1; - align-self: stretch; - justify-self: end; - position: relative; - /* De volledige 5px-grijpzone ligt aan de Gantt-kant. Gecentreerd op de paneelgrens bedekte deze - * anders 2,5px van de 8px brede verticale tabelscrollbar. */ - transform: translateX(100%); - z-index: 6; - width: 5px; - min-width: 5px; - padding: 0; - border: 0; - border-inline-start: 1px solid var(--theme-border); - background: transparent; - cursor: col-resize; - touch-action: none; -} - -.gantt-workspace-splitter:hover, -.gantt-workspace-splitter[data-resizing="true"], -.gantt-workspace-splitter:focus-visible { - background: transparent; - outline: none; -} - -.gantt-task-grid { - position: relative; - display: flex; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-task-grid > .task-grid-shell { - width: 100%; - height: 100%; -} - -/* Issue #89: de subtaak-plus staat ná de naam en wordt met `margin-inline-start: auto` tegen - * de rechter kolomrand gedrukt, zodat hij op elke samenvattende rij op dezelfde plek staat en - * de naamtekst links de hiërarchie kan laten zien. De lichte rand maakt hem als knop herkenbaar. */ -.gantt-task-grid-add-child { - flex: none; - margin-inline-start: auto; - /* Vrij van de kolomrand (issue #89, punt 4 vroeg juist om lucht rond zo'n knop). */ - margin-inline-end: 6px; - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--theme-border-light); - border-radius: 3px; - color: var(--theme-text-muted); - background: transparent; - line-height: 1; -} - -.gantt-task-grid-add-child:hover, -.gantt-task-grid-add-child:focus-visible { - color: var(--theme-accent); - border-color: var(--theme-accent); - background: var(--theme-hover); - outline: none; -} - -/* Vervolgmelding op observatie 3a/3b (probleem B): was `display: inline-flex` — een INLINE box - * sizet zich (shrink-to-fit) naar zijn EIGEN inhoud (triehoekje + tekst), niet naar de beschikbare - * breedte van zijn ouder (`.task-grid-cell-content`, block, dus geen flex-context die deze rij zou - * dwingen te krimpen). `min-width:0`/`overflow:hidden` op deze laag of de tekst-span erin doen dan - * NIETS — die eigenschappen laten iets krimpen ALS iets anders het beperkt, maar niets deed dat - * hier. `display: flex` (blokniveau) vult i.p.v. dat de beschikbare breedte van de ouder, wat de - * tekst-span (`.full-task-grid-name-label`, `min-width:0`) een ECHTE beperking geeft om binnen te - * krimpen — pas dan werken overflow:hidden/text-overflow:ellipsis daarop zoals bedoeld. */ -.full-task-grid-name { - min-width: 0; - display: flex; - align-items: center; - /* Issue #89: triehoekje (16px) + deze gap (4px) = één inspringeenheid van 20px, dezelfde - * TASK_NAME_INDENT_UNIT als in src/engine/taskGrid/nameIndent.ts. */ - gap: 4px; -} - -/* Discussion #97: samenvattingen (containertaken) en mijlpalen zijn nu alleen via inspringing te - * onderscheiden van een gewone taak. `data-grid-task-kind` (gezet in FullTaskGrid.tsx, weggelaten - * tijdens naam-editing) stuurt hier vet + een subtiele achtergrondtint voor samenvattingen, en - * vet + de mijlpaalkleur voor mijlpalen — bewust dezelfde kleurfamilie als de Gantt-balk - * (`--color-task-milestone`), niet de rode mockupkleur uit de discussion. De tint zit op de hele - * naamrij (niet alleen de tekst), zodat hij als rijvlak leesbaar is; de selectiekleur van de rij - * (`.task-grid-data-row[data-grid-row-selected="true"]`) wint hier altijd van, dus geen eigen - * z-index-gedoe nodig — de tint zit hieronder in de laagvolgorde. */ -.full-task-grid-name[data-grid-task-kind="summary"] { - background: var(--theme-grid-summary-bg); -} - -.full-task-grid-name[data-grid-task-kind="summary"] .full-task-grid-name-label { - font-weight: 600; -} - -.full-task-grid-name[data-grid-task-kind="milestone"] .full-task-grid-name-label { - font-weight: 600; - color: var(--theme-grid-milestone-text); -} - -/* Geselecteerde rij wint van de samenvattingstint (leesbaarheid op de selectiekleur staat voorop); - * de vette/gekleurde tekst blijft intact — alleen de achtergrond van de naamrij vervalt. */ -.task-grid-data-row[data-grid-row-selected="true"] .full-task-grid-name[data-grid-task-kind="summary"] { - background: transparent; -} - -/* De naameditor vult de rij vanaf de tekstpositie tot de rechter kolomrand. */ -.full-task-grid-name > .task-grid-editor-host { - flex: 1 1 auto; - min-width: 0; -} - -/* De algemene celpadding liet rechts van een taaknaam zeven ongebruikte pixels liggen: genoeg voor - * nog een letter vóór de ellipsis. Links blijft de normale inspringruimte intact. */ -.task-grid-cell:has(.full-task-grid-name) { - padding-inline-end: 1px; -} - -/* Issue #89: de contentspan is een flex-item van de cel en kromp tot zijn inhoud, zodat de - * rechts uitgelijnde subtaak-plus (`margin-inline-start: auto`) nooit verder kwam dan het einde - * van de naamtekst. De naamcel laat zijn contentspan daarom de hele celbreedte vullen; de - * naamtekst zelf blijft via `.full-task-grid-name-label` krimpen en afknippen. */ -.task-grid-cell:has(.full-task-grid-name) > .task-grid-cell-content { - flex: 1 1 auto; -} - -/* Vervolgmelding op observatie 3a/3b (probleem B): `.full-task-grid-name` is zelf een - * `inline-flex`-rij (in-/uitklaptriehoekje + evt. "+"-knop + de tekst) zonder eigen - * overflow/text-overflow — `text-overflow: ellipsis` op de vooroudercontainer - * (`.task-grid-cell-content`) kan de afknipplek niet correct berekenen zodra de tekst genest zit - * in een EIGEN flex-container in plaats van een rechtstreeks tekstkind: de ellips ("…") werd - * daardoor nooit getoond, en de daadwerkelijke afknip leunde uitsluitend op de - * `overflow: hidden` van die voorouder — geen eigen begrenzing op déze laag. Bij smalle kolommen - * bleef de layout-doos van `.full-task-grid-name` daardoor BREDER dan zijn eigen ouder (gemeten: - * tot 30px breder dan `.task-grid-cell-content`), puur toevallig nog steeds geklemd door die - * voorouder — een aanname die breekt zodra iets in die keten verandert. Deze klasse geeft de - * TEKST zelf (niet de rij eromheen) zijn eigen `min-width:0` (kan binnen de flex-rij krimpen, - * ondanks het vaste-breedte triehoekje ernaast) plus `overflow:hidden`/`text-overflow:ellipsis` — - * de clip zit nu op de plek waar de tekst zelf staat, niet alleen bij de voorouder. */ -.full-task-grid-name-label { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.full-task-grid-disclosure { - flex: 0 0 16px; - width: 16px; - height: 18px; - padding: 0; - border: 0; - color: var(--theme-text-muted); - background: transparent; - line-height: 1; -} - -.full-task-grid-error { - position: absolute; - inset-inline: 8px; - inset-block-end: 8px; - z-index: 8; - padding: 6px 9px; - border: 1px solid color-mix(in srgb, var(--error) 55%, var(--theme-border)); - border-radius: 6px; - color: var(--error); - background: var(--theme-surface); - box-shadow: var(--shadow-pop); - font-size: 12px; -} - -.task-grid-group-cell { - display: flex; - align-items: center; -} - -.task-grid-group-toggle { - width: 100%; - height: 100%; - display: flex; - align-items: center; - gap: 6px; - padding-inline: 0 8px; - color: inherit; - background: transparent; - border: 0; - font: inherit; - font-weight: 600; - text-align: start; -} - -.task-grid-group-toggle:focus-visible, -.task-grid-resize-handle:focus-visible { - outline: 2px solid var(--theme-accent); - outline-offset: -2px; -} - -.task-grid-group-count { - flex: none; - color: var(--theme-text-muted); - font-family: var(--font-code); - font-size: 10px; -} - -/* Browserreview, observatie 7 (grijpzone-kant): was `inset-inline-end: -4px; width: 8px` — een - * band van 4px aan WEERSZIJDEN van de kolomgrens. `beginResize`'s `onPointerDown` roept - * `event.preventDefault()` aan, wat de NATIEVE HTML5-slepginitiatie onderdrukt (preventDefault op - * pointerdown/mousedown is precies het mechanisme waarmee je dragstart voorkomt) — een poging om - * een kolom te HERORDENEN door 'm net bij de rand vast te pakken (een natuurlijke plek om te - * grijpen) landde zo op de resize-greep i.p.v. de herordenbare kopruimte, en startte dan een resize - * (of niets) in plaats van een sleep. Bij een kolom van de minimumbreedte (40px) at 8px band 20% van - * de breedte in beslag — en ELKE kolom draagt ook nog eens 2px overhang van de greep van zijn linker - * buur. Smaller gemaakt (4px totaal i.p.v. 8px) zodat er meer ruimte overblijft voor herordenen; de - * greep blijft een gangbare resize-hitbox (vergelijkbaar met Excel/Sheets, die ook een paar pixels - * breed zijn, geen 8). - */ -.task-grid-resize-handle { - position: absolute; - inset-block: 0; - inset-inline-end: -2px; - z-index: 2; - width: 4px; - padding: 0; - border: 0; - background: transparent; - cursor: col-resize; - touch-action: none; -} - -.task-grid-resize-handle:hover { - background: color-mix(in srgb, var(--theme-accent) 38%, transparent); -} - -.task-grid-shell { - position: relative; - width: fit-content; - max-width: 100%; - /* Eén bron voor de "kolom toevoegen"-strook: een 20px-knop met 6px lucht aan weerszijden, zodat - * hij vrijstaat van de laatste kolominhoud én van de rand van het kader (issue #89). */ - --task-grid-chooser-strip: 32px; -} - -.task-grid-column-header[data-grid-dragging="true"] { - opacity: 0.5; -} - -.task-grid-column-header[data-grid-drop-before="true"]::before, -.task-grid-column-header[data-grid-drop-after="true"]::after { - content: ""; - position: absolute; - z-index: 4; - inset-block: 0; - width: 2px; - background: var(--theme-accent); -} - -.task-grid-column-header[data-grid-drop-before="true"]::before { - inset-inline-start: -1px; -} - -.task-grid-column-header[data-grid-drop-after="true"]::after { - inset-inline-end: -1px; -} - -.task-grid-remove-column { - position: absolute; - inset-block-start: 50%; - inset-inline-end: 7px; - width: 17px; - height: 17px; - display: grid; - place-items: center; - padding: 0; - color: var(--theme-text-muted); - background: var(--theme-surface-alt); - border: 1px solid transparent; - border-radius: 3px; - opacity: 0; - transform: translateY(-50%); -} - -.task-grid-column-header:hover .task-grid-remove-column, -.task-grid-column-header:focus-within .task-grid-remove-column { - opacity: 1; -} - -.task-grid-remove-column:hover, -.task-grid-remove-column:focus-visible { - color: var(--theme-text); - border-color: var(--theme-border); - outline: none; -} - -.task-grid-column-chooser-anchor { - position: absolute; - z-index: calc(var(--z-sticky) + 1); - inset-block-start: 0; - inset-inline-end: 0; - width: var(--task-grid-chooser-strip, 32px); - height: var(--task-grid-header-height); - display: flex; - align-items: center; - justify-content: center; - padding-inline: 6px; - background: var(--theme-surface-alt); -} - -.task-grid-add-column { - width: 20px; - height: 20px; - display: grid; - place-items: center; - padding: 0; - color: var(--theme-text-muted); - background: transparent; - border: 1px solid transparent; - border-radius: 3px; -} - -.task-grid-add-column:hover, -.task-grid-add-column:focus-visible, -.task-grid-add-column[aria-expanded="true"] { - color: var(--theme-text); - border-color: var(--theme-border); - background: var(--theme-hover); - outline: none; -} - -.task-grid-no-columns { - position: absolute; - inset-block-start: var(--task-grid-header-height); - inset-inline: 0; - min-height: 28px; - display: flex; - align-items: center; - padding-inline: 10px 40px; - color: var(--theme-text-muted); - font-size: 11px; - pointer-events: none; -} - -.task-grid-column-chooser { - z-index: var(--z-contextmenu); - width: min(310px, calc(100vw - 16px)); - overflow-y: auto; - padding: 7px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: 6px; - box-shadow: var(--shadow-pop); - font-family: var(--font-body); - font-size: 11px; -} - -.task-grid-column-chooser-title { - padding: 3px 5px 7px; - font-family: var(--font-heading); - font-size: 12px; - font-weight: 700; -} - -.task-grid-column-chooser-section-label { - padding: 7px 5px 3px; - color: var(--theme-text-muted); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.task-grid-column-chooser-list { - display: grid; -} - -.task-grid-column-chooser-item, -.task-grid-column-chooser-category { - width: 100%; - min-height: 28px; - display: flex; - align-items: center; - gap: 7px; - padding: 5px 7px; - color: var(--theme-text); - background: transparent; - border: 0; - border-radius: 3px; - font: inherit; - text-align: start; -} - -.task-grid-column-chooser-item:hover:not(:disabled), -.task-grid-column-chooser-item:focus-visible, -.task-grid-column-chooser-category:hover, -.task-grid-column-chooser-category:focus-visible { - background: var(--theme-hover); - outline: none; -} - -.task-grid-column-chooser-item:disabled { - color: var(--theme-text-muted); - cursor: default; -} - -.task-grid-column-chooser-check { - width: 13px; - flex: none; -} - -.task-grid-column-chooser-search { - height: 30px; - display: flex; - align-items: center; - gap: 6px; - margin: 5px 2px; - padding-inline: 7px; - color: var(--theme-text-muted); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 4px; -} - -.task-grid-column-chooser-search:focus-within { - border-color: var(--theme-accent); -} - -.task-grid-column-chooser-search input { - min-width: 0; - flex: 1; - color: var(--theme-text); - background: transparent; - border: 0; - outline: none; - font: inherit; -} - -.task-grid-column-chooser-categories { - margin-block-start: 5px; - padding-block-start: 4px; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-column-chooser-category { - font-weight: 600; -} - -.task-grid-column-chooser-category-count { - margin-inline-start: auto; - color: var(--theme-text-muted); - font-family: var(--font-code); - font-size: 10px; -} - -.task-grid-column-chooser-category svg[data-expanded="true"] { - transform: rotate(180deg); -} - -.task-grid-column-chooser-empty { - padding: 6px 7px; - color: var(--theme-text-muted); -} - -.task-grid-editor-host { - position: relative; - width: 100%; - height: 100%; -} - -.task-grid-editor-input { - width: 100%; - height: 100%; - min-width: 0; - padding-inline: 5px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-accent); - font: inherit; -} - -.task-grid-date-editor { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - width: 100%; - height: 100%; -} - -.task-grid-date-editor .task-grid-editor-input { - border-inline-end: 0; -} - -.task-grid-date-picker { - width: 2.25rem; - min-width: 0; - height: 100%; - padding: 0 2px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-accent); - font: inherit; -} - -.task-grid-assignment-editor { - position: absolute; - z-index: var(--z-dropdown); - inset-block-start: 0; - inset-inline-start: 0; - display: grid; - gap: 5px; - width: max(100%, 420px); - max-width: min(560px, 80vw); - padding: 6px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-accent); - box-shadow: var(--shadow-pop); -} - -.task-grid-assignment-tokens { - display: grid; - gap: 4px; -} - -.task-grid-assignment-token { - display: grid; - grid-template-columns: minmax(110px, 1fr) 76px 132px 26px; - gap: 4px; - align-items: center; -} - -.task-grid-assignment-resource { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.task-grid-assignment-token input, -.task-grid-assignment-token select, -.task-grid-assignment-editor > input, -.task-grid-assignment-token button, -.task-grid-assignment-options button { - min-width: 0; - height: 26px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 3px; - font: inherit; -} - -.task-grid-assignment-token input, -.task-grid-assignment-editor > input { - padding-inline: 5px; -} - -.task-grid-assignment-token button, -.task-grid-assignment-options button { - cursor: pointer; -} - -.task-grid-assignment-token button:hover, -.task-grid-assignment-options button:hover { - background: var(--theme-hover); -} - -.task-grid-assignment-options { - display: grid; - max-height: 180px; - overflow: auto; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-assignment-options button { - padding-inline: 6px; - text-align: start; - border: 0; - border-radius: 0; -} - -/* Browserreview, observatie 6 (tweede plek met dezelfde afwijking): dit paneel portaalt naar - * document.body (RelationCellEditor.tsx) zonder eigen `font-size` — zonder deze regel erven al zijn - * kinderen (chips, tokens, invoervelden — geen van allen zet zelf een font-size) html/body's volle - * `calc(13px * --ui-font-scale)` i.p.v. de compacte roosterdichtheid. `.task-grid-column-chooser` - * (dezelfde soort portal-popup) had dit al goed; hier hetzelfde patroon toegepast. */ -.task-grid-relation-editor { - position: fixed; - z-index: var(--z-dropdown); - display: grid; - gap: 5px; - max-width: min(680px, 85vw); - max-height: calc(100vh - 16px); - overflow: auto; - padding: 6px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-accent); - box-shadow: var(--shadow-pop); - font-family: var(--font-body); - font-size: 11px; -} - -.task-grid-relation-editor-anchor { - position: absolute; - inset: 0; - pointer-events: none; -} - -.task-grid-relation-cell { - display: block; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.task-grid-relation-chip { - display: inline-flex; - align-items: center; - min-width: 0; -} - -.task-grid-relation-jump { - min-width: 0; - padding: 0; - overflow: hidden; - background: transparent; - border: 0; - cursor: pointer; - font: inherit; - text-overflow: ellipsis; - text-decoration: underline; - text-underline-offset: 2px; - white-space: nowrap; -} - -.task-grid-relation-icon { - display: inline-flex; - align-items: center; - margin-inline-start: 2px; -} - -/* Issue #94: één kleur per chip (referentie, type/lag en het sturend-icoon), gestuurd door de - * richting op de chip zelf — niet meer alleen het referentiedeel accentkleur, de rest tekstkleur. - * Sturend blijft zichtbaar als de STERKERE tint van diezelfde rolkleur, niet als los "wel/geen - * kleur"; het warning-icoon blijft altijd zijn eigen --warning-kleur houden. */ -.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-jump, -.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-detail { - color: var(--relation-predecessor); -} - -.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-jump, -.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-detail { - color: var(--relation-successor); -} - -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-jump, -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-detail, -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-icon--driving > svg { - color: var(--relation-predecessor-driving); - font-weight: 700; -} - -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-jump, -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-detail, -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-icon--driving > svg { - color: var(--relation-successor-driving); - font-weight: 700; -} - -.task-grid-relation-chip--warning .task-grid-relation-icon--warning > svg { - color: var(--warning, #d97706); -} - -.task-grid-relation-separator { - color: var(--theme-text-muted); -} - -.task-grid-relation-tokens, -.task-grid-relation-options { - display: grid; - gap: 4px; -} - -.task-grid-relation-token { - display: grid; - grid-template-columns: minmax(180px, 1fr) 64px 86px 26px; - gap: 4px; - align-items: center; -} - -.task-grid-relation-reference { - min-width: 0; - overflow: hidden; - color: inherit; - text-align: start; - text-overflow: ellipsis; - white-space: nowrap; -} - -button.task-grid-relation-reference { - padding: 0; - color: var(--theme-accent); - background: transparent; - border: 0; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 2px; -} - -.task-grid-relation-token input, -.task-grid-relation-token select, -.task-grid-relation-token > button:not(.task-grid-relation-reference), -.task-grid-relation-add input, -.task-grid-relation-add button, -.task-grid-relation-options button { - min-width: 0; - height: 26px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 3px; - font: inherit; -} - -.task-grid-relation-token input, -.task-grid-relation-add input { - padding-inline: 5px; -} - -.task-grid-relation-add { - display: grid; - grid-template-columns: minmax(160px, 1fr) auto; - gap: 4px; -} - -.task-grid-relation-add button, -.task-grid-relation-token > button:not(.task-grid-relation-reference), -.task-grid-relation-options button { - cursor: pointer; -} - -.task-grid-relation-options { - max-height: 180px; - overflow: auto; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-relation-options button { - padding-inline: 6px; - text-align: start; - border: 0; - border-radius: 0; -} - -.task-grid-relation-add button:hover, -.task-grid-relation-token > button:hover, -.task-grid-relation-options button:hover { - background: var(--theme-hover); -} - -.task-grid-relation-context { - position: fixed; - z-index: var(--z-dropdown); - display: grid; - min-width: 190px; - padding-block: 4px; - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: 5px; - box-shadow: var(--shadow-pop); -} - -.task-grid-relation-context button { - padding: 6px 10px; - color: var(--theme-text); - background: transparent; - border: 0; - cursor: pointer; - font: inherit; - text-align: start; -} - -.task-grid-relation-context button:hover { - background: var(--theme-hover); -} - -.task-grid-relation-context .task-grid-relation-context-danger { - color: var(--error); -} - -.task-grid-editor-error { - position: absolute; - z-index: var(--z-dropdown); - inset-block-start: 100%; - inset-inline-start: 0; - max-width: 320px; - padding: 5px 7px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--error); - box-shadow: var(--shadow-pop); - font-size: 11px; - white-space: normal; -} - -/* ============================================================================ - OpenAEC Foundation — Design Tokens - Source: OpenAEC-style-book/brandbook/DESIGN-SYSTEM.md §2 + LAYOUTS.md §0/§4.1 - These are theme-independent primitives. Theme-specific tokens (--theme-*) - reference these and are defined per [data-theme] block below. - ============================================================================ */ -:root { - /* Brand — primary */ - --deep-forge: #36363E; - - /* Path tracing (issue #114): de rijmarkering in het taakraster gebruikt dezelfde tinten als de - Gantt-balken — GANTT_TRACE_COLORS in src/engine/renderer/themePalette.ts is de bron; - tests/planning/check-dependency-presentation.ts bewaakt dat beide gelijk blijven. */ - --trace-predecessor: #F59E0B; - --trace-successor: #A78BFA; - - /* Brand — secondary */ - --signal-orange: #EA580C; - --warm-gold: #F59E0B; - --scaffold-gray: #A1A1AA; - - /* Backgrounds */ - --blueprint-white: #FAFAF9; - --concrete: #F5F5F4; - --night-build: #2A2A32; - - /* Semantic */ - --success: #16A34A; - --error: #DC2626; - --info: #2563EB; - --warning: #F59E0B; - - /* Dashboard / dense UI tokens (DESIGN-SYSTEM.md §2.5) */ - --dashboard-bg: #2A2A32; - --dashboard-surface: #36363E; - --dashboard-surface-hover: #3E3E48; - --dashboard-sidebar: #222228; - --dashboard-border: #4A4A54; - --dashboard-border-light: #404048; - --dashboard-text: #FAFAF9; - --dashboard-text-muted: #A1A1AA; - --dashboard-text-dim: #666670; - --dashboard-accent: #D97706; - --dashboard-accent-bg: #2E2418; - --dashboard-accent-hover: #EA580C; - --dashboard-terminal: #F59E0B; - --dashboard-terminal-dim: #D97706; - - /* Typography families */ - --font-heading: "Space Grotesk", system-ui, sans-serif; - --font-body: "Inter", system-ui, sans-serif; - --font-code: "JetBrains Mono", monospace; - - /* Spacing scale (4px base) */ - --sp-1: 0.25rem; /* 4px */ - --sp-2: 0.5rem; /* 8px */ - --sp-3: 0.75rem; /* 12px */ - --sp-4: 1rem; /* 16px */ - --sp-5: 1.25rem; /* 20px */ - --sp-6: 1.5rem; /* 24px */ - --sp-8: 2rem; /* 32px */ - --sp-10: 2.5rem; /* 40px */ - --sp-12: 3rem; /* 48px */ - --sp-16: 4rem; /* 64px */ - --sp-20: 5rem; /* 80px */ - --sp-24: 6rem; /* 96px */ - - /* Border radius */ - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 14px; - --radius-full: 9999px; - - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); - --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); - - /* Z-index scale (LAYOUTS.md §0.3) */ - --z-base: 0; - --z-dropdown: 10; - --z-sticky: 20; - --z-sidebar: 30; - --z-navbar: 40; - --z-modal-backdrop: 50; - --z-modal: 60; - --z-toast: 70; - --z-tooltip: 80; - --z-contextmenu: 90; - - /* Layout sizes (LAYOUTS.md §4.1) */ - --titlebar-height: 32px; - --toolbar-height: 40px; - --statusbar-height: 24px; - --sidebar-width: 260px; - --inspector-width: 280px; -} - -/* ============================================================================ - Theme CSS Variables - ============================================================================ */ - -@theme { - --color-surface: var(--theme-surface); - --color-surface-alt: var(--theme-surface-alt); - --color-surface-hover: var(--theme-hover); - --color-border: var(--theme-border); - --color-text-primary: var(--theme-text); - --color-text-secondary: var(--theme-text-dim); - --color-accent: var(--theme-accent); - --color-accent-hover: var(--theme-accent-hover); - --color-accent-on: var(--theme-accent-on); - /* U2 — ÉÉN balkpalet voor licht én donker (besluit eigenaar). Elke waarde haalt >=3:1 tegen - ZOWEL de lichte kaart (#FAFAFA) als de donkere kaart (#2E3239); vroeger waren de balken op - merk-verzadiging afgestemd op wit en haalden ze op de donkere kaart 1.9-2.7:1. Eén stap minder - verzadigd, zelfde tintfamilie, zodat legenda, gidsen en rapport dezelfde taal blijven spreken. - Gemeten (WCAG 2.x relatieve luminantie), licht / donker / wit-label-op-balk: - critical #DA5252 3.79 / 3.25 / 3.95 - normal #648BE0 3.19 / 3.87 / 3.33 - complete #5778D6 3.97 / 3.10 / 4.15 - milestone #986DE2 3.58 / 3.45 / 3.74 - baseline #808694 3.50 / 3.53 / 3.65 - float #1E976F 3.52 / 3.50 / 3.68 - LET OP: dezelfde zes waarden staan als merk-hex in `src/engine/renderer/themePalette.ts` - (BRAND/`--theme-bar-float`-fallback) — die tekenlaag leest geen CSS in headless tests. */ - --color-critical: #DA5252; - --color-task-normal: #648BE0; - --color-task-milestone: #986DE2; - --color-task-float: #1E976F; - --color-task-baseline: #808694; - --color-task-complete: #5778D6; -} - -/* Dark theme — OpenAEC default - Construction Amber on Deep Forge. Dashboard tokens uit DESIGN-SYSTEM.md §2.5. */ -:root, -[data-theme="dark"] { - /* Koeler charcoal + Soft Depth. Werkruimte-tint (--theme-bg) is donkerder dan - de kaarten (--theme-surface) zodat panelen "zweven". */ - --theme-bg: #1B1D22; - --theme-surface: #2E3239; - --theme-surface-alt: #23262D; - --theme-surface-elevated: #363B43; - --theme-border: #3A3F48; - --theme-border-light: #33373F; - /* Rand van inputs/selects/knoppen — zichtbaarder dan kaartrand (~3:1 op de card). */ - --theme-control-border: #4A515C; - --theme-accent: #D97706; - --theme-accent-hover: #EA580C; - /* Tekstkleur op accent-achtergronden (wit in light/dark, zwart in high-contrast). */ - --theme-accent-on: #ffffff; - --theme-text: #F1F3F5; - --theme-text-dim: #B6BCC6; - --theme-text-muted: #7C8492; - --theme-hover: rgba(255, 255, 255, 0.06); - --theme-active: #D97706; - --theme-active-border: #EA580C; - --theme-warning-text: #F59E0B; +@import "tailwindcss"; + +/* De gedeelde taakgrid is een dicht planninginstrument: één DOM-boom, vaste rijhoogte en één + scherpe actieve-celrand. Alle kleurkeuzes blijven via de bestaande thematokens lopen. */ +.task-grid-core { + position: relative; + overflow: auto; + background: var(--theme-surface); + color: var(--theme-text); + font-family: var(--font-body); + font-size: 11px; + outline: none; +} + +.task-grid-header-row, +.task-grid-data-row { + display: grid; +} + +/* De vaste plusknop staat buiten de scrollende kolommen. Geef de header en body daarom een echte + * afsluitende strook; padding op de scrollcontainer zelf schoof uitsluitend het maximale + * scrollbereik op en liet de knop bij scrollLeft=0 alsnog bovenop de laatste kolomtitel liggen. */ +.task-grid-header-row, +.task-grid-body { + box-sizing: content-box; + padding-inline-end: var(--task-grid-chooser-strip, 16px); +} + +/* De gridheader is sticky binnen dezelfde scroller als de gevirtualiseerde rijen. Laat de verticale + * scrollbaan pas onder die header beginnen, zodat duim en track nooit door de kolomkop lopen. */ +.task-grid-core::-webkit-scrollbar-track:vertical { + margin-block-start: var(--task-grid-header-height); +} + +.task-grid-header-row { + position: sticky; + top: 0; + z-index: var(--z-sticky); + background: var(--theme-surface-alt); + box-shadow: inset 0 -1px 0 var(--theme-border); +} + +.task-grid-column-header, +.task-grid-cell { + min-width: 0; + box-sizing: border-box; + display: flex; + align-items: center; + border-inline-end: 1px solid var(--theme-border-light); + border-bottom: 1px solid var(--theme-border-light); + background: var(--theme-surface); +} + +.task-grid-column-header { + padding-inline: 8px 10px; + overflow: visible; + background: var(--theme-surface-alt); + color: var(--theme-text-muted); + font-family: var(--font-heading); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + user-select: none; +} + +.task-grid-column-label, +.task-grid-cell-content, +.task-grid-group-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Een editorpop-over is bewust breder/hoger dan zijn 28px-cel. De gewone celinhoud blijft + * ellipsized, maar de actieve editor mag niet door die wrapper worden afgeknipt: anders zijn + * dropdowns en chipknoppen wel in de DOM aanwezig maar fysiek niet aanklikbaar. + * Issue #89: de contentspan is een flex-item van de cel en kromp zonder `flex: 1 1 auto` tot de + * intrinsieke breedte van het invoerveld (~20 tekens), zodat de naameditor maar een deel van de + * kolom vulde. De editor hoort de volle resterende kolombreedte te krijgen; de naameditor staat + * daarbij genest in de inspringrij (`.full-task-grid-name`), vandaar de afstammelingsselector. */ +.task-grid-cell-content:has(.task-grid-editor-host) { + flex: 1 1 auto; + overflow: visible; +} + +.task-grid-cell { + position: relative; + padding-inline: 7px; + outline: none; +} + +.task-grid-cell[data-grid-selected="true"] { + background: color-mix(in srgb, var(--theme-accent) 12%, var(--theme-surface)); + box-shadow: inset 0 -1px 0 var(--theme-accent); +} + +.task-grid-cell[data-grid-active="true"] { + z-index: 4 !important; + box-shadow: inset 0 0 0 2px var(--theme-accent); +} + +.task-grid-cell[data-grid-readonly="true"]::after { + content: ""; + position: absolute; + inset-block-start: 3px; + inset-inline-end: 3px; + width: 4px; + height: 4px; + border-block-start: 1px solid var(--theme-text-muted); + border-inline-end: 1px solid var(--theme-text-muted); +} + +.task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.task-grid-cell[aria-invalid="true"] { + box-shadow: inset 0 0 0 2px var(--error); +} + +.task-grid-data-row[data-grid-dimmed="true"] .task-grid-cell-content { + opacity: 0.58; +} + +/* Browserreview, observatie 4: de rij-indicators (selectiebalk, drop-zone tijdens rijsleep) waren + * tot nu toe een `box-shadow: inset` op de RIJ zelf — maar een box-shadow van een element schildert + * ALTIJD vóór zijn eigen kinderen (de schilderorde is achtergrond/rand/box-shadow, dan pas + * children), en elke `.task-grid-cell` heeft een eigen ONdoorzichtige achtergrond. Onder elke kolom + * met inhoud (dus praktisch de hele tabelbreedte) werd de balk/lijn daardoor volledig overschilderd + * door de cellen erboven — zichtbaar bleef alleen het smalle restje rijbreedte VOORBIJ de laatste + * kolom. Twee bijna-onzichtbare lijnen die daar toevallig samenvallen (bv. "after" van rij N en + * "before" van rij N+1, beide maar 2px uit elkaar) konden zo als "twee oranje lijnen" ogen, en de + * selectiebalk op de gesleepte rij zelf was al net zo onzichtbaar. + * + * Fix: de indicator is nu een `::before`-pseudo-element, `position:absolute` op de rij (die + * daarvoor `position:relative` krijgt) met `z-index` boven de hoogste celwaarde (actieve cel = 4 + * `!important`) — een pseudo-element ligt zo als een eigen laag BOVEN alle cellen, ongeacht wat + * daaronder staat. `pointer-events:none` houdt 'm buiten alle hit-testing (klikken/slepen blijven + * op de cellen zelf werken). */ +.task-grid-data-row { + position: relative; +} + +.task-grid-data-row[data-grid-row-selected="true"] .task-grid-cell { + background: color-mix(in srgb, var(--theme-accent) 7%, var(--theme-surface)); +} + +.task-grid-data-row[data-grid-dragging="true"] { + opacity: 0.56; +} + +.task-grid-data-row[data-grid-row-selected="true"]::before, +.task-grid-data-row[data-grid-drop-zone]::before { + content: ""; + position: absolute; + inset: 0; + z-index: 6; + pointer-events: none; +} + +.task-grid-data-row[data-grid-row-selected="true"]::before { + box-shadow: inset 3px 0 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="before"]::before { + box-shadow: inset 0 2px 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="after"]::before { + box-shadow: inset 0 -2px 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="nest"]::before { + box-shadow: inset 0 0 0 2px var(--theme-accent); +} + +.task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { + opacity: 0.25; +} + +.task-grid-data-row.task-grid-trace-focus { + outline: 2px solid var(--theme-accent); + outline-offset: -2px; +} + +.task-grid-data-row.task-grid-trace-predecessor, +.task-grid-data-row.task-grid-trace-predecessor-driving { + border-inline-start: 3px solid var(--trace-predecessor, #F59E0B); +} + +.task-grid-data-row.task-grid-trace-successor, +.task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start: 3px dashed var(--trace-successor, #A78BFA); +} + +.task-grid-data-row.task-grid-trace-predecessor-driving, +.task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start-width: 5px; + font-weight: 600; +} + +@media (forced-colors: active) { + .task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { + opacity: 1; + text-decoration: line-through; + text-decoration-style: dotted; + } + + .task-grid-data-row.task-grid-trace-dimmed { + border-inline-start: 2px dotted GrayText; + } + + .task-grid-data-row.task-grid-trace-focus { + outline-color: Highlight; + } + + .task-grid-data-row.task-grid-trace-predecessor, + .task-grid-data-row.task-grid-trace-predecessor-driving { + border-inline-start-color: Highlight; + border-inline-start-style: solid; + } + + .task-grid-data-row.task-grid-trace-successor, + .task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start-color: Highlight; + border-inline-start-style: dashed; + } + + .task-grid-cell[data-grid-selected="true"] { + background: Highlight; + color: HighlightText; + box-shadow: inset 0 -2px 0 HighlightText; + } + + .task-grid-cell[data-grid-active="true"] { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + .task-grid-cell[data-grid-readonly="true"]::after { + border-color: GrayText; + border-style: dotted; + } + + .task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { + text-decoration: underline dotted; + } + + .task-grid-cell[aria-invalid="true"] { + outline: 3px double Mark; + outline-offset: -3px; + } + + .task-grid-relation-chip--driving { + font-weight: 700; + text-decoration: underline double; + } + + .task-grid-relation-chip--warning { + text-decoration: underline wavy; + } +} + +.task-grid-group-row, +.task-grid-group-cell { + box-sizing: border-box; + background: var(--theme-surface-alt); + border-bottom: 1px solid var(--theme-border); +} + +.full-task-grid { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.full-task-grid > .task-grid-shell { + width: 100%; + height: 100%; +} + +.gantt-workspace { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto auto; + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-workspace-grid, +.gantt-workspace-timeline { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-workspace-grid { + display: flex; + grid-column: 1; + grid-row: 1; +} + +.gantt-workspace-timeline { + display: flex; + grid-column: 2; + grid-row: 1; +} + +.gantt-workspace-histogram { + display: flex; + flex-direction: column; + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; +} + +.gantt-workspace-minimap { + display: flex; + grid-column: 2; + grid-row: 3; + min-width: 0; +} + +.gantt-workspace-splitter { + grid-column: 1; + grid-row: 1; + align-self: stretch; + justify-self: end; + position: relative; + /* De volledige 5px-grijpzone ligt aan de Gantt-kant. Gecentreerd op de paneelgrens bedekte deze + * anders 2,5px van de 8px brede verticale tabelscrollbar. */ + transform: translateX(100%); + z-index: 6; + width: 5px; + min-width: 5px; + padding: 0; + border: 0; + border-inline-start: 1px solid var(--theme-border); + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.gantt-workspace-splitter:hover, +.gantt-workspace-splitter[data-resizing="true"], +.gantt-workspace-splitter:focus-visible { + background: transparent; + outline: none; +} + +.gantt-task-grid { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-task-grid > .task-grid-shell { + width: 100%; + height: 100%; +} + +/* Issue #89: de subtaak-plus staat ná de naam en wordt met `margin-inline-start: auto` tegen + * de rechter kolomrand gedrukt, zodat hij op elke samenvattende rij op dezelfde plek staat en + * de naamtekst links de hiërarchie kan laten zien. De lichte rand maakt hem als knop herkenbaar. */ +.gantt-task-grid-add-child { + flex: none; + margin-inline-start: auto; + /* Vrij van de kolomrand (issue #89, punt 4 vroeg juist om lucht rond zo'n knop). */ + margin-inline-end: 6px; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid var(--theme-border-light); + border-radius: 3px; + color: var(--theme-text-muted); + background: transparent; + line-height: 1; +} + +.gantt-task-grid-add-child:hover, +.gantt-task-grid-add-child:focus-visible { + color: var(--theme-accent); + border-color: var(--theme-accent); + background: var(--theme-hover); + outline: none; +} + +/* Vervolgmelding op observatie 3a/3b (probleem B): was `display: inline-flex` — een INLINE box + * sizet zich (shrink-to-fit) naar zijn EIGEN inhoud (triehoekje + tekst), niet naar de beschikbare + * breedte van zijn ouder (`.task-grid-cell-content`, block, dus geen flex-context die deze rij zou + * dwingen te krimpen). `min-width:0`/`overflow:hidden` op deze laag of de tekst-span erin doen dan + * NIETS — die eigenschappen laten iets krimpen ALS iets anders het beperkt, maar niets deed dat + * hier. `display: flex` (blokniveau) vult i.p.v. dat de beschikbare breedte van de ouder, wat de + * tekst-span (`.full-task-grid-name-label`, `min-width:0`) een ECHTE beperking geeft om binnen te + * krimpen — pas dan werken overflow:hidden/text-overflow:ellipsis daarop zoals bedoeld. */ +.full-task-grid-name { + min-width: 0; + display: flex; + align-items: center; + /* Issue #89: triehoekje (16px) + deze gap (4px) = één inspringeenheid van 20px, dezelfde + * TASK_NAME_INDENT_UNIT als in src/engine/taskGrid/nameIndent.ts. */ + gap: 4px; +} + +/* Discussion #97: samenvattingen (containertaken) en mijlpalen zijn nu alleen via inspringing te + * onderscheiden van een gewone taak. `data-grid-task-kind` (gezet in FullTaskGrid.tsx, weggelaten + * tijdens naam-editing) stuurt hier vet + een subtiele achtergrondtint voor samenvattingen, en + * vet + de mijlpaalkleur voor mijlpalen — bewust dezelfde kleurfamilie als de Gantt-balk + * (`--color-task-milestone`), niet de rode mockupkleur uit de discussion. De tint zit op de hele + * naamrij (niet alleen de tekst), zodat hij als rijvlak leesbaar is; de selectiekleur van de rij + * (`.task-grid-data-row[data-grid-row-selected="true"]`) wint hier altijd van, dus geen eigen + * z-index-gedoe nodig — de tint zit hieronder in de laagvolgorde. */ +.full-task-grid-name[data-grid-task-kind="summary"] { + background: var(--theme-grid-summary-bg); +} + +.full-task-grid-name[data-grid-task-kind="summary"] .full-task-grid-name-label { + font-weight: 600; +} + +.full-task-grid-name[data-grid-task-kind="milestone"] .full-task-grid-name-label { + font-weight: 600; + color: var(--theme-grid-milestone-text); +} + +/* Geselecteerde rij wint van de samenvattingstint (leesbaarheid op de selectiekleur staat voorop); + * de vette/gekleurde tekst blijft intact — alleen de achtergrond van de naamrij vervalt. */ +.task-grid-data-row[data-grid-row-selected="true"] .full-task-grid-name[data-grid-task-kind="summary"] { + background: transparent; +} + +/* De naameditor vult de rij vanaf de tekstpositie tot de rechter kolomrand. */ +.full-task-grid-name > .task-grid-editor-host { + flex: 1 1 auto; + min-width: 0; +} + +/* De algemene celpadding liet rechts van een taaknaam zeven ongebruikte pixels liggen: genoeg voor + * nog een letter vóór de ellipsis. Links blijft de normale inspringruimte intact. */ +.task-grid-cell:has(.full-task-grid-name) { + padding-inline-end: 1px; +} + +/* Issue #89: de contentspan is een flex-item van de cel en kromp tot zijn inhoud, zodat de + * rechts uitgelijnde subtaak-plus (`margin-inline-start: auto`) nooit verder kwam dan het einde + * van de naamtekst. De naamcel laat zijn contentspan daarom de hele celbreedte vullen; de + * naamtekst zelf blijft via `.full-task-grid-name-label` krimpen en afknippen. */ +.task-grid-cell:has(.full-task-grid-name) > .task-grid-cell-content { + flex: 1 1 auto; +} + +/* Vervolgmelding op observatie 3a/3b (probleem B): `.full-task-grid-name` is zelf een + * `inline-flex`-rij (in-/uitklaptriehoekje + evt. "+"-knop + de tekst) zonder eigen + * overflow/text-overflow — `text-overflow: ellipsis` op de vooroudercontainer + * (`.task-grid-cell-content`) kan de afknipplek niet correct berekenen zodra de tekst genest zit + * in een EIGEN flex-container in plaats van een rechtstreeks tekstkind: de ellips ("…") werd + * daardoor nooit getoond, en de daadwerkelijke afknip leunde uitsluitend op de + * `overflow: hidden` van die voorouder — geen eigen begrenzing op déze laag. Bij smalle kolommen + * bleef de layout-doos van `.full-task-grid-name` daardoor BREDER dan zijn eigen ouder (gemeten: + * tot 30px breder dan `.task-grid-cell-content`), puur toevallig nog steeds geklemd door die + * voorouder — een aanname die breekt zodra iets in die keten verandert. Deze klasse geeft de + * TEKST zelf (niet de rij eromheen) zijn eigen `min-width:0` (kan binnen de flex-rij krimpen, + * ondanks het vaste-breedte triehoekje ernaast) plus `overflow:hidden`/`text-overflow:ellipsis` — + * de clip zit nu op de plek waar de tekst zelf staat, niet alleen bij de voorouder. */ +.full-task-grid-name-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.full-task-grid-disclosure { + flex: 0 0 16px; + width: 16px; + height: 18px; + padding: 0; + border: 0; + color: var(--theme-text-muted); + background: transparent; + line-height: 1; +} + +.full-task-grid-error { + position: absolute; + inset-inline: 8px; + inset-block-end: 8px; + z-index: 8; + padding: 6px 9px; + border: 1px solid color-mix(in srgb, var(--error) 55%, var(--theme-border)); + border-radius: 6px; + color: var(--error); + background: var(--theme-surface); + box-shadow: var(--shadow-pop); + font-size: 12px; +} + +.task-grid-group-cell { + display: flex; + align-items: center; +} + +.task-grid-group-toggle { + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 6px; + padding-inline: 0 8px; + color: inherit; + background: transparent; + border: 0; + font: inherit; + font-weight: 600; + text-align: start; +} + +.task-grid-group-toggle:focus-visible, +.task-grid-resize-handle:focus-visible { + outline: 2px solid var(--theme-accent); + outline-offset: -2px; +} + +.task-grid-group-count { + flex: none; + color: var(--theme-text-muted); + font-family: var(--font-code); + font-size: 10px; +} + +/* Browserreview, observatie 7 (grijpzone-kant): was `inset-inline-end: -4px; width: 8px` — een + * band van 4px aan WEERSZIJDEN van de kolomgrens. `beginResize`'s `onPointerDown` roept + * `event.preventDefault()` aan, wat de NATIEVE HTML5-slepginitiatie onderdrukt (preventDefault op + * pointerdown/mousedown is precies het mechanisme waarmee je dragstart voorkomt) — een poging om + * een kolom te HERORDENEN door 'm net bij de rand vast te pakken (een natuurlijke plek om te + * grijpen) landde zo op de resize-greep i.p.v. de herordenbare kopruimte, en startte dan een resize + * (of niets) in plaats van een sleep. Bij een kolom van de minimumbreedte (40px) at 8px band 20% van + * de breedte in beslag — en ELKE kolom draagt ook nog eens 2px overhang van de greep van zijn linker + * buur. Smaller gemaakt (4px totaal i.p.v. 8px) zodat er meer ruimte overblijft voor herordenen; de + * greep blijft een gangbare resize-hitbox (vergelijkbaar met Excel/Sheets, die ook een paar pixels + * breed zijn, geen 8). + */ +.task-grid-resize-handle { + position: absolute; + inset-block: 0; + inset-inline-end: -2px; + z-index: 2; + width: 4px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.task-grid-resize-handle:hover { + background: color-mix(in srgb, var(--theme-accent) 38%, transparent); +} + +.task-grid-shell { + position: relative; + width: fit-content; + max-width: 100%; + /* Eén bron voor de "kolom toevoegen"-strook: een 20px-knop met 6px lucht aan weerszijden, zodat + * hij vrijstaat van de laatste kolominhoud én van de rand van het kader (issue #89). */ + --task-grid-chooser-strip: 32px; +} + +.task-grid-column-header[data-grid-dragging="true"] { + opacity: 0.5; +} + +.task-grid-column-header[data-grid-drop-before="true"]::before, +.task-grid-column-header[data-grid-drop-after="true"]::after { + content: ""; + position: absolute; + z-index: 4; + inset-block: 0; + width: 2px; + background: var(--theme-accent); +} + +.task-grid-column-header[data-grid-drop-before="true"]::before { + inset-inline-start: -1px; +} + +.task-grid-column-header[data-grid-drop-after="true"]::after { + inset-inline-end: -1px; +} + +.task-grid-remove-column { + position: absolute; + inset-block-start: 50%; + inset-inline-end: 7px; + width: 17px; + height: 17px; + display: grid; + place-items: center; + padding: 0; + color: var(--theme-text-muted); + background: var(--theme-surface-alt); + border: 1px solid transparent; + border-radius: 3px; + opacity: 0; + transform: translateY(-50%); +} + +.task-grid-column-header:hover .task-grid-remove-column, +.task-grid-column-header:focus-within .task-grid-remove-column { + opacity: 1; +} + +.task-grid-remove-column:hover, +.task-grid-remove-column:focus-visible { + color: var(--theme-text); + border-color: var(--theme-border); + outline: none; +} + +.task-grid-column-chooser-anchor { + position: absolute; + z-index: calc(var(--z-sticky) + 1); + inset-block-start: 0; + inset-inline-end: 0; + width: var(--task-grid-chooser-strip, 32px); + height: var(--task-grid-header-height); + display: flex; + align-items: center; + justify-content: center; + padding-inline: 6px; + background: var(--theme-surface-alt); +} + +.task-grid-add-column { + width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0; + color: var(--theme-text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 3px; +} + +.task-grid-add-column:hover, +.task-grid-add-column:focus-visible, +.task-grid-add-column[aria-expanded="true"] { + color: var(--theme-text); + border-color: var(--theme-border); + background: var(--theme-hover); + outline: none; +} + +.task-grid-no-columns { + position: absolute; + inset-block-start: var(--task-grid-header-height); + inset-inline: 0; + min-height: 28px; + display: flex; + align-items: center; + padding-inline: 10px 40px; + color: var(--theme-text-muted); + font-size: 11px; + pointer-events: none; +} + +.task-grid-column-chooser { + z-index: var(--z-contextmenu); + width: min(310px, calc(100vw - 16px)); + overflow-y: auto; + padding: 7px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: 6px; + box-shadow: var(--shadow-pop); + font-family: var(--font-body); + font-size: 11px; +} + +.task-grid-column-chooser-title { + padding: 3px 5px 7px; + font-family: var(--font-heading); + font-size: 12px; + font-weight: 700; +} + +.task-grid-column-chooser-section-label { + padding: 7px 5px 3px; + color: var(--theme-text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.task-grid-column-chooser-list { + display: grid; +} + +.task-grid-column-chooser-item, +.task-grid-column-chooser-category { + width: 100%; + min-height: 28px; + display: flex; + align-items: center; + gap: 7px; + padding: 5px 7px; + color: var(--theme-text); + background: transparent; + border: 0; + border-radius: 3px; + font: inherit; + text-align: start; +} + +.task-grid-column-chooser-item:hover:not(:disabled), +.task-grid-column-chooser-item:focus-visible, +.task-grid-column-chooser-category:hover, +.task-grid-column-chooser-category:focus-visible { + background: var(--theme-hover); + outline: none; +} + +.task-grid-column-chooser-item:disabled { + color: var(--theme-text-muted); + cursor: default; +} + +.task-grid-column-chooser-check { + width: 13px; + flex: none; +} + +.task-grid-column-chooser-search { + height: 30px; + display: flex; + align-items: center; + gap: 6px; + margin: 5px 2px; + padding-inline: 7px; + color: var(--theme-text-muted); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 4px; +} + +.task-grid-column-chooser-search:focus-within { + border-color: var(--theme-accent); +} + +.task-grid-column-chooser-search input { + min-width: 0; + flex: 1; + color: var(--theme-text); + background: transparent; + border: 0; + outline: none; + font: inherit; +} + +.task-grid-column-chooser-categories { + margin-block-start: 5px; + padding-block-start: 4px; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-column-chooser-category { + font-weight: 600; +} + +.task-grid-column-chooser-category-count { + margin-inline-start: auto; + color: var(--theme-text-muted); + font-family: var(--font-code); + font-size: 10px; +} + +.task-grid-column-chooser-category svg[data-expanded="true"] { + transform: rotate(180deg); +} + +.task-grid-column-chooser-empty { + padding: 6px 7px; + color: var(--theme-text-muted); +} + +.task-grid-editor-host { + position: relative; + width: 100%; + height: 100%; +} + +.task-grid-editor-input { + width: 100%; + height: 100%; + min-width: 0; + padding-inline: 5px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-accent); + font: inherit; +} + +.task-grid-date-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + height: 100%; +} + +.task-grid-date-editor .task-grid-editor-input { + border-inline-end: 0; +} + +.task-grid-date-picker { + width: 2.25rem; + min-width: 0; + height: 100%; + padding: 0 2px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-accent); + font: inherit; +} + +.task-grid-assignment-editor { + position: absolute; + z-index: var(--z-dropdown); + inset-block-start: 0; + inset-inline-start: 0; + display: grid; + gap: 5px; + width: max(100%, 420px); + max-width: min(560px, 80vw); + padding: 6px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-accent); + box-shadow: var(--shadow-pop); +} + +.task-grid-assignment-tokens { + display: grid; + gap: 4px; +} + +.task-grid-assignment-token { + display: grid; + grid-template-columns: minmax(110px, 1fr) 76px 132px 26px; + gap: 4px; + align-items: center; +} + +.task-grid-assignment-resource { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-grid-assignment-token input, +.task-grid-assignment-token select, +.task-grid-assignment-editor > input, +.task-grid-assignment-token button, +.task-grid-assignment-options button { + min-width: 0; + height: 26px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 3px; + font: inherit; +} + +.task-grid-assignment-token input, +.task-grid-assignment-editor > input { + padding-inline: 5px; +} + +.task-grid-assignment-token button, +.task-grid-assignment-options button { + cursor: pointer; +} + +.task-grid-assignment-token button:hover, +.task-grid-assignment-options button:hover { + background: var(--theme-hover); +} + +.task-grid-assignment-options { + display: grid; + max-height: 180px; + overflow: auto; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-assignment-options button { + padding-inline: 6px; + text-align: start; + border: 0; + border-radius: 0; +} + +/* Browserreview, observatie 6 (tweede plek met dezelfde afwijking): dit paneel portaalt naar + * document.body (RelationCellEditor.tsx) zonder eigen `font-size` — zonder deze regel erven al zijn + * kinderen (chips, tokens, invoervelden — geen van allen zet zelf een font-size) html/body's volle + * `calc(13px * --ui-font-scale)` i.p.v. de compacte roosterdichtheid. `.task-grid-column-chooser` + * (dezelfde soort portal-popup) had dit al goed; hier hetzelfde patroon toegepast. */ +.task-grid-relation-editor { + position: fixed; + z-index: var(--z-dropdown); + display: grid; + gap: 5px; + max-width: min(680px, 85vw); + max-height: calc(100vh - 16px); + overflow: auto; + padding: 6px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-accent); + box-shadow: var(--shadow-pop); + font-family: var(--font-body); + font-size: 11px; +} + +.task-grid-relation-editor-anchor { + position: absolute; + inset: 0; + pointer-events: none; +} + +.task-grid-relation-cell { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-grid-relation-chip { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.task-grid-relation-jump { + min-width: 0; + padding: 0; + overflow: hidden; + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-overflow: ellipsis; + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; +} + +.task-grid-relation-icon { + display: inline-flex; + align-items: center; + margin-inline-start: 2px; +} + +/* Issue #94: één kleur per chip (referentie, type/lag en het sturend-icoon), gestuurd door de + * richting op de chip zelf — niet meer alleen het referentiedeel accentkleur, de rest tekstkleur. + * Sturend blijft zichtbaar als de STERKERE tint van diezelfde rolkleur, niet als los "wel/geen + * kleur"; het warning-icoon blijft altijd zijn eigen --warning-kleur houden. */ +.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-jump, +.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-detail { + color: var(--relation-predecessor); +} + +.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-jump, +.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-detail { + color: var(--relation-successor); +} + +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-jump, +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-detail, +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-icon--driving > svg { + color: var(--relation-predecessor-driving); + font-weight: 700; +} + +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-jump, +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-detail, +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-icon--driving > svg { + color: var(--relation-successor-driving); + font-weight: 700; +} + +.task-grid-relation-chip--warning .task-grid-relation-icon--warning > svg { + color: var(--warning, #d97706); +} + +.task-grid-relation-separator { + color: var(--theme-text-muted); +} + +.task-grid-relation-tokens, +.task-grid-relation-options { + display: grid; + gap: 4px; +} + +.task-grid-relation-token { + display: grid; + grid-template-columns: minmax(180px, 1fr) 64px 86px 26px; + gap: 4px; + align-items: center; +} + +.task-grid-relation-reference { + min-width: 0; + overflow: hidden; + color: inherit; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +button.task-grid-relation-reference { + padding: 0; + color: var(--theme-accent); + background: transparent; + border: 0; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.task-grid-relation-token input, +.task-grid-relation-token select, +.task-grid-relation-token > button:not(.task-grid-relation-reference), +.task-grid-relation-add input, +.task-grid-relation-add button, +.task-grid-relation-options button { + min-width: 0; + height: 26px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 3px; + font: inherit; +} + +.task-grid-relation-token input, +.task-grid-relation-add input { + padding-inline: 5px; +} + +.task-grid-relation-add { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto; + gap: 4px; +} + +.task-grid-relation-add button, +.task-grid-relation-token > button:not(.task-grid-relation-reference), +.task-grid-relation-options button { + cursor: pointer; +} + +.task-grid-relation-options { + max-height: 180px; + overflow: auto; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-relation-options button { + padding-inline: 6px; + text-align: start; + border: 0; + border-radius: 0; +} + +.task-grid-relation-add button:hover, +.task-grid-relation-token > button:hover, +.task-grid-relation-options button:hover { + background: var(--theme-hover); +} + +.task-grid-relation-context { + position: fixed; + z-index: var(--z-dropdown); + display: grid; + min-width: 190px; + padding-block: 4px; + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: 5px; + box-shadow: var(--shadow-pop); +} + +.task-grid-relation-context button { + padding: 6px 10px; + color: var(--theme-text); + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-align: start; +} + +.task-grid-relation-context button:hover { + background: var(--theme-hover); +} + +.task-grid-relation-context .task-grid-relation-context-danger { + color: var(--error); +} + +.task-grid-editor-error { + position: absolute; + z-index: var(--z-dropdown); + inset-block-start: 100%; + inset-inline-start: 0; + max-width: 320px; + padding: 5px 7px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--error); + box-shadow: var(--shadow-pop); + font-size: 11px; + white-space: normal; +} + +/* ============================================================================ + OpenAEC Foundation — Design Tokens + Source: OpenAEC-style-book/brandbook/DESIGN-SYSTEM.md §2 + LAYOUTS.md §0/§4.1 + These are theme-independent primitives. Theme-specific tokens (--theme-*) + reference these and are defined per [data-theme] block below. + ============================================================================ */ +:root { + /* Brand — primary */ + --deep-forge: #36363E; + + /* Path tracing (issue #114): de rijmarkering in het taakraster gebruikt dezelfde tinten als de + Gantt-balken — GANTT_TRACE_COLORS in src/engine/renderer/themePalette.ts is de bron; + tests/planning/check-dependency-presentation.ts bewaakt dat beide gelijk blijven. */ + --trace-predecessor: #F59E0B; + --trace-successor: #A78BFA; + + /* Brand — secondary */ + --signal-orange: #EA580C; + --warm-gold: #F59E0B; + --scaffold-gray: #A1A1AA; + + /* Backgrounds */ + --blueprint-white: #FAFAF9; + --concrete: #F5F5F4; + --night-build: #2A2A32; + + /* Semantic */ + --success: #16A34A; + --error: #DC2626; + --info: #2563EB; + --warning: #F59E0B; + + /* Dashboard / dense UI tokens (DESIGN-SYSTEM.md §2.5) */ + --dashboard-bg: #2A2A32; + --dashboard-surface: #36363E; + --dashboard-surface-hover: #3E3E48; + --dashboard-sidebar: #222228; + --dashboard-border: #4A4A54; + --dashboard-border-light: #404048; + --dashboard-text: #FAFAF9; + --dashboard-text-muted: #A1A1AA; + --dashboard-text-dim: #666670; + --dashboard-accent: #D97706; + --dashboard-accent-bg: #2E2418; + --dashboard-accent-hover: #EA580C; + --dashboard-terminal: #F59E0B; + --dashboard-terminal-dim: #D97706; + + /* Typography families */ + --font-heading: "Space Grotesk", system-ui, sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-code: "JetBrains Mono", monospace; + + /* Spacing scale (4px base) */ + --sp-1: 0.25rem; /* 4px */ + --sp-2: 0.5rem; /* 8px */ + --sp-3: 0.75rem; /* 12px */ + --sp-4: 1rem; /* 16px */ + --sp-5: 1.25rem; /* 20px */ + --sp-6: 1.5rem; /* 24px */ + --sp-8: 2rem; /* 32px */ + --sp-10: 2.5rem; /* 40px */ + --sp-12: 3rem; /* 48px */ + --sp-16: 4rem; /* 64px */ + --sp-20: 5rem; /* 80px */ + --sp-24: 6rem; /* 96px */ + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 14px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); + + /* Z-index scale (LAYOUTS.md §0.3) */ + --z-base: 0; + --z-dropdown: 10; + --z-sticky: 20; + --z-sidebar: 30; + --z-navbar: 40; + --z-modal-backdrop: 50; + --z-modal: 60; + --z-toast: 70; + --z-tooltip: 80; + --z-contextmenu: 90; + + /* Layout sizes (LAYOUTS.md §4.1) */ + --titlebar-height: 32px; + --toolbar-height: 40px; + --statusbar-height: 24px; + --sidebar-width: 260px; + --inspector-width: 280px; +} + +/* ============================================================================ + Theme CSS Variables + ============================================================================ */ + +@theme { + --color-surface: var(--theme-surface); + --color-surface-alt: var(--theme-surface-alt); + --color-surface-hover: var(--theme-hover); + --color-border: var(--theme-border); + --color-text-primary: var(--theme-text); + --color-text-secondary: var(--theme-text-dim); + --color-accent: var(--theme-accent); + --color-accent-hover: var(--theme-accent-hover); + --color-accent-on: var(--theme-accent-on); + /* U2 — ÉÉN balkpalet voor licht én donker (besluit eigenaar). Elke waarde haalt >=3:1 tegen + ZOWEL de lichte kaart (#FAFAFA) als de donkere kaart (#2E3239); vroeger waren de balken op + merk-verzadiging afgestemd op wit en haalden ze op de donkere kaart 1.9-2.7:1. Eén stap minder + verzadigd, zelfde tintfamilie, zodat legenda, gidsen en rapport dezelfde taal blijven spreken. + Gemeten (WCAG 2.x relatieve luminantie), licht / donker / wit-label-op-balk: + critical #DA5252 3.79 / 3.25 / 3.95 + normal #648BE0 3.19 / 3.87 / 3.33 + complete #5778D6 3.97 / 3.10 / 4.15 + milestone #986DE2 3.58 / 3.45 / 3.74 + baseline #808694 3.50 / 3.53 / 3.65 + float #1E976F 3.52 / 3.50 / 3.68 + LET OP: dezelfde zes waarden staan als merk-hex in `src/engine/renderer/themePalette.ts` + (BRAND/`--theme-bar-float`-fallback) — die tekenlaag leest geen CSS in headless tests. */ + --color-critical: #DA5252; + --color-task-normal: #648BE0; + --color-task-milestone: #986DE2; + --color-task-float: #1E976F; + --color-task-baseline: #808694; + --color-task-complete: #5778D6; +} + +/* Dark theme — OpenAEC default + Construction Amber on Deep Forge. Dashboard tokens uit DESIGN-SYSTEM.md §2.5. */ +:root, +[data-theme="dark"] { + /* Koeler charcoal + Soft Depth. Werkruimte-tint (--theme-bg) is donkerder dan + de kaarten (--theme-surface) zodat panelen "zweven". */ + --theme-bg: #1B1D22; + --theme-surface: #2E3239; + --theme-surface-alt: #23262D; + --theme-surface-elevated: #363B43; + --theme-border: #3A3F48; + --theme-border-light: #33373F; + /* Rand van inputs/selects/knoppen — zichtbaarder dan kaartrand (~3:1 op de card). */ + --theme-control-border: #4A515C; + --theme-accent: #D97706; + --theme-accent-hover: #EA580C; + /* Tekstkleur op accent-achtergronden (wit in light/dark, zwart in high-contrast). */ + --theme-accent-on: #ffffff; + --theme-text: #F1F3F5; + --theme-text-dim: #B6BCC6; + --theme-text-muted: #7C8492; + --theme-hover: rgba(255, 255, 255, 0.06); + --theme-active: #D97706; + --theme-active-border: #EA580C; + --theme-warning-text: #F59E0B; + /* Semantische tekstkleuren, donkere variant — LET OP: dit blok is `:root, [data-theme="dark"]`, + dus dit is de basis en het lichte thema zet verderop zijn eigen, verzadigde waarden terug. + De oorspronkelijke waarden waren op wit gekozen: als TEKST op een donkere kaart haalt #DC2626 + maar 2,55:1 (gemeten 2026-09-14 op de validatiestrook van de verdeeldialoog, tekst op een + 10%-color-mix-tint). Deze lichtere tinten halen daar >=4,5:1. + Let op: waar deze tokens als ACHTERGROND met witte tekst dienen (.ops-toast, .btn--danger) + zetten de regels verderop in dit bestand een eigen donkere tint terug — een lichte tint is + een tekstkleur, geen vlakkleur. */ + --success: #4ADE80; + --error: #FCA5A5; + --warning: #FBBF24; --theme-grid-weekend: rgba(255, 255, 255, 0.035); /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): dekt hele weken (5+ kolommen aaneen), dus bewust nog subtieler dan de per-dag weekend-arcering hierboven. */ --theme-grid-week-band: rgba(255, 255, 255, 0.028); - /* Discussion #97: subtiele achtergrondtint voor samenvattingsrijen in het taakraster (naamkolom) - — dezelfde alpha-aanpak als de weekendarcering hierboven, zodat het geen eigen kleurfamilie - introduceert. Mijlpalen krijgen géén tint, alleen vette tekst in een lichtere paarstint - (beter leesbaar op donkere kaarten dan --color-task-milestone zelf). */ - --theme-grid-summary-bg: rgba(255, 255, 255, 0.045); - --theme-grid-milestone-text: #A78BFA; - /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ - --theme-bar-float: #1E976F; - --theme-critical-text: #F87171; - --theme-input-bg: #23262D; - --theme-dropdown-bg: #2E3239; - --theme-scrollbar-track: #23262D; - --theme-scrollbar-thumb: #454B55; - /* Effen vlakken i.p.v. gradients ("geen rare gradients"). */ - --theme-ribbon-bg: #2A2D34; - --theme-ribbon-tab-bg: #23262D; - --theme-ribbon-content-bg: #2A2D34; - --theme-file-tab-bg: #D97706; - --theme-file-tab-hover: #EA580C; - --theme-titlebar-bg: #2A2D34; - --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.07); - --theme-titlebar-btn-active: rgba(255, 255, 255, 0.12); - /* Soft Depth — schaduw-tokens. */ - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.30), 0 6px 18px rgba(0, 0, 0, 0.35); - --shadow-pop: 0 10px 30px rgba(0, 0, 0, 0.50); - --shadow-glow: 0 2px 10px rgba(217, 119, 6, 0.40); - - /* Issue #94: rolkleuren voor Voorgangers/Opvolgers in het taakraster — voorganger volgt de - bestaande goud/amber-familie van GANTT_TRACE_COLORS, opvolger de paars/violet-familie - (zelfde conventie als de rolstip in TaskDependenciesSection). "Driving" is bewust de - STERKERE tint van diezelfde rolkleur, niet een andere kleur: op dit donkere thema betekent - sterker = lichter (op een lichte achtergrond zou het juist donkerder zijn). */ - --relation-predecessor: #F59E0B; - --relation-predecessor-driving: #FCD34D; - --relation-successor: #A78BFA; - --relation-successor-driving: #C4B5FD; - - color-scheme: dark; -} - + /* Discussion #97: subtiele achtergrondtint voor samenvattingsrijen in het taakraster (naamkolom) + — dezelfde alpha-aanpak als de weekendarcering hierboven, zodat het geen eigen kleurfamilie + introduceert. Mijlpalen krijgen géén tint, alleen vette tekst in een lichtere paarstint + (beter leesbaar op donkere kaarten dan --color-task-milestone zelf). */ + --theme-grid-summary-bg: rgba(255, 255, 255, 0.045); + --theme-grid-milestone-text: #A78BFA; + /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ + --theme-bar-float: #1E976F; + --theme-critical-text: #F87171; + --theme-input-bg: #23262D; + --theme-dropdown-bg: #2E3239; + --theme-scrollbar-track: #23262D; + --theme-scrollbar-thumb: #454B55; + /* Effen vlakken i.p.v. gradients ("geen rare gradients"). */ + --theme-ribbon-bg: #2A2D34; + --theme-ribbon-tab-bg: #23262D; + --theme-ribbon-content-bg: #2A2D34; + --theme-file-tab-bg: #D97706; + --theme-file-tab-hover: #EA580C; + --theme-titlebar-bg: #2A2D34; + --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.07); + --theme-titlebar-btn-active: rgba(255, 255, 255, 0.12); + /* Soft Depth — schaduw-tokens. */ + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.30), 0 6px 18px rgba(0, 0, 0, 0.35); + --shadow-pop: 0 10px 30px rgba(0, 0, 0, 0.50); + --shadow-glow: 0 2px 10px rgba(217, 119, 6, 0.40); + + /* Issue #94: rolkleuren voor Voorgangers/Opvolgers in het taakraster — voorganger volgt de + bestaande goud/amber-familie van GANTT_TRACE_COLORS, opvolger de paars/violet-familie + (zelfde conventie als de rolstip in TaskDependenciesSection). "Driving" is bewust de + STERKERE tint van diezelfde rolkleur, niet een andere kleur: op dit donkere thema betekent + sterker = lichter (op een lichte achtergrond zou het juist donkerder zijn). */ + --relation-predecessor: #F59E0B; + --relation-predecessor-driving: #FCD34D; + --relation-successor: #A78BFA; + --relation-successor-driving: #C4B5FD; + + color-scheme: dark; +} + /* Light theme — OpenAEC light variant Blueprint White-kaarten op een neutraal-grijze werkruimte, Deep Forge-tekst, Amber accent (zelfde als dark — accent kleuren wisselen niet tussen modes). @@ -1394,14 +1405,19 @@ button.task-grid-relation-reference { /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): subtieler dan de weekend- arcering, want hij bedekt hele weken i.p.v. losse dagkolommen. */ --theme-grid-week-band: rgba(54, 54, 62, 0.03); - /* Discussion #97: zelfde neutrale-grijsschaal-aanpak als de rest van dit lichte thema — een - alpha-tint van Deep Forge, geen eigen kleur. Mijlpaaltekst gaat iets dieper dan - --color-task-milestone (#7C3AED) voor voldoende contrast op de kaart (AA). */ - --theme-grid-summary-bg: rgba(54, 54, 62, 0.06); - --theme-grid-milestone-text: #6D28D9; - /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ - --theme-bar-float: #1E976F; + /* Discussion #97: zelfde neutrale-grijsschaal-aanpak als de rest van dit lichte thema — een + alpha-tint van Deep Forge, geen eigen kleur. Mijlpaaltekst gaat iets dieper dan + --color-task-milestone (#7C3AED) voor voldoende contrast op de kaart (AA). */ + --theme-grid-summary-bg: rgba(54, 54, 62, 0.06); + --theme-grid-milestone-text: #6D28D9; + /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ + --theme-bar-float: #1E976F; --theme-critical-text: #C81E1E; /* 5.5:1 op de kaart, 4.6:1 op de werkruimte */ + /* Semantische tekstkleuren, verzadigd — op een witte kaart is dit de leesbare kant (de lichtere + tinten uit het gedeelde blok hierboven zijn daar juist onleesbaar). */ + --success: #16A34A; + --error: #DC2626; + --warning: #F59E0B; --theme-input-bg: #FFFFFF; --theme-dropdown-bg: #FFFFFF; --theme-scrollbar-track: #E5E5E8; @@ -1432,259 +1448,264 @@ button.task-grid-relation-reference { color-scheme: light; } -/* High Contrast theme — accessibility (WCAG AAA, niet brand-bound) */ -[data-theme="high-contrast"] { - --theme-bg: #000000; - --theme-surface: #0a0a0a; - --theme-surface-alt: #0a0a0a; - --theme-surface-elevated: #141414; - --theme-border: #ffffff; - --theme-border-light: #cccccc; - --theme-control-border: #ffffff; - --theme-accent: #ffff00; - --theme-accent-hover: #ffcc00; - --theme-accent-on: #000000; - --theme-text: #ffffff; - --theme-text-dim: #cccccc; - --theme-text-muted: #999999; - --theme-hover: #333333; - --theme-active: #ffff00; - --theme-active-border: #ffff00; - --theme-warning-text: #ffff00; - /* Gantt weekend-kolomband: zwakke maar zichtbare band op zwart - (1.23:1 vs #000, 1.16:1 vs #0a0a0a) — was onzichtbaar toen hij uit - input-bg #000000 (= bg) werd getekend. */ +/* High Contrast theme — accessibility (WCAG AAA, niet brand-bound) */ +[data-theme="high-contrast"] { + --theme-bg: #000000; + --theme-surface: #0a0a0a; + --theme-surface-alt: #0a0a0a; + --theme-surface-elevated: #141414; + --theme-border: #ffffff; + --theme-border-light: #cccccc; + --theme-control-border: #ffffff; + --theme-accent: #ffff00; + --theme-accent-hover: #ffcc00; + --theme-accent-on: #000000; + --theme-text: #ffffff; + --theme-text-dim: #cccccc; + --theme-text-muted: #999999; + --theme-hover: #333333; + --theme-active: #ffff00; + --theme-active-border: #ffff00; + --theme-warning-text: #ffff00; + /* Gantt weekend-kolomband: zwakke maar zichtbare band op zwart + (1.23:1 vs #000, 1.16:1 vs #0a0a0a) — was onzichtbaar toen hij uit + input-bg #000000 (= bg) werd getekend. */ --theme-grid-weekend: #1c1c1c; /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): iets zwakker dan de weekend-band maar nog zichtbaar op #0a0a0a (de band moet als vlak leesbaar blijven, niet als per-dag-arcering concurreren). */ --theme-grid-week-band: #141414; - /* Discussion #97: het paars van --color-task-milestone haalt op zwart geen AAA-contrast, dus - high contrast krijgt een eigen, helderdere mijlpaaltint (>=7:1 op #0a0a0a) — zichtbaar - onderscheiden van het accentgeel. De samenvattingstint volgt de bestaande vlakke-kleur-aanpak - van dit thema (geen alpha, expliciete tint zoals --theme-grid-weekend hierboven). */ - --theme-grid-summary-bg: #1c1c1c; - --theme-grid-milestone-text: #D9A6FF; - /* Gantt float-balk: heldere emerald op zwart (op ~90% opacity 8.85:1). Het U2-besluit "één - balkpalet" gold voor licht en dark; high-contrast houdt bewust zijn eigen, hardere tint. */ - --theme-bar-float: #34D399; - /* Kritieke-pad-tekst: helder rood, >=4.5:1 op #0a0a0a/#000 (7.1–7.6:1). */ - --theme-critical-text: #FF6B6B; - --theme-input-bg: #000000; - --theme-dropdown-bg: #0a0a0a; - --theme-scrollbar-track: #0a0a0a; - --theme-scrollbar-thumb: #ffffff; - --theme-ribbon-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); - --theme-ribbon-tab-bg: linear-gradient(to bottom, #141414 0%, #0d0d0d 100%); - --theme-ribbon-content-bg: #0a0a0a; - --theme-file-tab-bg: #ffff00; - --theme-file-tab-hover: #ffcc00; - --theme-titlebar-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); - --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.15); - --theme-titlebar-btn-active: rgba(255, 255, 255, 0.2); - /* Geen schaduw op zwart; randen dragen de scheiding. */ - --shadow-card: none; - --shadow-pop: none; - --shadow-glow: none; - - /* Issue #94: zie dark hierboven — goudfamilie voor voorganger, hier vervangen door cyaan voor - opvolger (paars haalt op zwart geen AAA), beide met maximaal contrast op #0a0a0a. */ - --relation-predecessor: #FFB000; - --relation-predecessor-driving: #FFD54F; - --relation-successor: #22D3EE; - --relation-successor-driving: #67E8F9; - - color-scheme: dark; -} - -@layer base { - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - /* OpenAEC default font assignments */ - h1, h2, h3 { - font-family: var(--font-heading); - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; - } - h4, h5, h6 { - font-family: var(--font-heading); - font-weight: 500; - letter-spacing: -0.01em; - line-height: 1.3; - } - code, kbd, samp, pre { - font-family: var(--font-code); - } -} - -/* Interface-lettertype-schaal (issue #25.4). --ui-font-scale wordt door App.tsx gezet vanuit de - uiFontScale-instelling (default 1). Tailwind-`text-*`-klassen zijn rem-gebaseerd en volgen deze - html-font-size vanzelf; de losse px-font-sizes in de chrome-css volgen NIET automatisch en - schalen daarom expliciet mee via `calc(px * var(--ui-font-scale, 1))` (zie de 10 chrome-CSS- - bestanden). De fallback `1` houdt de pre-hydratie-weergave en headless tests zonder App-effect - op de huidige 13px, zodat er geen verkeerde-grootte-flikkering ontstaat. */ -html, body, #root { - height: 100%; - width: 100%; - overflow: hidden; - font-family: var(--font-body); - font-size: calc(13px * var(--ui-font-scale, 1)); - background: var(--theme-bg); - color: var(--theme-text); -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} -::-webkit-scrollbar-track { - background: var(--theme-scrollbar-track); -} -::-webkit-scrollbar-thumb { - background: var(--theme-scrollbar-thumb); - border-radius: 4px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--theme-text-dim); -} - -/* Zwevende scrollbalken over de Gantt-kaart (issue #35). De balken van de Gantt zijn overlays - BINNEN het pane in plaats van een eigen goot/rij eromheen, zodat de kaart en de takenlijst tot - de rand doorlopen. Een dekkende track zou dat weer tenietdoen — die legt alsnog een 8px strook - over het canvas — dus alleen de duim is zichtbaar. De DIKTE blijft de 8px hierboven: die is - gelijk aan de SCROLLBAR_GUTTER in GanttCanvas.tsx, waarmee de overlay-strook precies één - scrollbalk breed/hoog is en er geen dode klikzone naast de balk ontstaat. */ -.gantt-overlay-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--theme-scrollbar-thumb) transparent; -} -.gantt-overlay-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -/* Selection */ -::selection { - background: var(--theme-accent); - color: var(--theme-accent-on); -} - -/* RTL support (Arabic) */ -[dir="rtl"] { - direction: rtl; - text-align: right; -} - -[dir="rtl"] .flex { - /* Tailwind flex already handles RTL via logical properties */ -} - -[dir="rtl"] select, -[dir="rtl"] input[type="text"], -[dir="rtl"] input[type="number"] { - text-align: right; -} - -[dir="rtl"] .px-3 { - padding-inline-start: 0.75rem; - padding-inline-end: 0.75rem; -} - -[dir="rtl"] .ml-8 { - margin-inline-start: 2rem; - margin-inline-end: 0; -} - -[dir="rtl"] .ml-2 { - margin-inline-start: 0.5rem; - margin-inline-end: 0; -} - -[dir="rtl"] .mr-2 { - margin-inline-end: 0.5rem; - margin-inline-start: 0; -} - -[dir="rtl"] .border-r { - border-right: none; - border-left: 1px solid var(--theme-border); -} - -[dir="rtl"] .border-l { - border-left: none; - border-right: 1px solid var(--theme-border); -} - -/* Issue #46 (slot): iconen die een RICHTING aanwijzen (de chevrons en het paneel-glyph in de - rechter-rail) moeten in RTL meespiegelen — daar staat de rail links, dus "de rail wegklappen" - wijst de andere kant op. Zelfde truc als `.ops-icon-rail` in Ribbon.css, maar dan bruikbaar - buiten de ribbon. Uitsluitend voor richtingsiconen; nooit voor iconen met tekst of een cijfer. */ -[dir="rtl"] .ops-icon-inline-flip { - transform: scaleX(-1); -} - -/* Gantt tooltip */ -/* Issue #58: de titel liep buiten de kader-rand door. Oorzaak was `white-space: nowrap` naast een - `max-width`: de doos werd wél op 280px geklemd, maar de tekst zette gewoon door en stak zichtbaar - uit (gemeten 178px bij een lange naam, 359px bij een naam zonder spaties). De keuze is WRAPPEN, - niet afkappen — de gebruiker moet de volledige taaknaam kunnen lezen. Drie dingen horen bij - elkaar en moeten samen blijven staan: - • `width: max-content` — zonder dit krimpt een absoluut gepositioneerde doos met wrappende - tekst tot de beschikbare ruimte rechts van `left`, dus een tooltip vlak bij de rechterrand - van het canvas zou een paar tekens breed worden. - • `max-width` schaalt mee met --ui-font-scale (zoals de font-size hieronder), zodat de tooltip - bij 125% niet minder tekst per regel toont; de `min()` met 100vw houdt hem in een smal - venster binnen beeld. - • `overflow-wrap: anywhere` breekt ook een naam zónder spaties (bestandsnaam/code); spaties - blijven de voorkeursbreekpunten. - De uitlijning binnen het venster (rechterrand/onderrand) zit NIET in CSS — dat kan alleen met een - gemeten positie: zie HoverTooltip.tsx. */ -.gantt-tooltip { - position: fixed; - pointer-events: none; - z-index: var(--z-tooltip); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: var(--radius-md); - padding: var(--sp-2) var(--sp-3); - font-family: var(--font-body); - font-size: calc(12px * var(--ui-font-scale, 1)); - line-height: 1.5; - color: var(--theme-text); - box-shadow: var(--shadow-lg); - width: max-content; - max-width: min(calc(360px * var(--ui-font-scale, 1)), calc(100vw - 32px)); - white-space: normal; - overflow-wrap: anywhere; -} - + /* Discussion #97: het paars van --color-task-milestone haalt op zwart geen AAA-contrast, dus + high contrast krijgt een eigen, helderdere mijlpaaltint (>=7:1 op #0a0a0a) — zichtbaar + onderscheiden van het accentgeel. De samenvattingstint volgt de bestaande vlakke-kleur-aanpak + van dit thema (geen alpha, expliciete tint zoals --theme-grid-weekend hierboven). */ + --theme-grid-summary-bg: #1c1c1c; + --theme-grid-milestone-text: #D9A6FF; + /* Gantt float-balk: heldere emerald op zwart (op ~90% opacity 8.85:1). Het U2-besluit "één + balkpalet" gold voor licht en dark; high-contrast houdt bewust zijn eigen, hardere tint. */ + --theme-bar-float: #34D399; + /* Kritieke-pad-tekst: helder rood, >=4.5:1 op #0a0a0a/#000 (7.1–7.6:1). */ + --theme-critical-text: #FF6B6B; + /* Semantische tekstkleuren, nog helderder dan in dark — dit thema mikt op AAA op zwart + (>=7:1 als tekst op de 10%-tint). Zie de noot in het dark-blok over achtergrondgebruik. */ + --success: #86EFAC; + --error: #FCA5A5; + --warning: #FDE047; + --theme-input-bg: #000000; + --theme-dropdown-bg: #0a0a0a; + --theme-scrollbar-track: #0a0a0a; + --theme-scrollbar-thumb: #ffffff; + --theme-ribbon-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); + --theme-ribbon-tab-bg: linear-gradient(to bottom, #141414 0%, #0d0d0d 100%); + --theme-ribbon-content-bg: #0a0a0a; + --theme-file-tab-bg: #ffff00; + --theme-file-tab-hover: #ffcc00; + --theme-titlebar-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); + --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.15); + --theme-titlebar-btn-active: rgba(255, 255, 255, 0.2); + /* Geen schaduw op zwart; randen dragen de scheiding. */ + --shadow-card: none; + --shadow-pop: none; + --shadow-glow: none; + + /* Issue #94: zie dark hierboven — goudfamilie voor voorganger, hier vervangen door cyaan voor + opvolger (paars haalt op zwart geen AAA), beide met maximaal contrast op #0a0a0a. */ + --relation-predecessor: #FFB000; + --relation-predecessor-driving: #FFD54F; + --relation-successor: #22D3EE; + --relation-successor-driving: #67E8F9; + + color-scheme: dark; +} + +@layer base { + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + /* OpenAEC default font assignments */ + h1, h2, h3 { + font-family: var(--font-heading); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + } + h4, h5, h6 { + font-family: var(--font-heading); + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.3; + } + code, kbd, samp, pre { + font-family: var(--font-code); + } +} + +/* Interface-lettertype-schaal (issue #25.4). --ui-font-scale wordt door App.tsx gezet vanuit de + uiFontScale-instelling (default 1). Tailwind-`text-*`-klassen zijn rem-gebaseerd en volgen deze + html-font-size vanzelf; de losse px-font-sizes in de chrome-css volgen NIET automatisch en + schalen daarom expliciet mee via `calc(px * var(--ui-font-scale, 1))` (zie de 10 chrome-CSS- + bestanden). De fallback `1` houdt de pre-hydratie-weergave en headless tests zonder App-effect + op de huidige 13px, zodat er geen verkeerde-grootte-flikkering ontstaat. */ +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; + font-family: var(--font-body); + font-size: calc(13px * var(--ui-font-scale, 1)); + background: var(--theme-bg); + color: var(--theme-text); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--theme-scrollbar-track); +} +::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-thumb); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--theme-text-dim); +} + +/* Zwevende scrollbalken over de Gantt-kaart (issue #35). De balken van de Gantt zijn overlays + BINNEN het pane in plaats van een eigen goot/rij eromheen, zodat de kaart en de takenlijst tot + de rand doorlopen. Een dekkende track zou dat weer tenietdoen — die legt alsnog een 8px strook + over het canvas — dus alleen de duim is zichtbaar. De DIKTE blijft de 8px hierboven: die is + gelijk aan de SCROLLBAR_GUTTER in GanttCanvas.tsx, waarmee de overlay-strook precies één + scrollbalk breed/hoog is en er geen dode klikzone naast de balk ontstaat. */ +.gantt-overlay-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--theme-scrollbar-thumb) transparent; +} +.gantt-overlay-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Selection */ +::selection { + background: var(--theme-accent); + color: var(--theme-accent-on); +} + +/* RTL support (Arabic) */ +[dir="rtl"] { + direction: rtl; + text-align: right; +} + +[dir="rtl"] .flex { + /* Tailwind flex already handles RTL via logical properties */ +} + +[dir="rtl"] select, +[dir="rtl"] input[type="text"], +[dir="rtl"] input[type="number"] { + text-align: right; +} + +[dir="rtl"] .px-3 { + padding-inline-start: 0.75rem; + padding-inline-end: 0.75rem; +} + +[dir="rtl"] .ml-8 { + margin-inline-start: 2rem; + margin-inline-end: 0; +} + +[dir="rtl"] .ml-2 { + margin-inline-start: 0.5rem; + margin-inline-end: 0; +} + +[dir="rtl"] .mr-2 { + margin-inline-end: 0.5rem; + margin-inline-start: 0; +} + +[dir="rtl"] .border-r { + border-right: none; + border-left: 1px solid var(--theme-border); +} + +[dir="rtl"] .border-l { + border-left: none; + border-right: 1px solid var(--theme-border); +} + +/* Issue #46 (slot): iconen die een RICHTING aanwijzen (de chevrons en het paneel-glyph in de + rechter-rail) moeten in RTL meespiegelen — daar staat de rail links, dus "de rail wegklappen" + wijst de andere kant op. Zelfde truc als `.ops-icon-rail` in Ribbon.css, maar dan bruikbaar + buiten de ribbon. Uitsluitend voor richtingsiconen; nooit voor iconen met tekst of een cijfer. */ +[dir="rtl"] .ops-icon-inline-flip { + transform: scaleX(-1); +} + +/* Gantt tooltip */ +/* Issue #58: de titel liep buiten de kader-rand door. Oorzaak was `white-space: nowrap` naast een + `max-width`: de doos werd wél op 280px geklemd, maar de tekst zette gewoon door en stak zichtbaar + uit (gemeten 178px bij een lange naam, 359px bij een naam zonder spaties). De keuze is WRAPPEN, + niet afkappen — de gebruiker moet de volledige taaknaam kunnen lezen. Drie dingen horen bij + elkaar en moeten samen blijven staan: + • `width: max-content` — zonder dit krimpt een absoluut gepositioneerde doos met wrappende + tekst tot de beschikbare ruimte rechts van `left`, dus een tooltip vlak bij de rechterrand + van het canvas zou een paar tekens breed worden. + • `max-width` schaalt mee met --ui-font-scale (zoals de font-size hieronder), zodat de tooltip + bij 125% niet minder tekst per regel toont; de `min()` met 100vw houdt hem in een smal + venster binnen beeld. + • `overflow-wrap: anywhere` breekt ook een naam zónder spaties (bestandsnaam/code); spaties + blijven de voorkeursbreekpunten. + De uitlijning binnen het venster (rechterrand/onderrand) zit NIET in CSS — dat kan alleen met een + gemeten positie: zie HoverTooltip.tsx. */ +.gantt-tooltip { + position: fixed; + pointer-events: none; + z-index: var(--z-tooltip); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-3); + font-family: var(--font-body); + font-size: calc(12px * var(--ui-font-scale, 1)); + line-height: 1.5; + color: var(--theme-text); + box-shadow: var(--shadow-lg); + width: max-content; + max-width: min(calc(360px * var(--ui-font-scale, 1)), calc(100vw - 32px)); + white-space: normal; + overflow-wrap: anywhere; +} + .gantt-tooltip .tooltip-title { - font-weight: 600; - font-size: calc(13px * var(--ui-font-scale, 1)); - margin-bottom: 4px; - color: var(--theme-text); -} - -.gantt-tooltip .tooltip-row { - display: flex; - justify-content: space-between; - gap: 16px; -} - -.gantt-tooltip .tooltip-label { - color: var(--theme-text-dim); -} - -.gantt-tooltip .tooltip-value { - color: var(--theme-text); - font-weight: 500; -} - + font-weight: 600; + font-size: calc(13px * var(--ui-font-scale, 1)); + margin-bottom: 4px; + color: var(--theme-text); +} + +.gantt-tooltip .tooltip-row { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.gantt-tooltip .tooltip-label { + color: var(--theme-text-dim); +} + +.gantt-tooltip .tooltip-value { + color: var(--theme-text); + font-weight: 500; +} + .gantt-tooltip .tooltip-critical-yes { color: var(--error); font-weight: 600; @@ -1872,106 +1893,106 @@ html, body, #root { background: color-mix(in srgb, var(--theme-accent) 18%, transparent); } - -/* Toast notification — gecentraliseerde gebruikersmeldingen (bevinding K8). De stapel-container - positioneert; de losse melding verzorgt alleen inhoud + klik. De naam is ops-toast (was - gantt-toast): de meldingen komen ook uit Backstage/tabel/rapport, waar geen canvas is. */ -.ops-toast-stack { - position: fixed; - bottom: var(--sp-6); - left: 50%; - transform: translateX(-50%); - z-index: var(--z-toast); - display: flex; - flex-direction: column; - gap: var(--sp-2); - align-items: center; - pointer-events: none; /* de stapel zelf vangt geen klikken … */ -} - -.ops-toast { - pointer-events: auto; /* … de losse melding wél (klik = wegklikken) */ - background: var(--error); - color: #ffffff; - padding: var(--sp-3) var(--sp-6); - border-radius: var(--radius-md); - font-family: var(--font-body); - font-size: calc(13px * var(--ui-font-scale, 1)); - line-height: 1.4; - box-shadow: var(--shadow-lg); - max-width: 500px; - animation: toast-in 0.3s ease-out; -} - -.ops-toast.toast-info { - background: var(--info); -} - -.ops-toast.toast-error { - background: var(--error); -} - -.ops-toast-count { - font-size: calc(11px * var(--ui-font-scale, 1)); - opacity: 0.8; - margin-inline-start: var(--sp-2); - font-variant-numeric: tabular-nums; -} - -.ops-toast-detail { - font-size: calc(11px * var(--ui-font-scale, 1)); - opacity: 0.85; - margin-top: var(--sp-1); - word-break: break-word; -} - -/* "Lees meer"-tekstlink (mpp-nul-data-etappe): gedeeld door de toast (NotificationHost) en het - eigenschappenpaneel (TaskTimephasedNotice) — geen nieuwe kleur, erft `color` van de context - (wit op de toast, de normale tekstkleur in het paneel), alleen onderstreping als affordance. */ -.ops-textlink { - display: inline; - padding: 0; - background: none; - border: none; - color: inherit; - font: inherit; - text-decoration: underline; - cursor: pointer; -} - -.ops-toast-readmore { - display: block; - margin-top: var(--sp-1); - font-size: calc(11px * var(--ui-font-scale, 1)); -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Onbepaalde voortgangsbalk (update-download zonder bekende totale grootte). - Een smal segment dat heen en weer loopt over de track. */ -@keyframes ops-indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(300%); } -} -.ops-indeterminate-bar { - animation: ops-indeterminate 1.2s ease-in-out infinite; -} - -/* ============================================================================ - OpenAEC Component Utility Classes - Source: DESIGN-SYSTEM.md §3 — herbruikbare patterns voor knoppen, inputs, - alerts, badges, tags, progress bars. BEM-style naming (.x__y / .x--mod). - ============================================================================ */ - + +/* Toast notification — gecentraliseerde gebruikersmeldingen (bevinding K8). De stapel-container + positioneert; de losse melding verzorgt alleen inhoud + klik. De naam is ops-toast (was + gantt-toast): de meldingen komen ook uit Backstage/tabel/rapport, waar geen canvas is. */ +.ops-toast-stack { + position: fixed; + bottom: var(--sp-6); + left: 50%; + transform: translateX(-50%); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--sp-2); + align-items: center; + pointer-events: none; /* de stapel zelf vangt geen klikken … */ +} + +.ops-toast { + pointer-events: auto; /* … de losse melding wél (klik = wegklikken) */ + background: var(--error); + color: #ffffff; + padding: var(--sp-3) var(--sp-6); + border-radius: var(--radius-md); + font-family: var(--font-body); + font-size: calc(13px * var(--ui-font-scale, 1)); + line-height: 1.4; + box-shadow: var(--shadow-lg); + max-width: 500px; + animation: toast-in 0.3s ease-out; +} + +.ops-toast.toast-info { + background: var(--info); +} + +.ops-toast.toast-error { + background: var(--error); +} + +.ops-toast-count { + font-size: calc(11px * var(--ui-font-scale, 1)); + opacity: 0.8; + margin-inline-start: var(--sp-2); + font-variant-numeric: tabular-nums; +} + +.ops-toast-detail { + font-size: calc(11px * var(--ui-font-scale, 1)); + opacity: 0.85; + margin-top: var(--sp-1); + word-break: break-word; +} + +/* "Lees meer"-tekstlink (mpp-nul-data-etappe): gedeeld door de toast (NotificationHost) en het + eigenschappenpaneel (TaskTimephasedNotice) — geen nieuwe kleur, erft `color` van de context + (wit op de toast, de normale tekstkleur in het paneel), alleen onderstreping als affordance. */ +.ops-textlink { + display: inline; + padding: 0; + background: none; + border: none; + color: inherit; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.ops-toast-readmore { + display: block; + margin-top: var(--sp-1); + font-size: calc(11px * var(--ui-font-scale, 1)); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Onbepaalde voortgangsbalk (update-download zonder bekende totale grootte). + Een smal segment dat heen en weer loopt over de track. */ +@keyframes ops-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(300%); } +} +.ops-indeterminate-bar { + animation: ops-indeterminate 1.2s ease-in-out infinite; +} + +/* ============================================================================ + OpenAEC Component Utility Classes + Source: DESIGN-SYSTEM.md §3 — herbruikbare patterns voor knoppen, inputs, + alerts, badges, tags, progress bars. BEM-style naming (.x__y / .x--mod). + ============================================================================ */ + /* Buttons (DESIGN-SYSTEM.md §3.1) */ /* Echte bedieningselementen moeten ook buiten de gedeelde `.btn`-klasse direct als zodanig herkenbaar zijn. `:where()` houdt de specificiteit op nul: invoer-, sleep- en resizecursors @@ -1987,270 +2008,288 @@ html, body, #root { } .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--sp-2); - padding: var(--sp-3) var(--sp-6); - font-family: var(--font-body); - font-size: 0.875rem; - font-weight: 600; - border-radius: var(--radius-md); - border: none; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; -} -.btn:focus-visible { - outline: 2px solid var(--theme-accent); - outline-offset: 2px; -} -.btn--sm { padding: var(--sp-2) var(--sp-4); font-size: 0.75rem; } -.btn--lg { padding: var(--sp-4) var(--sp-8); font-size: 1rem; } - -.btn--primary { - background: var(--theme-accent); - color: var(--theme-accent-on); -} -.btn--primary:hover:not(:disabled) { - /* theme-aware hover: light = #B45309 (white text 5.0:1), dark = #EA580C - (unchanged from --signal-orange). Avoids the 3.56:1 fail white-on-#EA580C - gave in light mode. */ - background: var(--theme-accent-hover); -} - -.btn--secondary { - background: transparent; - color: var(--theme-text); - border: 2px solid var(--theme-text); -} -.btn--secondary:hover:not(:disabled) { - background: var(--theme-text); - color: var(--theme-bg); -} - -.btn--ghost { - background: transparent; - color: var(--theme-accent); - border: 2px solid var(--theme-accent); -} -.btn--ghost:hover:not(:disabled) { - background: var(--theme-accent); - color: var(--theme-accent-on); -} - -.btn--dark { - background: var(--deep-forge); - color: var(--blueprint-white); -} -.btn--dark:hover:not(:disabled) { - background: #27272A; -} - -.btn--danger { - background: var(--error); - color: #ffffff; -} -.btn--danger:hover:not(:disabled) { - background: #B91C1C; -} - -.btn:disabled, -.btn--disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Inputs (DESIGN-SYSTEM.md §3.2) */ -.input { - font-family: var(--font-body); - font-size: 0.875rem; - padding: var(--sp-3) var(--sp-4); - border: 1.5px solid var(--theme-control-border); - border-radius: var(--radius-md); - background: var(--theme-input-bg); - color: var(--theme-text); - transition: border-color 0.15s ease, box-shadow 0.15s ease; - width: 100%; -} -.input:focus, -.input:focus-visible { - outline: none; - border-color: var(--theme-accent); - box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.20); -} -.input--error { - border-color: var(--error); -} -.input--error:focus, -.input--error:focus-visible { - border-color: var(--error); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.20); -} -.input-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - margin-bottom: var(--sp-2); - color: var(--theme-text); -} -.input-hint { - font-size: 0.75rem; - /* was --scaffold-gray #A1A1AA (~2.5:1 op licht, onleesbaar); text-muted is - AA in elk thema (light 5.3:1, HC 7:1, dark gelijk aan oude scaffold-gray). */ - color: var(--theme-text-muted); - margin-top: var(--sp-1); -} -.input-hint--error { - color: var(--error); -} - -/* Alerts (DESIGN-SYSTEM.md §3.5) */ -.alert { - padding: var(--sp-4) var(--sp-6); - border-radius: var(--radius-md); - border-left: 4px solid; - font-family: var(--font-body); - font-size: 0.875rem; - line-height: 1.5; -} -.alert--info { background: #EFF6FF; border-left-color: var(--info); color: #1E40AF; } -.alert--success { background: #F0FDF4; border-left-color: var(--success); color: #166534; } -.alert--warning { background: #FFFBEB; border-left-color: #D97706; color: #92400E; } -.alert--error { background: #FEF2F2; border-left-color: var(--error); color: #991B1B; } - -/* Badges (DESIGN-SYSTEM.md §3.6) */ -.badge { - display: inline-block; - font-family: var(--font-body); - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 0.2em 0.6em; - border-radius: var(--radius-full); - white-space: nowrap; -} -.badge--amber { background: #FEF3C7; color: #92400E; } -.badge--green { background: #DCFCE7; color: #166534; } -.badge--red { background: #FEE2E2; color: #991B1B; } -.badge--blue { background: #DBEAFE; color: #1E40AF; } -.badge--gray { background: #F4F4F5; color: #3F3F46; } - -/* Tags (DESIGN-SYSTEM.md §3.7) */ -.tag { - display: inline-flex; - align-items: center; - gap: var(--sp-1); - font-family: var(--font-body); - font-size: 0.75rem; - font-weight: 500; - padding: var(--sp-1) var(--sp-3); - border-radius: var(--radius-full); - border: 1px solid var(--theme-border); - background: var(--theme-surface); - color: var(--theme-text-dim); -} - -/* Progress bars (DESIGN-SYSTEM.md §3.8) */ -.progress { - height: 8px; - background: var(--theme-border); - border-radius: var(--radius-full); - overflow: hidden; -} -.progress__fill { - height: 100%; - background: var(--theme-accent); - border-radius: var(--radius-full); - transition: width 0.3s ease; -} -.progress__fill--success { background: var(--success); } -.progress__fill--danger { background: var(--error); } - -/* OpenAEC merk-accent strip — DESIGN-SYSTEM.md §2.1 */ -.brand-accent-strip { - height: 2px; - background: linear-gradient(90deg, #D97706 0%, #F59E0B 40%, #EA580C 100%); - flex-shrink: 0; -} - -[data-theme="high-contrast"] .brand-accent-strip { - display: none; -} - -/* ============================================================================ - UI Overhaul — Soft Depth helpers (2026-06-19 spec) - Gedeelde "zwevende kaart" + werkruimte-tint zodat alle panelen consistent zijn. - ============================================================================ */ -.ui-workspace { - background: var(--theme-bg); -} -.ui-card { - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); -} -/* Kaart-koptekst (Space Grotesk small-caps), bv. paneel-headers. */ -.ui-card-header { - font-family: var(--font-heading); - font-size: calc(10px * var(--ui-font-scale, 1)); - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--theme-text); -} -/* Dialogen/popovers: sterkere schaduw. */ -.ui-pop { - box-shadow: var(--shadow-pop); -} - -/* ============================================================================ - Uniforme checkboxes (2026-07-13) — ÉÉN stijl voor ALLE checkboxes in de app. - `appearance: none` schakelt de native (blauwe, OS-afhankelijke) checkbox uit. - Aangevinkt = massief accent-oranje vlak ZONDER vinkje (er is bewust geen - ::after-content), zodat er nooit een scheef-gecentreerd vinkje kan ontstaan. - Het accent volgt het thema (oranje in dark/light, geel in high-contrast). - Vervangt de vroegere wildgroei: bare native (blauw), Tailwind `accent-accent` - en losse `accent-color`-overrides renderen nu allemaal identiek. - ============================================================================ */ -input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - width: 15px; - height: 15px; - flex-shrink: 0; - margin: 0; - box-sizing: border-box; - vertical-align: middle; - border: 1.5px solid var(--theme-control-border); - border-radius: var(--radius-sm); - background: var(--theme-surface); - cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; -} -input[type="checkbox"]:hover { - border-color: var(--theme-accent); -} -input[type="checkbox"]:checked { - background: var(--theme-accent); - border-color: var(--theme-accent); -} -input[type="checkbox"]:focus-visible { - outline: none; - box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 30%, transparent); -} -input[type="checkbox"]:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Cel met de toetsenbordcursor in de TableEditor (issue #26). Bewust ONOPVALLEND: geen vlak, geen - rand, geen accentkleur — het gevulde accentvlak van hiervoor domineerde de tabel. Sinds één klik - meteen bewerkt is deze toestand ook zeldzaam (alleen na Escape of pijltjesnavigatie), dus een - stippellijntje onder de tekst is genoeg als aanwijzing waar de volgende toets landt. */ -.table-cell-cursor { - text-decoration: underline dotted var(--theme-text-dim); - text-underline-offset: 3px; -} + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-6); + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} +.btn:focus-visible { + outline: 2px solid var(--theme-accent); + outline-offset: 2px; +} +.btn--sm { padding: var(--sp-2) var(--sp-4); font-size: 0.75rem; } +.btn--lg { padding: var(--sp-4) var(--sp-8); font-size: 1rem; } + +.btn--primary { + background: var(--theme-accent); + color: var(--theme-accent-on); +} +.btn--primary:hover:not(:disabled) { + /* theme-aware hover: light = #B45309 (white text 5.0:1), dark = #EA580C + (unchanged from --signal-orange). Avoids the 3.56:1 fail white-on-#EA580C + gave in light mode. */ + background: var(--theme-accent-hover); +} + +.btn--secondary { + background: transparent; + color: var(--theme-text); + border: 2px solid var(--theme-text); +} +.btn--secondary:hover:not(:disabled) { + background: var(--theme-text); + color: var(--theme-bg); +} + +.btn--ghost { + background: transparent; + color: var(--theme-accent); + border: 2px solid var(--theme-accent); +} +.btn--ghost:hover:not(:disabled) { + background: var(--theme-accent); + color: var(--theme-accent-on); +} + +.btn--dark { + background: var(--deep-forge); + color: var(--blueprint-white); +} +.btn--dark:hover:not(:disabled) { + background: #27272A; +} + +.btn--danger { + background: var(--error); + color: #ffffff; +} +.btn--danger:hover:not(:disabled) { + background: #B91C1C; +} + +.btn:disabled, +.btn--disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Inputs (DESIGN-SYSTEM.md §3.2) */ +.input { + font-family: var(--font-body); + font-size: 0.875rem; + padding: var(--sp-3) var(--sp-4); + border: 1.5px solid var(--theme-control-border); + border-radius: var(--radius-md); + background: var(--theme-input-bg); + color: var(--theme-text); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} +.input:focus, +.input:focus-visible { + outline: none; + border-color: var(--theme-accent); + box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.20); +} +.input--error { + border-color: var(--error); +} +.input--error:focus, +.input--error:focus-visible { + border-color: var(--error); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.20); +} +.input-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: var(--sp-2); + color: var(--theme-text); +} +.input-hint { + font-size: 0.75rem; + /* was --scaffold-gray #A1A1AA (~2.5:1 op licht, onleesbaar); text-muted is + AA in elk thema (light 5.3:1, HC 7:1, dark gelijk aan oude scaffold-gray). */ + color: var(--theme-text-muted); + margin-top: var(--sp-1); +} +.input-hint--error { + color: var(--error); +} + +/* Alerts (DESIGN-SYSTEM.md §3.5) */ +.alert { + padding: var(--sp-4) var(--sp-6); + border-radius: var(--radius-md); + border-left: 4px solid; + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.5; +} +.alert--info { background: #EFF6FF; border-left-color: var(--info); color: #1E40AF; } +.alert--success { background: #F0FDF4; border-left-color: var(--success); color: #166534; } +.alert--warning { background: #FFFBEB; border-left-color: #D97706; color: #92400E; } +.alert--error { background: #FEF2F2; border-left-color: var(--error); color: #991B1B; } + +/* Badges (DESIGN-SYSTEM.md §3.6) */ +.badge { + display: inline-block; + font-family: var(--font-body); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.2em 0.6em; + border-radius: var(--radius-full); + white-space: nowrap; +} +.badge--amber { background: #FEF3C7; color: #92400E; } +.badge--green { background: #DCFCE7; color: #166534; } +.badge--red { background: #FEE2E2; color: #991B1B; } +.badge--blue { background: #DBEAFE; color: #1E40AF; } +.badge--gray { background: #F4F4F5; color: #3F3F46; } + +/* Tags (DESIGN-SYSTEM.md §3.7) */ +.tag { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-family: var(--font-body); + font-size: 0.75rem; + font-weight: 500; + padding: var(--sp-1) var(--sp-3); + border-radius: var(--radius-full); + border: 1px solid var(--theme-border); + background: var(--theme-surface); + color: var(--theme-text-dim); +} + +/* Progress bars (DESIGN-SYSTEM.md §3.8) */ +.progress { + height: 8px; + background: var(--theme-border); + border-radius: var(--radius-full); + overflow: hidden; +} +.progress__fill { + height: 100%; + background: var(--theme-accent); + border-radius: var(--radius-full); + transition: width 0.3s ease; +} +.progress__fill--success { background: var(--success); } +.progress__fill--danger { background: var(--error); } + +/* OpenAEC merk-accent strip — DESIGN-SYSTEM.md §2.1 */ +.brand-accent-strip { + height: 2px; + background: linear-gradient(90deg, #D97706 0%, #F59E0B 40%, #EA580C 100%); + flex-shrink: 0; +} + +[data-theme="high-contrast"] .brand-accent-strip { + display: none; +} + +/* ============================================================================ + UI Overhaul — Soft Depth helpers (2026-06-19 spec) + Gedeelde "zwevende kaart" + werkruimte-tint zodat alle panelen consistent zijn. + ============================================================================ */ +.ui-workspace { + background: var(--theme-bg); +} +.ui-card { + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); +} +/* Kaart-koptekst (Space Grotesk small-caps), bv. paneel-headers. */ +.ui-card-header { + font-family: var(--font-heading); + font-size: calc(10px * var(--ui-font-scale, 1)); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--theme-text); +} +/* Dialogen/popovers: sterkere schaduw. */ +.ui-pop { + box-shadow: var(--shadow-pop); +} + +/* ============================================================================ + Uniforme checkboxes (2026-07-13) — ÉÉN stijl voor ALLE checkboxes in de app. + `appearance: none` schakelt de native (blauwe, OS-afhankelijke) checkbox uit. + Aangevinkt = massief accent-oranje vlak ZONDER vinkje (er is bewust geen + ::after-content), zodat er nooit een scheef-gecentreerd vinkje kan ontstaan. + Het accent volgt het thema (oranje in dark/light, geel in high-contrast). + Vervangt de vroegere wildgroei: bare native (blauw), Tailwind `accent-accent` + en losse `accent-color`-overrides renderen nu allemaal identiek. + ============================================================================ */ +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 15px; + height: 15px; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + vertical-align: middle; + border: 1.5px solid var(--theme-control-border); + border-radius: var(--radius-sm); + background: var(--theme-surface); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +input[type="checkbox"]:hover { + border-color: var(--theme-accent); +} +input[type="checkbox"]:checked { + background: var(--theme-accent); + border-color: var(--theme-accent); +} +input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 30%, transparent); +} +input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Cel met de toetsenbordcursor in de TableEditor (issue #26). Bewust ONOPVALLEND: geen vlak, geen + rand, geen accentkleur — het gevulde accentvlak van hiervoor domineerde de tabel. Sinds één klik + meteen bewerkt is deze toestand ook zeldzaam (alleen na Escape of pijltjesnavigatie), dus een + stippellijntje onder de tekst is genoeg als aanwijzing waar de volgende toets landt. */ +.table-cell-cursor { + text-decoration: underline dotted var(--theme-text-dim); + text-underline-offset: 3px; +} + +/* ── Semantische tokens als VLAK, niet als tekst ─────────────────────────────────────────────── + In dark/high-contrast zijn --success/--error/--warning opgetild tot leesbare TEKSTkleuren op een + donkere kaart (zie de noot in het [data-theme="dark"]-blok). Op de handvol plekken waar zo'n + token juist een ACHTERGROND onder witte tekst is, zou die verhoging het omgekeerde doen: wit op + #FCA5A5 haalt 1,9:1. Daar gaat het vlak daarom terug naar de verzadigde variant; de witte tekst + houdt zo zijn 4,8:1. */ +[data-theme="dark"] .ops-toast, +[data-theme="dark"] .ops-toast.toast-error, +[data-theme="dark"] .btn--danger, +[data-theme="dark"] .ribbon-btn.danger:hover, +[data-theme="high-contrast"] .ops-toast, +[data-theme="high-contrast"] .ops-toast.toast-error, +[data-theme="high-contrast"] .btn--danger, +[data-theme="high-contrast"] .ribbon-btn.danger:hover { + background: #DC2626; + border-color: #DC2626; +} From 254d4605b894a404b4b1467da620fd113c38d759 Mon Sep 17 00:00:00 2001 From: Nozzit <150452728+Nozzit@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:33:51 +0200 Subject: [PATCH 108/108] =?UTF-8?q?chore(css):=20oorspronkelijke=20regelei?= =?UTF-8?q?nden=20van=20globals.css=20hersteld=20=E2=80=94=20alleen=20de?= =?UTF-8?q?=20thema-overrides=20blijven=20als=20verschil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/styles/globals.css | 3924 ++++++++++++++++++++-------------------- 1 file changed, 1962 insertions(+), 1962 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index 510361d64..798cdc213 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,1304 +1,1304 @@ -@import "tailwindcss"; - -/* De gedeelde taakgrid is een dicht planninginstrument: één DOM-boom, vaste rijhoogte en één - scherpe actieve-celrand. Alle kleurkeuzes blijven via de bestaande thematokens lopen. */ -.task-grid-core { - position: relative; - overflow: auto; - background: var(--theme-surface); - color: var(--theme-text); - font-family: var(--font-body); - font-size: 11px; - outline: none; -} - -.task-grid-header-row, -.task-grid-data-row { - display: grid; -} - -/* De vaste plusknop staat buiten de scrollende kolommen. Geef de header en body daarom een echte - * afsluitende strook; padding op de scrollcontainer zelf schoof uitsluitend het maximale - * scrollbereik op en liet de knop bij scrollLeft=0 alsnog bovenop de laatste kolomtitel liggen. */ -.task-grid-header-row, -.task-grid-body { - box-sizing: content-box; - padding-inline-end: var(--task-grid-chooser-strip, 16px); -} - -/* De gridheader is sticky binnen dezelfde scroller als de gevirtualiseerde rijen. Laat de verticale - * scrollbaan pas onder die header beginnen, zodat duim en track nooit door de kolomkop lopen. */ -.task-grid-core::-webkit-scrollbar-track:vertical { - margin-block-start: var(--task-grid-header-height); -} - -.task-grid-header-row { - position: sticky; - top: 0; - z-index: var(--z-sticky); - background: var(--theme-surface-alt); - box-shadow: inset 0 -1px 0 var(--theme-border); -} - -.task-grid-column-header, -.task-grid-cell { - min-width: 0; - box-sizing: border-box; - display: flex; - align-items: center; - border-inline-end: 1px solid var(--theme-border-light); - border-bottom: 1px solid var(--theme-border-light); - background: var(--theme-surface); -} - -.task-grid-column-header { - padding-inline: 8px 10px; - overflow: visible; - background: var(--theme-surface-alt); - color: var(--theme-text-muted); - font-family: var(--font-heading); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - user-select: none; -} - -.task-grid-column-label, -.task-grid-cell-content, -.task-grid-group-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Een editorpop-over is bewust breder/hoger dan zijn 28px-cel. De gewone celinhoud blijft - * ellipsized, maar de actieve editor mag niet door die wrapper worden afgeknipt: anders zijn - * dropdowns en chipknoppen wel in de DOM aanwezig maar fysiek niet aanklikbaar. - * Issue #89: de contentspan is een flex-item van de cel en kromp zonder `flex: 1 1 auto` tot de - * intrinsieke breedte van het invoerveld (~20 tekens), zodat de naameditor maar een deel van de - * kolom vulde. De editor hoort de volle resterende kolombreedte te krijgen; de naameditor staat - * daarbij genest in de inspringrij (`.full-task-grid-name`), vandaar de afstammelingsselector. */ -.task-grid-cell-content:has(.task-grid-editor-host) { - flex: 1 1 auto; - overflow: visible; -} - -.task-grid-cell { - position: relative; - padding-inline: 7px; - outline: none; -} - -.task-grid-cell[data-grid-selected="true"] { - background: color-mix(in srgb, var(--theme-accent) 12%, var(--theme-surface)); - box-shadow: inset 0 -1px 0 var(--theme-accent); -} - -.task-grid-cell[data-grid-active="true"] { - z-index: 4 !important; - box-shadow: inset 0 0 0 2px var(--theme-accent); -} - -.task-grid-cell[data-grid-readonly="true"]::after { - content: ""; - position: absolute; - inset-block-start: 3px; - inset-inline-end: 3px; - width: 4px; - height: 4px; - border-block-start: 1px solid var(--theme-text-muted); - border-inline-end: 1px solid var(--theme-text-muted); -} - -.task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { - text-decoration-line: underline; - text-decoration-style: dotted; - text-underline-offset: 3px; -} - -.task-grid-cell[aria-invalid="true"] { - box-shadow: inset 0 0 0 2px var(--error); -} - -.task-grid-data-row[data-grid-dimmed="true"] .task-grid-cell-content { - opacity: 0.58; -} - -/* Browserreview, observatie 4: de rij-indicators (selectiebalk, drop-zone tijdens rijsleep) waren - * tot nu toe een `box-shadow: inset` op de RIJ zelf — maar een box-shadow van een element schildert - * ALTIJD vóór zijn eigen kinderen (de schilderorde is achtergrond/rand/box-shadow, dan pas - * children), en elke `.task-grid-cell` heeft een eigen ONdoorzichtige achtergrond. Onder elke kolom - * met inhoud (dus praktisch de hele tabelbreedte) werd de balk/lijn daardoor volledig overschilderd - * door de cellen erboven — zichtbaar bleef alleen het smalle restje rijbreedte VOORBIJ de laatste - * kolom. Twee bijna-onzichtbare lijnen die daar toevallig samenvallen (bv. "after" van rij N en - * "before" van rij N+1, beide maar 2px uit elkaar) konden zo als "twee oranje lijnen" ogen, en de - * selectiebalk op de gesleepte rij zelf was al net zo onzichtbaar. - * - * Fix: de indicator is nu een `::before`-pseudo-element, `position:absolute` op de rij (die - * daarvoor `position:relative` krijgt) met `z-index` boven de hoogste celwaarde (actieve cel = 4 - * `!important`) — een pseudo-element ligt zo als een eigen laag BOVEN alle cellen, ongeacht wat - * daaronder staat. `pointer-events:none` houdt 'm buiten alle hit-testing (klikken/slepen blijven - * op de cellen zelf werken). */ -.task-grid-data-row { - position: relative; -} - -.task-grid-data-row[data-grid-row-selected="true"] .task-grid-cell { - background: color-mix(in srgb, var(--theme-accent) 7%, var(--theme-surface)); -} - -.task-grid-data-row[data-grid-dragging="true"] { - opacity: 0.56; -} - -.task-grid-data-row[data-grid-row-selected="true"]::before, -.task-grid-data-row[data-grid-drop-zone]::before { - content: ""; - position: absolute; - inset: 0; - z-index: 6; - pointer-events: none; -} - -.task-grid-data-row[data-grid-row-selected="true"]::before { - box-shadow: inset 3px 0 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="before"]::before { - box-shadow: inset 0 2px 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="after"]::before { - box-shadow: inset 0 -2px 0 var(--theme-accent); -} - -.task-grid-data-row[data-grid-drop-zone="nest"]::before { - box-shadow: inset 0 0 0 2px var(--theme-accent); -} - -.task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { - opacity: 0.25; -} - -.task-grid-data-row.task-grid-trace-focus { - outline: 2px solid var(--theme-accent); - outline-offset: -2px; -} - -.task-grid-data-row.task-grid-trace-predecessor, -.task-grid-data-row.task-grid-trace-predecessor-driving { - border-inline-start: 3px solid var(--trace-predecessor, #F59E0B); -} - -.task-grid-data-row.task-grid-trace-successor, -.task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start: 3px dashed var(--trace-successor, #A78BFA); -} - -.task-grid-data-row.task-grid-trace-predecessor-driving, -.task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start-width: 5px; - font-weight: 600; -} - -@media (forced-colors: active) { - .task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { - opacity: 1; - text-decoration: line-through; - text-decoration-style: dotted; - } - - .task-grid-data-row.task-grid-trace-dimmed { - border-inline-start: 2px dotted GrayText; - } - - .task-grid-data-row.task-grid-trace-focus { - outline-color: Highlight; - } - - .task-grid-data-row.task-grid-trace-predecessor, - .task-grid-data-row.task-grid-trace-predecessor-driving { - border-inline-start-color: Highlight; - border-inline-start-style: solid; - } - - .task-grid-data-row.task-grid-trace-successor, - .task-grid-data-row.task-grid-trace-successor-driving { - border-inline-start-color: Highlight; - border-inline-start-style: dashed; - } - - .task-grid-cell[data-grid-selected="true"] { - background: Highlight; - color: HighlightText; - box-shadow: inset 0 -2px 0 HighlightText; - } - - .task-grid-cell[data-grid-active="true"] { - outline: 2px solid Highlight; - outline-offset: -2px; - } - - .task-grid-cell[data-grid-readonly="true"]::after { - border-color: GrayText; - border-style: dotted; - } - - .task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { - text-decoration: underline dotted; - } - - .task-grid-cell[aria-invalid="true"] { - outline: 3px double Mark; - outline-offset: -3px; - } - - .task-grid-relation-chip--driving { - font-weight: 700; - text-decoration: underline double; - } - - .task-grid-relation-chip--warning { - text-decoration: underline wavy; - } -} - -.task-grid-group-row, -.task-grid-group-cell { - box-sizing: border-box; - background: var(--theme-surface-alt); - border-bottom: 1px solid var(--theme-border); -} - -.full-task-grid { - position: relative; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.full-task-grid > .task-grid-shell { - width: 100%; - height: 100%; -} - -.gantt-workspace { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr) auto auto; - flex: 1 1 auto; - width: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-workspace-grid, -.gantt-workspace-timeline { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-workspace-grid { - display: flex; - grid-column: 1; - grid-row: 1; -} - -.gantt-workspace-timeline { - display: flex; - grid-column: 2; - grid-row: 1; -} - -.gantt-workspace-histogram { - display: flex; - flex-direction: column; - grid-column: 1 / -1; - grid-row: 2; - min-width: 0; -} - -.gantt-workspace-minimap { - display: flex; - grid-column: 2; - grid-row: 3; - min-width: 0; -} - -.gantt-workspace-splitter { - grid-column: 1; - grid-row: 1; - align-self: stretch; - justify-self: end; - position: relative; - /* De volledige 5px-grijpzone ligt aan de Gantt-kant. Gecentreerd op de paneelgrens bedekte deze - * anders 2,5px van de 8px brede verticale tabelscrollbar. */ - transform: translateX(100%); - z-index: 6; - width: 5px; - min-width: 5px; - padding: 0; - border: 0; - border-inline-start: 1px solid var(--theme-border); - background: transparent; - cursor: col-resize; - touch-action: none; -} - -.gantt-workspace-splitter:hover, -.gantt-workspace-splitter[data-resizing="true"], -.gantt-workspace-splitter:focus-visible { - background: transparent; - outline: none; -} - -.gantt-task-grid { - position: relative; - display: flex; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gantt-task-grid > .task-grid-shell { - width: 100%; - height: 100%; -} - -/* Issue #89: de subtaak-plus staat ná de naam en wordt met `margin-inline-start: auto` tegen - * de rechter kolomrand gedrukt, zodat hij op elke samenvattende rij op dezelfde plek staat en - * de naamtekst links de hiërarchie kan laten zien. De lichte rand maakt hem als knop herkenbaar. */ -.gantt-task-grid-add-child { - flex: none; - margin-inline-start: auto; - /* Vrij van de kolomrand (issue #89, punt 4 vroeg juist om lucht rond zo'n knop). */ - margin-inline-end: 6px; - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--theme-border-light); - border-radius: 3px; - color: var(--theme-text-muted); - background: transparent; - line-height: 1; -} - -.gantt-task-grid-add-child:hover, -.gantt-task-grid-add-child:focus-visible { - color: var(--theme-accent); - border-color: var(--theme-accent); - background: var(--theme-hover); - outline: none; -} - -/* Vervolgmelding op observatie 3a/3b (probleem B): was `display: inline-flex` — een INLINE box - * sizet zich (shrink-to-fit) naar zijn EIGEN inhoud (triehoekje + tekst), niet naar de beschikbare - * breedte van zijn ouder (`.task-grid-cell-content`, block, dus geen flex-context die deze rij zou - * dwingen te krimpen). `min-width:0`/`overflow:hidden` op deze laag of de tekst-span erin doen dan - * NIETS — die eigenschappen laten iets krimpen ALS iets anders het beperkt, maar niets deed dat - * hier. `display: flex` (blokniveau) vult i.p.v. dat de beschikbare breedte van de ouder, wat de - * tekst-span (`.full-task-grid-name-label`, `min-width:0`) een ECHTE beperking geeft om binnen te - * krimpen — pas dan werken overflow:hidden/text-overflow:ellipsis daarop zoals bedoeld. */ -.full-task-grid-name { - min-width: 0; - display: flex; - align-items: center; - /* Issue #89: triehoekje (16px) + deze gap (4px) = één inspringeenheid van 20px, dezelfde - * TASK_NAME_INDENT_UNIT als in src/engine/taskGrid/nameIndent.ts. */ - gap: 4px; -} - -/* Discussion #97: samenvattingen (containertaken) en mijlpalen zijn nu alleen via inspringing te - * onderscheiden van een gewone taak. `data-grid-task-kind` (gezet in FullTaskGrid.tsx, weggelaten - * tijdens naam-editing) stuurt hier vet + een subtiele achtergrondtint voor samenvattingen, en - * vet + de mijlpaalkleur voor mijlpalen — bewust dezelfde kleurfamilie als de Gantt-balk - * (`--color-task-milestone`), niet de rode mockupkleur uit de discussion. De tint zit op de hele - * naamrij (niet alleen de tekst), zodat hij als rijvlak leesbaar is; de selectiekleur van de rij - * (`.task-grid-data-row[data-grid-row-selected="true"]`) wint hier altijd van, dus geen eigen - * z-index-gedoe nodig — de tint zit hieronder in de laagvolgorde. */ -.full-task-grid-name[data-grid-task-kind="summary"] { - background: var(--theme-grid-summary-bg); -} - -.full-task-grid-name[data-grid-task-kind="summary"] .full-task-grid-name-label { - font-weight: 600; -} - -.full-task-grid-name[data-grid-task-kind="milestone"] .full-task-grid-name-label { - font-weight: 600; - color: var(--theme-grid-milestone-text); -} - -/* Geselecteerde rij wint van de samenvattingstint (leesbaarheid op de selectiekleur staat voorop); - * de vette/gekleurde tekst blijft intact — alleen de achtergrond van de naamrij vervalt. */ -.task-grid-data-row[data-grid-row-selected="true"] .full-task-grid-name[data-grid-task-kind="summary"] { - background: transparent; -} - -/* De naameditor vult de rij vanaf de tekstpositie tot de rechter kolomrand. */ -.full-task-grid-name > .task-grid-editor-host { - flex: 1 1 auto; - min-width: 0; -} - -/* De algemene celpadding liet rechts van een taaknaam zeven ongebruikte pixels liggen: genoeg voor - * nog een letter vóór de ellipsis. Links blijft de normale inspringruimte intact. */ -.task-grid-cell:has(.full-task-grid-name) { - padding-inline-end: 1px; -} - -/* Issue #89: de contentspan is een flex-item van de cel en kromp tot zijn inhoud, zodat de - * rechts uitgelijnde subtaak-plus (`margin-inline-start: auto`) nooit verder kwam dan het einde - * van de naamtekst. De naamcel laat zijn contentspan daarom de hele celbreedte vullen; de - * naamtekst zelf blijft via `.full-task-grid-name-label` krimpen en afknippen. */ -.task-grid-cell:has(.full-task-grid-name) > .task-grid-cell-content { - flex: 1 1 auto; -} - -/* Vervolgmelding op observatie 3a/3b (probleem B): `.full-task-grid-name` is zelf een - * `inline-flex`-rij (in-/uitklaptriehoekje + evt. "+"-knop + de tekst) zonder eigen - * overflow/text-overflow — `text-overflow: ellipsis` op de vooroudercontainer - * (`.task-grid-cell-content`) kan de afknipplek niet correct berekenen zodra de tekst genest zit - * in een EIGEN flex-container in plaats van een rechtstreeks tekstkind: de ellips ("…") werd - * daardoor nooit getoond, en de daadwerkelijke afknip leunde uitsluitend op de - * `overflow: hidden` van die voorouder — geen eigen begrenzing op déze laag. Bij smalle kolommen - * bleef de layout-doos van `.full-task-grid-name` daardoor BREDER dan zijn eigen ouder (gemeten: - * tot 30px breder dan `.task-grid-cell-content`), puur toevallig nog steeds geklemd door die - * voorouder — een aanname die breekt zodra iets in die keten verandert. Deze klasse geeft de - * TEKST zelf (niet de rij eromheen) zijn eigen `min-width:0` (kan binnen de flex-rij krimpen, - * ondanks het vaste-breedte triehoekje ernaast) plus `overflow:hidden`/`text-overflow:ellipsis` — - * de clip zit nu op de plek waar de tekst zelf staat, niet alleen bij de voorouder. */ -.full-task-grid-name-label { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.full-task-grid-disclosure { - flex: 0 0 16px; - width: 16px; - height: 18px; - padding: 0; - border: 0; - color: var(--theme-text-muted); - background: transparent; - line-height: 1; -} - -.full-task-grid-error { - position: absolute; - inset-inline: 8px; - inset-block-end: 8px; - z-index: 8; - padding: 6px 9px; - border: 1px solid color-mix(in srgb, var(--error) 55%, var(--theme-border)); - border-radius: 6px; - color: var(--error); - background: var(--theme-surface); - box-shadow: var(--shadow-pop); - font-size: 12px; -} - -.task-grid-group-cell { - display: flex; - align-items: center; -} - -.task-grid-group-toggle { - width: 100%; - height: 100%; - display: flex; - align-items: center; - gap: 6px; - padding-inline: 0 8px; - color: inherit; - background: transparent; - border: 0; - font: inherit; - font-weight: 600; - text-align: start; -} - -.task-grid-group-toggle:focus-visible, -.task-grid-resize-handle:focus-visible { - outline: 2px solid var(--theme-accent); - outline-offset: -2px; -} - -.task-grid-group-count { - flex: none; - color: var(--theme-text-muted); - font-family: var(--font-code); - font-size: 10px; -} - -/* Browserreview, observatie 7 (grijpzone-kant): was `inset-inline-end: -4px; width: 8px` — een - * band van 4px aan WEERSZIJDEN van de kolomgrens. `beginResize`'s `onPointerDown` roept - * `event.preventDefault()` aan, wat de NATIEVE HTML5-slepginitiatie onderdrukt (preventDefault op - * pointerdown/mousedown is precies het mechanisme waarmee je dragstart voorkomt) — een poging om - * een kolom te HERORDENEN door 'm net bij de rand vast te pakken (een natuurlijke plek om te - * grijpen) landde zo op de resize-greep i.p.v. de herordenbare kopruimte, en startte dan een resize - * (of niets) in plaats van een sleep. Bij een kolom van de minimumbreedte (40px) at 8px band 20% van - * de breedte in beslag — en ELKE kolom draagt ook nog eens 2px overhang van de greep van zijn linker - * buur. Smaller gemaakt (4px totaal i.p.v. 8px) zodat er meer ruimte overblijft voor herordenen; de - * greep blijft een gangbare resize-hitbox (vergelijkbaar met Excel/Sheets, die ook een paar pixels - * breed zijn, geen 8). - */ -.task-grid-resize-handle { - position: absolute; - inset-block: 0; - inset-inline-end: -2px; - z-index: 2; - width: 4px; - padding: 0; - border: 0; - background: transparent; - cursor: col-resize; - touch-action: none; -} - -.task-grid-resize-handle:hover { - background: color-mix(in srgb, var(--theme-accent) 38%, transparent); -} - -.task-grid-shell { - position: relative; - width: fit-content; - max-width: 100%; - /* Eén bron voor de "kolom toevoegen"-strook: een 20px-knop met 6px lucht aan weerszijden, zodat - * hij vrijstaat van de laatste kolominhoud én van de rand van het kader (issue #89). */ - --task-grid-chooser-strip: 32px; -} - -.task-grid-column-header[data-grid-dragging="true"] { - opacity: 0.5; -} - -.task-grid-column-header[data-grid-drop-before="true"]::before, -.task-grid-column-header[data-grid-drop-after="true"]::after { - content: ""; - position: absolute; - z-index: 4; - inset-block: 0; - width: 2px; - background: var(--theme-accent); -} - -.task-grid-column-header[data-grid-drop-before="true"]::before { - inset-inline-start: -1px; -} - -.task-grid-column-header[data-grid-drop-after="true"]::after { - inset-inline-end: -1px; -} - -.task-grid-remove-column { - position: absolute; - inset-block-start: 50%; - inset-inline-end: 7px; - width: 17px; - height: 17px; - display: grid; - place-items: center; - padding: 0; - color: var(--theme-text-muted); - background: var(--theme-surface-alt); - border: 1px solid transparent; - border-radius: 3px; - opacity: 0; - transform: translateY(-50%); -} - -.task-grid-column-header:hover .task-grid-remove-column, -.task-grid-column-header:focus-within .task-grid-remove-column { - opacity: 1; -} - -.task-grid-remove-column:hover, -.task-grid-remove-column:focus-visible { - color: var(--theme-text); - border-color: var(--theme-border); - outline: none; -} - -.task-grid-column-chooser-anchor { - position: absolute; - z-index: calc(var(--z-sticky) + 1); - inset-block-start: 0; - inset-inline-end: 0; - width: var(--task-grid-chooser-strip, 32px); - height: var(--task-grid-header-height); - display: flex; - align-items: center; - justify-content: center; - padding-inline: 6px; - background: var(--theme-surface-alt); -} - -.task-grid-add-column { - width: 20px; - height: 20px; - display: grid; - place-items: center; - padding: 0; - color: var(--theme-text-muted); - background: transparent; - border: 1px solid transparent; - border-radius: 3px; -} - -.task-grid-add-column:hover, -.task-grid-add-column:focus-visible, -.task-grid-add-column[aria-expanded="true"] { - color: var(--theme-text); - border-color: var(--theme-border); - background: var(--theme-hover); - outline: none; -} - -.task-grid-no-columns { - position: absolute; - inset-block-start: var(--task-grid-header-height); - inset-inline: 0; - min-height: 28px; - display: flex; - align-items: center; - padding-inline: 10px 40px; - color: var(--theme-text-muted); - font-size: 11px; - pointer-events: none; -} - -.task-grid-column-chooser { - z-index: var(--z-contextmenu); - width: min(310px, calc(100vw - 16px)); - overflow-y: auto; - padding: 7px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: 6px; - box-shadow: var(--shadow-pop); - font-family: var(--font-body); - font-size: 11px; -} - -.task-grid-column-chooser-title { - padding: 3px 5px 7px; - font-family: var(--font-heading); - font-size: 12px; - font-weight: 700; -} - -.task-grid-column-chooser-section-label { - padding: 7px 5px 3px; - color: var(--theme-text-muted); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.task-grid-column-chooser-list { - display: grid; -} - -.task-grid-column-chooser-item, -.task-grid-column-chooser-category { - width: 100%; - min-height: 28px; - display: flex; - align-items: center; - gap: 7px; - padding: 5px 7px; - color: var(--theme-text); - background: transparent; - border: 0; - border-radius: 3px; - font: inherit; - text-align: start; -} - -.task-grid-column-chooser-item:hover:not(:disabled), -.task-grid-column-chooser-item:focus-visible, -.task-grid-column-chooser-category:hover, -.task-grid-column-chooser-category:focus-visible { - background: var(--theme-hover); - outline: none; -} - -.task-grid-column-chooser-item:disabled { - color: var(--theme-text-muted); - cursor: default; -} - -.task-grid-column-chooser-check { - width: 13px; - flex: none; -} - -.task-grid-column-chooser-search { - height: 30px; - display: flex; - align-items: center; - gap: 6px; - margin: 5px 2px; - padding-inline: 7px; - color: var(--theme-text-muted); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 4px; -} - -.task-grid-column-chooser-search:focus-within { - border-color: var(--theme-accent); -} - -.task-grid-column-chooser-search input { - min-width: 0; - flex: 1; - color: var(--theme-text); - background: transparent; - border: 0; - outline: none; - font: inherit; -} - -.task-grid-column-chooser-categories { - margin-block-start: 5px; - padding-block-start: 4px; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-column-chooser-category { - font-weight: 600; -} - -.task-grid-column-chooser-category-count { - margin-inline-start: auto; - color: var(--theme-text-muted); - font-family: var(--font-code); - font-size: 10px; -} - -.task-grid-column-chooser-category svg[data-expanded="true"] { - transform: rotate(180deg); -} - -.task-grid-column-chooser-empty { - padding: 6px 7px; - color: var(--theme-text-muted); -} - -.task-grid-editor-host { - position: relative; - width: 100%; - height: 100%; -} - -.task-grid-editor-input { - width: 100%; - height: 100%; - min-width: 0; - padding-inline: 5px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-accent); - font: inherit; -} - -.task-grid-date-editor { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - width: 100%; - height: 100%; -} - -.task-grid-date-editor .task-grid-editor-input { - border-inline-end: 0; -} - -.task-grid-date-picker { - width: 2.25rem; - min-width: 0; - height: 100%; - padding: 0 2px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-accent); - font: inherit; -} - -.task-grid-assignment-editor { - position: absolute; - z-index: var(--z-dropdown); - inset-block-start: 0; - inset-inline-start: 0; - display: grid; - gap: 5px; - width: max(100%, 420px); - max-width: min(560px, 80vw); - padding: 6px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-accent); - box-shadow: var(--shadow-pop); -} - -.task-grid-assignment-tokens { - display: grid; - gap: 4px; -} - -.task-grid-assignment-token { - display: grid; - grid-template-columns: minmax(110px, 1fr) 76px 132px 26px; - gap: 4px; - align-items: center; -} - -.task-grid-assignment-resource { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.task-grid-assignment-token input, -.task-grid-assignment-token select, -.task-grid-assignment-editor > input, -.task-grid-assignment-token button, -.task-grid-assignment-options button { - min-width: 0; - height: 26px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 3px; - font: inherit; -} - -.task-grid-assignment-token input, -.task-grid-assignment-editor > input { - padding-inline: 5px; -} - -.task-grid-assignment-token button, -.task-grid-assignment-options button { - cursor: pointer; -} - -.task-grid-assignment-token button:hover, -.task-grid-assignment-options button:hover { - background: var(--theme-hover); -} - -.task-grid-assignment-options { - display: grid; - max-height: 180px; - overflow: auto; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-assignment-options button { - padding-inline: 6px; - text-align: start; - border: 0; - border-radius: 0; -} - -/* Browserreview, observatie 6 (tweede plek met dezelfde afwijking): dit paneel portaalt naar - * document.body (RelationCellEditor.tsx) zonder eigen `font-size` — zonder deze regel erven al zijn - * kinderen (chips, tokens, invoervelden — geen van allen zet zelf een font-size) html/body's volle - * `calc(13px * --ui-font-scale)` i.p.v. de compacte roosterdichtheid. `.task-grid-column-chooser` - * (dezelfde soort portal-popup) had dit al goed; hier hetzelfde patroon toegepast. */ -.task-grid-relation-editor { - position: fixed; - z-index: var(--z-dropdown); - display: grid; - gap: 5px; - max-width: min(680px, 85vw); - max-height: calc(100vh - 16px); - overflow: auto; - padding: 6px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-accent); - box-shadow: var(--shadow-pop); - font-family: var(--font-body); - font-size: 11px; -} - -.task-grid-relation-editor-anchor { - position: absolute; - inset: 0; - pointer-events: none; -} - -.task-grid-relation-cell { - display: block; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.task-grid-relation-chip { - display: inline-flex; - align-items: center; - min-width: 0; -} - -.task-grid-relation-jump { - min-width: 0; - padding: 0; - overflow: hidden; - background: transparent; - border: 0; - cursor: pointer; - font: inherit; - text-overflow: ellipsis; - text-decoration: underline; - text-underline-offset: 2px; - white-space: nowrap; -} - -.task-grid-relation-icon { - display: inline-flex; - align-items: center; - margin-inline-start: 2px; -} - -/* Issue #94: één kleur per chip (referentie, type/lag en het sturend-icoon), gestuurd door de - * richting op de chip zelf — niet meer alleen het referentiedeel accentkleur, de rest tekstkleur. - * Sturend blijft zichtbaar als de STERKERE tint van diezelfde rolkleur, niet als los "wel/geen - * kleur"; het warning-icoon blijft altijd zijn eigen --warning-kleur houden. */ -.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-jump, -.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-detail { - color: var(--relation-predecessor); -} - -.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-jump, -.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-detail { - color: var(--relation-successor); -} - -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-jump, -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-detail, -.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-icon--driving > svg { - color: var(--relation-predecessor-driving); - font-weight: 700; -} - -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-jump, -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-detail, -.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-icon--driving > svg { - color: var(--relation-successor-driving); - font-weight: 700; -} - -.task-grid-relation-chip--warning .task-grid-relation-icon--warning > svg { - color: var(--warning, #d97706); -} - -.task-grid-relation-separator { - color: var(--theme-text-muted); -} - -.task-grid-relation-tokens, -.task-grid-relation-options { - display: grid; - gap: 4px; -} - -.task-grid-relation-token { - display: grid; - grid-template-columns: minmax(180px, 1fr) 64px 86px 26px; - gap: 4px; - align-items: center; -} - -.task-grid-relation-reference { - min-width: 0; - overflow: hidden; - color: inherit; - text-align: start; - text-overflow: ellipsis; - white-space: nowrap; -} - -button.task-grid-relation-reference { - padding: 0; - color: var(--theme-accent); - background: transparent; - border: 0; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 2px; -} - -.task-grid-relation-token input, -.task-grid-relation-token select, -.task-grid-relation-token > button:not(.task-grid-relation-reference), -.task-grid-relation-add input, -.task-grid-relation-add button, -.task-grid-relation-options button { - min-width: 0; - height: 26px; - color: var(--theme-text); - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: 3px; - font: inherit; -} - -.task-grid-relation-token input, -.task-grid-relation-add input { - padding-inline: 5px; -} - -.task-grid-relation-add { - display: grid; - grid-template-columns: minmax(160px, 1fr) auto; - gap: 4px; -} - -.task-grid-relation-add button, -.task-grid-relation-token > button:not(.task-grid-relation-reference), -.task-grid-relation-options button { - cursor: pointer; -} - -.task-grid-relation-options { - max-height: 180px; - overflow: auto; - border-block-start: 1px solid var(--theme-border-light); -} - -.task-grid-relation-options button { - padding-inline: 6px; - text-align: start; - border: 0; - border-radius: 0; -} - -.task-grid-relation-add button:hover, -.task-grid-relation-token > button:hover, -.task-grid-relation-options button:hover { - background: var(--theme-hover); -} - -.task-grid-relation-context { - position: fixed; - z-index: var(--z-dropdown); - display: grid; - min-width: 190px; - padding-block: 4px; - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: 5px; - box-shadow: var(--shadow-pop); -} - -.task-grid-relation-context button { - padding: 6px 10px; - color: var(--theme-text); - background: transparent; - border: 0; - cursor: pointer; - font: inherit; - text-align: start; -} - -.task-grid-relation-context button:hover { - background: var(--theme-hover); -} - -.task-grid-relation-context .task-grid-relation-context-danger { - color: var(--error); -} - -.task-grid-editor-error { - position: absolute; - z-index: var(--z-dropdown); - inset-block-start: 100%; - inset-inline-start: 0; - max-width: 320px; - padding: 5px 7px; - color: var(--theme-text); - background: var(--theme-surface-elevated); - border: 1px solid var(--error); - box-shadow: var(--shadow-pop); - font-size: 11px; - white-space: normal; -} - -/* ============================================================================ - OpenAEC Foundation — Design Tokens - Source: OpenAEC-style-book/brandbook/DESIGN-SYSTEM.md §2 + LAYOUTS.md §0/§4.1 - These are theme-independent primitives. Theme-specific tokens (--theme-*) - reference these and are defined per [data-theme] block below. - ============================================================================ */ -:root { - /* Brand — primary */ - --deep-forge: #36363E; - - /* Path tracing (issue #114): de rijmarkering in het taakraster gebruikt dezelfde tinten als de - Gantt-balken — GANTT_TRACE_COLORS in src/engine/renderer/themePalette.ts is de bron; - tests/planning/check-dependency-presentation.ts bewaakt dat beide gelijk blijven. */ - --trace-predecessor: #F59E0B; - --trace-successor: #A78BFA; - - /* Brand — secondary */ - --signal-orange: #EA580C; - --warm-gold: #F59E0B; - --scaffold-gray: #A1A1AA; - - /* Backgrounds */ - --blueprint-white: #FAFAF9; - --concrete: #F5F5F4; - --night-build: #2A2A32; - - /* Semantic */ - --success: #16A34A; - --error: #DC2626; - --info: #2563EB; - --warning: #F59E0B; - - /* Dashboard / dense UI tokens (DESIGN-SYSTEM.md §2.5) */ - --dashboard-bg: #2A2A32; - --dashboard-surface: #36363E; - --dashboard-surface-hover: #3E3E48; - --dashboard-sidebar: #222228; - --dashboard-border: #4A4A54; - --dashboard-border-light: #404048; - --dashboard-text: #FAFAF9; - --dashboard-text-muted: #A1A1AA; - --dashboard-text-dim: #666670; - --dashboard-accent: #D97706; - --dashboard-accent-bg: #2E2418; - --dashboard-accent-hover: #EA580C; - --dashboard-terminal: #F59E0B; - --dashboard-terminal-dim: #D97706; - - /* Typography families */ - --font-heading: "Space Grotesk", system-ui, sans-serif; - --font-body: "Inter", system-ui, sans-serif; - --font-code: "JetBrains Mono", monospace; - - /* Spacing scale (4px base) */ - --sp-1: 0.25rem; /* 4px */ - --sp-2: 0.5rem; /* 8px */ - --sp-3: 0.75rem; /* 12px */ - --sp-4: 1rem; /* 16px */ - --sp-5: 1.25rem; /* 20px */ - --sp-6: 1.5rem; /* 24px */ - --sp-8: 2rem; /* 32px */ - --sp-10: 2.5rem; /* 40px */ - --sp-12: 3rem; /* 48px */ - --sp-16: 4rem; /* 64px */ - --sp-20: 5rem; /* 80px */ - --sp-24: 6rem; /* 96px */ - - /* Border radius */ - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 14px; - --radius-full: 9999px; - - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); - --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); - - /* Z-index scale (LAYOUTS.md §0.3) */ - --z-base: 0; - --z-dropdown: 10; - --z-sticky: 20; - --z-sidebar: 30; - --z-navbar: 40; - --z-modal-backdrop: 50; - --z-modal: 60; - --z-toast: 70; - --z-tooltip: 80; - --z-contextmenu: 90; - - /* Layout sizes (LAYOUTS.md §4.1) */ - --titlebar-height: 32px; - --toolbar-height: 40px; - --statusbar-height: 24px; - --sidebar-width: 260px; - --inspector-width: 280px; -} - -/* ============================================================================ - Theme CSS Variables - ============================================================================ */ - -@theme { - --color-surface: var(--theme-surface); - --color-surface-alt: var(--theme-surface-alt); - --color-surface-hover: var(--theme-hover); - --color-border: var(--theme-border); - --color-text-primary: var(--theme-text); - --color-text-secondary: var(--theme-text-dim); - --color-accent: var(--theme-accent); - --color-accent-hover: var(--theme-accent-hover); - --color-accent-on: var(--theme-accent-on); - /* U2 — ÉÉN balkpalet voor licht én donker (besluit eigenaar). Elke waarde haalt >=3:1 tegen - ZOWEL de lichte kaart (#FAFAFA) als de donkere kaart (#2E3239); vroeger waren de balken op - merk-verzadiging afgestemd op wit en haalden ze op de donkere kaart 1.9-2.7:1. Eén stap minder - verzadigd, zelfde tintfamilie, zodat legenda, gidsen en rapport dezelfde taal blijven spreken. - Gemeten (WCAG 2.x relatieve luminantie), licht / donker / wit-label-op-balk: - critical #DA5252 3.79 / 3.25 / 3.95 - normal #648BE0 3.19 / 3.87 / 3.33 - complete #5778D6 3.97 / 3.10 / 4.15 - milestone #986DE2 3.58 / 3.45 / 3.74 - baseline #808694 3.50 / 3.53 / 3.65 - float #1E976F 3.52 / 3.50 / 3.68 - LET OP: dezelfde zes waarden staan als merk-hex in `src/engine/renderer/themePalette.ts` - (BRAND/`--theme-bar-float`-fallback) — die tekenlaag leest geen CSS in headless tests. */ - --color-critical: #DA5252; - --color-task-normal: #648BE0; - --color-task-milestone: #986DE2; - --color-task-float: #1E976F; - --color-task-baseline: #808694; - --color-task-complete: #5778D6; -} - -/* Dark theme — OpenAEC default - Construction Amber on Deep Forge. Dashboard tokens uit DESIGN-SYSTEM.md §2.5. */ -:root, -[data-theme="dark"] { - /* Koeler charcoal + Soft Depth. Werkruimte-tint (--theme-bg) is donkerder dan - de kaarten (--theme-surface) zodat panelen "zweven". */ - --theme-bg: #1B1D22; - --theme-surface: #2E3239; - --theme-surface-alt: #23262D; - --theme-surface-elevated: #363B43; - --theme-border: #3A3F48; - --theme-border-light: #33373F; - /* Rand van inputs/selects/knoppen — zichtbaarder dan kaartrand (~3:1 op de card). */ - --theme-control-border: #4A515C; - --theme-accent: #D97706; - --theme-accent-hover: #EA580C; - /* Tekstkleur op accent-achtergronden (wit in light/dark, zwart in high-contrast). */ - --theme-accent-on: #ffffff; - --theme-text: #F1F3F5; - --theme-text-dim: #B6BCC6; - --theme-text-muted: #7C8492; - --theme-hover: rgba(255, 255, 255, 0.06); - --theme-active: #D97706; - --theme-active-border: #EA580C; - --theme-warning-text: #F59E0B; +@import "tailwindcss"; + +/* De gedeelde taakgrid is een dicht planninginstrument: één DOM-boom, vaste rijhoogte en één + scherpe actieve-celrand. Alle kleurkeuzes blijven via de bestaande thematokens lopen. */ +.task-grid-core { + position: relative; + overflow: auto; + background: var(--theme-surface); + color: var(--theme-text); + font-family: var(--font-body); + font-size: 11px; + outline: none; +} + +.task-grid-header-row, +.task-grid-data-row { + display: grid; +} + +/* De vaste plusknop staat buiten de scrollende kolommen. Geef de header en body daarom een echte + * afsluitende strook; padding op de scrollcontainer zelf schoof uitsluitend het maximale + * scrollbereik op en liet de knop bij scrollLeft=0 alsnog bovenop de laatste kolomtitel liggen. */ +.task-grid-header-row, +.task-grid-body { + box-sizing: content-box; + padding-inline-end: var(--task-grid-chooser-strip, 16px); +} + +/* De gridheader is sticky binnen dezelfde scroller als de gevirtualiseerde rijen. Laat de verticale + * scrollbaan pas onder die header beginnen, zodat duim en track nooit door de kolomkop lopen. */ +.task-grid-core::-webkit-scrollbar-track:vertical { + margin-block-start: var(--task-grid-header-height); +} + +.task-grid-header-row { + position: sticky; + top: 0; + z-index: var(--z-sticky); + background: var(--theme-surface-alt); + box-shadow: inset 0 -1px 0 var(--theme-border); +} + +.task-grid-column-header, +.task-grid-cell { + min-width: 0; + box-sizing: border-box; + display: flex; + align-items: center; + border-inline-end: 1px solid var(--theme-border-light); + border-bottom: 1px solid var(--theme-border-light); + background: var(--theme-surface); +} + +.task-grid-column-header { + padding-inline: 8px 10px; + overflow: visible; + background: var(--theme-surface-alt); + color: var(--theme-text-muted); + font-family: var(--font-heading); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + user-select: none; +} + +.task-grid-column-label, +.task-grid-cell-content, +.task-grid-group-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Een editorpop-over is bewust breder/hoger dan zijn 28px-cel. De gewone celinhoud blijft + * ellipsized, maar de actieve editor mag niet door die wrapper worden afgeknipt: anders zijn + * dropdowns en chipknoppen wel in de DOM aanwezig maar fysiek niet aanklikbaar. + * Issue #89: de contentspan is een flex-item van de cel en kromp zonder `flex: 1 1 auto` tot de + * intrinsieke breedte van het invoerveld (~20 tekens), zodat de naameditor maar een deel van de + * kolom vulde. De editor hoort de volle resterende kolombreedte te krijgen; de naameditor staat + * daarbij genest in de inspringrij (`.full-task-grid-name`), vandaar de afstammelingsselector. */ +.task-grid-cell-content:has(.task-grid-editor-host) { + flex: 1 1 auto; + overflow: visible; +} + +.task-grid-cell { + position: relative; + padding-inline: 7px; + outline: none; +} + +.task-grid-cell[data-grid-selected="true"] { + background: color-mix(in srgb, var(--theme-accent) 12%, var(--theme-surface)); + box-shadow: inset 0 -1px 0 var(--theme-accent); +} + +.task-grid-cell[data-grid-active="true"] { + z-index: 4 !important; + box-shadow: inset 0 0 0 2px var(--theme-accent); +} + +.task-grid-cell[data-grid-readonly="true"]::after { + content: ""; + position: absolute; + inset-block-start: 3px; + inset-inline-end: 3px; + width: 4px; + height: 4px; + border-block-start: 1px solid var(--theme-text-muted); + border-inline-end: 1px solid var(--theme-text-muted); +} + +.task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.task-grid-cell[aria-invalid="true"] { + box-shadow: inset 0 0 0 2px var(--error); +} + +.task-grid-data-row[data-grid-dimmed="true"] .task-grid-cell-content { + opacity: 0.58; +} + +/* Browserreview, observatie 4: de rij-indicators (selectiebalk, drop-zone tijdens rijsleep) waren + * tot nu toe een `box-shadow: inset` op de RIJ zelf — maar een box-shadow van een element schildert + * ALTIJD vóór zijn eigen kinderen (de schilderorde is achtergrond/rand/box-shadow, dan pas + * children), en elke `.task-grid-cell` heeft een eigen ONdoorzichtige achtergrond. Onder elke kolom + * met inhoud (dus praktisch de hele tabelbreedte) werd de balk/lijn daardoor volledig overschilderd + * door de cellen erboven — zichtbaar bleef alleen het smalle restje rijbreedte VOORBIJ de laatste + * kolom. Twee bijna-onzichtbare lijnen die daar toevallig samenvallen (bv. "after" van rij N en + * "before" van rij N+1, beide maar 2px uit elkaar) konden zo als "twee oranje lijnen" ogen, en de + * selectiebalk op de gesleepte rij zelf was al net zo onzichtbaar. + * + * Fix: de indicator is nu een `::before`-pseudo-element, `position:absolute` op de rij (die + * daarvoor `position:relative` krijgt) met `z-index` boven de hoogste celwaarde (actieve cel = 4 + * `!important`) — een pseudo-element ligt zo als een eigen laag BOVEN alle cellen, ongeacht wat + * daaronder staat. `pointer-events:none` houdt 'm buiten alle hit-testing (klikken/slepen blijven + * op de cellen zelf werken). */ +.task-grid-data-row { + position: relative; +} + +.task-grid-data-row[data-grid-row-selected="true"] .task-grid-cell { + background: color-mix(in srgb, var(--theme-accent) 7%, var(--theme-surface)); +} + +.task-grid-data-row[data-grid-dragging="true"] { + opacity: 0.56; +} + +.task-grid-data-row[data-grid-row-selected="true"]::before, +.task-grid-data-row[data-grid-drop-zone]::before { + content: ""; + position: absolute; + inset: 0; + z-index: 6; + pointer-events: none; +} + +.task-grid-data-row[data-grid-row-selected="true"]::before { + box-shadow: inset 3px 0 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="before"]::before { + box-shadow: inset 0 2px 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="after"]::before { + box-shadow: inset 0 -2px 0 var(--theme-accent); +} + +.task-grid-data-row[data-grid-drop-zone="nest"]::before { + box-shadow: inset 0 0 0 2px var(--theme-accent); +} + +.task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { + opacity: 0.25; +} + +.task-grid-data-row.task-grid-trace-focus { + outline: 2px solid var(--theme-accent); + outline-offset: -2px; +} + +.task-grid-data-row.task-grid-trace-predecessor, +.task-grid-data-row.task-grid-trace-predecessor-driving { + border-inline-start: 3px solid var(--trace-predecessor, #F59E0B); +} + +.task-grid-data-row.task-grid-trace-successor, +.task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start: 3px dashed var(--trace-successor, #A78BFA); +} + +.task-grid-data-row.task-grid-trace-predecessor-driving, +.task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start-width: 5px; + font-weight: 600; +} + +@media (forced-colors: active) { + .task-grid-data-row.task-grid-trace-dimmed .task-grid-cell-content { + opacity: 1; + text-decoration: line-through; + text-decoration-style: dotted; + } + + .task-grid-data-row.task-grid-trace-dimmed { + border-inline-start: 2px dotted GrayText; + } + + .task-grid-data-row.task-grid-trace-focus { + outline-color: Highlight; + } + + .task-grid-data-row.task-grid-trace-predecessor, + .task-grid-data-row.task-grid-trace-predecessor-driving { + border-inline-start-color: Highlight; + border-inline-start-style: solid; + } + + .task-grid-data-row.task-grid-trace-successor, + .task-grid-data-row.task-grid-trace-successor-driving { + border-inline-start-color: Highlight; + border-inline-start-style: dashed; + } + + .task-grid-cell[data-grid-selected="true"] { + background: Highlight; + color: HighlightText; + box-shadow: inset 0 -2px 0 HighlightText; + } + + .task-grid-cell[data-grid-active="true"] { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + .task-grid-cell[data-grid-readonly="true"]::after { + border-color: GrayText; + border-style: dotted; + } + + .task-grid-cell[data-grid-stale="true"] .task-grid-cell-content { + text-decoration: underline dotted; + } + + .task-grid-cell[aria-invalid="true"] { + outline: 3px double Mark; + outline-offset: -3px; + } + + .task-grid-relation-chip--driving { + font-weight: 700; + text-decoration: underline double; + } + + .task-grid-relation-chip--warning { + text-decoration: underline wavy; + } +} + +.task-grid-group-row, +.task-grid-group-cell { + box-sizing: border-box; + background: var(--theme-surface-alt); + border-bottom: 1px solid var(--theme-border); +} + +.full-task-grid { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.full-task-grid > .task-grid-shell { + width: 100%; + height: 100%; +} + +.gantt-workspace { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto auto; + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-workspace-grid, +.gantt-workspace-timeline { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-workspace-grid { + display: flex; + grid-column: 1; + grid-row: 1; +} + +.gantt-workspace-timeline { + display: flex; + grid-column: 2; + grid-row: 1; +} + +.gantt-workspace-histogram { + display: flex; + flex-direction: column; + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; +} + +.gantt-workspace-minimap { + display: flex; + grid-column: 2; + grid-row: 3; + min-width: 0; +} + +.gantt-workspace-splitter { + grid-column: 1; + grid-row: 1; + align-self: stretch; + justify-self: end; + position: relative; + /* De volledige 5px-grijpzone ligt aan de Gantt-kant. Gecentreerd op de paneelgrens bedekte deze + * anders 2,5px van de 8px brede verticale tabelscrollbar. */ + transform: translateX(100%); + z-index: 6; + width: 5px; + min-width: 5px; + padding: 0; + border: 0; + border-inline-start: 1px solid var(--theme-border); + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.gantt-workspace-splitter:hover, +.gantt-workspace-splitter[data-resizing="true"], +.gantt-workspace-splitter:focus-visible { + background: transparent; + outline: none; +} + +.gantt-task-grid { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.gantt-task-grid > .task-grid-shell { + width: 100%; + height: 100%; +} + +/* Issue #89: de subtaak-plus staat ná de naam en wordt met `margin-inline-start: auto` tegen + * de rechter kolomrand gedrukt, zodat hij op elke samenvattende rij op dezelfde plek staat en + * de naamtekst links de hiërarchie kan laten zien. De lichte rand maakt hem als knop herkenbaar. */ +.gantt-task-grid-add-child { + flex: none; + margin-inline-start: auto; + /* Vrij van de kolomrand (issue #89, punt 4 vroeg juist om lucht rond zo'n knop). */ + margin-inline-end: 6px; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid var(--theme-border-light); + border-radius: 3px; + color: var(--theme-text-muted); + background: transparent; + line-height: 1; +} + +.gantt-task-grid-add-child:hover, +.gantt-task-grid-add-child:focus-visible { + color: var(--theme-accent); + border-color: var(--theme-accent); + background: var(--theme-hover); + outline: none; +} + +/* Vervolgmelding op observatie 3a/3b (probleem B): was `display: inline-flex` — een INLINE box + * sizet zich (shrink-to-fit) naar zijn EIGEN inhoud (triehoekje + tekst), niet naar de beschikbare + * breedte van zijn ouder (`.task-grid-cell-content`, block, dus geen flex-context die deze rij zou + * dwingen te krimpen). `min-width:0`/`overflow:hidden` op deze laag of de tekst-span erin doen dan + * NIETS — die eigenschappen laten iets krimpen ALS iets anders het beperkt, maar niets deed dat + * hier. `display: flex` (blokniveau) vult i.p.v. dat de beschikbare breedte van de ouder, wat de + * tekst-span (`.full-task-grid-name-label`, `min-width:0`) een ECHTE beperking geeft om binnen te + * krimpen — pas dan werken overflow:hidden/text-overflow:ellipsis daarop zoals bedoeld. */ +.full-task-grid-name { + min-width: 0; + display: flex; + align-items: center; + /* Issue #89: triehoekje (16px) + deze gap (4px) = één inspringeenheid van 20px, dezelfde + * TASK_NAME_INDENT_UNIT als in src/engine/taskGrid/nameIndent.ts. */ + gap: 4px; +} + +/* Discussion #97: samenvattingen (containertaken) en mijlpalen zijn nu alleen via inspringing te + * onderscheiden van een gewone taak. `data-grid-task-kind` (gezet in FullTaskGrid.tsx, weggelaten + * tijdens naam-editing) stuurt hier vet + een subtiele achtergrondtint voor samenvattingen, en + * vet + de mijlpaalkleur voor mijlpalen — bewust dezelfde kleurfamilie als de Gantt-balk + * (`--color-task-milestone`), niet de rode mockupkleur uit de discussion. De tint zit op de hele + * naamrij (niet alleen de tekst), zodat hij als rijvlak leesbaar is; de selectiekleur van de rij + * (`.task-grid-data-row[data-grid-row-selected="true"]`) wint hier altijd van, dus geen eigen + * z-index-gedoe nodig — de tint zit hieronder in de laagvolgorde. */ +.full-task-grid-name[data-grid-task-kind="summary"] { + background: var(--theme-grid-summary-bg); +} + +.full-task-grid-name[data-grid-task-kind="summary"] .full-task-grid-name-label { + font-weight: 600; +} + +.full-task-grid-name[data-grid-task-kind="milestone"] .full-task-grid-name-label { + font-weight: 600; + color: var(--theme-grid-milestone-text); +} + +/* Geselecteerde rij wint van de samenvattingstint (leesbaarheid op de selectiekleur staat voorop); + * de vette/gekleurde tekst blijft intact — alleen de achtergrond van de naamrij vervalt. */ +.task-grid-data-row[data-grid-row-selected="true"] .full-task-grid-name[data-grid-task-kind="summary"] { + background: transparent; +} + +/* De naameditor vult de rij vanaf de tekstpositie tot de rechter kolomrand. */ +.full-task-grid-name > .task-grid-editor-host { + flex: 1 1 auto; + min-width: 0; +} + +/* De algemene celpadding liet rechts van een taaknaam zeven ongebruikte pixels liggen: genoeg voor + * nog een letter vóór de ellipsis. Links blijft de normale inspringruimte intact. */ +.task-grid-cell:has(.full-task-grid-name) { + padding-inline-end: 1px; +} + +/* Issue #89: de contentspan is een flex-item van de cel en kromp tot zijn inhoud, zodat de + * rechts uitgelijnde subtaak-plus (`margin-inline-start: auto`) nooit verder kwam dan het einde + * van de naamtekst. De naamcel laat zijn contentspan daarom de hele celbreedte vullen; de + * naamtekst zelf blijft via `.full-task-grid-name-label` krimpen en afknippen. */ +.task-grid-cell:has(.full-task-grid-name) > .task-grid-cell-content { + flex: 1 1 auto; +} + +/* Vervolgmelding op observatie 3a/3b (probleem B): `.full-task-grid-name` is zelf een + * `inline-flex`-rij (in-/uitklaptriehoekje + evt. "+"-knop + de tekst) zonder eigen + * overflow/text-overflow — `text-overflow: ellipsis` op de vooroudercontainer + * (`.task-grid-cell-content`) kan de afknipplek niet correct berekenen zodra de tekst genest zit + * in een EIGEN flex-container in plaats van een rechtstreeks tekstkind: de ellips ("…") werd + * daardoor nooit getoond, en de daadwerkelijke afknip leunde uitsluitend op de + * `overflow: hidden` van die voorouder — geen eigen begrenzing op déze laag. Bij smalle kolommen + * bleef de layout-doos van `.full-task-grid-name` daardoor BREDER dan zijn eigen ouder (gemeten: + * tot 30px breder dan `.task-grid-cell-content`), puur toevallig nog steeds geklemd door die + * voorouder — een aanname die breekt zodra iets in die keten verandert. Deze klasse geeft de + * TEKST zelf (niet de rij eromheen) zijn eigen `min-width:0` (kan binnen de flex-rij krimpen, + * ondanks het vaste-breedte triehoekje ernaast) plus `overflow:hidden`/`text-overflow:ellipsis` — + * de clip zit nu op de plek waar de tekst zelf staat, niet alleen bij de voorouder. */ +.full-task-grid-name-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.full-task-grid-disclosure { + flex: 0 0 16px; + width: 16px; + height: 18px; + padding: 0; + border: 0; + color: var(--theme-text-muted); + background: transparent; + line-height: 1; +} + +.full-task-grid-error { + position: absolute; + inset-inline: 8px; + inset-block-end: 8px; + z-index: 8; + padding: 6px 9px; + border: 1px solid color-mix(in srgb, var(--error) 55%, var(--theme-border)); + border-radius: 6px; + color: var(--error); + background: var(--theme-surface); + box-shadow: var(--shadow-pop); + font-size: 12px; +} + +.task-grid-group-cell { + display: flex; + align-items: center; +} + +.task-grid-group-toggle { + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 6px; + padding-inline: 0 8px; + color: inherit; + background: transparent; + border: 0; + font: inherit; + font-weight: 600; + text-align: start; +} + +.task-grid-group-toggle:focus-visible, +.task-grid-resize-handle:focus-visible { + outline: 2px solid var(--theme-accent); + outline-offset: -2px; +} + +.task-grid-group-count { + flex: none; + color: var(--theme-text-muted); + font-family: var(--font-code); + font-size: 10px; +} + +/* Browserreview, observatie 7 (grijpzone-kant): was `inset-inline-end: -4px; width: 8px` — een + * band van 4px aan WEERSZIJDEN van de kolomgrens. `beginResize`'s `onPointerDown` roept + * `event.preventDefault()` aan, wat de NATIEVE HTML5-slepginitiatie onderdrukt (preventDefault op + * pointerdown/mousedown is precies het mechanisme waarmee je dragstart voorkomt) — een poging om + * een kolom te HERORDENEN door 'm net bij de rand vast te pakken (een natuurlijke plek om te + * grijpen) landde zo op de resize-greep i.p.v. de herordenbare kopruimte, en startte dan een resize + * (of niets) in plaats van een sleep. Bij een kolom van de minimumbreedte (40px) at 8px band 20% van + * de breedte in beslag — en ELKE kolom draagt ook nog eens 2px overhang van de greep van zijn linker + * buur. Smaller gemaakt (4px totaal i.p.v. 8px) zodat er meer ruimte overblijft voor herordenen; de + * greep blijft een gangbare resize-hitbox (vergelijkbaar met Excel/Sheets, die ook een paar pixels + * breed zijn, geen 8). + */ +.task-grid-resize-handle { + position: absolute; + inset-block: 0; + inset-inline-end: -2px; + z-index: 2; + width: 4px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.task-grid-resize-handle:hover { + background: color-mix(in srgb, var(--theme-accent) 38%, transparent); +} + +.task-grid-shell { + position: relative; + width: fit-content; + max-width: 100%; + /* Eén bron voor de "kolom toevoegen"-strook: een 20px-knop met 6px lucht aan weerszijden, zodat + * hij vrijstaat van de laatste kolominhoud én van de rand van het kader (issue #89). */ + --task-grid-chooser-strip: 32px; +} + +.task-grid-column-header[data-grid-dragging="true"] { + opacity: 0.5; +} + +.task-grid-column-header[data-grid-drop-before="true"]::before, +.task-grid-column-header[data-grid-drop-after="true"]::after { + content: ""; + position: absolute; + z-index: 4; + inset-block: 0; + width: 2px; + background: var(--theme-accent); +} + +.task-grid-column-header[data-grid-drop-before="true"]::before { + inset-inline-start: -1px; +} + +.task-grid-column-header[data-grid-drop-after="true"]::after { + inset-inline-end: -1px; +} + +.task-grid-remove-column { + position: absolute; + inset-block-start: 50%; + inset-inline-end: 7px; + width: 17px; + height: 17px; + display: grid; + place-items: center; + padding: 0; + color: var(--theme-text-muted); + background: var(--theme-surface-alt); + border: 1px solid transparent; + border-radius: 3px; + opacity: 0; + transform: translateY(-50%); +} + +.task-grid-column-header:hover .task-grid-remove-column, +.task-grid-column-header:focus-within .task-grid-remove-column { + opacity: 1; +} + +.task-grid-remove-column:hover, +.task-grid-remove-column:focus-visible { + color: var(--theme-text); + border-color: var(--theme-border); + outline: none; +} + +.task-grid-column-chooser-anchor { + position: absolute; + z-index: calc(var(--z-sticky) + 1); + inset-block-start: 0; + inset-inline-end: 0; + width: var(--task-grid-chooser-strip, 32px); + height: var(--task-grid-header-height); + display: flex; + align-items: center; + justify-content: center; + padding-inline: 6px; + background: var(--theme-surface-alt); +} + +.task-grid-add-column { + width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0; + color: var(--theme-text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 3px; +} + +.task-grid-add-column:hover, +.task-grid-add-column:focus-visible, +.task-grid-add-column[aria-expanded="true"] { + color: var(--theme-text); + border-color: var(--theme-border); + background: var(--theme-hover); + outline: none; +} + +.task-grid-no-columns { + position: absolute; + inset-block-start: var(--task-grid-header-height); + inset-inline: 0; + min-height: 28px; + display: flex; + align-items: center; + padding-inline: 10px 40px; + color: var(--theme-text-muted); + font-size: 11px; + pointer-events: none; +} + +.task-grid-column-chooser { + z-index: var(--z-contextmenu); + width: min(310px, calc(100vw - 16px)); + overflow-y: auto; + padding: 7px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: 6px; + box-shadow: var(--shadow-pop); + font-family: var(--font-body); + font-size: 11px; +} + +.task-grid-column-chooser-title { + padding: 3px 5px 7px; + font-family: var(--font-heading); + font-size: 12px; + font-weight: 700; +} + +.task-grid-column-chooser-section-label { + padding: 7px 5px 3px; + color: var(--theme-text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.task-grid-column-chooser-list { + display: grid; +} + +.task-grid-column-chooser-item, +.task-grid-column-chooser-category { + width: 100%; + min-height: 28px; + display: flex; + align-items: center; + gap: 7px; + padding: 5px 7px; + color: var(--theme-text); + background: transparent; + border: 0; + border-radius: 3px; + font: inherit; + text-align: start; +} + +.task-grid-column-chooser-item:hover:not(:disabled), +.task-grid-column-chooser-item:focus-visible, +.task-grid-column-chooser-category:hover, +.task-grid-column-chooser-category:focus-visible { + background: var(--theme-hover); + outline: none; +} + +.task-grid-column-chooser-item:disabled { + color: var(--theme-text-muted); + cursor: default; +} + +.task-grid-column-chooser-check { + width: 13px; + flex: none; +} + +.task-grid-column-chooser-search { + height: 30px; + display: flex; + align-items: center; + gap: 6px; + margin: 5px 2px; + padding-inline: 7px; + color: var(--theme-text-muted); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 4px; +} + +.task-grid-column-chooser-search:focus-within { + border-color: var(--theme-accent); +} + +.task-grid-column-chooser-search input { + min-width: 0; + flex: 1; + color: var(--theme-text); + background: transparent; + border: 0; + outline: none; + font: inherit; +} + +.task-grid-column-chooser-categories { + margin-block-start: 5px; + padding-block-start: 4px; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-column-chooser-category { + font-weight: 600; +} + +.task-grid-column-chooser-category-count { + margin-inline-start: auto; + color: var(--theme-text-muted); + font-family: var(--font-code); + font-size: 10px; +} + +.task-grid-column-chooser-category svg[data-expanded="true"] { + transform: rotate(180deg); +} + +.task-grid-column-chooser-empty { + padding: 6px 7px; + color: var(--theme-text-muted); +} + +.task-grid-editor-host { + position: relative; + width: 100%; + height: 100%; +} + +.task-grid-editor-input { + width: 100%; + height: 100%; + min-width: 0; + padding-inline: 5px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-accent); + font: inherit; +} + +.task-grid-date-editor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + height: 100%; +} + +.task-grid-date-editor .task-grid-editor-input { + border-inline-end: 0; +} + +.task-grid-date-picker { + width: 2.25rem; + min-width: 0; + height: 100%; + padding: 0 2px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-accent); + font: inherit; +} + +.task-grid-assignment-editor { + position: absolute; + z-index: var(--z-dropdown); + inset-block-start: 0; + inset-inline-start: 0; + display: grid; + gap: 5px; + width: max(100%, 420px); + max-width: min(560px, 80vw); + padding: 6px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-accent); + box-shadow: var(--shadow-pop); +} + +.task-grid-assignment-tokens { + display: grid; + gap: 4px; +} + +.task-grid-assignment-token { + display: grid; + grid-template-columns: minmax(110px, 1fr) 76px 132px 26px; + gap: 4px; + align-items: center; +} + +.task-grid-assignment-resource { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-grid-assignment-token input, +.task-grid-assignment-token select, +.task-grid-assignment-editor > input, +.task-grid-assignment-token button, +.task-grid-assignment-options button { + min-width: 0; + height: 26px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 3px; + font: inherit; +} + +.task-grid-assignment-token input, +.task-grid-assignment-editor > input { + padding-inline: 5px; +} + +.task-grid-assignment-token button, +.task-grid-assignment-options button { + cursor: pointer; +} + +.task-grid-assignment-token button:hover, +.task-grid-assignment-options button:hover { + background: var(--theme-hover); +} + +.task-grid-assignment-options { + display: grid; + max-height: 180px; + overflow: auto; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-assignment-options button { + padding-inline: 6px; + text-align: start; + border: 0; + border-radius: 0; +} + +/* Browserreview, observatie 6 (tweede plek met dezelfde afwijking): dit paneel portaalt naar + * document.body (RelationCellEditor.tsx) zonder eigen `font-size` — zonder deze regel erven al zijn + * kinderen (chips, tokens, invoervelden — geen van allen zet zelf een font-size) html/body's volle + * `calc(13px * --ui-font-scale)` i.p.v. de compacte roosterdichtheid. `.task-grid-column-chooser` + * (dezelfde soort portal-popup) had dit al goed; hier hetzelfde patroon toegepast. */ +.task-grid-relation-editor { + position: fixed; + z-index: var(--z-dropdown); + display: grid; + gap: 5px; + max-width: min(680px, 85vw); + max-height: calc(100vh - 16px); + overflow: auto; + padding: 6px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-accent); + box-shadow: var(--shadow-pop); + font-family: var(--font-body); + font-size: 11px; +} + +.task-grid-relation-editor-anchor { + position: absolute; + inset: 0; + pointer-events: none; +} + +.task-grid-relation-cell { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-grid-relation-chip { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.task-grid-relation-jump { + min-width: 0; + padding: 0; + overflow: hidden; + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-overflow: ellipsis; + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; +} + +.task-grid-relation-icon { + display: inline-flex; + align-items: center; + margin-inline-start: 2px; +} + +/* Issue #94: één kleur per chip (referentie, type/lag en het sturend-icoon), gestuurd door de + * richting op de chip zelf — niet meer alleen het referentiedeel accentkleur, de rest tekstkleur. + * Sturend blijft zichtbaar als de STERKERE tint van diezelfde rolkleur, niet als los "wel/geen + * kleur"; het warning-icoon blijft altijd zijn eigen --warning-kleur houden. */ +.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-jump, +.task-grid-relation-chip[data-relation-direction="predecessor"] .task-grid-relation-detail { + color: var(--relation-predecessor); +} + +.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-jump, +.task-grid-relation-chip[data-relation-direction="successor"] .task-grid-relation-detail { + color: var(--relation-successor); +} + +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-jump, +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-detail, +.task-grid-relation-chip--driving[data-relation-direction="predecessor"] .task-grid-relation-icon--driving > svg { + color: var(--relation-predecessor-driving); + font-weight: 700; +} + +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-jump, +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-detail, +.task-grid-relation-chip--driving[data-relation-direction="successor"] .task-grid-relation-icon--driving > svg { + color: var(--relation-successor-driving); + font-weight: 700; +} + +.task-grid-relation-chip--warning .task-grid-relation-icon--warning > svg { + color: var(--warning, #d97706); +} + +.task-grid-relation-separator { + color: var(--theme-text-muted); +} + +.task-grid-relation-tokens, +.task-grid-relation-options { + display: grid; + gap: 4px; +} + +.task-grid-relation-token { + display: grid; + grid-template-columns: minmax(180px, 1fr) 64px 86px 26px; + gap: 4px; + align-items: center; +} + +.task-grid-relation-reference { + min-width: 0; + overflow: hidden; + color: inherit; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +button.task-grid-relation-reference { + padding: 0; + color: var(--theme-accent); + background: transparent; + border: 0; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.task-grid-relation-token input, +.task-grid-relation-token select, +.task-grid-relation-token > button:not(.task-grid-relation-reference), +.task-grid-relation-add input, +.task-grid-relation-add button, +.task-grid-relation-options button { + min-width: 0; + height: 26px; + color: var(--theme-text); + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: 3px; + font: inherit; +} + +.task-grid-relation-token input, +.task-grid-relation-add input { + padding-inline: 5px; +} + +.task-grid-relation-add { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto; + gap: 4px; +} + +.task-grid-relation-add button, +.task-grid-relation-token > button:not(.task-grid-relation-reference), +.task-grid-relation-options button { + cursor: pointer; +} + +.task-grid-relation-options { + max-height: 180px; + overflow: auto; + border-block-start: 1px solid var(--theme-border-light); +} + +.task-grid-relation-options button { + padding-inline: 6px; + text-align: start; + border: 0; + border-radius: 0; +} + +.task-grid-relation-add button:hover, +.task-grid-relation-token > button:hover, +.task-grid-relation-options button:hover { + background: var(--theme-hover); +} + +.task-grid-relation-context { + position: fixed; + z-index: var(--z-dropdown); + display: grid; + min-width: 190px; + padding-block: 4px; + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: 5px; + box-shadow: var(--shadow-pop); +} + +.task-grid-relation-context button { + padding: 6px 10px; + color: var(--theme-text); + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + text-align: start; +} + +.task-grid-relation-context button:hover { + background: var(--theme-hover); +} + +.task-grid-relation-context .task-grid-relation-context-danger { + color: var(--error); +} + +.task-grid-editor-error { + position: absolute; + z-index: var(--z-dropdown); + inset-block-start: 100%; + inset-inline-start: 0; + max-width: 320px; + padding: 5px 7px; + color: var(--theme-text); + background: var(--theme-surface-elevated); + border: 1px solid var(--error); + box-shadow: var(--shadow-pop); + font-size: 11px; + white-space: normal; +} + +/* ============================================================================ + OpenAEC Foundation — Design Tokens + Source: OpenAEC-style-book/brandbook/DESIGN-SYSTEM.md §2 + LAYOUTS.md §0/§4.1 + These are theme-independent primitives. Theme-specific tokens (--theme-*) + reference these and are defined per [data-theme] block below. + ============================================================================ */ +:root { + /* Brand — primary */ + --deep-forge: #36363E; + + /* Path tracing (issue #114): de rijmarkering in het taakraster gebruikt dezelfde tinten als de + Gantt-balken — GANTT_TRACE_COLORS in src/engine/renderer/themePalette.ts is de bron; + tests/planning/check-dependency-presentation.ts bewaakt dat beide gelijk blijven. */ + --trace-predecessor: #F59E0B; + --trace-successor: #A78BFA; + + /* Brand — secondary */ + --signal-orange: #EA580C; + --warm-gold: #F59E0B; + --scaffold-gray: #A1A1AA; + + /* Backgrounds */ + --blueprint-white: #FAFAF9; + --concrete: #F5F5F4; + --night-build: #2A2A32; + + /* Semantic */ + --success: #16A34A; + --error: #DC2626; + --info: #2563EB; + --warning: #F59E0B; + + /* Dashboard / dense UI tokens (DESIGN-SYSTEM.md §2.5) */ + --dashboard-bg: #2A2A32; + --dashboard-surface: #36363E; + --dashboard-surface-hover: #3E3E48; + --dashboard-sidebar: #222228; + --dashboard-border: #4A4A54; + --dashboard-border-light: #404048; + --dashboard-text: #FAFAF9; + --dashboard-text-muted: #A1A1AA; + --dashboard-text-dim: #666670; + --dashboard-accent: #D97706; + --dashboard-accent-bg: #2E2418; + --dashboard-accent-hover: #EA580C; + --dashboard-terminal: #F59E0B; + --dashboard-terminal-dim: #D97706; + + /* Typography families */ + --font-heading: "Space Grotesk", system-ui, sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-code: "JetBrains Mono", monospace; + + /* Spacing scale (4px base) */ + --sp-1: 0.25rem; /* 4px */ + --sp-2: 0.5rem; /* 8px */ + --sp-3: 0.75rem; /* 12px */ + --sp-4: 1rem; /* 16px */ + --sp-5: 1.25rem; /* 20px */ + --sp-6: 1.5rem; /* 24px */ + --sp-8: 2rem; /* 32px */ + --sp-10: 2.5rem; /* 40px */ + --sp-12: 3rem; /* 48px */ + --sp-16: 4rem; /* 64px */ + --sp-20: 5rem; /* 80px */ + --sp-24: 6rem; /* 96px */ + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 14px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); + + /* Z-index scale (LAYOUTS.md §0.3) */ + --z-base: 0; + --z-dropdown: 10; + --z-sticky: 20; + --z-sidebar: 30; + --z-navbar: 40; + --z-modal-backdrop: 50; + --z-modal: 60; + --z-toast: 70; + --z-tooltip: 80; + --z-contextmenu: 90; + + /* Layout sizes (LAYOUTS.md §4.1) */ + --titlebar-height: 32px; + --toolbar-height: 40px; + --statusbar-height: 24px; + --sidebar-width: 260px; + --inspector-width: 280px; +} + +/* ============================================================================ + Theme CSS Variables + ============================================================================ */ + +@theme { + --color-surface: var(--theme-surface); + --color-surface-alt: var(--theme-surface-alt); + --color-surface-hover: var(--theme-hover); + --color-border: var(--theme-border); + --color-text-primary: var(--theme-text); + --color-text-secondary: var(--theme-text-dim); + --color-accent: var(--theme-accent); + --color-accent-hover: var(--theme-accent-hover); + --color-accent-on: var(--theme-accent-on); + /* U2 — ÉÉN balkpalet voor licht én donker (besluit eigenaar). Elke waarde haalt >=3:1 tegen + ZOWEL de lichte kaart (#FAFAFA) als de donkere kaart (#2E3239); vroeger waren de balken op + merk-verzadiging afgestemd op wit en haalden ze op de donkere kaart 1.9-2.7:1. Eén stap minder + verzadigd, zelfde tintfamilie, zodat legenda, gidsen en rapport dezelfde taal blijven spreken. + Gemeten (WCAG 2.x relatieve luminantie), licht / donker / wit-label-op-balk: + critical #DA5252 3.79 / 3.25 / 3.95 + normal #648BE0 3.19 / 3.87 / 3.33 + complete #5778D6 3.97 / 3.10 / 4.15 + milestone #986DE2 3.58 / 3.45 / 3.74 + baseline #808694 3.50 / 3.53 / 3.65 + float #1E976F 3.52 / 3.50 / 3.68 + LET OP: dezelfde zes waarden staan als merk-hex in `src/engine/renderer/themePalette.ts` + (BRAND/`--theme-bar-float`-fallback) — die tekenlaag leest geen CSS in headless tests. */ + --color-critical: #DA5252; + --color-task-normal: #648BE0; + --color-task-milestone: #986DE2; + --color-task-float: #1E976F; + --color-task-baseline: #808694; + --color-task-complete: #5778D6; +} + +/* Dark theme — OpenAEC default + Construction Amber on Deep Forge. Dashboard tokens uit DESIGN-SYSTEM.md §2.5. */ +:root, +[data-theme="dark"] { + /* Koeler charcoal + Soft Depth. Werkruimte-tint (--theme-bg) is donkerder dan + de kaarten (--theme-surface) zodat panelen "zweven". */ + --theme-bg: #1B1D22; + --theme-surface: #2E3239; + --theme-surface-alt: #23262D; + --theme-surface-elevated: #363B43; + --theme-border: #3A3F48; + --theme-border-light: #33373F; + /* Rand van inputs/selects/knoppen — zichtbaarder dan kaartrand (~3:1 op de card). */ + --theme-control-border: #4A515C; + --theme-accent: #D97706; + --theme-accent-hover: #EA580C; + /* Tekstkleur op accent-achtergronden (wit in light/dark, zwart in high-contrast). */ + --theme-accent-on: #ffffff; + --theme-text: #F1F3F5; + --theme-text-dim: #B6BCC6; + --theme-text-muted: #7C8492; + --theme-hover: rgba(255, 255, 255, 0.06); + --theme-active: #D97706; + --theme-active-border: #EA580C; + --theme-warning-text: #F59E0B; /* Semantische tekstkleuren, donkere variant — LET OP: dit blok is `:root, [data-theme="dark"]`, dus dit is de basis en het lichte thema zet verderop zijn eigen, verzadigde waarden terug. De oorspronkelijke waarden waren op wit gekozen: als TEKST op een donkere kaart haalt #DC2626 @@ -1314,46 +1314,46 @@ button.task-grid-relation-reference { /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): dekt hele weken (5+ kolommen aaneen), dus bewust nog subtieler dan de per-dag weekend-arcering hierboven. */ --theme-grid-week-band: rgba(255, 255, 255, 0.028); - /* Discussion #97: subtiele achtergrondtint voor samenvattingsrijen in het taakraster (naamkolom) - — dezelfde alpha-aanpak als de weekendarcering hierboven, zodat het geen eigen kleurfamilie - introduceert. Mijlpalen krijgen géén tint, alleen vette tekst in een lichtere paarstint - (beter leesbaar op donkere kaarten dan --color-task-milestone zelf). */ - --theme-grid-summary-bg: rgba(255, 255, 255, 0.045); - --theme-grid-milestone-text: #A78BFA; - /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ - --theme-bar-float: #1E976F; - --theme-critical-text: #F87171; - --theme-input-bg: #23262D; - --theme-dropdown-bg: #2E3239; - --theme-scrollbar-track: #23262D; - --theme-scrollbar-thumb: #454B55; - /* Effen vlakken i.p.v. gradients ("geen rare gradients"). */ - --theme-ribbon-bg: #2A2D34; - --theme-ribbon-tab-bg: #23262D; - --theme-ribbon-content-bg: #2A2D34; - --theme-file-tab-bg: #D97706; - --theme-file-tab-hover: #EA580C; - --theme-titlebar-bg: #2A2D34; - --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.07); - --theme-titlebar-btn-active: rgba(255, 255, 255, 0.12); - /* Soft Depth — schaduw-tokens. */ - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.30), 0 6px 18px rgba(0, 0, 0, 0.35); - --shadow-pop: 0 10px 30px rgba(0, 0, 0, 0.50); - --shadow-glow: 0 2px 10px rgba(217, 119, 6, 0.40); - - /* Issue #94: rolkleuren voor Voorgangers/Opvolgers in het taakraster — voorganger volgt de - bestaande goud/amber-familie van GANTT_TRACE_COLORS, opvolger de paars/violet-familie - (zelfde conventie als de rolstip in TaskDependenciesSection). "Driving" is bewust de - STERKERE tint van diezelfde rolkleur, niet een andere kleur: op dit donkere thema betekent - sterker = lichter (op een lichte achtergrond zou het juist donkerder zijn). */ - --relation-predecessor: #F59E0B; - --relation-predecessor-driving: #FCD34D; - --relation-successor: #A78BFA; - --relation-successor-driving: #C4B5FD; - - color-scheme: dark; -} - + /* Discussion #97: subtiele achtergrondtint voor samenvattingsrijen in het taakraster (naamkolom) + — dezelfde alpha-aanpak als de weekendarcering hierboven, zodat het geen eigen kleurfamilie + introduceert. Mijlpalen krijgen géén tint, alleen vette tekst in een lichtere paarstint + (beter leesbaar op donkere kaarten dan --color-task-milestone zelf). */ + --theme-grid-summary-bg: rgba(255, 255, 255, 0.045); + --theme-grid-milestone-text: #A78BFA; + /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ + --theme-bar-float: #1E976F; + --theme-critical-text: #F87171; + --theme-input-bg: #23262D; + --theme-dropdown-bg: #2E3239; + --theme-scrollbar-track: #23262D; + --theme-scrollbar-thumb: #454B55; + /* Effen vlakken i.p.v. gradients ("geen rare gradients"). */ + --theme-ribbon-bg: #2A2D34; + --theme-ribbon-tab-bg: #23262D; + --theme-ribbon-content-bg: #2A2D34; + --theme-file-tab-bg: #D97706; + --theme-file-tab-hover: #EA580C; + --theme-titlebar-bg: #2A2D34; + --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.07); + --theme-titlebar-btn-active: rgba(255, 255, 255, 0.12); + /* Soft Depth — schaduw-tokens. */ + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.30), 0 6px 18px rgba(0, 0, 0, 0.35); + --shadow-pop: 0 10px 30px rgba(0, 0, 0, 0.50); + --shadow-glow: 0 2px 10px rgba(217, 119, 6, 0.40); + + /* Issue #94: rolkleuren voor Voorgangers/Opvolgers in het taakraster — voorganger volgt de + bestaande goud/amber-familie van GANTT_TRACE_COLORS, opvolger de paars/violet-familie + (zelfde conventie als de rolstip in TaskDependenciesSection). "Driving" is bewust de + STERKERE tint van diezelfde rolkleur, niet een andere kleur: op dit donkere thema betekent + sterker = lichter (op een lichte achtergrond zou het juist donkerder zijn). */ + --relation-predecessor: #F59E0B; + --relation-predecessor-driving: #FCD34D; + --relation-successor: #A78BFA; + --relation-successor-driving: #C4B5FD; + + color-scheme: dark; +} + /* Light theme — OpenAEC light variant Blueprint White-kaarten op een neutraal-grijze werkruimte, Deep Forge-tekst, Amber accent (zelfde als dark — accent kleuren wisselen niet tussen modes). @@ -1405,13 +1405,13 @@ button.task-grid-relation-reference { /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): subtieler dan de weekend- arcering, want hij bedekt hele weken i.p.v. losse dagkolommen. */ --theme-grid-week-band: rgba(54, 54, 62, 0.03); - /* Discussion #97: zelfde neutrale-grijsschaal-aanpak als de rest van dit lichte thema — een - alpha-tint van Deep Forge, geen eigen kleur. Mijlpaaltekst gaat iets dieper dan - --color-task-milestone (#7C3AED) voor voldoende contrast op de kaart (AA). */ - --theme-grid-summary-bg: rgba(54, 54, 62, 0.06); - --theme-grid-milestone-text: #6D28D9; - /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ - --theme-bar-float: #1E976F; + /* Discussion #97: zelfde neutrale-grijsschaal-aanpak als de rest van dit lichte thema — een + alpha-tint van Deep Forge, geen eigen kleur. Mijlpaaltekst gaat iets dieper dan + --color-task-milestone (#7C3AED) voor voldoende contrast op de kaart (AA). */ + --theme-grid-summary-bg: rgba(54, 54, 62, 0.06); + --theme-grid-milestone-text: #6D28D9; + /* U2: één balkpalet — gelijk aan --color-task-float, geen thema-eigen groen meer. */ + --theme-bar-float: #1E976F; --theme-critical-text: #C81E1E; /* 5.5:1 op de kaart, 4.6:1 op de werkruimte */ /* Semantische tekstkleuren, verzadigd — op een witte kaart is dit de leesbare kant (de lichtere tinten uit het gedeelde blok hierboven zijn daar juist onleesbaar). */ @@ -1448,264 +1448,264 @@ button.task-grid-relation-reference { color-scheme: light; } -/* High Contrast theme — accessibility (WCAG AAA, niet brand-bound) */ -[data-theme="high-contrast"] { - --theme-bg: #000000; - --theme-surface: #0a0a0a; - --theme-surface-alt: #0a0a0a; - --theme-surface-elevated: #141414; - --theme-border: #ffffff; - --theme-border-light: #cccccc; - --theme-control-border: #ffffff; - --theme-accent: #ffff00; - --theme-accent-hover: #ffcc00; - --theme-accent-on: #000000; - --theme-text: #ffffff; - --theme-text-dim: #cccccc; - --theme-text-muted: #999999; - --theme-hover: #333333; - --theme-active: #ffff00; - --theme-active-border: #ffff00; - --theme-warning-text: #ffff00; - /* Gantt weekend-kolomband: zwakke maar zichtbare band op zwart - (1.23:1 vs #000, 1.16:1 vs #0a0a0a) — was onzichtbaar toen hij uit - input-bg #000000 (= bg) werd getekend. */ +/* High Contrast theme — accessibility (WCAG AAA, niet brand-bound) */ +[data-theme="high-contrast"] { + --theme-bg: #000000; + --theme-surface: #0a0a0a; + --theme-surface-alt: #0a0a0a; + --theme-surface-elevated: #141414; + --theme-border: #ffffff; + --theme-border-light: #cccccc; + --theme-control-border: #ffffff; + --theme-accent: #ffff00; + --theme-accent-hover: #ffcc00; + --theme-accent-on: #000000; + --theme-text: #ffffff; + --theme-text-dim: #cccccc; + --theme-text-muted: #999999; + --theme-hover: #333333; + --theme-active: #ffff00; + --theme-active-border: #ffff00; + --theme-warning-text: #ffff00; + /* Gantt weekend-kolomband: zwakke maar zichtbare band op zwart + (1.23:1 vs #000, 1.16:1 vs #0a0a0a) — was onzichtbaar toen hij uit + input-bg #000000 (= bg) werd getekend. */ --theme-grid-weekend: #1c1c1c; /* Om-en-om weekband bij gecomprimeerde as (issue #21 punt 2): iets zwakker dan de weekend-band maar nog zichtbaar op #0a0a0a (de band moet als vlak leesbaar blijven, niet als per-dag-arcering concurreren). */ --theme-grid-week-band: #141414; - /* Discussion #97: het paars van --color-task-milestone haalt op zwart geen AAA-contrast, dus - high contrast krijgt een eigen, helderdere mijlpaaltint (>=7:1 op #0a0a0a) — zichtbaar - onderscheiden van het accentgeel. De samenvattingstint volgt de bestaande vlakke-kleur-aanpak - van dit thema (geen alpha, expliciete tint zoals --theme-grid-weekend hierboven). */ - --theme-grid-summary-bg: #1c1c1c; - --theme-grid-milestone-text: #D9A6FF; - /* Gantt float-balk: heldere emerald op zwart (op ~90% opacity 8.85:1). Het U2-besluit "één - balkpalet" gold voor licht en dark; high-contrast houdt bewust zijn eigen, hardere tint. */ - --theme-bar-float: #34D399; - /* Kritieke-pad-tekst: helder rood, >=4.5:1 op #0a0a0a/#000 (7.1–7.6:1). */ - --theme-critical-text: #FF6B6B; + /* Discussion #97: het paars van --color-task-milestone haalt op zwart geen AAA-contrast, dus + high contrast krijgt een eigen, helderdere mijlpaaltint (>=7:1 op #0a0a0a) — zichtbaar + onderscheiden van het accentgeel. De samenvattingstint volgt de bestaande vlakke-kleur-aanpak + van dit thema (geen alpha, expliciete tint zoals --theme-grid-weekend hierboven). */ + --theme-grid-summary-bg: #1c1c1c; + --theme-grid-milestone-text: #D9A6FF; + /* Gantt float-balk: heldere emerald op zwart (op ~90% opacity 8.85:1). Het U2-besluit "één + balkpalet" gold voor licht en dark; high-contrast houdt bewust zijn eigen, hardere tint. */ + --theme-bar-float: #34D399; + /* Kritieke-pad-tekst: helder rood, >=4.5:1 op #0a0a0a/#000 (7.1–7.6:1). */ + --theme-critical-text: #FF6B6B; /* Semantische tekstkleuren, nog helderder dan in dark — dit thema mikt op AAA op zwart (>=7:1 als tekst op de 10%-tint). Zie de noot in het dark-blok over achtergrondgebruik. */ --success: #86EFAC; --error: #FCA5A5; --warning: #FDE047; - --theme-input-bg: #000000; - --theme-dropdown-bg: #0a0a0a; - --theme-scrollbar-track: #0a0a0a; - --theme-scrollbar-thumb: #ffffff; - --theme-ribbon-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); - --theme-ribbon-tab-bg: linear-gradient(to bottom, #141414 0%, #0d0d0d 100%); - --theme-ribbon-content-bg: #0a0a0a; - --theme-file-tab-bg: #ffff00; - --theme-file-tab-hover: #ffcc00; - --theme-titlebar-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); - --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.15); - --theme-titlebar-btn-active: rgba(255, 255, 255, 0.2); - /* Geen schaduw op zwart; randen dragen de scheiding. */ - --shadow-card: none; - --shadow-pop: none; - --shadow-glow: none; - - /* Issue #94: zie dark hierboven — goudfamilie voor voorganger, hier vervangen door cyaan voor - opvolger (paars haalt op zwart geen AAA), beide met maximaal contrast op #0a0a0a. */ - --relation-predecessor: #FFB000; - --relation-predecessor-driving: #FFD54F; - --relation-successor: #22D3EE; - --relation-successor-driving: #67E8F9; - - color-scheme: dark; -} - -@layer base { - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - /* OpenAEC default font assignments */ - h1, h2, h3 { - font-family: var(--font-heading); - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; - } - h4, h5, h6 { - font-family: var(--font-heading); - font-weight: 500; - letter-spacing: -0.01em; - line-height: 1.3; - } - code, kbd, samp, pre { - font-family: var(--font-code); - } -} - -/* Interface-lettertype-schaal (issue #25.4). --ui-font-scale wordt door App.tsx gezet vanuit de - uiFontScale-instelling (default 1). Tailwind-`text-*`-klassen zijn rem-gebaseerd en volgen deze - html-font-size vanzelf; de losse px-font-sizes in de chrome-css volgen NIET automatisch en - schalen daarom expliciet mee via `calc(px * var(--ui-font-scale, 1))` (zie de 10 chrome-CSS- - bestanden). De fallback `1` houdt de pre-hydratie-weergave en headless tests zonder App-effect - op de huidige 13px, zodat er geen verkeerde-grootte-flikkering ontstaat. */ -html, body, #root { - height: 100%; - width: 100%; - overflow: hidden; - font-family: var(--font-body); - font-size: calc(13px * var(--ui-font-scale, 1)); - background: var(--theme-bg); - color: var(--theme-text); -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} -::-webkit-scrollbar-track { - background: var(--theme-scrollbar-track); -} -::-webkit-scrollbar-thumb { - background: var(--theme-scrollbar-thumb); - border-radius: 4px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--theme-text-dim); -} - -/* Zwevende scrollbalken over de Gantt-kaart (issue #35). De balken van de Gantt zijn overlays - BINNEN het pane in plaats van een eigen goot/rij eromheen, zodat de kaart en de takenlijst tot - de rand doorlopen. Een dekkende track zou dat weer tenietdoen — die legt alsnog een 8px strook - over het canvas — dus alleen de duim is zichtbaar. De DIKTE blijft de 8px hierboven: die is - gelijk aan de SCROLLBAR_GUTTER in GanttCanvas.tsx, waarmee de overlay-strook precies één - scrollbalk breed/hoog is en er geen dode klikzone naast de balk ontstaat. */ -.gantt-overlay-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--theme-scrollbar-thumb) transparent; -} -.gantt-overlay-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -/* Selection */ -::selection { - background: var(--theme-accent); - color: var(--theme-accent-on); -} - -/* RTL support (Arabic) */ -[dir="rtl"] { - direction: rtl; - text-align: right; -} - -[dir="rtl"] .flex { - /* Tailwind flex already handles RTL via logical properties */ -} - -[dir="rtl"] select, -[dir="rtl"] input[type="text"], -[dir="rtl"] input[type="number"] { - text-align: right; -} - -[dir="rtl"] .px-3 { - padding-inline-start: 0.75rem; - padding-inline-end: 0.75rem; -} - -[dir="rtl"] .ml-8 { - margin-inline-start: 2rem; - margin-inline-end: 0; -} - -[dir="rtl"] .ml-2 { - margin-inline-start: 0.5rem; - margin-inline-end: 0; -} - -[dir="rtl"] .mr-2 { - margin-inline-end: 0.5rem; - margin-inline-start: 0; -} - -[dir="rtl"] .border-r { - border-right: none; - border-left: 1px solid var(--theme-border); -} - -[dir="rtl"] .border-l { - border-left: none; - border-right: 1px solid var(--theme-border); -} - -/* Issue #46 (slot): iconen die een RICHTING aanwijzen (de chevrons en het paneel-glyph in de - rechter-rail) moeten in RTL meespiegelen — daar staat de rail links, dus "de rail wegklappen" - wijst de andere kant op. Zelfde truc als `.ops-icon-rail` in Ribbon.css, maar dan bruikbaar - buiten de ribbon. Uitsluitend voor richtingsiconen; nooit voor iconen met tekst of een cijfer. */ -[dir="rtl"] .ops-icon-inline-flip { - transform: scaleX(-1); -} - -/* Gantt tooltip */ -/* Issue #58: de titel liep buiten de kader-rand door. Oorzaak was `white-space: nowrap` naast een - `max-width`: de doos werd wél op 280px geklemd, maar de tekst zette gewoon door en stak zichtbaar - uit (gemeten 178px bij een lange naam, 359px bij een naam zonder spaties). De keuze is WRAPPEN, - niet afkappen — de gebruiker moet de volledige taaknaam kunnen lezen. Drie dingen horen bij - elkaar en moeten samen blijven staan: - • `width: max-content` — zonder dit krimpt een absoluut gepositioneerde doos met wrappende - tekst tot de beschikbare ruimte rechts van `left`, dus een tooltip vlak bij de rechterrand - van het canvas zou een paar tekens breed worden. - • `max-width` schaalt mee met --ui-font-scale (zoals de font-size hieronder), zodat de tooltip - bij 125% niet minder tekst per regel toont; de `min()` met 100vw houdt hem in een smal - venster binnen beeld. - • `overflow-wrap: anywhere` breekt ook een naam zónder spaties (bestandsnaam/code); spaties - blijven de voorkeursbreekpunten. - De uitlijning binnen het venster (rechterrand/onderrand) zit NIET in CSS — dat kan alleen met een - gemeten positie: zie HoverTooltip.tsx. */ -.gantt-tooltip { - position: fixed; - pointer-events: none; - z-index: var(--z-tooltip); - background: var(--theme-surface-elevated); - border: 1px solid var(--theme-border); - border-radius: var(--radius-md); - padding: var(--sp-2) var(--sp-3); - font-family: var(--font-body); - font-size: calc(12px * var(--ui-font-scale, 1)); - line-height: 1.5; - color: var(--theme-text); - box-shadow: var(--shadow-lg); - width: max-content; - max-width: min(calc(360px * var(--ui-font-scale, 1)), calc(100vw - 32px)); - white-space: normal; - overflow-wrap: anywhere; -} - + --theme-input-bg: #000000; + --theme-dropdown-bg: #0a0a0a; + --theme-scrollbar-track: #0a0a0a; + --theme-scrollbar-thumb: #ffffff; + --theme-ribbon-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); + --theme-ribbon-tab-bg: linear-gradient(to bottom, #141414 0%, #0d0d0d 100%); + --theme-ribbon-content-bg: #0a0a0a; + --theme-file-tab-bg: #ffff00; + --theme-file-tab-hover: #ffcc00; + --theme-titlebar-bg: linear-gradient(to bottom, #141414 0%, #0a0a0a 100%); + --theme-titlebar-btn-hover: rgba(255, 255, 255, 0.15); + --theme-titlebar-btn-active: rgba(255, 255, 255, 0.2); + /* Geen schaduw op zwart; randen dragen de scheiding. */ + --shadow-card: none; + --shadow-pop: none; + --shadow-glow: none; + + /* Issue #94: zie dark hierboven — goudfamilie voor voorganger, hier vervangen door cyaan voor + opvolger (paars haalt op zwart geen AAA), beide met maximaal contrast op #0a0a0a. */ + --relation-predecessor: #FFB000; + --relation-predecessor-driving: #FFD54F; + --relation-successor: #22D3EE; + --relation-successor-driving: #67E8F9; + + color-scheme: dark; +} + +@layer base { + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + /* OpenAEC default font assignments */ + h1, h2, h3 { + font-family: var(--font-heading); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + } + h4, h5, h6 { + font-family: var(--font-heading); + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.3; + } + code, kbd, samp, pre { + font-family: var(--font-code); + } +} + +/* Interface-lettertype-schaal (issue #25.4). --ui-font-scale wordt door App.tsx gezet vanuit de + uiFontScale-instelling (default 1). Tailwind-`text-*`-klassen zijn rem-gebaseerd en volgen deze + html-font-size vanzelf; de losse px-font-sizes in de chrome-css volgen NIET automatisch en + schalen daarom expliciet mee via `calc(px * var(--ui-font-scale, 1))` (zie de 10 chrome-CSS- + bestanden). De fallback `1` houdt de pre-hydratie-weergave en headless tests zonder App-effect + op de huidige 13px, zodat er geen verkeerde-grootte-flikkering ontstaat. */ +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; + font-family: var(--font-body); + font-size: calc(13px * var(--ui-font-scale, 1)); + background: var(--theme-bg); + color: var(--theme-text); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--theme-scrollbar-track); +} +::-webkit-scrollbar-thumb { + background: var(--theme-scrollbar-thumb); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--theme-text-dim); +} + +/* Zwevende scrollbalken over de Gantt-kaart (issue #35). De balken van de Gantt zijn overlays + BINNEN het pane in plaats van een eigen goot/rij eromheen, zodat de kaart en de takenlijst tot + de rand doorlopen. Een dekkende track zou dat weer tenietdoen — die legt alsnog een 8px strook + over het canvas — dus alleen de duim is zichtbaar. De DIKTE blijft de 8px hierboven: die is + gelijk aan de SCROLLBAR_GUTTER in GanttCanvas.tsx, waarmee de overlay-strook precies één + scrollbalk breed/hoog is en er geen dode klikzone naast de balk ontstaat. */ +.gantt-overlay-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--theme-scrollbar-thumb) transparent; +} +.gantt-overlay-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Selection */ +::selection { + background: var(--theme-accent); + color: var(--theme-accent-on); +} + +/* RTL support (Arabic) */ +[dir="rtl"] { + direction: rtl; + text-align: right; +} + +[dir="rtl"] .flex { + /* Tailwind flex already handles RTL via logical properties */ +} + +[dir="rtl"] select, +[dir="rtl"] input[type="text"], +[dir="rtl"] input[type="number"] { + text-align: right; +} + +[dir="rtl"] .px-3 { + padding-inline-start: 0.75rem; + padding-inline-end: 0.75rem; +} + +[dir="rtl"] .ml-8 { + margin-inline-start: 2rem; + margin-inline-end: 0; +} + +[dir="rtl"] .ml-2 { + margin-inline-start: 0.5rem; + margin-inline-end: 0; +} + +[dir="rtl"] .mr-2 { + margin-inline-end: 0.5rem; + margin-inline-start: 0; +} + +[dir="rtl"] .border-r { + border-right: none; + border-left: 1px solid var(--theme-border); +} + +[dir="rtl"] .border-l { + border-left: none; + border-right: 1px solid var(--theme-border); +} + +/* Issue #46 (slot): iconen die een RICHTING aanwijzen (de chevrons en het paneel-glyph in de + rechter-rail) moeten in RTL meespiegelen — daar staat de rail links, dus "de rail wegklappen" + wijst de andere kant op. Zelfde truc als `.ops-icon-rail` in Ribbon.css, maar dan bruikbaar + buiten de ribbon. Uitsluitend voor richtingsiconen; nooit voor iconen met tekst of een cijfer. */ +[dir="rtl"] .ops-icon-inline-flip { + transform: scaleX(-1); +} + +/* Gantt tooltip */ +/* Issue #58: de titel liep buiten de kader-rand door. Oorzaak was `white-space: nowrap` naast een + `max-width`: de doos werd wél op 280px geklemd, maar de tekst zette gewoon door en stak zichtbaar + uit (gemeten 178px bij een lange naam, 359px bij een naam zonder spaties). De keuze is WRAPPEN, + niet afkappen — de gebruiker moet de volledige taaknaam kunnen lezen. Drie dingen horen bij + elkaar en moeten samen blijven staan: + • `width: max-content` — zonder dit krimpt een absoluut gepositioneerde doos met wrappende + tekst tot de beschikbare ruimte rechts van `left`, dus een tooltip vlak bij de rechterrand + van het canvas zou een paar tekens breed worden. + • `max-width` schaalt mee met --ui-font-scale (zoals de font-size hieronder), zodat de tooltip + bij 125% niet minder tekst per regel toont; de `min()` met 100vw houdt hem in een smal + venster binnen beeld. + • `overflow-wrap: anywhere` breekt ook een naam zónder spaties (bestandsnaam/code); spaties + blijven de voorkeursbreekpunten. + De uitlijning binnen het venster (rechterrand/onderrand) zit NIET in CSS — dat kan alleen met een + gemeten positie: zie HoverTooltip.tsx. */ +.gantt-tooltip { + position: fixed; + pointer-events: none; + z-index: var(--z-tooltip); + background: var(--theme-surface-elevated); + border: 1px solid var(--theme-border); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-3); + font-family: var(--font-body); + font-size: calc(12px * var(--ui-font-scale, 1)); + line-height: 1.5; + color: var(--theme-text); + box-shadow: var(--shadow-lg); + width: max-content; + max-width: min(calc(360px * var(--ui-font-scale, 1)), calc(100vw - 32px)); + white-space: normal; + overflow-wrap: anywhere; +} + .gantt-tooltip .tooltip-title { - font-weight: 600; - font-size: calc(13px * var(--ui-font-scale, 1)); - margin-bottom: 4px; - color: var(--theme-text); -} - -.gantt-tooltip .tooltip-row { - display: flex; - justify-content: space-between; - gap: 16px; -} - -.gantt-tooltip .tooltip-label { - color: var(--theme-text-dim); -} - -.gantt-tooltip .tooltip-value { - color: var(--theme-text); - font-weight: 500; -} - + font-weight: 600; + font-size: calc(13px * var(--ui-font-scale, 1)); + margin-bottom: 4px; + color: var(--theme-text); +} + +.gantt-tooltip .tooltip-row { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.gantt-tooltip .tooltip-label { + color: var(--theme-text-dim); +} + +.gantt-tooltip .tooltip-value { + color: var(--theme-text); + font-weight: 500; +} + .gantt-tooltip .tooltip-critical-yes { color: var(--error); font-weight: 600; @@ -1893,106 +1893,106 @@ html, body, #root { background: color-mix(in srgb, var(--theme-accent) 18%, transparent); } - -/* Toast notification — gecentraliseerde gebruikersmeldingen (bevinding K8). De stapel-container - positioneert; de losse melding verzorgt alleen inhoud + klik. De naam is ops-toast (was - gantt-toast): de meldingen komen ook uit Backstage/tabel/rapport, waar geen canvas is. */ -.ops-toast-stack { - position: fixed; - bottom: var(--sp-6); - left: 50%; - transform: translateX(-50%); - z-index: var(--z-toast); - display: flex; - flex-direction: column; - gap: var(--sp-2); - align-items: center; - pointer-events: none; /* de stapel zelf vangt geen klikken … */ -} - -.ops-toast { - pointer-events: auto; /* … de losse melding wél (klik = wegklikken) */ - background: var(--error); - color: #ffffff; - padding: var(--sp-3) var(--sp-6); - border-radius: var(--radius-md); - font-family: var(--font-body); - font-size: calc(13px * var(--ui-font-scale, 1)); - line-height: 1.4; - box-shadow: var(--shadow-lg); - max-width: 500px; - animation: toast-in 0.3s ease-out; -} - -.ops-toast.toast-info { - background: var(--info); -} - -.ops-toast.toast-error { - background: var(--error); -} - -.ops-toast-count { - font-size: calc(11px * var(--ui-font-scale, 1)); - opacity: 0.8; - margin-inline-start: var(--sp-2); - font-variant-numeric: tabular-nums; -} - -.ops-toast-detail { - font-size: calc(11px * var(--ui-font-scale, 1)); - opacity: 0.85; - margin-top: var(--sp-1); - word-break: break-word; -} - -/* "Lees meer"-tekstlink (mpp-nul-data-etappe): gedeeld door de toast (NotificationHost) en het - eigenschappenpaneel (TaskTimephasedNotice) — geen nieuwe kleur, erft `color` van de context - (wit op de toast, de normale tekstkleur in het paneel), alleen onderstreping als affordance. */ -.ops-textlink { - display: inline; - padding: 0; - background: none; - border: none; - color: inherit; - font: inherit; - text-decoration: underline; - cursor: pointer; -} - -.ops-toast-readmore { - display: block; - margin-top: var(--sp-1); - font-size: calc(11px * var(--ui-font-scale, 1)); -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Onbepaalde voortgangsbalk (update-download zonder bekende totale grootte). - Een smal segment dat heen en weer loopt over de track. */ -@keyframes ops-indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(300%); } -} -.ops-indeterminate-bar { - animation: ops-indeterminate 1.2s ease-in-out infinite; -} - -/* ============================================================================ - OpenAEC Component Utility Classes - Source: DESIGN-SYSTEM.md §3 — herbruikbare patterns voor knoppen, inputs, - alerts, badges, tags, progress bars. BEM-style naming (.x__y / .x--mod). - ============================================================================ */ - + +/* Toast notification — gecentraliseerde gebruikersmeldingen (bevinding K8). De stapel-container + positioneert; de losse melding verzorgt alleen inhoud + klik. De naam is ops-toast (was + gantt-toast): de meldingen komen ook uit Backstage/tabel/rapport, waar geen canvas is. */ +.ops-toast-stack { + position: fixed; + bottom: var(--sp-6); + left: 50%; + transform: translateX(-50%); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--sp-2); + align-items: center; + pointer-events: none; /* de stapel zelf vangt geen klikken … */ +} + +.ops-toast { + pointer-events: auto; /* … de losse melding wél (klik = wegklikken) */ + background: var(--error); + color: #ffffff; + padding: var(--sp-3) var(--sp-6); + border-radius: var(--radius-md); + font-family: var(--font-body); + font-size: calc(13px * var(--ui-font-scale, 1)); + line-height: 1.4; + box-shadow: var(--shadow-lg); + max-width: 500px; + animation: toast-in 0.3s ease-out; +} + +.ops-toast.toast-info { + background: var(--info); +} + +.ops-toast.toast-error { + background: var(--error); +} + +.ops-toast-count { + font-size: calc(11px * var(--ui-font-scale, 1)); + opacity: 0.8; + margin-inline-start: var(--sp-2); + font-variant-numeric: tabular-nums; +} + +.ops-toast-detail { + font-size: calc(11px * var(--ui-font-scale, 1)); + opacity: 0.85; + margin-top: var(--sp-1); + word-break: break-word; +} + +/* "Lees meer"-tekstlink (mpp-nul-data-etappe): gedeeld door de toast (NotificationHost) en het + eigenschappenpaneel (TaskTimephasedNotice) — geen nieuwe kleur, erft `color` van de context + (wit op de toast, de normale tekstkleur in het paneel), alleen onderstreping als affordance. */ +.ops-textlink { + display: inline; + padding: 0; + background: none; + border: none; + color: inherit; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.ops-toast-readmore { + display: block; + margin-top: var(--sp-1); + font-size: calc(11px * var(--ui-font-scale, 1)); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Onbepaalde voortgangsbalk (update-download zonder bekende totale grootte). + Een smal segment dat heen en weer loopt over de track. */ +@keyframes ops-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(300%); } +} +.ops-indeterminate-bar { + animation: ops-indeterminate 1.2s ease-in-out infinite; +} + +/* ============================================================================ + OpenAEC Component Utility Classes + Source: DESIGN-SYSTEM.md §3 — herbruikbare patterns voor knoppen, inputs, + alerts, badges, tags, progress bars. BEM-style naming (.x__y / .x--mod). + ============================================================================ */ + /* Buttons (DESIGN-SYSTEM.md §3.1) */ /* Echte bedieningselementen moeten ook buiten de gedeelde `.btn`-klasse direct als zodanig herkenbaar zijn. `:where()` houdt de specificiteit op nul: invoer-, sleep- en resizecursors @@ -2008,273 +2008,273 @@ html, body, #root { } .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--sp-2); - padding: var(--sp-3) var(--sp-6); - font-family: var(--font-body); - font-size: 0.875rem; - font-weight: 600; - border-radius: var(--radius-md); - border: none; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; -} -.btn:focus-visible { - outline: 2px solid var(--theme-accent); - outline-offset: 2px; -} -.btn--sm { padding: var(--sp-2) var(--sp-4); font-size: 0.75rem; } -.btn--lg { padding: var(--sp-4) var(--sp-8); font-size: 1rem; } - -.btn--primary { - background: var(--theme-accent); - color: var(--theme-accent-on); -} -.btn--primary:hover:not(:disabled) { - /* theme-aware hover: light = #B45309 (white text 5.0:1), dark = #EA580C - (unchanged from --signal-orange). Avoids the 3.56:1 fail white-on-#EA580C - gave in light mode. */ - background: var(--theme-accent-hover); -} - -.btn--secondary { - background: transparent; - color: var(--theme-text); - border: 2px solid var(--theme-text); -} -.btn--secondary:hover:not(:disabled) { - background: var(--theme-text); - color: var(--theme-bg); -} - -.btn--ghost { - background: transparent; - color: var(--theme-accent); - border: 2px solid var(--theme-accent); -} -.btn--ghost:hover:not(:disabled) { - background: var(--theme-accent); - color: var(--theme-accent-on); -} - -.btn--dark { - background: var(--deep-forge); - color: var(--blueprint-white); -} -.btn--dark:hover:not(:disabled) { - background: #27272A; -} - -.btn--danger { - background: var(--error); - color: #ffffff; -} -.btn--danger:hover:not(:disabled) { - background: #B91C1C; -} - -.btn:disabled, -.btn--disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Inputs (DESIGN-SYSTEM.md §3.2) */ -.input { - font-family: var(--font-body); - font-size: 0.875rem; - padding: var(--sp-3) var(--sp-4); - border: 1.5px solid var(--theme-control-border); - border-radius: var(--radius-md); - background: var(--theme-input-bg); - color: var(--theme-text); - transition: border-color 0.15s ease, box-shadow 0.15s ease; - width: 100%; -} -.input:focus, -.input:focus-visible { - outline: none; - border-color: var(--theme-accent); - box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.20); -} -.input--error { - border-color: var(--error); -} -.input--error:focus, -.input--error:focus-visible { - border-color: var(--error); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.20); -} -.input-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - margin-bottom: var(--sp-2); - color: var(--theme-text); -} -.input-hint { - font-size: 0.75rem; - /* was --scaffold-gray #A1A1AA (~2.5:1 op licht, onleesbaar); text-muted is - AA in elk thema (light 5.3:1, HC 7:1, dark gelijk aan oude scaffold-gray). */ - color: var(--theme-text-muted); - margin-top: var(--sp-1); -} -.input-hint--error { - color: var(--error); -} - -/* Alerts (DESIGN-SYSTEM.md §3.5) */ -.alert { - padding: var(--sp-4) var(--sp-6); - border-radius: var(--radius-md); - border-left: 4px solid; - font-family: var(--font-body); - font-size: 0.875rem; - line-height: 1.5; -} -.alert--info { background: #EFF6FF; border-left-color: var(--info); color: #1E40AF; } -.alert--success { background: #F0FDF4; border-left-color: var(--success); color: #166534; } -.alert--warning { background: #FFFBEB; border-left-color: #D97706; color: #92400E; } -.alert--error { background: #FEF2F2; border-left-color: var(--error); color: #991B1B; } - -/* Badges (DESIGN-SYSTEM.md §3.6) */ -.badge { - display: inline-block; - font-family: var(--font-body); - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 0.2em 0.6em; - border-radius: var(--radius-full); - white-space: nowrap; -} -.badge--amber { background: #FEF3C7; color: #92400E; } -.badge--green { background: #DCFCE7; color: #166534; } -.badge--red { background: #FEE2E2; color: #991B1B; } -.badge--blue { background: #DBEAFE; color: #1E40AF; } -.badge--gray { background: #F4F4F5; color: #3F3F46; } - -/* Tags (DESIGN-SYSTEM.md §3.7) */ -.tag { - display: inline-flex; - align-items: center; - gap: var(--sp-1); - font-family: var(--font-body); - font-size: 0.75rem; - font-weight: 500; - padding: var(--sp-1) var(--sp-3); - border-radius: var(--radius-full); - border: 1px solid var(--theme-border); - background: var(--theme-surface); - color: var(--theme-text-dim); -} - -/* Progress bars (DESIGN-SYSTEM.md §3.8) */ -.progress { - height: 8px; - background: var(--theme-border); - border-radius: var(--radius-full); - overflow: hidden; -} -.progress__fill { - height: 100%; - background: var(--theme-accent); - border-radius: var(--radius-full); - transition: width 0.3s ease; -} -.progress__fill--success { background: var(--success); } -.progress__fill--danger { background: var(--error); } - -/* OpenAEC merk-accent strip — DESIGN-SYSTEM.md §2.1 */ -.brand-accent-strip { - height: 2px; - background: linear-gradient(90deg, #D97706 0%, #F59E0B 40%, #EA580C 100%); - flex-shrink: 0; -} - -[data-theme="high-contrast"] .brand-accent-strip { - display: none; -} - -/* ============================================================================ - UI Overhaul — Soft Depth helpers (2026-06-19 spec) - Gedeelde "zwevende kaart" + werkruimte-tint zodat alle panelen consistent zijn. - ============================================================================ */ -.ui-workspace { - background: var(--theme-bg); -} -.ui-card { - background: var(--theme-surface); - border: 1px solid var(--theme-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); -} -/* Kaart-koptekst (Space Grotesk small-caps), bv. paneel-headers. */ -.ui-card-header { - font-family: var(--font-heading); - font-size: calc(10px * var(--ui-font-scale, 1)); - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--theme-text); -} -/* Dialogen/popovers: sterkere schaduw. */ -.ui-pop { - box-shadow: var(--shadow-pop); -} - -/* ============================================================================ - Uniforme checkboxes (2026-07-13) — ÉÉN stijl voor ALLE checkboxes in de app. - `appearance: none` schakelt de native (blauwe, OS-afhankelijke) checkbox uit. - Aangevinkt = massief accent-oranje vlak ZONDER vinkje (er is bewust geen - ::after-content), zodat er nooit een scheef-gecentreerd vinkje kan ontstaan. - Het accent volgt het thema (oranje in dark/light, geel in high-contrast). - Vervangt de vroegere wildgroei: bare native (blauw), Tailwind `accent-accent` - en losse `accent-color`-overrides renderen nu allemaal identiek. - ============================================================================ */ -input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - width: 15px; - height: 15px; - flex-shrink: 0; - margin: 0; - box-sizing: border-box; - vertical-align: middle; - border: 1.5px solid var(--theme-control-border); - border-radius: var(--radius-sm); - background: var(--theme-surface); - cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; -} -input[type="checkbox"]:hover { - border-color: var(--theme-accent); -} -input[type="checkbox"]:checked { - background: var(--theme-accent); - border-color: var(--theme-accent); -} -input[type="checkbox"]:focus-visible { - outline: none; - box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 30%, transparent); -} -input[type="checkbox"]:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Cel met de toetsenbordcursor in de TableEditor (issue #26). Bewust ONOPVALLEND: geen vlak, geen - rand, geen accentkleur — het gevulde accentvlak van hiervoor domineerde de tabel. Sinds één klik - meteen bewerkt is deze toestand ook zeldzaam (alleen na Escape of pijltjesnavigatie), dus een - stippellijntje onder de tekst is genoeg als aanwijzing waar de volgende toets landt. */ -.table-cell-cursor { - text-decoration: underline dotted var(--theme-text-dim); - text-underline-offset: 3px; -} + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-6); + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} +.btn:focus-visible { + outline: 2px solid var(--theme-accent); + outline-offset: 2px; +} +.btn--sm { padding: var(--sp-2) var(--sp-4); font-size: 0.75rem; } +.btn--lg { padding: var(--sp-4) var(--sp-8); font-size: 1rem; } + +.btn--primary { + background: var(--theme-accent); + color: var(--theme-accent-on); +} +.btn--primary:hover:not(:disabled) { + /* theme-aware hover: light = #B45309 (white text 5.0:1), dark = #EA580C + (unchanged from --signal-orange). Avoids the 3.56:1 fail white-on-#EA580C + gave in light mode. */ + background: var(--theme-accent-hover); +} + +.btn--secondary { + background: transparent; + color: var(--theme-text); + border: 2px solid var(--theme-text); +} +.btn--secondary:hover:not(:disabled) { + background: var(--theme-text); + color: var(--theme-bg); +} + +.btn--ghost { + background: transparent; + color: var(--theme-accent); + border: 2px solid var(--theme-accent); +} +.btn--ghost:hover:not(:disabled) { + background: var(--theme-accent); + color: var(--theme-accent-on); +} + +.btn--dark { + background: var(--deep-forge); + color: var(--blueprint-white); +} +.btn--dark:hover:not(:disabled) { + background: #27272A; +} + +.btn--danger { + background: var(--error); + color: #ffffff; +} +.btn--danger:hover:not(:disabled) { + background: #B91C1C; +} + +.btn:disabled, +.btn--disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Inputs (DESIGN-SYSTEM.md §3.2) */ +.input { + font-family: var(--font-body); + font-size: 0.875rem; + padding: var(--sp-3) var(--sp-4); + border: 1.5px solid var(--theme-control-border); + border-radius: var(--radius-md); + background: var(--theme-input-bg); + color: var(--theme-text); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} +.input:focus, +.input:focus-visible { + outline: none; + border-color: var(--theme-accent); + box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.20); +} +.input--error { + border-color: var(--error); +} +.input--error:focus, +.input--error:focus-visible { + border-color: var(--error); + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.20); +} +.input-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: var(--sp-2); + color: var(--theme-text); +} +.input-hint { + font-size: 0.75rem; + /* was --scaffold-gray #A1A1AA (~2.5:1 op licht, onleesbaar); text-muted is + AA in elk thema (light 5.3:1, HC 7:1, dark gelijk aan oude scaffold-gray). */ + color: var(--theme-text-muted); + margin-top: var(--sp-1); +} +.input-hint--error { + color: var(--error); +} + +/* Alerts (DESIGN-SYSTEM.md §3.5) */ +.alert { + padding: var(--sp-4) var(--sp-6); + border-radius: var(--radius-md); + border-left: 4px solid; + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.5; +} +.alert--info { background: #EFF6FF; border-left-color: var(--info); color: #1E40AF; } +.alert--success { background: #F0FDF4; border-left-color: var(--success); color: #166534; } +.alert--warning { background: #FFFBEB; border-left-color: #D97706; color: #92400E; } +.alert--error { background: #FEF2F2; border-left-color: var(--error); color: #991B1B; } + +/* Badges (DESIGN-SYSTEM.md §3.6) */ +.badge { + display: inline-block; + font-family: var(--font-body); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.2em 0.6em; + border-radius: var(--radius-full); + white-space: nowrap; +} +.badge--amber { background: #FEF3C7; color: #92400E; } +.badge--green { background: #DCFCE7; color: #166534; } +.badge--red { background: #FEE2E2; color: #991B1B; } +.badge--blue { background: #DBEAFE; color: #1E40AF; } +.badge--gray { background: #F4F4F5; color: #3F3F46; } + +/* Tags (DESIGN-SYSTEM.md §3.7) */ +.tag { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + font-family: var(--font-body); + font-size: 0.75rem; + font-weight: 500; + padding: var(--sp-1) var(--sp-3); + border-radius: var(--radius-full); + border: 1px solid var(--theme-border); + background: var(--theme-surface); + color: var(--theme-text-dim); +} + +/* Progress bars (DESIGN-SYSTEM.md §3.8) */ +.progress { + height: 8px; + background: var(--theme-border); + border-radius: var(--radius-full); + overflow: hidden; +} +.progress__fill { + height: 100%; + background: var(--theme-accent); + border-radius: var(--radius-full); + transition: width 0.3s ease; +} +.progress__fill--success { background: var(--success); } +.progress__fill--danger { background: var(--error); } + +/* OpenAEC merk-accent strip — DESIGN-SYSTEM.md §2.1 */ +.brand-accent-strip { + height: 2px; + background: linear-gradient(90deg, #D97706 0%, #F59E0B 40%, #EA580C 100%); + flex-shrink: 0; +} + +[data-theme="high-contrast"] .brand-accent-strip { + display: none; +} + +/* ============================================================================ + UI Overhaul — Soft Depth helpers (2026-06-19 spec) + Gedeelde "zwevende kaart" + werkruimte-tint zodat alle panelen consistent zijn. + ============================================================================ */ +.ui-workspace { + background: var(--theme-bg); +} +.ui-card { + background: var(--theme-surface); + border: 1px solid var(--theme-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); +} +/* Kaart-koptekst (Space Grotesk small-caps), bv. paneel-headers. */ +.ui-card-header { + font-family: var(--font-heading); + font-size: calc(10px * var(--ui-font-scale, 1)); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--theme-text); +} +/* Dialogen/popovers: sterkere schaduw. */ +.ui-pop { + box-shadow: var(--shadow-pop); +} + +/* ============================================================================ + Uniforme checkboxes (2026-07-13) — ÉÉN stijl voor ALLE checkboxes in de app. + `appearance: none` schakelt de native (blauwe, OS-afhankelijke) checkbox uit. + Aangevinkt = massief accent-oranje vlak ZONDER vinkje (er is bewust geen + ::after-content), zodat er nooit een scheef-gecentreerd vinkje kan ontstaan. + Het accent volgt het thema (oranje in dark/light, geel in high-contrast). + Vervangt de vroegere wildgroei: bare native (blauw), Tailwind `accent-accent` + en losse `accent-color`-overrides renderen nu allemaal identiek. + ============================================================================ */ +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 15px; + height: 15px; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + vertical-align: middle; + border: 1.5px solid var(--theme-control-border); + border-radius: var(--radius-sm); + background: var(--theme-surface); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} +input[type="checkbox"]:hover { + border-color: var(--theme-accent); +} +input[type="checkbox"]:checked { + background: var(--theme-accent); + border-color: var(--theme-accent); +} +input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 30%, transparent); +} +input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Cel met de toetsenbordcursor in de TableEditor (issue #26). Bewust ONOPVALLEND: geen vlak, geen + rand, geen accentkleur — het gevulde accentvlak van hiervoor domineerde de tabel. Sinds één klik + meteen bewerkt is deze toestand ook zeldzaam (alleen na Escape of pijltjesnavigatie), dus een + stippellijntje onder de tekst is genoeg als aanwijzing waar de volgende toets landt. */ +.table-cell-cursor { + text-decoration: underline dotted var(--theme-text-dim); + text-underline-offset: 3px; +} /* ── Semantische tokens als VLAK, niet als tekst ─────────────────────────────────────────────── In dark/high-contrast zijn --success/--error/--warning opgetild tot leesbare TEKSTkleuren op een