Queue Feature Developer Guide
This guide documents the appointment-centric queue system built on the new clinical schema. It covers architecture, endpoints, service internals, the gap-fitting algorithm, serialization, and testing.
Table of Contents
- Architecture Overview
- Data Model
- API Endpoints
- Services
- Gap-Fitting Queue Algorithm
- Serialization (AppointmentBlueprint :queue view)
- Status Mappings
- Walk-In Check-In Flow
- Authorization
- Testing
- Known Limitations
Architecture Overview
The queue is appointment-centric. Every patient in the queue has a Clinical::Appointment, whether they booked ahead or walked in. Encounters enrich each appointment with clinical visit status but are not the primary queue entity.
Appointment (queue entry)
|
+-----------------+
| |
Fixed/Tele Walk-In
(booked ahead) (created at check-in)
| |
v v
Encounter Encounter
(created later) (created immediately)Key Design Decisions
- Appointment as queue entry: Simplifies query patterns -- one table to query for the entire queue.
- Walk-in check-in creates both records: A
POST check_inatomically creates an Appointment (type:walk_in) and a linked Encounter in one transaction. - Pre-serialized facility queue:
TodaysQueueServicereturns pre-serialized data. The controller renders it directly without additional Blueprint calls. - Per-practitioner queue generation:
GenerateTodayruns per practitioner. The facility-level service aggregates results across practitioners.
File Map
| Layer | File | Purpose |
|---|---|---|
| Model | models/appointment.rb | Queue scopes, helpers, encounter association |
| Blueprint | blueprints/appointment_blueprint.rb | :queue view for serialization |
| Service | services/queue/generate_today.rb | Per-practitioner queue algorithm |
| Service | services/encounters/check_in_service.rb | Walk-in check-in (Appointment + Encounter) |
| Service | services/encounters/todays_queue_service.rb | Facility-wide queue aggregation |
| Controller | controllers/queue_controller.rb | 4 endpoints, patient resolution |
| Policy | policies/queue_policy.rb | Authorization for queue actions |
| Routes | routes.rb (lines 84-90) | Facility-scoped queue routes |
All paths are relative to backend/domains/clinical/.
Data Model
Appointment (queue entry)
The Clinical::Appointment model is the primary queue entity. Queue-specific additions:
# Association to Encounter (one-to-one)
has_one :encounter, class_name: 'Clinical::Encounter', dependent: :nullify
# Transient attribute set by GenerateToday (not persisted)
attr_accessor :time_to_doctor_ts
# Queue scopes
scope :for_date, ->(date) { where('DATE(start_time) = ?', date) }
scope :today, -> { where('DATE(start_time) = ?', Date.current) }
scope :not_cancelled, -> { where.not(status: %w[cancelled noshow]) }Queue Helper Methods
| Method | Returns | Description |
|---|---|---|
fixed? | Boolean | True for fixed or telemedicine appointment types |
walk_in? | Boolean | True for walk_in appointment type |
queue_status | String | Returns encounter status if encounter exists, else appointment status |
cancelled? | Boolean | True for cancelled or noshow |
active? | Boolean | True for proposed, pending, booked, or arrived |
Appointment Types
| Type | Description | Queue Behavior |
|---|---|---|
fixed | Pre-scheduled appointment with time slot | Uses start_time as time_to_doctor_ts |
telemedicine | Virtual appointment with time slot | Treated same as fixed for queue ordering |
walk_in | Created at check-in, no pre-set slot | Fitted into gaps by the queue algorithm |
FHIR R4 Status Transitions
proposed -> pending -> booked -> arrived -> fulfilled
\ \ \
-> cancelled -> noshowAppointments follow FHIR R4 status values. The VALID_TRANSITIONS hash enforces these at the model level.
API Endpoints
All endpoints are scoped under /clinical/facilities/:facility_id/queue.
GET /clinical/facilities/:facility_id/queue
Returns today's queue for the entire facility (all practitioners combined).
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
date | ISO 8601 string | Today | Date to fetch queue for |
Response (200):
{
"data": {
"queue": [
{
"id": "01234567-...",
"status": "arrived",
"appointment_type": "walk_in",
"start_time": "2025-01-15T10:00:00Z",
"end_time": "2025-01-15T10:15:00Z",
"note": "Chest pain",
"time_to_doctor_ts": "2025-01-15T10:00:00Z",
"queue_status": "in_progress",
"legacy_status": "WITH_DOCTOR",
"encounter": {
"id": "01234567-...",
"status": "in_progress",
"start_time": "2025-01-15T10:02:00Z",
"length_minutes": 15
},
"patient": {
"id": "01234567-...",
"name": "John Doe",
"phone": "9876543210"
},
"practitioner": {
"id": "01234567-...",
"display_name": "Dr. Smith"
}
}
],
"statistics": {
"total_patients": 12,
"in_progress": 1,
"waiting": 8,
"completed": 3,
"avg_duration": 15
},
"date": "2025-01-15",
"facility_id": "01234567-..."
}
}POST /clinical/facilities/:facility_id/queue/check_in
Walk-in patient check-in. Creates an Appointment + Encounter atomically.
Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
patient_id | UUID | One of patient_id or patient_params | Existing patient ID |
patient_params | Object | One of patient_id or patient_params | Inline patient creation |
patient_params.first_name | String | Yes (if inline) | Patient first name |
patient_params.last_name | String | No | Patient last name |
patient_params.phone_number | String | No | Patient phone |
patient_params.gender | String | No | Patient gender |
practitioner_id | UUID | Yes | Practitioner to see |
notes | String | No | Chief complaint / reason |
priority | String | No | routine (default), urgent, asap, stat |
telemedicine | String | No | "true" for virtual encounter |
Response (201):
{
"data": {
"id": "01234567-...",
"status": "arrived",
"appointment_type": "walk_in",
"start_time": "2025-01-15T10:30:00Z",
"queue_status": "arrived",
"legacy_status": "IN_QUEUE",
"patient": { "id": "...", "name": "Jane Doe", "phone": "..." },
"practitioner": { "id": "...", "display_name": "Dr. Smith" }
}
}Error (422):
{
"errors": ["Patient is required"]
}GET /clinical/facilities/:facility_id/queue/practitioners/available
Returns practitioners with active roles at this facility.
Response (200):
{
"data": [
{
"id": "01234567-...",
"display_name": "Dr. Smith",
"role_type": "physician",
"specialty": "General Medicine"
}
]
}GET /clinical/facilities/:facility_id/queue/practitioners/:practitioner_id
Returns queue for a specific practitioner at this facility.
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
date | ISO 8601 string | Today | Date to fetch queue for |
Response (200):
{
"data": {
"queue": [ ... ],
"statistics": { ... },
"practitioner_id": "01234567-..."
}
}Services
Clinical::Queue::GenerateToday
Per-practitioner queue generation. This is the core algorithm.
Signature:
Clinical::Queue::GenerateToday.call(
practitioner: practitioner,
facility: facility,
date: Date.current # optional, defaults to today
)Returns a Result with:
result.data[:queue] # Array of Clinical::Appointment with time_to_doctor_ts set
result.data[:statistics] # Hash with :total_patients, :in_progress, :waiting, :completed, :avg_duration, :next_available
result.data[:practitioner_id] # UUIDQuery:
Clinical::Appointment
.for_practitioner(practitioner.id)
.for_facility(facility.id)
.for_date(date)
.not_cancelled
.includes(:patient, :encounter, :facility, :practitioner)
.order(:start_time)Clinical::Encounters::TodaysQueueService
Facility-wide queue aggregation. Finds practitioners via appointment joins, calls GenerateToday per practitioner, and pre-serializes the combined result.
Signature:
Clinical::Encounters::TodaysQueueService.call(
facility: facility,
date: Date.current, # optional
current_user: user # optional, for authorization context
)Returns a pre-serialized Result:
result.data[:queue] # Array of hashes (already serialized via AppointmentBlueprint :queue)
result.data[:statistics] # Aggregated stats across all practitioners
result.data[:date] # ISO 8601 date string
result.data[:facility_id] # UUIDThe controller renders result.data directly -- no additional Blueprint call needed.
Clinical::Encounters::CheckInService
Walk-in check-in that creates both records in a transaction.
Signature:
Clinical::Encounters::CheckInService.call(
tenant: tenant,
patient: patient,
facility: facility,
practitioner: practitioner,
params: { notes: 'Fever', priority: 'urgent', telemedicine: 'true' }
)Transaction steps:
- Create
Appointment(status:booked, type:walk_in, start_time: now) - Create
Encounter(status:planned, class: ambulatory/virtual, linked to appointment) - Add practitioner as primary participant on encounter
- Transition appointment
booked -> arrived - Transition encounter
planned -> arrived
If any step fails, the entire transaction rolls back.
Gap-Fitting Queue Algorithm
The GenerateToday service builds the queue in a specific order, interleaving walk-ins with fixed appointments.
Step 1: Categorize Appointments
Each appointment is placed into one of four buckets:
| Bucket | Criteria |
|---|---|
in_progress | Encounter exists with status in_progress |
fixed_past | fixed? is true AND start_time < now |
fixed_future | fixed? is true AND start_time >= now |
walk_ins | walk_in? is true AND not in_progress |
Step 2: Build Queue with Time Cursor
A time cursor tracks the estimated next available time slot:
Queue Build Order:
1. In-progress appointments (time_to_doctor = encounter.start_time)
2. Past fixed appointments sorted by start_time
3. Walk-ins fitted into gaps before each future fixed appointment
4. Future fixed appointments sorted by start_time
5. Remaining walk-ins appended at the endStep 3: Gap Fitting
For each future fixed appointment, the algorithm checks if there is a gap before it:
gap_minutes = (fixed_appt.start_time - time_cursor) / 60
If gap_minutes >= avg_duration (15 min):
- Assign time_cursor to walk-in's time_to_doctor_ts
- Advance cursor by avg_duration
- Repeat until no gap or no more walk-ins
Then add the fixed appointment with time_to_doctor_ts = start_timeExample
Given (current time: 10:00):
- Fixed appointment at 10:30
- Fixed appointment at 11:00
- 3 walk-ins arrived at 09:50, 09:55, 10:05
Result:
10:00 - Walk-in 1 (fitted before 10:30 fixed)
10:15 - Walk-in 2 (fitted before 10:30 fixed)
10:30 - Fixed appointment (no more gap)
10:45 - Walk-in 3 (fitted before 11:00 fixed)
11:00 - Fixed appointmentStatistics
The service calculates:
| Stat | Calculation |
|---|---|
total_patients | Count of all non-cancelled appointments |
in_progress | Count where queue_status == 'in_progress' |
waiting | Count where queue_status is booked, arrived, or triaged |
completed | Count where queue_status is fulfilled or finished |
avg_duration | Default 15 minutes (hardcoded) |
next_available | Last queue entry's time_to_doctor_ts + avg_duration |
Serialization
The AppointmentBlueprint :queue view produces the JSON shape used by all queue endpoints.
Fields
| Field | Source | Description |
|---|---|---|
id | appointment.id | UUIDv7 |
status | appointment.status | FHIR R4 status |
appointment_type | appointment.appointment_type | fixed, walk_in, or telemedicine |
start_time | appointment.start_time | ISO 8601 |
end_time | appointment.end_time | ISO 8601 (null for walk-ins) |
note | appointment.note | Chief complaint / notes |
time_to_doctor_ts | Transient attr | Estimated time to see doctor (ISO 8601) |
queue_status | appointment.queue_status | Encounter status if present, else appointment status |
legacy_status | Computed | Maps to legacy frontend status strings |
encounter | Nested hash or null | { id, status, start_time, length_minutes } |
patient | Nested hash | { id, name, phone } |
practitioner | Nested hash or null | { id, display_name } |
Status Mappings
Queue Status Resolution
If encounter present:
queue_status = encounter.status (e.g., "in_progress", "finished")
Else:
queue_status = appointment.status (e.g., "booked", "arrived")Legacy Status Mapping
The legacy_status field maps to strings expected by the existing frontend:
| Appointment Status | Legacy Status |
|---|---|
booked | NOT_CONFIRMED |
arrived | IN_QUEUE |
fulfilled | CHECKED_OUT |
cancelled | CANCELLED |
noshow | NO_SHOW |
When an encounter exists, legacy_status delegates to encounter.legacy_status which maps encounter-level statuses (e.g., in_progress -> WITH_DOCTOR).
Walk-In Check-In Flow
Sequence
Frontend Controller CheckInService
| | |
|-- POST /queue/check_in -->| |
| |-- resolve_patient -------->|
| | (find by ID or create) |
| | |
| |-- CheckInService.call ---->|
| | |-- Transaction:
| | | 1. Create Appointment (booked, walk_in)
| | | 2. Create Encounter (planned, ambulatory)
| | | 3. Add primary participant
| | | 4. Transition appt -> arrived
| | | 5. Transition encounter -> arrived
| | |
| |<-- { appointment, encounter }
| | |
| |-- Serialize with Blueprint |
|<-- 201 { data: ... } -----| |Patient Resolution
The controller resolves the patient before calling the service:
- If
patient_idis provided: look up existing patient - If
patient_paramsis provided: create a new patient inline - If neither: return 422 validation error
Authorization
Queue endpoints use QueuePolicy:
| Action | Policy Method | Required Permission |
|---|---|---|
show (GET queue) | show? | view_queue |
check_in (POST) | check_in? | manage_queue |
available_practitioners | show? | view_queue |
practitioner_queue | show? | view_queue |
The controller stubs authorization via authorize :queue, :action? (headless policy, no record).
Testing
Test files are at backend/domains/clinical/spec/:
| File | Covers |
|---|---|
models/appointment_spec.rb | Model scopes, helpers, state machine, validations |
services/queue/generate_today_spec.rb | Queue algorithm, gap-fitting, statistics |
services/encounters/check_in_service_spec.rb | Walk-in check-in, transaction rollback |
services/encounters/todays_queue_service_spec.rb | Facility-wide aggregation, serialization |
requests/queue_spec.rb | All 4 endpoints, auth stubs, error cases |
Running Tests
cd backend
# All queue tests
bundle exec rspec domains/clinical/spec/models/appointment_spec.rb \
domains/clinical/spec/services/queue/generate_today_spec.rb \
domains/clinical/spec/services/encounters/check_in_service_spec.rb \
domains/clinical/spec/services/encounters/todays_queue_service_spec.rb \
domains/clinical/spec/requests/queue_spec.rb
# Single file
bundle exec rspec domains/clinical/spec/services/queue/generate_today_spec.rbTest Data Pattern
Tests use find_or_create_by! for shared records (tenant, organization, facility) and create! for per-test records (patients, appointments). All specs include a skip guard:
before do
skip 'Clinical schema not available' unless Clinical::Appointment.table_exists?
endKnown Limitations
-
Average duration hardcoded to 15 minutes: There is no
slot_minutesordefault_slot_durationonIdentity::PractitionerorPractitionerRoleyet. The constantDEFAULT_SLOT_MINUTES = 15is used everywhere. -
No real-time push: Queue updates are not pushed via Pusher/WebSocket. The frontend must poll or refresh.
-
No queue reordering: There is no manual drag-and-drop reordering. Queue order is computed fresh on each request based on appointment times.
-
Table name in raw SQL:
TodaysQueueServiceuses a raw SQL join (clinical_appointments) which assumes the table name. If the table name changes, this join must be updated. -
Facility timezone:
GenerateTodayreadsfacility.timezoneif available, defaulting to UTC. Ensure facilities have timezone set for accurate queue ordering across time zones.