Date: 2026-05-08
Scope: full repo at main (commit 1dba877), against SPEC.md as of the
same SHA. Sister document to doc/REVIEW_01.md — that one captures
security and "fishy patterns"; this one captures overcomplicated
solutions that could be rewritten more simply with no functional change.
This document is meant to be referenced on each refactor loop alongside SPEC.md and REVIEW_01.md. Findings are ordered by severity (highest first) and each carries:
R02-Nxx) — quote when committing a fix.open / accepted-by-design / fixed-in-<sha>.When a finding is closed, set Status to fixed-in-<sha> and append a
short note. Do not delete entries — they're history, same contract as
REVIEW_01.
open finding whose severity ≥ MEDIUM.fixed-in-<sha> and add a one-line note.src/Services/CapacityCalculator.php — authoritative implementation.public/assets/js/sprint-planner.js lines 172–222 — recomputeRow()applyServerCapacity() reproduce the same round_half,
after_reserves, committed_p1, available chain on the client.App\Services\CapacityCalculator (PHP) and in sprint-planner.js
(JS). Any edit must touch both."per_worker
values though, and applyServerCapacity() is called immediately
after each save (lines 249, 559, 612).recomputeRow()'s arithmetic. Two paths:
Available from
server values only; on edit, optionally show a "saving…" indicator.buildTaskRow() mirrors the Twig partial by handd5a09ff. Row markup extracted to
views/sprints/_task_row.twig; _task_list.twig includes it once per
task and once more inside a hidden <template
data-task-row-template> (admin-only). buildTaskRow now clones the
template and only populates the per-task fields — ~150 lines of
hand-rolled DOM gone, along with the ownerChoices() /
sprintWorkerHeaders() helpers that only fed it. TwigViewTest pins
the template's presence (admin path) and absence (read-only path).public/assets/js/sprint-planner.js ~lines 361–510 —
buildTaskRow(task, ...).
views/sprints/_task_list.twig lines 200–283.7c298d3 — owner dropdown empty in JS-built rows because
ownerChoices() scraped the pre-Phase-10 [data-owner-filter]
option selector that no longer existed.23ab365 — JS-built cells missed data-col, breaking the
Columns dropdown and Phase 13 focus auto-hide.whitespace-nowrap re-mirror in the "Task table polish"
commit f204611.buildTaskRow is a
forgetful copy that has bitten the project at least three times.Suggested rewrite: server-rendered hidden <template
data-task-row-template> block at the bottom of _task_list.twig
containing one rendered row with placeholder values (or just the
blank shell — the JS already fills the data). On task creation:
const tr = template.content.firstElementChild.cloneNode(true);
tr.dataset.taskId = data.task.id;
// …apply server-returned task fields
Eliminates the manual mirror. The template renders inside the
existing autoescape, so no XSS surface change. ~80 LoC removed from
sprint-planner.js. New test: assert the template element exists in
_task_list.twig (drift guard, same shape as the autoescape pin
R01-N21).
src/Controllers/SprintController.php::create lines 91–99…::delete lines ~1020–1026src/Controllers/WorkerController.php::create/::updatesrc/Controllers/UserController.php::update/::tombstonesrc/Controllers/SettingsController.php::updatesrc/Controllers/ImportController.php::upload/::commit
Existing parallel: SprintController::gateJsonAdmin() at line
1169 — used 8× across the JSON endpoints (lines 349, 510, 589, 649,
705, 762, 806, 928). The pattern works. The form path just lacks the
twin.What: every form-handling action begins with the same three lines:
$actor = SessionGuard::requireAdmin($this->users);
if ($actor instanceof Response) { return $actor; }
if (!SessionGuard::verifyCsrf($req)) { return Response::text('CSRF token invalid', 403); }
Why it's worth fixing: ~7 sites, three lines each, all identical.
More importantly: a developer adding a new form endpoint can forget
the CSRF check or the admin gate and the omission won't be obvious in
review (the surrounding code looks fine). Both the JSON
(gateJsonAdmin returning a JSON envelope) and the form (a
Response::text 403) flavours are reasonable; missing either is a
vulnerability.
Suggested rewrite: add SessionGuard::requireAdminForm(Request,
UserRepository): User|Response mirroring requireAdminJson (already
at SessionGuard:256). It runs both the admin check and CSRF, returns
the user on success or a 403 text response on either failure. Each
controller method drops 4 lines to one. gateJsonAdmin in
SprintController can also move into SessionGuard for symmetry —
it's not sprint-specific.
public/assets/js/sprint-planner.js
pendingCells/cellDebounce/
queueCell/flushCells)pendingAssign/assignTimers)pendingStatus/
statusTimers)
Plus a fourth, simpler one in sprint-settings.js lines 94–141 for
meta + week masks.Suggested rewrite: a small factory in sprint-planner.js's
helper section:
function makeBatchSaver(url, debounceMs, onSuccess) {
const pending = new Map();
let timer = null;
function flush() {
if (pending.size === 0) return;
const items = Array.from(pending.values());
pending.clear();
request('PATCH', url, items)
.then(onSuccess)
.catch((e) => flash(e.message, true));
}
return {
queue(key, item) {
pending.set(key, item);
clearTimeout(timer);
timer = setTimeout(flush, debounceMs);
},
};
}
Three callers, each ~5 lines. ~80 LoC saved across both JS files. The cell-popover-status pipeline keeps its own onSuccess closure; the rest is shared.
SprintController::updateMeta() mixes 4 concerns in 144 linessrc/Controllers/SprintController.php::updateMeta lines
346–489.sprint_weeks when
start_date/end_date moves (Phase 21), (f) the audit emission, (g) the
envelope construction. Reading top-to-bottom is hard.parseAndValidateMetaPatch(Request): array — returns the
field-set or a Response on failure, like gateJsonAdmin.applyDateRangeChange(Sprint, string $start, string $end): array
— does the realign + audit, returns the diff descriptor for the
response envelope.applyReserveChange(Sprint, float $reserve): bool — same shape.
The method body becomes ~30 lines of orchestration. Tests can call
the helpers directly without spinning up a full transaction.:beamerpublic/assets/js/sprint-planner.js
ownerFilterKey (~line 1515), focusKey (~1565),
statusFilterKey (~1596), columnsKey (~1740), tabKey (~1989).'sp:' + sprintId + ':<name>' +
keySuffix (the :beamer namespacing for the present view).Suggested rewrite: at the top of the IIFE,
const stateKeys = ['ownerFilter', 'focusWorker', 'statusFilter',
'hiddenCols', 'tab'];
const key = (n) => `sp:${sprintId}:${n}${keySuffix}`;
function resetState() { stateKeys.forEach(n => localStorage.removeItem(key(n))); }
Reset becomes one line. New filters add one entry to stateKeys.
views/sprints/new.twig, views/sprints/settings.twig,
views/workers/index.twig, views/users/index.twig,
views/settings/index.twig, views/auth/local.twig, the Phase 20
views/sprints/import_*.twig. Roughly 20 instances of the
<label>…<input class="mt-1 block w-full rounded-md border-slate-300
border shadow-sm px-3 py-2 …"> pattern + optional hint span.dark: sweep."Suggested rewrite: a views/_macros/form.twig (or inline at top
of layout.twig) with:
{% macro field(name, label, type='text', value='', hint=null, attrs={}) %}
<label class="block">
<span class="text-sm font-medium ...">{{ label }}</span>
<input type="{{ type }}" name="{{ name }}" value="{{ value }}"
class="mt-1 block w-full rounded-md border-slate-300 ..."
{% for k, v in attrs %} {{ k }}="{{ v }}"{% endfor %}>
{% if hint %}<span class="text-xs ...">{{ hint }}</span>{% endif %}
</label>
{% endmacro %}
Saves ~80 lines. Same auto-escape applies. Audit each replacement with the existing TwigViewTest smoke renders.
views/sprints/_task_list.twig
<div data-X-root> + button + panel + clear button + option
list, with identical Tailwind class strings.{% macro filter_dropdown(id, label,
triggerText) %} that yields a slot for the option list would cut
~60 lines. The dropdown-close-on-outside-click logic in
sprint-planner.js (lines that listen for data-*-root) already
treats them uniformly — the markup should follow.id (used as data-{id}-root,
data-{id}-trigger, data-{id}-dropdown), triggerLabel,
headerLabel, and renders the three rendered to {% block options %}.views/**/*.twig. Notable cases:
show.twig, present.twig, settings, audit.w-14 rounded border …) appears 3× across
show.twig and _task_list.twig.home.twig,
show.twig, settings.twig, users/index.twig..cell-popover / .task-menu / .task-modal blocks already exist
in assets/css/input.css under @layer components (per SPEC.md
Phase 17/18/22 notes), proving the team is comfortable with @apply-
based component classes when the markup gets too thick — these other
recurrences just haven't been promoted yet.@layer components blocks for
.btn-secondary, .input-number, .alert-error, .alert-warning
in assets/css/input.css. Replace inline duplicates. ~30 individual
copies eliminated; central styling for review.public/index.php does ~60 lines of inline service wiringpublic/index.php lines 158–196.§3 already lists every dependency, so a small
factory class is a one-time cost. Today the bootstrap mixes safety-
critical work (early FatalErrorHandler::register, schema-pending
503, HTTPS redirect) with mechanical wiring. Separating them makes
the safety-critical sequence easier to audit (which is itself a
REVIEW_01 concern: R01-N13 had to register the handler twice to cover
the wiring window).App\Container::build(PDO $pdo, View $view):
array returning ['controllers' => [...], 'services' => [...]].
index.php becomes ~25 lines: env + autoload + early handler +
schema check + $container = Container::build(...) + router setup.
No DI library required — same plain new calls, just relocated.
Tests don't change (they construct individual controllers directly).recordForRequest() always passes $req, $actor as the trailing pairSprintController.php,
TaskController.php, WorkerController.php, UserController.php,
ImportController.php, SettingsController.php.req: $req, actor: $actor).$this->record('CREATE',
'sprint', $id, $before, $after, $req, $actor) style helper, or
better, a per-request audit closure built once at the top of each
method ($audit = $this->auditFor($req, $actor); $audit('CREATE',
'sprint', $id, null, $snap);) — would shrink the bulk by ~30%.TaskController::updatesrc/Controllers/TaskController.php lines 133–181.title, priority,
owner_worker_id, description, url with bespoke validation per
field. URL validation (regex + length) inline at lines 170–181.private const UPDATABLE = ['title' =>
['string', 1, 200], 'priority' => ['int_in', [1,2]],
'owner_worker_id' => ['nullable_int'], 'description' => ['string',
0, 8000], 'url' => ['url_or_empty', 2048]]; with a small
validateField($name, $value) switch on the type tag. Same per-
field semantics, table-driven, easier to extend.create() and updateMeta()src/Controllers/SprintController.php lines 110–124
(create()) and ~lines 373–401 (updateMeta()).Y-m-d with
DateTimeImmutable::createFromFormat, check $endD < $startD, and
call weeksBetween(...) to enforce the 26-week ceiling.
R01-N12's validateDateFilters() in AuditController is yet another
variant of the same idea (round-trip strict parse).Domain\DateRange::parse(string, string): self|array would let the
controllers do $range = DateRange::parse($start, $end); if
(is_array($range)) return $errorRedirect($range['error']);.src/Domain/. Static
helpers weeksBetween, isIsoDate, validateDateFilters collapse
into one type. Tests for it are already half-written across the
three call sites.Listed so a future reviewer doesn't re-flag them.
sprint-planner.js ~lines 787–885). SPEC.md
Phase 17 documents seven iterations of a number-stepper popover that
was eventually deleted entirely. The current popover (commit
10ea4b8) is a different one — slider + status pills, ~100 lines,
uses element-local pointer events with a 250 ms grace timer. That
scar tissue is worth preserving: the previous attempts at
document-level pointer tracking failed in production. Don't refactor
this without reading the Phase 17 saga first.buildTaskRow itself was scaffolded once in vanilla JS when the
team migrated off jQuery in Phase 19. The right fix is the
template-clone approach in R02-N02; rewriting the inner helpers is
not worth it.capacity_table is called twice on the same page
(Phase "tabs"). That is already the simplification — a macro for
what would otherwise be 50 lines of duplicated table.TaskController::delete, SprintController::removeWorker,
SprintController::delete, etc.) snapshots descendants before the
FK fires. This is verbose, but the verbosity is the audit
contract. Don't try to "abstract" it; the explicit shape is the
point.SESSION_SECRET removal demonstrates the team's
preference for "delete the dead config" over "wire it up." Apply the
same posture to any future "the var is documented but unused" find:
delete first.If a single refactor sprint is allotted, the highest leverage-per-hour sequence:
buildTaskRow) — kills a class of
recurring bugs and is mechanically simple. Worth doing first.SessionGuard, ~25 lines removed across controllers, security-
relevant.R02-N05/N06/N10 and the LOW findings are good "while I'm in here" opportunistic cleanups, not standalone work items.
Same posture as REVIEW_01:
open.fixed-in-<sha> with a one-line note.