Repository source: docs/plans/event_time/event_time_step3_development_plan.md
Completion Contract
Normal entry: a member on a profile with event support enabled fills in (or leaves blank) the new time field when composing a thread via the shared compose form.
End-to-end outcome: a thread whose root post carries a valid event time renders that time next to its date in the event block, on both the board card and the thread page, for any profile; a thread without one renders byte-identical to today.
Required recovery: a malformed event time is rejected before any write happens — never written, never half-applied — same fail-closed pattern as the existing
event_datevalidation.Deployment/external verification: not applicable.
Release condition: full test suite (
php tests/run.php) passes; rendered board/thread output for existing fixture threads (none with a time) is unchanged on every profile.
Key Risks
High risk: a malformed time must never corrupt a thread or crash a page. Mitigation: validate/normalize at compose time with the same strict-format-plus-round-trip shape
normalizeEventDatealready uses, raising the sameRuntimeExceptionthe existing compose-error path already catches. Early validation: Stage 2's malformed-input case.Three separate SELECT call sites already carry
event_date/event_location/event_linkexplicitly (ThreadRepository::fetchThreads(),ThreadRepository::byId(),Application::fetchThread()) — confirmed current via direct grep, not assumption. Missing any one of the three would make the time show on some surfaces but not others. Mitigation: Stage 3 updates all three explicitly; Stage 4 verification renders the same event thread on the board AND the thread page to catch a mismatch.Two independent read-model write paths must agree: the full rebuild (
ReadModelBuilder) and the live incremental path (IncrementalReadModelUpdater). Mitigation: both already consume the samePostRecordDTO; adding the field to that one DTO (Stage 1) means both read from a single source of truth. Early validation: Stage 3 verifies both paths directly (one via rebuild, one via a live create).No in-place schema migration exists in this codebase (confirmed in Step 1); a new column requires the existing schema-version-bump → full-rebuild path. Mitigation: follow that exact precedent (already used for
event_dateitself); no new mechanism needed.
Stage 1
Goal: the canonical post-record format and its parser recognize one new optional, root-only
Event-Timeheader.Dependencies: none.
Expected changes:
src/ForumRewrite/Canonical/PostRecord.php: add one nullable trailing constructor property,?string $eventTime = null, after$eventLink.src/ForumRewrite/Canonical/PostRecordParser.php: addEvent-TimetoEVENT_HEADERS(rejected on replies, same as the other three); parse it with a newparseEventTimemirroringparseEventDate's shape — strictHH:MM(24-hour) regex plus a round-trip check; pass the result into thePostRecordconstructor as the new trailing argument.
Verification approach:
php tests/run.phpfull suite.Parse hand-built record strings directly: no header;
Event-Date+Event-Timeboth valid; a malformedEvent-Time(e.g.25:99,7:30pm);Event-Timepresent on a reply record. Confirm each produces the expectedPostRecordvalue or the expected thrown exception.
Risks or open questions:
Impact: a loosely-defined
Event-Timeformat now makes Stage 2's write-time validation and Stage 4's rendering inconsistent later.Early warning / validation: the hand-built parse cases above, before Stage 2 begins.
Mitigation: reuse
parseEventDate's exact validation shape (regex + round-trip), with a 24-hourHH:MMpattern.
Canonical components/API contracts touched:
PostRecord,PostRecordParser.
Stage 2
Goal: a member can submit the new optional time field when composing a thread, via a native picker; invalid input is rejected before any write.
Dependencies: Stage 1 (parser must accept the header before the write path can build/validate it).
Expected changes:
src/ForumRewrite/Write/LocalWriteService.php: newnormalizeEventTimevalidator mirroringnormalizeEventDate;createThread()reads optionalevent_timefrom$input, normalizes it, and passes it tobuildThreadPostRecord();buildThreadPostRecord()emitsEvent-Time:immediately afterEvent-Date:(only when non-empty, same conditional-emission pattern as the other event headers).templates/partials/thread_compose_form.php: swap the existing event-date input fromtype="text"to nativetype="date"; add one new nativetype="time"input forevent_time, both optional, same label style as the existing fields.src/ForumRewrite/Http/ComposeAndAccountKeyController.php: both the GET-prefill call site (composeThread()) and the validation-error re-render call site (submitComposeThread()) threadevent_timethrough the same way they already threadevent_date, andrenderComposeThreadPage()forwards it as aneventTimetemplate var.
Verification approach:
php tests/run.phpfull suite.Call
LocalWriteService::createThread()directly against a scratch repository: date+time both filled; date only; malformed time. Confirm the malformed case throwsRuntimeExceptionand writes no file; confirm the others write a canonical record with exactly the expected headers.Render the compose page with
event_timeon the query string (GET-prefill path) and confirm the native time input shows the correct value.
Risks or open questions:
Impact: see Key Risks (malformed time must fail closed).
Early warning / validation: the malformed-time direct-call case above.
Mitigation: validate before any
writeFile()call, same orderingcreateThread()already uses for its other fields.
Canonical components/API contracts touched:
LocalWriteService::createThread(),thread_compose_form.php,ComposeAndAccountKeyController.
Stage 3
Goal: the read model carries the new field through to every thread row, via both the full-rebuild and incremental paths, and every existing read site returns it.
Dependencies: Stage 1 (needs
PostRecordto carry the field) and Stage 2 (needs a way to produce records that have it, for end-to-end verification).Expected changes:
src/ForumRewrite/ReadModel/ReadModelSchema.php:threadstable gains one nullableTEXTcolumn,event_time;ReadModelMetadata::SCHEMA_VERSIONbumped to trigger the existing full-rebuild path.src/ForumRewrite/ReadModel/ReadModelBuilder.php: the in-memory thread-summary builder and theINSERT INTO threadscolumn list/bindings both gainevent_time, populated from the root post'sPostRecord.src/ForumRewrite/ReadModel/IncrementalReadModelUpdater.php: itsINSERT INTO threadscolumn list/bindings gainevent_time, from the samePostRecord(single source of truth with the rebuild path).src/ForumRewrite/ReadModel/ThreadRepository.php(fetchThreads()andbyId()) andsrc/ForumRewrite/Application.php(fetchThread()): all three explicitSELECTcolumn lists gainthreads.event_time.
Verification approach:
php tests/run.phpfull suite.Add one fixture thread with date+time and keep an existing date-only event thread and ordinary threads in a scratch repository; run a full rebuild and query the new thread's row via
ThreadRepository::fetchThreads()/byId()and viaApplication::fetchThread()'s path:event_timepresent and correct on all three; the date-only thread'sevent_timeis null on all three.Call
LocalWriteService::createThread()live (incremental path, no rebuild) with a time; confirm the resulting thread row already has it without running a rebuild.
Risks or open questions:
Impact / mitigation: both Key Risks items above (the three SELECT call sites; the two read-model write paths).
Early warning / validation: the rebuild-path and live-incremental-path checks above, run separately, before Stage 4 begins.
Canonical components/API contracts touched:
ReadModelSchema(threadstable),ReadModelBuilder,IncrementalReadModelUpdater,ThreadRepository,Application::fetchThread().
Stage 4
Goal: a thread with an event time renders it next to the date in the event block, on the board and the thread page, for any profile; everything else is unchanged; full end-to-end verification closes out the feature.
Dependencies: Stage 3 (the field must reach
$threadbefore a template can render it).Expected changes:
templates/partials/event_block.php: rendersevent_timeimmediately afterevent_datewhen present (e.g.📅 2026-11-14 at 19:00); renders exactly as today when absent.
Verification approach:
php tests/run.phpfull suite.Render the Stage 3 fixture's time-bearing event thread's board card and thread page: confirm the time appears correctly on both.
Render an existing date-only event thread (no time) and an ordinary non-event thread, on both surfaces, on at least two profiles (
mitrapcluband one other, e.g.qdb): byte-identical to pre-feature output, confirming zero regression anywhere event support isn't the subject of the test.
Risks or open questions:
Impact: a mismatch between the board card and thread page (only one updated) would look like a regression on the other.
Early warning / validation: the same-thread, both-surfaces check above.
Mitigation: one shared partial already included from both (
event_block.php), rather than two independent block implementations.
Canonical components/API contracts touched:
event_block.php.
Waiting for "Approved Step 3" before branching and starting Step 4.