OscarPro Schedule Template Schema — Source Findings¶
Status: Provisional — findings marked from code are definitive; those marked needs DB query are not settled, and the queries that would settle them are listed under DB queries still wanted.
Source repo: oscar-pro/src/main/java/ca/kai/schedule/
FHIR converter (existing): oscar-pro/src/main/java/ca/kai/fhir/converter/r4/{ScheduleConverter,SlotConverter,ScheduleTemplateCodeConverter}.java
This note answers the six assumptions the schema investigation was scoped to verify. Each finding flags whether it's from code (definitive) or needs DB query (still pending).
TL;DR — the picture changed materially¶
OscarPro already has partial FHIR Slot/Schedule converter code in production. This was not flagged in the original plan. Three components exist:
ScheduleConverter.java— emitsSchedule+ bundledSlot[]per providerSlotConverter.java— walkstimecodeand producesList<Slot>for a given dateScheduleTemplateCodeConverter.java— emits a FHIRValueSetfrom thescheduletemplatecodetable
This significantly reshapes the converter contract's role: it is no longer a greenfield handoff. It's a migration spec from the existing partial converter (which has several base-FHIR violations — see §"Existing converter bugs" below) to the new profile-compliant emission.
Per-assumption findings¶
Assumption 1: timecode length is always 96¶
Code finding (definitive): FALSE — length is variable, treated as such by the existing converter.
SlotConverter.toSlot() line 40 computes int slotDuration = minutesInADay / timeCode.length();. The slot duration is derived from the length, not assumed. Common deployments may use 96 (15-min) or 48 (30-min) or 24 (60-min), but the converter handles any divisor of 1440.
Impact on the Slot profile: Do not constrain Slot.start/.end to a 15-minute grid. Keep them as base FHIR instant. Document the convention that slot duration = 1440 / len(timecode) minutes in oscar-to-fhir.md.
DB query still wanted: distribution of observed timecode lengths in production (96 vs 48 vs other) — informs example fixtures but doesn't change the profile.
Assumption 2: scheduletemplatecode.duration is reliably populated¶
Code finding (partial): Field is String (not int) with default "15". Default value means the field is never null in new rows. The existing ScheduleTemplateCodeConverter line 77 wraps it in new IntegerType(duration) — which throws if the string is non-numeric. So if a legacy row has e.g. "15min" or "", the existing converter crashes.
Impact on the Slot profile: Acceptable to require duration in FSH bindings IF the DB query (below) confirms no rows violate. Otherwise add defensive handling note in converter-contracts.md.
DB query still wanted: SELECT COUNT(*) FROM scheduletemplatecode WHERE duration IS NULL OR duration !~ '^[0-9]+$';
Assumption 3: Providers share templates across locations¶
Code finding (definitive): ScheduleTemplate has NO location relationship. Primary key is (provider_no, name). Templates are provider-scoped; location is attached only at Schedule construction time (and the existing converter doesn't attach Location at all — see §"Existing converter bugs" #3 below).
Impact on the Schedule profile: Confirms Schedule.actor[Location] should be 0..1 MS (or 0..* if a provider works at multiple locations under one Schedule). Existing converter emits no Location, so any Schedule design that requires one is a change to current behavior. Document in fhir-to-oscar.md.
DB query still wanted: count of distinct templates per provider (do providers maintain many templates or just one?) — informs whether one Schedule per provider or per (provider, template) is more accurate.
Assumption 4: Every timecode char has a matching scheduletemplatecode row¶
Code finding (partial): SlotConverter line 47-49 calls findByCode(timeCodeChar). If the lookup returns null, the slot is still emitted but without the template-code extension — no exception thrown. So partial coverage is tolerated by current code.
Also: '_' is treated specially (line 43) — when timecode[i] == '_', no slot is emitted at all (continue). This is the "no slot here" convention.
Impact on the Slot profile: rawCode slice on Slot.serviceType.coding should be 0..* MS, not 1..* MS. The existing converter already produces Slots without rawCode mapping for unmatched chars. Tightening to 1..* would break the existing converter.
DB query still wanted: SELECT DISTINCT char FROM (unnested timecode chars across all scheduletemplate rows) WHERE char NOT IN (SELECT code FROM scheduletemplatecode); — quantifies the gap.
Assumption 5: Slots starting after 23:30 cross midnight¶
Code finding (definitive): No — midnight overflow is prevented by construction. SlotConverter.createSlot() lines 79-80 compute LocalTime starting from 00:00:00 plus index offsets. LocalTime is 24-hour-wrapped: the last slot index (e.g. 95 in a 96-char timecode) produces start 23:45 and end 00:00 (next day boundary, but LocalTime.plusMinutes(slotDuration * (index+1)) may wrap silently).
Potential bug: the existing code uses LocalTime.plusMinutes(...) which wraps at midnight (a known JDK behavior). The end time of the last slot could be incorrectly 00:00 (same date) instead of 00:00 next day. Worth a follow-up unit test, not blocking.
Impact on the Slot profile: Profile is unaffected; Slot.end is instant which doesn't have this issue at the FHIR level. Flag the converter behavior in converter-contracts.md.
Assumption 6: How blackout/holiday days are represented¶
Code finding (definitive): Two-layer model.
scheduleholidaytable (entityScheduleHoliday.java) — keyed bysdate, withholiday_name. Existing converter checks this and emits ahttps://apps.health/List/schedule-date-is-holidayboolean extension on the day's Bundle entry (line 162-163).ScheduleDate.availablechar — default'1'(IS_AVAILABLE). Other values presumably indicate within-day blockouts. Existing converter does not currently emit this signal into the FHIR output.
Impact on the Slot profile and the booking flow: The IG should map holiday days to Slot.status = busy-unavailable (or omit slot emission entirely). The existing converter does neither — it emits an out-of-band extension. Document the new contract: holidays → no slots, or slots with busy-unavailable. Decision for booking-flow-design.md.
DB query still wanted: SELECT COUNT(*) FROM scheduledate WHERE available != '1'; — quantifies within-day blockout usage. If zero, ignore the available field; if non-zero, decide its FHIR mapping.
Existing converter bugs / divergences (new — not in original spike scope)¶
Discovered while reading the existing converter; surfacing here because they affect the converter contract's scope.
-
Slot.scheduleis never set. Base FHIR R4 requiresSlot.schedule 1..1.SlotConverter.toSlot()returns slots without setting.schedule. Any consumer running base-FHIR validation against current OscarPro output gets a validation error. -
Slot.statusis never set. Also1..1required in base FHIR. Same problem. -
Schedule.actorreferencesPractitioner/, notPractitionerRole/. Line 82 ofScheduleConverter:schedule.addActor().setReference(Practitioner.class.getSimpleName() + "/" + providerNo);. OurNexusEmrCoreScheduleprofile constrains actor toNexusEmrCorePractitionerRole. This is a direct conflict — the existing converter must be updated. -
Extension URLs use
https://apps.health/instead ofhttps://fhir.apps.health/. Inconsistent with the canonical IG base. Examples:EXTENSION_URL_SCHEDULE_TEMPLATE_CODE = "https://apps.health/Slot/schedule-template-code",EXTENSION_URL_SCHEDULE_DATE = "https://apps.health/List/schedule-date". These should be migrated or aliased. -
No appointment-overlay logic. Plan §3 calls for overlaying the
appointmenttable to setSlot.status=busyon slots that have a booked appointment. Existing converter does not do this — all slots are emitted with no busy/free distinction. -
Nested Bundle-of-Bundles structure.
toSchedule()returns a Bundle containing per-provider Bundles containing Schedule + per-day Bundles containing Slots. Unusual; downstream consumers may struggle with the nesting. Consider flattening in the migration.
Recommended adjustments to the implementation plan¶
Suggested before the Schedule profile work starts:
- Slot profile: relax
Slot.serviceType.coding[rawCode]to0..*(not1..*). Confirmed by code; DB query will refine the rationale. - Slot profile: do not assume 96-char timecode in any example fixture. Use whatever the existing converter produces.
- Schedule profile: keep
Schedule.actor[PractitionerRole] 0..1 MSas designed — but note the existing converter usesPractitioner, notPractitionerRole. The converter contract must explicitly require the converter migrate toPractitionerRole. - Converter contract: rescope from "greenfield handoff" to "migration spec for existing converter," covering at minimum: setting
Slot.status+Slot.schedule, changing actor type, aligning extension URLs, adding appointment-overlay for busy/free status, deciding holiday →busy-unavailablevs omit. booking-flow-design.md: document the holiday + within-day-blockout (ScheduleDate.available) decision.
DB queries still wanted¶
Minimum query set to close the spike:
-- Q1: timecode length distribution
SELECT LENGTH(timecode) AS len, COUNT(*) FROM scheduletemplate GROUP BY LENGTH(timecode);
-- Q2: scheduletemplatecode duration sanity
SELECT COUNT(*) AS bad_duration FROM scheduletemplatecode
WHERE duration IS NULL OR duration !~ '^[0-9]+$';
-- Q3: distinct templates per provider
SELECT provider_no, COUNT(*) AS template_count FROM scheduletemplate
GROUP BY provider_no ORDER BY template_count DESC LIMIT 20;
-- Q4: timecode chars without scheduletemplatecode coverage
-- (DB-specific; example for Postgres using string_to_array + unnest;
-- for MySQL, use a temp table walking the string char-by-char)
WITH chars AS (
SELECT DISTINCT SUBSTRING(timecode FROM g FOR 1) AS c
FROM scheduletemplate, generate_series(1, LENGTH(timecode)) AS g
WHERE timecode IS NOT NULL
)
SELECT c FROM chars WHERE c != '_' AND c NOT IN (SELECT code FROM scheduletemplatecode);
-- Q5: within-day blockout usage
SELECT available, COUNT(*) FROM scheduledate GROUP BY available;
-- Q6: scheduleholiday population
SELECT COUNT(*) FROM scheduleholiday;
SELECT MIN(sdate), MAX(sdate) FROM scheduleholiday;
These run read-only against any OscarPro dev/staging DB. Results paste into this file under a "DB findings" section, and the spike closes.
Status checklist¶
- [x] Source-code investigation complete (this file)
- [ ] DB queries Q1–Q6 run; results captured below
- [ ] Recommended plan adjustments accepted or rejected
- [ ] Schedule and Slot profile work unblocked