Kulcare Docs
DeveloperQueue

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

  1. Architecture Overview
  2. Data Model
  3. API Endpoints
  4. Services
  5. Gap-Fitting Queue Algorithm
  6. Serialization (AppointmentBlueprint :queue view)
  7. Status Mappings
  8. Walk-In Check-In Flow
  9. Authorization
  10. Testing
  11. 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_in atomically creates an Appointment (type: walk_in) and a linked Encounter in one transaction.
  • Pre-serialized facility queue: TodaysQueueService returns pre-serialized data. The controller renders it directly without additional Blueprint calls.
  • Per-practitioner queue generation: GenerateToday runs per practitioner. The facility-level service aggregates results across practitioners.

File Map

LayerFilePurpose
Modelmodels/appointment.rbQueue scopes, helpers, encounter association
Blueprintblueprints/appointment_blueprint.rb:queue view for serialization
Serviceservices/queue/generate_today.rbPer-practitioner queue algorithm
Serviceservices/encounters/check_in_service.rbWalk-in check-in (Appointment + Encounter)
Serviceservices/encounters/todays_queue_service.rbFacility-wide queue aggregation
Controllercontrollers/queue_controller.rb4 endpoints, patient resolution
Policypolicies/queue_policy.rbAuthorization for queue actions
Routesroutes.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

MethodReturnsDescription
fixed?BooleanTrue for fixed or telemedicine appointment types
walk_in?BooleanTrue for walk_in appointment type
queue_statusStringReturns encounter status if encounter exists, else appointment status
cancelled?BooleanTrue for cancelled or noshow
active?BooleanTrue for proposed, pending, booked, or arrived

Appointment Types

TypeDescriptionQueue Behavior
fixedPre-scheduled appointment with time slotUses start_time as time_to_doctor_ts
telemedicineVirtual appointment with time slotTreated same as fixed for queue ordering
walk_inCreated at check-in, no pre-set slotFitted into gaps by the queue algorithm

FHIR R4 Status Transitions

proposed -> pending -> booked -> arrived -> fulfilled
                  \         \         \
                   -> cancelled   -> noshow

Appointments 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:

ParamTypeDefaultDescription
dateISO 8601 stringTodayDate 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:

ParamTypeRequiredDescription
patient_idUUIDOne of patient_id or patient_paramsExisting patient ID
patient_paramsObjectOne of patient_id or patient_paramsInline patient creation
patient_params.first_nameStringYes (if inline)Patient first name
patient_params.last_nameStringNoPatient last name
patient_params.phone_numberStringNoPatient phone
patient_params.genderStringNoPatient gender
practitioner_idUUIDYesPractitioner to see
notesStringNoChief complaint / reason
priorityStringNoroutine (default), urgent, asap, stat
telemedicineStringNo"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:

ParamTypeDefaultDescription
dateISO 8601 stringTodayDate 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]  # UUID

Query:

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]  # UUID

The 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:

  1. Create Appointment (status: booked, type: walk_in, start_time: now)
  2. Create Encounter (status: planned, class: ambulatory/virtual, linked to appointment)
  3. Add practitioner as primary participant on encounter
  4. Transition appointment booked -> arrived
  5. 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:

BucketCriteria
in_progressEncounter exists with status in_progress
fixed_pastfixed? is true AND start_time < now
fixed_futurefixed? is true AND start_time >= now
walk_inswalk_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 end

Step 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_time

Example

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 appointment

Statistics

The service calculates:

StatCalculation
total_patientsCount of all non-cancelled appointments
in_progressCount where queue_status == 'in_progress'
waitingCount where queue_status is booked, arrived, or triaged
completedCount where queue_status is fulfilled or finished
avg_durationDefault 15 minutes (hardcoded)
next_availableLast queue entry's time_to_doctor_ts + avg_duration

Serialization

The AppointmentBlueprint :queue view produces the JSON shape used by all queue endpoints.

Fields

FieldSourceDescription
idappointment.idUUIDv7
statusappointment.statusFHIR R4 status
appointment_typeappointment.appointment_typefixed, walk_in, or telemedicine
start_timeappointment.start_timeISO 8601
end_timeappointment.end_timeISO 8601 (null for walk-ins)
noteappointment.noteChief complaint / notes
time_to_doctor_tsTransient attrEstimated time to see doctor (ISO 8601)
queue_statusappointment.queue_statusEncounter status if present, else appointment status
legacy_statusComputedMaps to legacy frontend status strings
encounterNested hash or null{ id, status, start_time, length_minutes }
patientNested hash{ id, name, phone }
practitionerNested 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 StatusLegacy Status
bookedNOT_CONFIRMED
arrivedIN_QUEUE
fulfilledCHECKED_OUT
cancelledCANCELLED
noshowNO_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:

  1. If patient_id is provided: look up existing patient
  2. If patient_params is provided: create a new patient inline
  3. If neither: return 422 validation error

Authorization

Queue endpoints use QueuePolicy:

ActionPolicy MethodRequired Permission
show (GET queue)show?view_queue
check_in (POST)check_in?manage_queue
available_practitionersshow?view_queue
practitioner_queueshow?view_queue

The controller stubs authorization via authorize :queue, :action? (headless policy, no record).


Testing

Test files are at backend/domains/clinical/spec/:

FileCovers
models/appointment_spec.rbModel scopes, helpers, state machine, validations
services/queue/generate_today_spec.rbQueue algorithm, gap-fitting, statistics
services/encounters/check_in_service_spec.rbWalk-in check-in, transaction rollback
services/encounters/todays_queue_service_spec.rbFacility-wide aggregation, serialization
requests/queue_spec.rbAll 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.rb

Test 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?
end

Known Limitations

  1. Average duration hardcoded to 15 minutes: There is no slot_minutes or default_slot_duration on Identity::Practitioner or PractitionerRole yet. The constant DEFAULT_SLOT_MINUTES = 15 is used everywhere.

  2. No real-time push: Queue updates are not pushed via Pusher/WebSocket. The frontend must poll or refresh.

  3. No queue reordering: There is no manual drag-and-drop reordering. Queue order is computed fresh on each request based on appointment times.

  4. Table name in raw SQL: TodaysQueueService uses a raw SQL join (clinical_appointments) which assumes the table name. If the table name changes, this join must be updated.

  5. Facility timezone: GenerateToday reads facility.timezone if available, defaulting to UTC. Ensure facilities have timezone set for accurate queue ordering across time zones.

On this page