All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Tooling- and dev-stack-heavy release on top of v0.25.0. Most of the
work lives outside the running app: the Dockerfile splits into four
named build targets (css-builder / css-watcher / runtime /
tests), a docker-compose.dev.yml overlay adds a Tailwind --watch
sidecar with edit-without-rebuild bind mounts, and a hand-written
bin/appctl Bash wrapper replaces the Makefile so the project no
longer needs make. A new /check Claude Code skill runs lint +
PHPUnit inside the tests stage via a Haiku-powered subagent, keeping
verbose phpunit output out of the main session. Inside the app, three
REVIEW_02 simplifications fired: the JS-side capacity arithmetic is
gone (server response is now authoritative), the task-row markup
collapses into a single _task_row.twig partial cloned from a hidden
<template> instead of being rebuilt by hand, and the form-endpoint
admin/CSRF triple-line gets folded into a single
SessionGuard::requireAdminForm helper. Smaller items: the present
view gains a sprint-switcher dropdown and the brand logo, htmx no
longer fights strict CSP by injecting an inline indicator stylesheet,
the new-sprint settings page only pre-checks weekdays whose calendar
date falls inside the chosen [start, end] range, the published host
port becomes configurable via HTTP_PORT, and two dev-stack
regressions (a css-watcher restart loop and a host-bind-mount
read-only-database 500) get fixed.
/check Claude Code skill + container-tester (Haiku) subagent.
New project-level slash command at .claude/skills/check/SKILL.md
delegates to a Haiku subagent (.claude/agents/container-tester.md,
Bash + Read only) that runs lint + PHPUnit inside the new tests
Dockerfile target and surfaces only failures + a one-line summary.
Verbose phpunit / docker build output stays inside the subagent and
never reaches the main session. Subcommands: /check,
/check lint, /check test, /check <free text>. .gitignore
flips from a blanket /.claude/ ignore to /.claude/* + !agents/ +
!skills/ so the two committed files ship with the repo while
session scratch / worktrees stay ignored.HTTP_PORT env var for the published host port. docker-compose.yml
switches from a hard-coded 8088:80 to ${HTTP_PORT:-8080}:80 so
operators set the host port in .env alongside APP_BASE_URL
instead of editing the compose file. Default moves from 8088 to
8080 so the compose default and the APP_BASE_URL default
finally agree. Docs: SPEC §8 env block, README quick-setup, and a
new admin-manual §3.3 "Host port".<select> (visible to every signed-in user, hidden when only one
sprint exists) lists all sprints newest-start-first; on change the
page navigates to the chosen sprint's /sprints/{id}/present. The
change handler lives next to the existing data-close-present
block in sprint-planner.js so strict CSP stays intact.doc/REVIEW_02.md — simplification + maintainability audit.
Sister document to REVIEW_01.md (security). Catalogs over-complicated
patterns that can be rewritten more simply with no functional change;
same R02-Nxx / Severity / Status format so the /loop workflow
plugs in directly. The bold preamble requires re-reading SPEC.md
before raising any finding (the spec is the contract these refactors
must preserve, and §9's build-phase log explains why "complicated"
patterns landed that way). Three HIGH findings (R02-N01..N03) all
landed in this release.Dockerfile into 4 targets + dev compose overlay.
The single Dockerfile now exposes css-builder (one-shot Tailwind
for prod, existing behaviour), css-watcher (node sidecar running
tailwindcss --watch, dev only), runtime (PHP/Apache prod image,
renamed but otherwise unchanged), and tests (FROM runtime +
composer install w/ dev deps for phpunit). docker-compose.yml
stays prod-shaped (now with explicit target: runtime).
docker-compose.dev.yml is an explicit overlay (no auto-load) that
flips APP_ENV=development (Twig auto_reload), bind-mounts the
source over the runtime image, and runs the css-watcher sidecar as
${HOST_UID}:${HOST_GID} so files written into bind-mounted host
paths land with normal ownership instead of root. bin/dev-css-watcher.sh
seeds vendor JS bundles (alpine-csp, htmx, sortable) into the
host-mounted public/assets/js/vendor on first start, then execs
tailwindcss --watch directly (skipping npx, which would want to
write $HOME under a non-root user). tests runs under
profiles: [test] for one-shot --rm invocations.make → appctl. A hand-written Bash wrapper at
bin/appctl (with a top-level ./appctl symlink) replaces the
short-lived Makefile so the project no longer needs make.
Subcommand surface follows the verb-then-action pattern:
./appctl dev start|stop|build|shell|logs,
./appctl prod start|stop|build, and the unchanged check trio
./appctl lint|test|check. HOST_UID / HOST_GID are still
exported for the css-watcher's bind-mount ownership; the long
docker compose -f docker-compose.yml -f docker-compose.dev.yml …
invocation is what's being wrapped. Bash completion ships at
bin/appctl-completion.bash; the first interactive ./appctl
invocation offers to add a source line to ~/.bashrc, with a
marker file under ~/.config/appctl/ so the prompt only appears
once (APPCTL_NO_COMPLETION_PROMPT=1 silences it for CI). SPEC §3
/ §11, README, doc/admin-manual.md, the /check skill, and the
container-tester agent all updated so make check no longer
appears anywhere.materializeWeeks now takes endDate and seeds each week's
active_days_mask with the Mo–Fr bits whose calendar dates fall
inside [start, end] (max_working_days = popcount(mask)). The
settings page still renders all five checkboxes per week; only the
in-range ones are pre-checked, and editing/saving works as before.
The Weeks-table Start column also gains dd.mm.YYYY formatting,
matching the audit page's date convention.<style>.
Strict style-src 'self' was blocking htmx's built-in indicator
stylesheet on every page load, flooding the audit log with
csp_violation rows. Disable the auto-injection via
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
and ship the equivalent .htmx-indicator / .htmx-request rules
from app.css instead.sprint-planner.js (~30 lines of roundHalf / after_reserves /
committed_p1 chained math) so 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. 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. Removed: capacity(),
committedPrio1FromDom(), recomputeAllCapacity(), and the
data-reserve-fraction attribute on show.twig /
present.twig. 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. SPEC §5 /
§9 / §13 refreshed; the "any edit must touch both" line in §5 goes
away.<template data-task-row-template> instead
of mirroring _task_list in JS. Extracted the task-row markup
into views/sprints/_task_row.twig and call it twice from
_task_list.twig — once inside the for-loop, once inside a hidden
<template data-task-row-template> at the bottom (admin-only).
sprint-planner.js's buildTaskRow now clones the template and
populates the few fields that vary; ~150 lines of hand-rolled DOM
construction are gone, along with the ownerChoices() /
sprintWorkerHeaders() helpers that only existed to feed it.
Server-rendered rows and JS-built rows share a single Twig source,
so the historical drift incidents (hotfixes 7c298d3, 23ab365, and
the whitespace-nowrap re-mirror in f204611) can no longer happen.
TwigViewTest pins both directions: a marker on the template body
for the admin path, and the absence of <template
data-task-row-template> for read-only users.SessionGuard::requireAdminForm + drop
SprintController::gateJsonAdmin. Form-handling controllers used
to open with the same three-line block (requireAdmin + Response
early-return + verifyCsrf) at nine sites: SprintController::create
/ ::delete, WorkerController::create / ::update,
UserController::update / ::tombstone, SettingsController::update,
ImportController::upload / ::commit. New
SessionGuard::requireAdminForm(Request, UserRepository): User|Response
mirrors the existing requireAdminJson but speaks the form dialect
(redirect to /auth/login when anonymous, 403 text on non-admin,
403 text on CSRF mismatch). All nine sites collapse to a single
call. SprintController::gateJsonAdmin was an identical local twin
of requireAdminJson; its eight call sites switch to the
SessionGuard helper and the private method goes away. Net: +37
-64 lines; tests unchanged at 340 / 340.appctl dev start regressions caused by the bind-mount
overlay clashing with the upstream images. The css-watcher
restarted forever because Tailwind's --watch listens on stdin and
exits on EOF; compose up closes stdin by default, so
restart: unless-stopped looped it. Fixed by adding
stdin_open + tty to keep chokidar alive. The host bind mount at
/var/www/data was masking the Dockerfile's chown www-data,
leaving app.sqlite (root:1000 644) and no sessions/ dir, so the
first request 500'd with attempt to write a readonly database.
Self-heal in bin/docker-entrypoint.sh by mkdir-ing the data +
session dirs and chown-ing to www-data on every start (twice —
migrate runs as root and would otherwise leave new SQLite/WAL
files root-owned).Small UX-polish release on top of v0.24.0. The anonymous welcome
card on / is rebuilt to share the local-login mask's silhouette —
narrow max-w-md box centred under a 144×144 cycle logo that lives
inside the card, with full-width stacked sign-in buttons. The home
sprint table loses its "Status" column: is_archived had no
functional effect anywhere in the app (no list filter, no edit
gate, no permission split) so the active/archived chip was pulled
until the flag actually means something.
Welcome page matches the local-login mask. views/home.twig's
anonymous branch now mirrors views/auth/local.twig: a single
max-w-md mx-auto card with the brand cycle logo (144×144,
id="brand-cycle-glow-card" to avoid colliding with the header
logo's gradient id) centred at the top of the card, then the
"Sprint Planner" heading, the description text, and the two
sign-in buttons stacked full-width (flex items-center
justify-center w-full) rather than wrapped inline. The same
logo treatment is applied to auth/local.twig so both entry
points feel like one product.
Drop the Status column from the home sprint table. Removed the
<th>Status</th> header and the per-row chip <td> from
views/home.twig. The chip was the only consumer of s.isArchived
in the list, and grepping the codebase confirmed is_archived /
isArchived is only referenced for display (home.twig,
sprints/show.twig, sprints/present.twig); no controller, no
list query, no permission gate cares about it. Pulling the column
avoids advertising a state that does nothing yet.
UX-focused release on top of v0.23.0: the per-row task hamburger is
rebuilt as a single right-anchored two-pane popup that carries the
menu and a read-only info pane (title / description / URL /
linked-task chips) plus a sprint-chooser flyout. Non-admins now see
the hamburger and can run every action it offers
(Edit task / Move task to sprint / Copy task to sprint /
Move up/down / Delete task); the trigger also stays visible in
the /present beamer view so presenters can read description / URL
/ refs during a discussion. Inline title / owner / priority editing
and + Add task remain admin-only.
Hamburger popup visible in beamer / /present view. Drops
[data-task-menu-trigger] from the .beamer-root hide rule in
assets/css/input.css. Now that all the per-row task info
(description / URL / linked-task chips) lives inside the
hamburger popup, presenters need the trigger reachable during
discussions; the existing .handle and [data-delete-task] hides
stay in place.
Task hamburger: two-pane popup with right-anchored sprint flyout;
actions opened up to non-admins. The per-row hamburger trigger now
renders for every signed-in user (no more {% if currentUser.isAdmin %}
on the title cell), and clicking it opens a single body-attached
popup positioned to the right of the icon and vertically centred on
it (cellPopover positioning model). Three panes:
▸ items toggle the third pane.← Sprint X / → Sprint Y,
hover title = "Copied from / to: ()") at
the bottom.
POST /tasks/{id}/move /
/copy endpoints and closes the popup.
Closing mirrors the Phase-18 cell popover: outside-pointerdown,
Escape, scroll / resize, and a 250 ms mouseleave grace.
Server-side, the five hamburger-action endpoints
(PATCH /tasks/{id}, DELETE /tasks/{id}, POST /tasks/{id}/move,
POST /tasks/{id}/copy, POST /sprints/{id}/tasks/reorder) now
use SessionGuard::requireAuthJson instead of requireAdminJson —
any signed-in user can edit / move / copy / pick up / delete a task,
matching the explicit operator decision to give non-admins the same
hamburger affordances. Inline title / owner / priority editing and
+ Add task stay admin-only as today (still gated by
{% if currentUser.isAdmin %} in _task_list.twig and by
requireAdminJson on POST /sprints/{id}/tasks and
PATCH /tasks/{id}/assignments). Touchpoints:
src/Controllers/TaskController.php (5 guard swaps),
views/sprints/_task_list.twig (drop the admin gate around the
hamburger button; revert the title cell to the simple
flex layout that ships the URL link affordance only),
public/assets/js/sprint-planner.js (replaces the old single-column
task-menu with the wider task-menu-inner flex container —
buildTaskMenu, fillTaskMenuFlyout, fillTaskMenuInfo,
positionTaskMenu (right + vcenter, falls back to left flip),
openTaskMenu / closeTaskMenu with cancelTaskMenuGrace /
scheduleTaskMenuGrace; retires the brief info-popover from the
prior commit and its renderTaskRefs helper); and
assets/css/input.css (new .task-menu-list, .task-menu-info,
.task-menu-flyout, .task-menu-info-ref-chip, .task-menu-divider;
retires the unused .task-title-grid, .task-info-popover,
.task-desc-popover). No migration / new endpoint — the existing
data-description, data-url, and linkedMap keep driving the
popup; new data-task-title / data-links row attributes feed the
info pane without an extra round-trip.Quality-of-life release on top of v0.22.0: explicit OIDC kill-switch
for dev / testing, a production-bootstrap guard against shipping an
unreachable instance, and a refreshed admin-only Runtime panel that
surfaces app version + creator instead of the PHP version.
OIDC_ENABLED kill-switch for dev / testing. New env var (default
true) that, when set to false / 0 / no / off (case-insensitive,
trimmed), forces OidcClient::isConfigured() to return false even when
every ENTRA_* var is populated. /auth/login and /auth/callback both
short-circuit to the same operator-facing 503 config page, with copy
that distinguishes "disabled by flag" from "not configured". Lets dev /
on-prem deployments route everyone through LOCAL_ADMIN_* without
unsetting the Entra creds in .env. New OidcClient::isExplicitlyDisabled()
helper and 6 lock-in tests in tests/Auth/OidcClientTest.php.App\Meta — single source of truth for app version + creator.
New class exposes Meta::VERSION (0.23.0) and Meta::CREATOR
(Alessandro Chiapparini); bump alongside the release commit so the
CHANGELOG heading, the git tag, and the in-app Runtime panel stay
aligned.public/index.php now aborts with a 503 + Retry-After: 30 and an
error_log line when APP_ENV=production and neither OIDC nor
LOCAL_ADMIN_* is enabled. Stops a fully unreachable instance from
shipping silently after a misconfigured deploy./ swaps contents. Drops the PHP
row; adds App version and Creator (sourced from App\Meta). The
OIDC row's value vocabulary changes from configured / not configured
to enabled / disabled, matching the Local admin row, so
OIDC_ENABLED=false reads naturally. No leak-surface change — the gate
(currentUser is not null and currentUser.isAdmin from R01-N02) is
unchanged, and TwigViewTest::testHomeForAnonymousUserHidesRuntimePanel
was updated to assert that neither the new appVersion nor
appCreator strings render for anonymous visitors.First tagged release. The minor version mirrors the latest build phase
shipped (Phase 22). Section grouping below reflects the build-phase history
captured in SPEC.md §9 plus the security-review hardening pass tracked in
doc/REVIEW_01.md (R01-N01..R01-N34).
/audit admin page with filters,
pagination, collapsible diffs; security headers + strict-ish CSP; CSRF
audit (18/18 mutations); PHPUnit harness with 59 tests.GET /users + POST /users/{id} with
self-demote and last-admin guardrails.onclick replaced by data-href + app.js; CSP
dropped unsafe-inline and the Tailwind CDN host.sprint_weeks.active_days_mask becomes the source of truth;
max_working_days is a cached popcount(mask) projection. Five
checkboxes per week in Sprint Settings; row of five dots per week in
the sprint view (green = active, gray = off). Migration 002 backfills
legacy rows.[data-focus-select] picks one sprint worker; applyFocusColumnVisibility
hides all-zero columns; [data-reset-filters] wipes search, prio,
ownerFilterSet, focusWorker, and hiddenCols in one click. State persists
in localStorage.data-menu-trigger button with an
inline-SVG icon; vanilla-JS toggle (~30 lines) honours outside-click,
Escape, and focus return./sprints/{id}/present renders a stripped-down view for projection;
shared partial with /sprints/{id} via a loadSprintPage helper.
Auto vertical column headers when overflow detected.prefers-color-scheme auto-detect); theme-init.js
synchronously sets <html class="dark"> from localStorage to prevent
FOUC; comprehensive dark: class sweep across every view.@layer base reset); per-cell click-to-open
vertical-slider popover on day, RTB, and task-assignment inputs. After
seven iterations the implementation was removed in favour of plain
typed entry; ArrowUp/ArrowDown stepping retained via browser default.task_assignments.status ∈ {zugewiesen, gestartet, abgeschlossen,
abgebrochen}; new app_settings KV table with opt-in
task_status_enabled flag; admin /settings page; Status multi-select
filter; first non-admin write surface (PATCH
/tasks/{id}/assignments/status). Migration 003.views/*.php rewritten as *.twig. Strict CSP:
script-src 'self' and style-src 'self' only — no third-party
hosts./sprints/import; multipart upload (≤ 5 MB, ZIP magic-byte check),
preview screen with target picker (Create new / Merge into empty
existing), per-sheet skip toggle, transactional commit; per-sprint
IMPORTED_FROM_XLSX audit row. New service trio:
XlsxColorClassifier, XlsxSprintImporter, SprintImporter.
Composer dep phpoffice/phpspreadsheet ^3.4; runtime stage adds
zip + gd extensions.PATCH
/sprints/{id} resyncs sprint_weeks whenever start_date or
end_date changes (target = floor((end − start)/7) + 1, capped at
26). Existing rows realign; appended rows default to MASK_ALL;
trailing rows shrink with the same audit-cascaded-days flow as the
legacy replaceWeeks.description (≤ 8000
chars), url (http(s)://, ≤ 2048 chars), and linked_task_id for
bidirectional cross-sprint copy chips. Two new admin JSON endpoints
(POST /tasks/{id}/move, POST /tasks/{id}/copy). Migration 004.POST /sprints/{id}/delete form gated by requireAdmin + CSRF +
typed-confirmation match; full-cascade audit (every descendant
snapshotted) plus an UPDATE audit for cross-sprint linked rows whose
linked_task_id was nulled out./sprints/import link moved from inline in the header to the top of
the admin section in the hamburger menu./sprints/new no longer
collects n_weeks; the count is derived from start_date / end_date
(capped at 26; above that redirects with ?error=dates_too_long).whitespace-nowrap;
per-row assignment cells gain whitespace-nowrap; .assign-status-*
tint moved off the <td> onto the inner field.mouseleave with a 250 ms grace timer./sprints/{id} is
split into "Arbeitstage and capacity" + "Capacity and tasks" tabs,
persisted in localStorage. The present view's Close button calls
history.back() when possible, falling back to window.close() then
to navigation..cell-popover panel anchored 8 px right of the cell; left column is
a slider whose max comes from assignment_slider_max (1..100,
default 10), right column is four status pills.@layer base rule that hides native number-spinner
arrows app-wide stays.doc/REVIEW_01.md)LOCAL_ADMIN_PASSWORD
→ LOCAL_ADMIN_PASSWORD_HASH; password_verify() against a bcrypt
hash; no plaintext fallback./ is admin-only.
views/home.twig's "Runtime" <details> block was leaking PHP
version, env, SQLite path, schema version, and OIDC flags to anonymous
visitors; tightened to currentUser is not null and currentUser.isAdmin.
In-page /healthz hint also removed (the route stays public for
liveness probes)./auth/callback;
BOOTSTRAP_ADMIN_OID / BOOTSTRAP_ADMIN_EMAIL name the principal up
front. Without one of them set, OIDC never auto-promotes anyone.SESSION_SECRET removed. The env var was documented but
unused; deployments that rotated it got a false sense of security.TrustedProxies honours a comma-separated TRUSTED_PROXIES=
env var and walks the XFF chain right-to-left; with the env blank,
forwarded headers are ignored. Session cookie marked Secure when
either APP_BASE_URL is HTTPS or the live request is effectively
HTTPS via a trusted proxy. One-shot HTTP→HTTPS redirect (308) when
the proxy explicitly reports X-Forwarded-Proto: http.auth_throttle. Policy in computeLockout(attempts, now):
1–4 → no lock; 5–9 → +5 min; 10–19 → +30 min; 20+ → +1 hour. Counter
rolls over after a 15-min idle window; success deletes the row.
LOGIN_FAILED audit row distinguishes throttled hits from
credential mismatches.IDLE_TIMEOUT_SECONDS = 1800); last_active stamp
drops auth keys + regenerates the session id past the threshold.
Login now unset()s csrf_token so a pre-login token can't be
replayed against the authenticated session.sprint_id with placeholders in MAX(sort_order)
lookups. Three repo-level read paths previously interpolated the
integer parameter directly into SQL (not exploitable today; route
layer int-casts) — switched to prepared statements.AuditRepository::distinctColumn. Private helper interpolated its
$col argument; added explicit in_array($col, ['action',
'entity_type'], true) guard./audit date filters server-side. Strict
Y-m-d parse via DateTimeImmutable::createFromFormat round-trip
equality; bad input drops the filter and surfaces a per-field error.FatalErrorHandler
registers both set_exception_handler and a fatal-mask
register_shutdown_function. On fire, every output buffer is drained
and a minimal 500 page is written with the same security headers a
normal response carries (CSP / HSTS / X-Frame-Options /
X-Content-Type-Options / Referrer-Policy). public/index.php now
sources its security headers from
FatalErrorHandler::securityHeaders() — single source of truth.MAX_SESSION_PAYLOAD_BYTES. Pruning
paths emit an IMPORT_PREVIEW_ABANDONED audit row.noreferrer on external task URL link. The
user-controlled t.url link in views/sprints/_task_list.twig now
carries rel="noopener noreferrer"; sequential sprint IDs no longer
leak via Referer.bin/audit.sh composer-audit helper. Wraps composer
audit --locked --no-interaction inside the runtime image so the
audit reflects the live container's dependency tree (host PHP often
lacks the right ext-* set). Honours SPRINT_PLANER_IMAGE for
non-default tags. Admin manual §5.5 documents a recommended cadence.OidcClaims::resolveEmail() is
the single decision point: drops the preferred_username fallback;
honours claims.email only when email_verified !== false; falls
back to entra:<oid> when no trusted email is available.report-uri /csp-report + audit endpoint. Strict
CSP gains a report URI; new public POST /csp-report route
(CspReportController) writes one audit row per browser-fired
report. Bodies that fail to decode or exceed the 16 KiB cap return
204 with no row written.Response::redirect rejects non-path locations. Refuses
anything that is not a single / followed by a non-/, plus
CR/LF/NUL guard (header injection). The HTTP→HTTPS canonical
redirect moves to a new Response::external($url, $status) helper
that scheme-restricts to http/https.tests/Http/TwigAutoescapeTest.php: a behaviour pin renders a
known XSS payload through the wired-up View env and asserts HTML
escaping; a static guard walks every .twig file under views/ and
fails on any |raw, |safe, or {% autoescape directive.bin/docker-entrypoint.sh
→ php bin/migrate.php runs BEFORE Apache binds the port; a failed
migration aborts container start. The web request path only checks
schema_version and returns 503 when something is pending.users.tombstoned_at for soft erasure. Privacy-request
path that overwrites email / OID with placeholder values, retains
the row for FK integrity, and stops the user from signing back in.X-Permitted-Cross-Domain-Policies: none header. Emit
alongside the other security headers.$changed[] in SettingsController::update.
Code-quality cleanup; no behaviour change.The following review findings were closed without code change after
analysis; see doc/REVIEW_01.md for the rationale on each:
R01-N09 (SameSite=Lax retained — Strict would block the OIDC
callback), R01-N17 (concurrent-tab OIDC clobber is correct
RFC behaviour), R01-N29, R01-N30, R01-N32, R01-N33, R01-N34.