Kaynağa Gözat

Fix R02-N01: drop JS-side capacity arithmetic, server is authoritative

The capacity formula was reproduced in sprint-planner.js (~30 lines of
roundHalf / after_reserves / committed_p1 chained math) so the on-screen
totals updated during typing without the 400 ms PATCH round trip. The
PHP CapacityCalculator is the spec's contract — every PATCH response
already returns the freshly computed per_worker values that
applyServerCapacity() applies, so the JS copy was just a typing-feel
optimisation that doubled the maintenance cost of any future change to
the capacity rule (a documented two-place-edit hazard, doc/REVIEW_02.md
R02-N01).

Path B from the finding: keep an immediate visual update for the
Ressourcen sum (a plain DOM sum of [data-day] inputs) but drop the
reserve arithmetic. Available / Reserves now move on the server
response, ~400 ms after the last keystroke — the user usually pauses
before reading the cell anyway. Removed:
- capacity() (the reserve arithmetic)
- committedPrio1FromDom() (unused after capacity())
- recomputeAllCapacity() (server response is now authoritative)
- the data-reserve-fraction attribute on show.twig + present.twig
  (only consumer was capacity()).

POST /tasks/{id}/move now also returns per_worker for the source
sprint, so the move-out path can call applyServerCapacity() instead of
recomputing locally; the source-sprint capacity strip was previously
refreshed via recomputeAllCapacity() and would otherwise go stale until
the next edit.

JS file shrinks ~40 lines; the spec's "any edit must touch both" line
in §5 goes away. CapacityCalculator.php is unchanged; existing PHP
tests pass without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 gün önce
ebeveyn
işleme
14b1cfd8a9

+ 23 - 66
public/assets/js/sprint-planner.js

@@ -15,7 +15,11 @@
  *    drag-reorder of worker rows and task rows
  *  - the existing JSON-envelope PATCH/POST endpoints for live edits
  *
- * Capacity math MUST stay in lock-step with App\Services\CapacityCalculator.
+ * Capacity math lives only in App\Services\CapacityCalculator. The client
+ * does a plain ressourcen sum for the typing-responsive feel; Reserves /
+ * Available come from the PATCH response (`per_worker`), which is the
+ * single source of truth.
+ *
  * Behaviours (all preserved verbatim from the previous jQuery version):
  *  - Day cells (per worker, per week) snap to 0.5 on blur, batch-saved via
  *    PATCH /sprints/{id}/week-cells with 400 ms debounce.
@@ -37,11 +41,10 @@
     const root = document.querySelector('[data-sprint-root]');
     if (!root) { return; }
 
-    const sprintId        = parseInt(root.getAttribute('data-sprint-id'), 10);
-    const csrf            = String(root.getAttribute('data-csrf') || '');
-    const reserveFraction = Number(root.getAttribute('data-reserve-fraction') || 0);
-    const isBeamer        = Number(root.getAttribute('data-beamer')) === 1;
-    const keySuffix       = isBeamer ? ':beamer' : '';
+    const sprintId  = parseInt(root.getAttribute('data-sprint-id'), 10);
+    const csrf      = String(root.getAttribute('data-csrf') || '');
+    const isBeamer  = Number(root.getAttribute('data-beamer')) === 1;
+    const keySuffix = isBeamer ? ':beamer' : '';
 
     const taskSection = root.querySelector('[data-task-section]');
     const taskStatusEnabled =
@@ -71,11 +74,10 @@
     }
 
     // ---------------------------------------------------------------------
-    // Capacity math — must match App\Services\CapacityCalculator
+    // Number snapping / formatting helpers
     // ---------------------------------------------------------------------
 
-    function roundHalf(x) { return Math.round(x * 2) / 2; }
-    const snap05  = roundHalf;
+    const snap05  = (x) => Math.round(x * 2) / 2;
     const snap005 = (x) => Math.round(x * 20) / 20;
 
     function fmtDays(x) {
@@ -85,33 +87,6 @@
     }
     function fmtRtb(x) { return Number(x).toFixed(2); }
 
-    function capacity(ressourcen, committedPrio1) {
-        committedPrio1 = committedPrio1 || 0;
-        const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
-        return {
-            ressourcen,
-            afterReserves,
-            committedPrio1,
-            available: afterReserves - committedPrio1,
-        };
-    }
-
-    // Sum of prio-1 task assignment cells per sprint worker, read from DOM.
-    function committedPrio1FromDom() {
-        const per = {};
-        qsa('tr[data-task-row]').forEach(function (row) {
-            if (parseInt(row.getAttribute('data-prio'), 10) !== 1) { return; }
-            qsa('[data-assign]', row).forEach(function (inp) {
-                const key = String(inp.getAttribute('data-sw-id'));
-                const v = Number(inp.value);
-                if (!Number.isNaN(v) && v > 0) {
-                    per[key] = (per[key] || 0) + v;
-                }
-            });
-        });
-        return per;
-    }
-
     // ---------------------------------------------------------------------
     // HTTP helper — spec §7 envelopes
     // ---------------------------------------------------------------------
