Summary
Replace the legacy PostNuke PostCalendar visual layer (bundled Smarty 2.3.1, pn* shim,
SQL-in-.html templates) with a modern, OpenEMR-owned calendar rendering layer fed by normalized
JSON endpoints. The appointment data model and write workflows are explicitly preserved in this
issue. Initial target is the provider/resource day view; month/week follow once the day/resource
workflow is stable.
This is the rendering-only first slice of the broader calendar modernization (see the modernization
RFC). It deliberately does not change the schema, recurrence storage, status codes, the FHIR
mapping, or any write path.
Why rendering-only, data-model-preserved
A working-tree sweep shows openemr_postcalendar_events is written from ~30+ sites across the
codebase, including:
- encounter↔appointment linkage —
library/encounter_events.inc.php
- reminders / recall —
library/MedEx/API.php, modules/sms_email_reminder/*, interface/batchcom/*
- holidays —
src/Services/HolidayService.php, interface/main/holidays/Holidays_Storage.php
- telehealth —
oe-module-comlink-telehealth · fax/SMS — oe-module-faxsms
- CCDA import —
src/Services/Cda/CdaTemplateImportDispose.php
- tracker writing status/room back to events —
src/Services/PatientTrackerService.php
- the entire patient portal scheduler —
portal/add_edit_event_user.php, portal/find_appt_popup_user.php
- the canonical service —
src/Services/AppointmentService.php
Changing the schema would require touching all of them. Replacing only the rendering touches none
of them: every writer above keeps working unchanged because the tables don't change.
Scope
In scope
- New OpenEMR-owned day / resource (provider-column) calendar rendering.
- Read-only normalized JSON endpoint(s) exposing existing appointment + availability data.
- Threading the new view into existing launch points behind a feature flag, with the legacy
calendar remaining the default until proven.
- Reusing the existing add/edit dialog (
add_edit_event.php) as the editor — no write-path change.
Out of scope (explicit)
- Any schema change; recurrence (
pc_recurrspec) stays serialized; status (pc_apptstatus) stays as-is.
- FHIR remodel / RRULE / UTC migration (covered by the RFC's later phases).
- Write-path refactor; tracker/portal/reminder writers; the patient portal UI.
- Month and week views (fast-follow issues once day/resource is stable).
Architecture of the change
Read — normalized JSON endpoint(s)
- Add a read method to
src/Services/AppointmentService.php (reuse its existing event SELECT; do not
duplicate SQL) and expose it via a thin controller:
GET /calendar/events — appointments + availability blocks for a date + facility + provider set.
GET /calendar/resources — the provider/room list that forms the day-view columns.
- This replaces the read leaks identified in Phase 0 (
views/{day,week,month}/ajax_template.html,
find_appt_popup.php) for the new view — the new renderer never touches the DB directly.
- The endpoint normalizes the split
pc_eventDate + pc_startTime/pc_endTime into ISO
datetime strings for the client. It does not change storage and does not fix timezone
semantics — tz correctness remains a known follow-up.
Render — OpenEMR-owned, no premium/commercial deps
- Build the day/resource grid as OpenEMR-owned rendering (time rows × provider columns; absolutely
positioned event blocks; availability shading from the in_office/out_of_office categories).
- Do not adopt a full calendar framework for this view. The provider/resource-column view is the
feature that is paywalled across the ecosystem: FullCalendar resource/timeline is Premium;
Schedule-X resource view is Premium (and v4 moved drag/resize to Premium); MUI X resource layouts
are MIT but alpha and pull in all of MUI. Owning the grid avoids the licensing trap entirely and
matches OpenEMR's dependency-minimization posture.
- Permissive primitives are acceptable where they reduce risk: e.g.
interact.js (MIT) for
drag/resize hit-handling, and a small date utility. No GPLv3-keyed or commercial scheduler bundles.
Write / edit — unchanged
- Clicking an empty slot or an event opens the existing
add_edit_event.php dialog (same
workflow, same validation, same writers). The new grid only replaces the canvas around it, then
refreshes from the JSON endpoint on save. No appointment write logic is reimplemented here.
Threading / rollout
- Gate the new view behind a feature flag via
ContextManifest::featureEnabled(); legacy calendar
stays the default. Light it up first where the day/resource workflow matters most.
- Route the top-nav Calendar entry and the patient-summary "Add appointment" launch to the new view
when the flag is on, using the existing ScriptFilterEvent/StyleFilterEvent/menu hooks. The flag
is the rollback switch.
JSON contract (read-only, this issue)
GET /calendar/events?date=YYYY-MM-DD&facility=<id>&providers=<id,id>&category=<id>&status=<code>
Tasks
Acceptance criteria
- The day/resource view renders appointments and availability for a chosen date/facility/provider set
entirely from the JSON endpoint — no direct DB access from the view, no Smarty, no pn*.
- Creating/editing/cancelling from the new view goes through the existing dialog and writers; data
written is identical to the legacy calendar (same rows, same columns).
- Every existing writer (encounter linkage, MedEx/reminders, holidays, telehealth, faxsms, CCDA
import, tracker, portal) continues to function with zero changes.
- The new view is reachable only behind the feature flag; toggling it off restores the legacy
calendar with no data migration.
Out of scope / follow-ups
- Month and week views (separate issues, after day/resource is stable).
- Timezone normalization, status→FHIR value-set mapping, recurrence→RRULE, schema normalization
(RFC later phases).
- Patient portal rendering (
portal/*) — separate track.
- Write-path consolidation into
AppointmentService (RFC Phase 2).
Summary
Replace the legacy PostNuke PostCalendar visual layer (bundled Smarty 2.3.1,
pn*shim,SQL-in-
.htmltemplates) with a modern, OpenEMR-owned calendar rendering layer fed by normalizedJSON endpoints. The appointment data model and write workflows are explicitly preserved in this
issue. Initial target is the provider/resource day view; month/week follow once the day/resource
workflow is stable.
This is the rendering-only first slice of the broader calendar modernization (see the modernization
RFC). It deliberately does not change the schema, recurrence storage, status codes, the FHIR
mapping, or any write path.
Why rendering-only, data-model-preserved
A working-tree sweep shows
openemr_postcalendar_eventsis written from ~30+ sites across thecodebase, including:
library/encounter_events.inc.phplibrary/MedEx/API.php,modules/sms_email_reminder/*,interface/batchcom/*src/Services/HolidayService.php,interface/main/holidays/Holidays_Storage.phpoe-module-comlink-telehealth· fax/SMS —oe-module-faxsmssrc/Services/Cda/CdaTemplateImportDispose.phpsrc/Services/PatientTrackerService.phpportal/add_edit_event_user.php,portal/find_appt_popup_user.phpsrc/Services/AppointmentService.phpChanging the schema would require touching all of them. Replacing only the rendering touches none
of them: every writer above keeps working unchanged because the tables don't change.
Scope
In scope
calendar remaining the default until proven.
add_edit_event.php) as the editor — no write-path change.Out of scope (explicit)
pc_recurrspec) stays serialized; status (pc_apptstatus) stays as-is.Architecture of the change
Read — normalized JSON endpoint(s)
src/Services/AppointmentService.php(reuse its existing event SELECT; do notduplicate SQL) and expose it via a thin controller:
GET /calendar/events— appointments + availability blocks for a date + facility + provider set.GET /calendar/resources— the provider/room list that forms the day-view columns.views/{day,week,month}/ajax_template.html,find_appt_popup.php) for the new view — the new renderer never touches the DB directly.pc_eventDate+pc_startTime/pc_endTimeinto ISOdatetime strings for the client. It does not change storage and does not fix timezone
semantics — tz correctness remains a known follow-up.
Render — OpenEMR-owned, no premium/commercial deps
positioned event blocks; availability shading from the
in_office/out_of_officecategories).feature that is paywalled across the ecosystem: FullCalendar resource/timeline is Premium;
Schedule-X resource view is Premium (and v4 moved drag/resize to Premium); MUI X resource layouts
are MIT but alpha and pull in all of MUI. Owning the grid avoids the licensing trap entirely and
matches OpenEMR's dependency-minimization posture.
interact.js(MIT) fordrag/resize hit-handling, and a small date utility. No GPLv3-keyed or commercial scheduler bundles.
Write / edit — unchanged
add_edit_event.phpdialog (sameworkflow, same validation, same writers). The new grid only replaces the canvas around it, then
refreshes from the JSON endpoint on save. No appointment write logic is reimplemented here.
Threading / rollout
ContextManifest::featureEnabled(); legacy calendarstays the default. Light it up first where the day/resource workflow matters most.
when the flag is on, using the existing
ScriptFilterEvent/StyleFilterEvent/menu hooks. The flagis the rollback switch.
JSON contract (read-only, this issue)
GET /calendar/events?date=YYYY-MM-DD&facility=<id>&providers=<id,id>&category=<id>&status=<code>{ "date": "2026-06-03", "facility": 3, "resources": [ // also available via /calendar/resources { "id": "1", "name": "Dr. Smith", "type": "provider", "color": "#2b6" } ], "events": [ { "eid": 1421, "uuid": "9f2c…", // reuse existing binary(16), hex-encoded "resourceId": "1", // pc_aid "pid": "1207", "patientName": "Doe, Jane", "title": "Office Visit", "catId": 9, "categoryColor": "#cce5ff", "start": "2026-06-03T09:00:00", // normalized from pc_eventDate + pc_startTime (local, as-stored) "end": "2026-06-03T09:30:00", "status": "-", // raw pc_apptstatus; mapping is a follow-up "room": "", "allDay": false, "kind": "appointment", // or "availability" for in_office/out_of_office "recurring": true // surfaced from pc_recurrtype; expansion stays server-side } ] }Tasks
AppointmentServicereusing the existing event query (events + resources)./calendar/eventsand/calendar/resources(read-only, ACL-checked).add_edit_event.phpdialog; refresh grid from endpoint on save.ContextManifest; route nav + add-appointment launch points behind it.Acceptance criteria
entirely from the JSON endpoint — no direct DB access from the view, no Smarty, no
pn*.written is identical to the legacy calendar (same rows, same columns).
import, tracker, portal) continues to function with zero changes.
calendar with no data migration.
Out of scope / follow-ups
(RFC later phases).
portal/*) — separate track.AppointmentService(RFC Phase 2).