Repository source: docs/plans/event_time/event_time_step4_implementation_summary.md
Stage 1 - Canonical record type and parser accept Event-Time
Changes:
src/ForumRewrite/Canonical/PostRecord.php: added one trailing nullable constructor property,?string $eventTime = null, after$eventLink.src/ForumRewrite/Canonical/PostRecordParser.php:Added
Event-TimetoEVENT_HEADERS(so it's rejected on replies, same as the other three event headers).Added
parseEventTime(), mirroringparseEventDate()'s shape: strict^\d{2}:\d{2}$regex, then aDateTimeImmutable::createFromFormat('!H:i', ...)round-trip check.parse()extractsEvent-Timewhen present and passes it as the new trailing argument toPostRecord.
Verification:
php tests/run.phpfull suite: 946/950 passed, same 4 pre-existing failures as before this change (LocalAppSmokeTest::testFeatureFlagsPageShowsLockedBadgeWithReasonForNonMutableFlags,QuoteCardDisplayNumberTest::testQdbWelcomeDisplaysThreeNewestNewsItemsAndLinksToAllNews,WriteApiSmokeTest::testTaskQueueProcessesQueuedAgentReplyOnce,WriteApiSmokeTest::testQdbPermalinkShowsViewersExistingUpvoteAsPressedAndDisabled), no new failures.Hand-built record parse cases, run directly against
PostRecordParser:No event headers at all →
eventDate/eventTimebothnull.Event-Date: 2026-11-14+Event-Time: 19:00→ both parsed correctly (eventTime === '19:00').Event-Time: 25:99(malformed) → threwCanonicalRecordParseException: Event-Time must be a valid 24-hour time.Event-Time: 19:00on a reply record (hasThread-ID/Parent-ID) → threwCanonicalRecordParseException: Replies must not include typed root header: Event-Time.
Notes:
All four cases behaved exactly as planned; no surprises in this stage.
Stage 2 - Write-path validation and compose form
Changes:
src/ForumRewrite/Write/LocalWriteService.php:Added
normalizeEventTime(), mirroringnormalizeEventDate()(optional, strictHH:MMregex +DateTimeImmutable::createFromFormat('!H:i', ...)round-trip).assertEventSupportAllowsInput()now also gatesevent_time(not justevent_date/event_location/event_link) when event support is disabled.createThread()reads, normalizes, and forwardsevent_timetobuildThreadPostRecord().buildThreadPostRecord()emitsEvent-Time:immediately afterEvent-Date:, only when non-empty.
templates/partials/thread_compose_form.php: swapped the event-date input fromtype="text"to nativetype="date"(dropped the now-redundant placeholder); added a new nativetype="time"input forevent_time, both optional.src/ForumRewrite/Http/ComposeAndAccountKeyController.php: threadedevent_timethroughcomposeThread()(GET-prefill),submitComposeThread()'s validation-error re-render, andrenderComposeThreadPage()(signature +eventTimetemplate var) — identical shape to the existingevent_datehandling at each site.
Verification:
php tests/run.phpfull suite: 946/950 passed, same 4 pre-existing failures, no new ones.Direct
LocalWriteService::createThread()calls against a scratch repository: date+time both filled → wrote a record with both headers; date-only → wrote a record with justEvent-Date; malformed time (99:99) → threwRuntimeException: event_time must be a valid 24-hour time.and confirmed (via a repository file scan) zero files were written for that attempt.Started the dev server and fetched
/compose/thread?event_date=2026-11-14&event_time=19:00: confirmed the rendered page's nativetype="date"/type="time"inputs both carry the correct prefilled values.
Notes:
Caught and fixed one gap vs. the Step 3 plan while implementing:
assertEventSupportAllowsInput()only checked the original three fields, soevent_timealone would have bypassed the event-support gate when the flag is off. Added it to that check in this stage rather than deferring, since it's part of making Stage 2's gate correct, not separate scope.
Stage 3 - Read model
Changes:
src/ForumRewrite/ReadModel/ReadModelSchema.php:threadstable gains one nullableTEXTcolumn,event_time.src/ForumRewrite/ReadModel/ReadModelMetadata.php:SCHEMA_VERSIONbumped'14'→'15'to trigger the existing full-rebuild path.src/ForumRewrite/ReadModel/ReadModelBuilder.php:INSERT INTO threadscolumn list/bindings, the parsed-post array, and the in-memory thread-summary array all gainevent_time.src/ForumRewrite/ReadModel/IncrementalReadModelUpdater.php:insertThread()'sINSERT INTO threadscolumn list/bindings gainevent_time.src/ForumRewrite/ReadModel/ThreadRepository.php(fetchThreads()andbyId()) andsrc/ForumRewrite/Application.php(fetchThread()): all three explicitSELECTcolumn lists gainthreads.event_time.
Verification:
php tests/run.phpfull suite: 946/950 passed, same 4 pre-existing failures, no new ones.Against a scratch repository: created one thread with date+time and one with date-only via
LocalWriteService::createThread()(incremental path). Queried both viaThreadRepository::fetchThreads()and::byId()immediately (no rebuild): the time-bearing thread'sevent_timewas'19:00'on both; the date-only thread's wasNULLon both.Then deleted the database and ran a full
ReadModelBuilder::rebuild()from the same canonical records. Re-queried viafetchThreads(),byId(), and a raw SQL statement mirroringApplication::fetchThread()'s column list: all three returnedevent_time === '19:00'for the time-bearing thread, confirming the full-rebuild path and all three read sites agree with the incremental path.
Notes:
All grep-confirmed read/write sites for
event_date(schema + 2 write paths + 3 read call sites) now have a parallelevent_timeentry — no site missed.
Stage 4 - Render and end-to-end verification
Changes:
templates/partials/event_block.php: extractsevent_time; when present, appends" at {time}"to the existingevent-block__datespan, right after the date; renders exactly as before when absent.
Verification:
php tests/run.phpfull suite: 946/950 passed, same 4 pre-existing failures, no new ones.Started the dev server (
FORUM_SITE_ID=mitrapclub) and created a live event thread with date+time+location+link viaLocalWriteService::createThread()(wired withFeatureFlagEvaluator::forApplication(), matching how the real app constructs it). Screenshotted the thread page and the board page: both show📅 2026-11-20 at 19:30 · MIT Media Lab, E14 · https://example.com/cypher-night-2, confirming the time renders correctly on both surfaces (closing the two-surfaces risk from Key Risks).Re-fetched the Cycle 8 fixture event thread (date + location + link, no time):
event-block__datestill renders as plain📅 2026-11-14with no" at ..."suffix — zero regression for an existing date-only event thread.Started a second dev server instance with
FORUM_SITE_ID=qdband confirmed the board page returns 200 with zeroevent-blockmarkup present (qdb's board uses thequotecard presentation slot, notthread_card/event_block) — confirms no crash or stray markup on a profile that doesn't surface events via this card type.
Notes:
Completion Contract met: a thread with an event time renders it next to the date on both the board card and the thread page, for any profile with event support enabled; a thread without one is byte-identical to before this feature; malformed input fails closed at write time (Stage 2); no deployment step needed beyond the schema-version-triggered rebuild (Stage 3).