OmniEHR provides a robust appointment scheduling system with 15-minute time slots, real-time conflict detection, and comprehensive schedule management across multiple practitioners.
Overview
The scheduling system operates Monday through Saturday from 9:00 AM to 12:00 PM with fixed 15-minute appointment slots.
Slot-Based Booking Fixed 15-minute slots prevent double-booking and ensure consistent scheduling
Conflict Detection Real-time validation prevents overlapping appointments for practitioners
Multi-Practitioner Support for multiple practitioners with independent schedules
Availability Views Real-time availability display based on existing bookings
Scheduling Configuration
Clinic Hours and Slot Configuration
export const SLOT_INTERVAL_MINUTES = 15 ;
export const CLINIC_OPEN_MINUTES = 9 * 60 ; // 9:00 AM
export const CLINIC_CLOSE_MINUTES = 12 * 60 ; // 12:00 PM
// Available booking days (Monday=1 through Saturday=6)
const bookableWeekdays = new Set ([ 1 , 2 , 3 , 4 , 5 , 6 ]);
// Statuses that don't block slots
const nonBlockingStatuses = new Set ([
"cancelled" ,
"noshow" ,
"entered-in-error"
]);
Backend Validation
The server enforces strict scheduling rules:
const nonBlockingAppointmentStatuses = [
"cancelled" ,
"noshow" ,
"entered-in-error"
];
const slotDurationMinutes = 15 ;
const slotWindowStartMinutes = 9 * 60 ;
const slotWindowEndMinutes = 12 * 60 ;
const allowedBookingDays = new Set ([ 1 , 2 , 3 , 4 , 5 , 6 ]);
const ensureWithinBookableSlot = ({ start , end , minutesDuration }) => {
const startDate = new Date ( start );
const endDate = new Date ( end );
if ( Number . isNaN ( startDate . getTime ()) || Number . isNaN ( endDate . getTime ())) {
throw new ApiError ( 400 , "Appointment start/end must be valid datetime values" );
}
if ( startDate >= endDate ) {
throw new ApiError ( 400 , "Appointment end must be after start" );
}
if ( startDate . toDateString () !== endDate . toDateString ()) {
throw new ApiError ( 400 , "Appointments must start and end on the same day" );
}
if ( ! allowedBookingDays . has ( startDate . getDay ())) {
throw new ApiError ( 400 , "Appointments are only allowed Monday to Saturday" );
}
const startTotalMinutes = startDate . getHours () * 60 + startDate . getMinutes ();
const endTotalMinutes = endDate . getHours () * 60 + endDate . getMinutes ();
const duration = Math . round (( endDate . getTime () - startDate . getTime ()) / ( 1000 * 60 ));
if (
startTotalMinutes < slotWindowStartMinutes ||
endTotalMinutes > slotWindowEndMinutes ||
startTotalMinutes % slotDurationMinutes !== 0 ||
endTotalMinutes % slotDurationMinutes !== 0
) {
throw new ApiError ( 400 , "Appointments must be within 09:00-12:00 in 15-minute slot boundaries" );
}
if ( duration !== slotDurationMinutes ) {
throw new ApiError ( 400 , "Appointments must be exactly 15 minutes" );
}
};
Scheduling Rules : All appointments must be exactly 15 minutes, start on slot boundaries (00, 15, 30, 45 minutes), and fall within clinic hours on bookable weekdays.
Slot Generation
The system generates available time slots dynamically:
const format12Hour = ( totalMinutes ) => {
const hours24 = Math . floor ( totalMinutes / 60 );
const minutes = totalMinutes % 60 ;
const period = hours24 >= 12 ? "PM" : "AM" ;
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12 ;
return ` ${ pad ( hours12 ) } : ${ pad ( minutes ) } ${ period } ` ;
};
export const buildDailySlots = () => {
const slots = [];
for (
let minutes = CLINIC_OPEN_MINUTES ;
minutes < CLINIC_CLOSE_MINUTES ;
minutes += SLOT_INTERVAL_MINUTES
) {
const next = minutes + SLOT_INTERVAL_MINUTES ;
const hour = Math . floor ( minutes / 60 );
const minute = minutes % 60 ;
slots . push ({
value: ` ${ pad ( hour ) } : ${ pad ( minute ) } ` ,
label: ` ${ format12Hour ( minutes ) } - ${ format12Hour ( next ) } `
});
}
return slots ;
};
// Example output:
// [
// { value: "09:00", label: "09:00 AM - 09:15 AM" },
// { value: "09:15", label: "09:15 AM - 09:30 AM" },
// { value: "09:30", label: "09:30 AM - 09:45 AM" },
// ...
// { value: "11:45", label: "11:45 AM - 12:00 PM" }
// ]
Availability Detection
Checking Slot Availability
The system checks if a specific slot is available for a practitioner:
export const isSlotUnavailable = ({
appointments ,
practitionerId ,
dateInput ,
slotValue
}) => {
if ( ! isBookableDateInput ( dateInput )) {
return true ;
}
const slotRange = getSlotRange ( dateInput , slotValue );
if ( ! slotRange ) {
return true ;
}
return appointments . some (( appointment ) => {
// Skip cancelled/noshow appointments
if ( ! isBlockingAppointmentStatus ( appointment . status )) {
return false ;
}
// Check if appointment is for this practitioner
const appointmentPractitionerId = getPractitionerIdFromAppointment ( appointment );
if ( appointmentPractitionerId !== practitionerId ) {
return false ;
}
const appointmentStart = new Date ( appointment . start );
const appointmentEnd = new Date ( appointment . end );
if ( Number . isNaN ( appointmentStart . getTime ()) ||
Number . isNaN ( appointmentEnd . getTime ())) {
return false ;
}
// Check for time overlap
return appointmentStart < slotRange . end &&
appointmentEnd > slotRange . start ;
});
};
Practitioner Availability
Determine if a practitioner has any available slots on a given date:
practitionerHasAvailableSlot()
export const practitionerHasAvailableSlot = ({
appointments ,
practitionerId ,
dateInput
}) => {
if ( ! isBookableDateInput ( dateInput )) {
return false ;
}
const slots = buildDailySlots ();
return slots . some (
( slot ) => ! isSlotUnavailable ({
appointments ,
practitionerId ,
dateInput ,
slotValue: slot . value
})
);
};
Backend Conflict Detection
The server validates practitioner availability before creating appointments:
ensurePractitionerAvailability()
const ensurePractitionerAvailability = async ({
practitionerUserId ,
start ,
end ,
excludeAppointmentId
}) => {
const filter = {
practitionerUserId ,
status: { $nin: nonBlockingAppointmentStatuses },
start: { $lt: end },
end: { $gt: start }
};
if ( excludeAppointmentId ) {
filter . _id = { $ne: excludeAppointmentId };
}
const conflict = await Appointment . findOne ( filter )
. select ( "_id start end" )
. lean ();
if ( conflict ) {
throw new ApiError ( 409 , "Practitioner is not available in the selected time range" );
}
};
Schedule Page UI
The Schedule page provides comprehensive appointment management:
Loading Appointments
const loadAppointmentsTable = async () => {
const from = fromDate ?
getDayRangeFromDateInput ( fromDate ). start . toISOString () : undefined ;
const to = toDate ?
getDayRangeFromDateInput ( toDate ). end . toISOString () : undefined ;
const response = await fhirApi . listAppointments ( token , {
from ,
to
});
setAppointments ( bundleToResources ( response ));
};
const loadSlotAppointments = async ( dateInput ) => {
if ( ! isBookableDateInput ( dateInput )) {
setSlotAppointments ([]);
return ;
}
const { start , end } = getDayRangeFromDateInput ( dateInput );
const response = await fhirApi . listAppointments ( token , {
from: start . toISOString (),
to: end . toISOString ()
});
setSlotAppointments ( bundleToResources ( response ));
};
Slot Options with Availability
const slotOptions = useMemo (() => {
const slots = buildDailySlots ();
return slots . map (( slot ) => ({
... slot ,
unavailable:
! form . practitionerId ||
isSlotUnavailable ({
appointments: slotAppointments ,
practitionerId: form . practitionerId ,
dateInput: form . appointmentDate ,
slotValue: slot . value
})
}));
}, [ form . appointmentDate , form . practitionerId , slotAppointments ]);
// Render slot dropdown
< select
value = { form . slotValue }
onChange = { ( event ) => setForm (( prev ) => ({
... prev ,
slotValue: event . target . value
})) }
required
>
< option value = "" disabled > Choose a slot </ option >
{ slotOptions . map (( slot ) => (
< option
key = { slot . value }
value = { slot . value }
disabled = { slot . unavailable }
>
{ slot . label }
{ slot . unavailable ? " (Unavailable)" : "" }
</ option >
)) }
</ select >
Available Practitioners Filter
const availablePractitioners = useMemo (() => {
const scopedPractitioners = user . role === "practitioner"
? practitioners . filter (( p ) => p . id === user . id )
: practitioners ;
return scopedPractitioners . filter (( practitioner ) =>
practitionerHasAvailableSlot ({
appointments: slotAppointments ,
practitionerId: practitioner . id ,
dateInput: form . appointmentDate
})
);
}, [ form . appointmentDate , practitioners , slotAppointments , user . id , user . role ]);
Creating Appointments
Frontend Appointment Creation
const onCreateAppointment = async ( event ) => {
event . preventDefault ();
setLoading ( true );
setError ( "" );
try {
if ( ! isBookableDateInput ( form . appointmentDate )) {
throw new Error ( "Appointments can only be booked Monday to Saturday" );
}
const practitioner = practitionerMap . get ( form . practitionerId );
if ( ! practitioner ) {
throw new Error ( "Select an available practitioner" );
}
const slotRange = getSlotRange ( form . appointmentDate , form . slotValue );
if ( ! slotRange ) {
throw new Error ( "Select a valid appointment slot" );
}
const resource = {
resourceType: "Appointment" ,
status: "booked" ,
description: form . description ,
serviceCategory: form . serviceCategory ?
[{ text: form . serviceCategory }] : undefined ,
start: slotRange . start . toISOString (),
end: slotRange . end . toISOString (),
minutesDuration: 15 ,
participant: [
{
actor: {
reference: `Patient/ ${ form . patientId } `
},
status: "accepted"
},
{
actor: {
reference: `Practitioner/ ${ practitioner . id } ` ,
display: practitioner . fullName
},
status: "accepted"
}
],
reasonCode: form . reason ? [{ text: form . reason }] : undefined ,
comment: form . comment || undefined
};
await fhirApi . createAppointment ( token , resource );
await Promise . all ([
loadAppointmentsTable (),
loadSlotAppointments ( form . appointmentDate )
]);
} catch ( err ) {
setError ( err . message || "Unable to create appointment" );
} finally {
setLoading ( false );
}
};
Backend Appointment Creation
POST /api/fhir/Appointment
router . post (
"/Appointment" ,
authorize ( ... writeRoles ),
asyncHandler ( async ( req , res ) => {
const resource = appointmentResourceSchema . parse ( req . body );
const docPayload = appointmentResourceToDoc ( resource );
// Validate slot boundaries and clinic hours
ensureWithinBookableSlot ( docPayload );
// Ensure patient exists
await ensurePatientExists ( docPayload . patient . reference );
// Ensure practitioner exists and is active
await ensurePractitionerExists ( docPayload . practitionerUserId );
// Verify user has permission to book for this practitioner
ensureBookingPermission ( req . user , docPayload . practitionerUserId );
// Check for time conflicts
await ensurePractitionerAvailability ({
practitionerUserId: docPayload . practitionerUserId ,
start: docPayload . start ,
end: docPayload . end
});
const practitioner = await User . findById ( docPayload . practitionerUserId )
. select ( "fullName" )
. lean ();
const record = await Appointment . create ({
... docPayload ,
practitionerName: practitioner ?. fullName || docPayload . practitionerName ,
createdBy: req . user . sub
});
res . status ( 201 ). json ( appointmentDocToResource ( record ));
})
);
Service Categories
OmniEHR supports multiple service categories for appointment classification:
export const SERVICE_CATEGORY_OPTIONS = [
"Outpatient" ,
"Follow-up" ,
"Primary Care" ,
"Preventive Care" ,
"Annual Wellness Visit" ,
"Chronic Disease Management" ,
"Medication Review" ,
"Post-Discharge Follow-up" ,
"Urgent Care" ,
"Behavioral Health" ,
"Cardiology Consultation" ,
"Dermatology Consultation" ,
"Orthopedic Consultation" ,
"Telehealth Visit" ,
"Immunization"
];
Appointment Statuses
Appointments can have the following statuses:
Status Description Blocks Slot proposedAppointment proposed Yes pendingAwaiting confirmation Yes bookedConfirmed appointment Yes arrivedPatient has arrived Yes fulfilledAppointment completed Yes checked-inPatient checked in Yes waitlistOn waitlist Yes cancelledAppointment cancelled No noshowPatient did not show No entered-in-errorCreated in error No
Cancelled, noshow, and entered-in-error appointments do not block time slots and allow new bookings in those time periods.
Schedule Filtering
The schedule page includes comprehensive filtering options:
< article className = "card form-grid two-columns" >
< h2 > Schedule filters </ h2 >
< label >
From
< input
type = "date"
value = { fromDate }
onChange = { ( event ) => setFromDate ( event . target . value ) }
/>
</ label >
< label >
To
< input
type = "date"
value = { toDate }
onChange = { ( event ) => setToDate ( event . target . value ) }
/>
</ label >
< label >
Status
< select
value = { statusFilter }
onChange = { ( event ) => setStatusFilter ( event . target . value ) }
>
< option value = "all" > All statuses </ option >
< option value = "booked" > booked </ option >
< option value = "arrived" > arrived </ option >
< option value = "checked-in" > checked-in </ option >
< option value = "fulfilled" > fulfilled </ option >
< option value = "waitlist" > waitlist </ option >
< option value = "noshow" > noshow </ option >
< option value = "cancelled" > cancelled </ option >
</ select >
</ label >
< label >
Service category
< select
value = { serviceFilter }
onChange = { ( event ) => setServiceFilter ( event . target . value ) }
>
< option value = "all" > All services </ option >
{ serviceFilterOptions . map (( service ) => (
< option key = { service } value = { service } >
{ service }
</ option >
)) }
</ select >
</ label >
</ article >
Operations Metrics
The schedule page displays real-time operational metrics:
const operationsSnapshot = useMemo (() => {
const totalSlots = buildDailySlots (). length * scopedPractitioners . length ;
const bookedSlots = slotAppointments . filter (( appointment ) =>
isBlockingAppointmentStatus ( appointment . status )
). length ;
const fillRate = totalSlots > 0 ?
Math . round (( bookedSlots / totalSlots ) * 1000 ) / 10 : 0 ;
const checkedInCount = appointments . filter (( appointment ) =>
[ "arrived" , "checked-in" , "fulfilled" ]. includes ( appointment . status )
). length ;
const waitlistCount = appointments . filter (
( appointment ) => appointment . status === "waitlist"
). length ;
const noShowRate = calculateNoShowRate ( appointments , 90 );
const serviceMix = calculateServiceMix ( filteredAppointments ). slice ( 0 , 3 );
return {
fillRate ,
bookedSlots ,
totalSlots ,
checkedInCount ,
waitlistCount ,
noShowRate ,
serviceMix
};
}, [ appointments , filteredAppointments , scopedPractitioners . length , slotAppointments ]);
// Display metrics
< article className = "stats-grid" >
< div className = "metric-card" >
< h2 > Selected-day fill rate </ h2 >
< p className = "metric-value" > { operationsSnapshot . fillRate } % </ p >
< p className = "muted-text" >
{ operationsSnapshot . bookedSlots } / { operationsSnapshot . totalSlots } bookable slots used.
</ p >
</ div >
< div className = "metric-card" >
< h2 > No-show rate </ h2 >
< p className = "metric-value" > { operationsSnapshot . noShowRate } % </ p >
< p className = "muted-text" > Trailing 90-day trend. </ p >
</ div >
< div className = "metric-card" >
< h2 > Checked-in visits </ h2 >
< p className = "metric-value" > { operationsSnapshot . checkedInCount } </ p >
< p className = "muted-text" > Arrived/checked-in/fulfilled in current query window. </ p >
</ div >
< div className = "metric-card" >
< h2 > Waitlist </ h2 >
< p className = "metric-value" > { operationsSnapshot . waitlistCount } </ p >
< p className = "muted-text" > Appointments currently on waitlist status. </ p >
</ div >
</ article >
No-Show Rate Calculation
export const calculateNoShowRate = ( appointments , lookbackDays = 90 , now = new Date ()) => {
const lowerBound = new Date ( now . getTime () - lookbackDays * 24 * 60 * 60 * 1000 );
const historical = appointments . filter (( appointment ) => {
const start = toDate ( appointment . start );
return Boolean ( start && start >= lowerBound && start <= now );
});
if ( historical . length === 0 ) {
return 0 ;
}
const noShows = historical . filter (
( appointment ) => normalize ( appointment . status ) === "noshow"
);
return Math . round (( noShows . length / historical . length ) * 1000 ) / 10 ;
};
Appointment Data Model
const appointmentSchema = new mongoose . Schema ({
resourceType: {
type: String ,
default: "Appointment"
},
status: {
type: String ,
enum: [
"proposed" , "pending" , "booked" , "arrived" , "fulfilled" ,
"cancelled" , "noshow" , "entered-in-error" , "checked-in" , "waitlist"
],
default: "booked"
},
description: String ,
serviceCategory: String ,
start: {
type: Date ,
required: true
},
end: {
type: Date ,
required: true
},
minutesDuration: Number ,
patient: {
reference: {
type: mongoose . Schema . Types . ObjectId ,
ref: "Patient" ,
required: true
}
},
practitionerUserId: {
type: mongoose . Schema . Types . ObjectId ,
ref: "User" ,
required: true
},
practitionerName: String ,
reason: String ,
comment: String ,
createdBy: {
type: mongoose . Schema . Types . ObjectId ,
ref: "User"
}
}, {
timestamps: true
});
// Indexes for efficient querying
appointmentSchema . index ({ "patient.reference" : 1 , start: - 1 });
appointmentSchema . index ({ practitionerUserId: 1 , start: 1 , end: 1 });
appointmentSchema . index ({ start: 1 });
Role-Based Scheduling
Practitioner Restrictions
Practitioners can only book appointments on their own schedule:
const ensureBookingPermission = ( requestingUser , practitionerUserId ) => {
if (
requestingUser . role === "practitioner" &&
String ( practitionerUserId ) !== String ( requestingUser . sub )
) {
throw new ApiError ( 403 , "Practitioners can only book appointments under their own schedule" );
}
};
// Frontend: Filter practitioner list
const scopedPractitioners = useMemo (() => {
if ( user . role === "practitioner" ) {
return practitioners . filter (( p ) => p . id === user . id );
}
return practitioners ;
}, [ practitioners , user . id , user . role ]);
Integration with Patient Detail
The patient detail page includes inline appointment scheduling:
Patient Detail Appointment Form
< form className = "card form-grid two-columns" onSubmit = { onCreateAppointment } >
< h2 > Schedule follow-up </ h2 >
< label >
Date
< input
type = "date"
value = { appointmentForm . appointmentDate }
onChange = { ( event ) =>
setAppointmentForm (( prev ) => ({
... prev ,
appointmentDate: event . target . value
}))
}
required
/>
</ label >
< label >
Practitioner
< select
value = { appointmentForm . practitionerId }
onChange = { ( event ) =>
setAppointmentForm (( prev ) => ({
... prev ,
practitionerId: event . target . value
}))
}
disabled = { user . role === "practitioner" }
required
>
{ availablePractitioners . map (( practitioner ) => (
< option key = { practitioner . id } value = { practitioner . id } >
{ practitioner . fullName }
</ option >
)) }
</ select >
</ label >
{ /* Additional fields... */ }
< button
type = "submit"
className = "button"
disabled = {
loading ||
! appointmentForm . appointmentDate ||
! appointmentForm . slotValue ||
! appointmentForm . practitionerId ||
availablePractitioners . length === 0 ||
! isBookableDateInput ( appointmentForm . appointmentDate )
}
>
{ loading ? "Saving..." : "Schedule appointment" }
</ button >
</ form >
API Reference
Appointment Endpoints
Method Endpoint Description Required Role POST /api/fhir/AppointmentCreate appointment admin, practitioner GET /api/fhir/AppointmentList appointments admin, practitioner, auditor GET /api/fhir/Appointment/:idGet appointment by ID admin, practitioner, auditor PUT /api/fhir/Appointment/:idUpdate appointment admin, practitioner
Query Parameters
By Patient
By Practitioner
By Date Range
GET /api/fhir/Appointment?patient=Patient/507f1f77bcf86cd799439011
GET /api/fhir/Appointment?practitioner=Practitioner/507f1f77bcf86cd799439020
GET /api/fhir/Appointment?from=2026-03-01T00:00:00Z & to = 2026-03-31T23:59:59Z
Next Steps
Patient Management Learn about patient registry and demographics
Clinical Workflows View task management and care coordination
FHIR Resources Explore all supported FHIR resources
API Reference Complete API documentation