Visit Scheduling Config
How recurring VisitRequests become calendar occurrences via a UI-configurable occurrence projector.
How recurring VisitRequests become calendar occurrences, and how every knob
is admin-editable instead of hardcoded. Same config-driven philosophy as
policy_gates / cds_rules / compounding_configs.
VisitRequest itself (the recurring-visit intent — RRULE + bounds, one row per series, sparse overrides) is covered in the commit history under
feat(scheduling). This doc is the config + projection layer on top.
The layers
VisitRequest (Mongo write-truth) ──┐
visit_occurrence_override (Mongo) ──┤
▼
projectVisitOccurrences() ← pure core, reads VisitSchedulingConfig
│
┌───────────────────────────┴───────────────────────────┐
▼ read-time (shipped) ▼ materialized (next)
useProjectedVisitOccurrences(window) projector job (cron)
→ expands on the client per render → upserts public.visit_occurrence
→ no stored rows, always fresh → keyed on occurrence_key (idempotent)
└───────────────────────────┬───────────────────────────┘
▼
appointment calendar UI
Both paths run the identical pure core (projectVisitOccurrences); the only
difference is when and where it runs.
Integration with coding + revenue (the contract — read this)
A recurring visit occurrence is the upstream sibling of an Appointment. The
appointment → encounter → coding → revenue path already exists end-to-end; the
projector feeds it, it does not reinvent it. Hard rules:
- An occurrence is pre-clinical — it owes the coding/billing pipeline nothing
until the patient is kept.
visit_occurrencerows never appear incoding_worklist,v_receivables_unified, or any settlement tab. Don’t createSalesOrders orcoding_worklistrows from the projector. - The one debt is paid at check-in. When a VisitRequest occurrence is
checked in (
workflowState = 'wait-check-in'),visitRequest.acknowledgecreates anEncounter(administration.encounter.create,level=AMB,status=PLANNED, carryingpatientRef/clinicId/subClinicId/visitType/ doctor participant) and emitsmedication.encounterJourney.emitClinicalContextUpdatedwith avisit_contextenvelope — exactly mirroringappointment.controller.mixin.ts:563-633CHECKED_IN. - After that, coding + billing fire automatically.
fn_auto_populate_coding_worklistqueues the encounter once it’s paid (OPD) or discharged (IPD); revenue enters via theSalesOrderborn on charge-close. No extra wiring. - Carry scheme/payorPlan onto the encounter for correct coding/debtor-group
routing (
fn_auto_populate_coding_worklistreadsscheme_code). VisitRequest doesn’t capture payorPlan yet → follow-up: add a scheme field to the request/dialog, or resolve coverage at check-in like a walk-in.
What this layer must NOT own
- Daily-close / per-diem / “close at midnight” (UC/NHSO). There is no
midnight cron and no
facility_billing_rulerow. Per-day close is an evaluative pre-close gate in thercm-rule-engineedge function’s country pack (infrastructure/medbase/functions/rcm-rule-engine/country-packs/th-vajira.json:billCycleEligibility.{opd,ipd}→mode: PER_DAY,cutoffLocalTime,autoClose, grace, reopen), keyed off encounter LOS / discharge — not visit occurrences. Scheduling config must not try to own it. - Per-encounter conditional rules.
visit_scheduling_configis a catalog + projection-policy object, not a rule evaluator. If conditional projection rules are ever needed (e.g. “skip occurrences when the patient’s scheme has expired”), reuse thecds_rulestoken grammar (the same one the designedfacility_billing_rulemirrors) — don’t invent a new predicate DSL. - Ad-hoc cron.
projection.cadenceCronregisters through thecron_jobsregistry (migration036_cron_jobs_registry.sql), not a barecron.schedule.
Everything is UI-configurable
VisitSchedulingConfig (web/src/utils/scheduling/visitSchedulingConfig.ts) is
one object, edited at /admin/visit-scheduling-config
(VisitSchedulingConfigPage), persisted to public.visit_scheduling_config
(scope 'global' | clinicId). Nothing in the scheduling surfaces hardcodes
these any more:
| Knob | Drives |
|---|---|
projection.windowDays |
rolling-window size the calendar/job expands |
projection.onBlocked (keep/skip/next-open-day) |
holiday/blackout policy |
projection.projectStatuses |
which VisitRequest statuses appear |
projection.defaultDurationMinutes |
fallback occurrence length |
projection.materializeEnabled + cadenceCron |
read-time vs server job + its schedule |
catalog.visitTypes |
the dialog’s Visit-type dropdown |
catalog.recurrencePresets |
the dialog’s Repeats dropdown |
catalog.timezones + defaultTimezone |
the dialog’s Timezone dropdown |
Consumers read it via useVisitSchedulingConfig() (cached, synchronous default
so nothing blocks). The service (visit-scheduling-config.service.ts) falls
back to DEFAULT_VISIT_SCHEDULING_CONFIG when the row/table is absent — so the
whole system works before the migration runs and degrades gracefully offline.
To add/change options
Open /admin/visit-scheduling-config, edit, Save. No code, no deploy. (Adding
a brand-new knob = add a field to the model + a control on the page.)
Key files
| File | Role |
|---|---|
web/src/utils/scheduling/visitSchedulingConfig.ts |
config model + DEFAULT + merge |
web/src/services/visit-scheduling-config.service.ts |
Supabase load/save (graceful default) |
web/src/hooks/useVisitSchedulingConfig.ts |
cached config read |
web/src/containers/admin/visit-scheduling-config/VisitSchedulingConfigPage.tsx |
admin UI (/admin/visit-scheduling-config) |
web/src/utils/scheduling/expandVisitOccurrences.ts |
pure per-series RRULE expander (tz + holiday aware) |
web/src/utils/scheduling/projectVisitOccurrences.ts |
pure multi-series projector (deterministic occurrence_key) |
web/src/hooks/useProjectedVisitOccurrences.ts |
read-time calendar projection (config-driven) |
infrastructure/medbase/migrations/20260529c_visit_scheduling.sql |
visit_scheduling_config + visit_occurrence tables |
Materialized projector job (DEPLOYED + VERIFIED — invoke verified, 48 rows; apply the cron to automate)
Implemented as the Supabase edge function
visit-occurrence-projector
- the overrides access (folded into
GET /visitRequests/:_id?withOverrides=true, so no new gateway route). To activate:
- Apply
20260529c_visit_scheduling.sql(Supabase SQL editor /psql). supabase functions deploy visit-occurrence-projector.- Set secrets
MOLECULER_API_URL+MOLECULER_AUTH_TOKEN(same as the other*-mongo-syncfns) so it can read VisitRequests via REST. - Register the cron in the
cron_jobsregistry usingvisit_scheduling_config.projection.cadenceCron; flipmaterializeEnabledon.
The edge fn reads the config (window/statuses), fetches active VisitRequests +
their overrides via REST, expands with rrule (esm.sh), and upserts
visit_occurrence keyed on occurrence_key (idempotent), pruning this-series
rows left behind by the run. Design notes below.
Design
When materializeEnabled is on, a cron job stores occurrence rows so other
services / SQL consumers can read them (the read-time hook doesn’t need this).
Design (lift the same projectVisitOccurrences core server-side):
- Read active
VisitRequestseries + theirVisitOccurrenceOverrides. projectVisitOccurrences(series, overridesByReq, { start: now, end: now + windowDays }, { onBlocked }, { projectStatuses, defaultDurationMinutes })— config fromvisit_scheduling_config.upsertrows intopublic.visit_occurrenceonoccurrence_key(idempotent), stampingwindow_generated_at.- Prune rows for each series whose
window_generated_at< this run ANDoccurrence_dateoutside the window.
Two implementation options:
- Supabase edge function
visit-occurrence-projector(Deno) — needs a Deno RRULE port (ornpm:rrule); scheduled via thecron_jobsregistry usingprojection.cadenceCron. - Administration-service cron action
visitRequest.projectOccurrences— reuses Mongo access + the@supabase/supabase-jsclient the service already has; exposePOST /v2/administration/visitRequests/projectfor manual/cron trigger.
Overrides endpoint (the gap to close first)
useProjectedVisitOccurrences and the job currently project the base RRULE
only — per-occurrence cancel/reschedule/no-show live in Mongo
visit_occurrence_override but have no read path. Add to the administration
visitRequest module:
GET /v2/administration/visitRequests/:id/overrides- (or a batch
GET /v2/administration/visitOccurrenceOverrides?visitRequestIds=…) then passoverridesByRequestIdintoprojectVisitOccurrences(the param already exists).
Deploy checklist for the materialized path
- Apply
20260529c_visit_scheduling.sql(Supabase SQL editor orpsql -f). - Add the overrides endpoint to the administration visitRequest module → push (backend deploy) → whitelist
administration.visitRequest.*is already in the gateway, but a new sub-route still needs the gateway redeploy if aliases changed (see [[gateway-route-exposure]] /docs/architecture/miniapp-modal-override.md). - Ship the projector (edge fn or cron action); register its schedule in the
cron_jobsregistry (/super-admin/cron-jobs) fromprojection.cadenceCron. - Flip
materializeEnabledon at/admin/visit-scheduling-config.
Status
- ✅ Config model + service + hook + admin page + route + migration file
- ✅ Config-driven dialog (catalog) + read-time projector (window/policy/statuses)
- ✅ Pure projector core, 8 unit tests, deterministic
occurrence_key - ✅ Check-in → encounter → coding/revenue contract (
visitRequest.acknowledgecreates the Encounter + emitsvisit_contextonwait-check-in, mirroring appointment CHECKED_IN — kept visits now flow into coding/billing automatically) - ✅ Overrides access —
GET /visitRequests/:_id?withOverrides=true(folded intoget, no new route) - ✅ Migration applied (
visit_scheduling_configseeded +visit_occurrencelive) - ✅ Materialized projector DEPLOYED + VERIFIED —
visit-occurrence-projectoredge fn (self-login auth) invoked live: 6 active series → 48visit_occurrencerows (idempotent onoccurrence_key) - ✅ Cron — registered in the
cron_jobsregistry via API (managed_by='admin', nightly0 1 * * *), so it shows + toggles in the unifier at/super-admin/cron-jobs. Created two ways: the SQL20260529d_visit_occurrence_projector_cron.sql, OR straight through the admin API (cronJobs.service.upsertCronJob→cron_jobs→ sync trigger → pg_cron) — no migration needed. Seeded disabled like every other edge-fn cron; to run it, set the GUCsapp.settings.edge_functions_url+app.settings.service_role_keyon the DB, then flip the toggle on the page. - ⏳ Cron run-history (“logs”) in the page needs
20260530a_cron_job_run_history.sql(readscron.job_run_details); the page degrades gracefully until applied - ⏳ Scheme/payorPlan capture on VisitRequest (for coding debtor-group routing)
- ⏳ Unify edge-fn + read-time expansion behind one shared module (DST-precise tz)