@@ -166,10 +141,15 @@
     }
 
     // ---------------------------------------------------------------------
-    // Recompute worker row sum + capacity summary locally
+    // Worker row Σ-days + capacity Ressourcen — plain DOM sum.
+    //
+    // After-reserves / Available are NOT computed here; the server PATCH
+    // response (`per_worker` envelope) is authoritative — see
+    // `applyServerCapacity` below. That keeps the capacity formula in
+    // CapacityCalculator.php as the single source of truth.
     // ---------------------------------------------------------------------
 
-    function recomputeRow(swId, commitMap) {
+    function recomputeRow(swId) {
         const row = qs('[data-sw-row][data-sw-id="' + swId + '"]');
         if (!row) { return; }
         let sum = 0;
@@ -177,29 +157,10 @@
             const v = Number(inp.value);
             if (!Number.isNaN(v)) { sum += v; }
         });
-
-        const committed = (commitMap || committedPrio1FromDom())[String(swId)] || 0;
-        const cap = capacity(sum, committed);
-
-        const sumEl  = qs('[data-sum-days]', row);
-        if (sumEl) { sumEl.textContent = fmtDays(cap.ressourcen); }
+        const sumEl = qs('[data-sum-days]', row);
+        if (sumEl) { sumEl.textContent = fmtDays(sum); }
         qsa('[data-cap-ressourcen][data-sw-id="' + swId + '"]').forEach(function (r) {
-            r.textContent = fmtDays(cap.ressourcen);
-        });
-        qsa('[data-cap-after-reserves][data-sw-id="' + swId + '"]').forEach(function (a) {
-            a.textContent = fmtDays(cap.afterReserves);
-        });
-        qsa('[data-cap-available][data-sw-id="' + swId + '"]').forEach(function (av) {
-            av.textContent = fmtDays(cap.available);
-            av.classList.toggle('text-red-700', cap.available < 0);
-            av.classList.toggle('text-slate-900', cap.available >= 0);
-        });
-    }
-
-    function recomputeAllCapacity() {
-        const commit = committedPrio1FromDom();
-        qsa('[data-sw-row]').forEach(function (row) {
-            recomputeRow(parseInt(row.getAttribute('data-sw-id'), 10), commit);
+            r.textContent = fmtDays(sum);
         });
     }
 
@@ -445,7 +406,6 @@
                 flash('Saved');
                 applyFilters();
                 applyServerCapacity(data && data.per_worker);
-                recomputeAllCapacity();
             })
             .catch((e) => flash(e.message, true));
     });
@@ -464,7 +424,6 @@
             .then(function (data) {
                 tr.remove();
                 applyServerCapacity(data && data.per_worker);
-                recomputeAllCapacity();
                 flash('Task deleted');
                 if (qsa('tr[data-task-row]', taskTbody).length === 0) {
                     window.location.reload();
@@ -522,8 +481,6 @@
         });
         const totEl = qs('[data-task-tot]', tr);
         if (totEl) { totEl.textContent = fmtDays(tot); }
-
-        recomputeAllCapacity();
     });
     on(root, 'blur', '[data-assign]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); });
 
@@ -1113,11 +1070,11 @@
             closeTaskMenu();
             if (Number.isFinite(destId) && Number.isFinite(taskId)) {
                 request('POST', '/tasks/' + taskId + '/move', { sprint_id: destId })
-                    .then(function () {
+                    .then(function (data) {
                         flash('Moved to sprint');
                         const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
                         if (tr) { tr.remove(); }
-                        recomputeAllCapacity();
+                        applyServerCapacity(data && data.per_worker);
                         if (qsa('tr[data-task-row]', taskTbody).length === 0) {
                             window.location.reload();
                         }

+ 2 - 1
src/Controllers/TaskController.php

@@ -593,7 +593,8 @@ final class TaskController
         }
 
         return Response::ok([
-            'task' => $result['after']->toAuditSnapshot(),
+            'task'       => $result['after']->toAuditSnapshot(),
+            'per_worker' => $this->computeCapacity($result['before']->sprintId),
         ]);
     }
 

+ 0 - 1
views/sprints/present.twig

@@ -5,7 +5,6 @@
       data-sprint-root
       data-sprint-id="{{ sprint.id }}"
       data-csrf="{{ csrfToken }}"
-      data-reserve-fraction="{{ sprint.reserveFraction|number_format(4, '.', '') }}"
       data-beamer="1">
 
     <header class="flex items-center justify-between gap-4 px-4 py-2 border-b bg-slate-50 dark:bg-slate-800 dark:border-slate-700">

+ 1 - 2
views/sprints/show.twig

@@ -6,8 +6,7 @@
 <section class="space-y-6"
          data-sprint-root
          data-sprint-id="{{ sprint.id }}"
-         data-csrf="{{ csrfToken }}"
-         data-reserve-fraction="{{ sprint.reserveFraction|number_format(4, '.', '') }}">
+         data-csrf="{{ csrfToken }}">
 
     <header class="flex items-end justify-between gap-4">
         <div>