Learn how to build an interactive doctor appointment scheduling web application. The system includes a public interface that patients can use to select one of the available appointment slots displayed using a JavaScript calendar component. The private doctor's interface lets them define the slots and manage appointments. The application frontend is build using HTML and vanilla JavaScript. The backend API is created using ASP.NET Core (.NET 6), Entity Framework, and SQL Server (LocalDB).
Appointment Scheduling User Interface
Patient UI
The doctor appointment scheduling project includes a special UI for patients who can see free appointment slots:
Patients see a simplified calendar with a week view. On the left side, the application displays a date picker calendar that displays three months. This helps with fast navigation between the weeks. Days which have a free appointment slot use a bold font. This way, patients can quickly see what the first available day is and can choose the date and time for their appointment.
The main purpose of the patient UI is to let them choose a free appointment slot. Appointments of other patients are hidden, as well as slots from the past. The slots are read-only and can't be moved. As soon as a patient selects a slot, the color changes to orange, which indicates a "waiting" status (see more on appointment slot status below). The appointment request needs to be confirmed.
Doctor UI
Doctors use separate area to manage the appointments - create, move and edit appointment slots.
The doctor UI is more advanced - it displays all appointment slots and doctors have full control over them. They can edit the appointment details, confirm the requests sent by patients, move slots to a different time using drag and drop, create custom slots and delete them.
All appointment slots need to be defined in advance - we will discuss logic in more detail in the next section.
Essential Components
Although you can use DayPilot calendar components in Angular, React, and Vue, this time we will use simple JavaScript without any framework.
For an introduction to using the JavaScript calendar components from DayPilot, please see the HTML5/JavaScript Event Calendar [code.daypilot.org] tutorial.
How It Works
There are two main approaches to handling doctor appointments with a public interface.
- You can define working hours that show the available time. Patients can create new appointments within these working hours. This way, the application needs to apply additional rules, like appointment start and duration. The appointment database records are created when a patient requests a meeting.
- You can define individual slots in advance and the patients can only select one of the existing slots. No additional rules are required because the doctor has full control over the slots. The database records are created when generating the slots. When patients request a meeting, they only change the slot status.
In this project, we will use approach #2 with predefined slots.
This workflow requires that the slots are defined in advance. This application uses a semi-automatic system which lets you generate the slots for a certain range (instead of adding them one by one).
Day, Week and Month Calendar View in ASP.NET Core
The main scheduling interface is created using JavaScript calendar components from DayPilot.
It's a traditional day/week/month view that you know from Google Calendar or Outlook.
In this application, we extend the functionality of a calendar application with scheduling features:
- The events/appointment have status which determines the slot color.
- Slot management part that lets you create or delete slots in blocks.
The frontend part of the doctor section is defined in Doctor.cshml
ASP.NET Core view.
The view loads the DayPilot scheduling JavaScript library:
<script src="~/lib/daypilot/daypilot-all.min.js"></script>
And includes placeholders for the calendar views (day, week, and mont). This is where the calendar components will be displayed. When switching the views, we will hide and show the components as needed.
<div id="day"></div>
<div id="week"></div>
<div id="month"></div>
Now we need to initialize the calendar components. Each component has its own instance - the day and week view use the DayPilot.Calendar class, the month view uses the DayPilot.Month class.
You can see that the configuration is very simple - we rely on the defaults. However, it is necessary to add the events handlers which define the behavior.
Day view:
const day = new DayPilot.Calendar("day", {
viewType: "Day",
visible: false,
eventDeleteHandling: "Update",
onTimeRangeSelected: (args) => {
app.createAppointmentSlot(args);
},
onEventMoved: (args) => {
app.moveAppointmentSlot(args);
},
onEventResized: (args) => {
app.moveAppointmentSlot(args);
},
onEventDeleted: (args) => {
app.deleteAppointmentSlot(args);
},
onBeforeEventRender: (args) => {
app.renderAppointmentSlot(args);
},
onEventClick: (args) => {
app.editAppointmentSlot(args);
}
});
day.init();
Week view:
const week = new DayPilot.Calendar("week", {
viewType: "Week",
eventDeleteHandling: "Update",
onTimeRangeSelected: (args) => {
app.createAppointmentSlot(args);
},
onEventMoved: (args) => {
app.moveAppointmentSlot(args);
},
onEventResized: (args) => {
app.moveAppointmentSlot(args);
},
onEventDeleted: (args) => {
app.deleteAppointmentSlot(args);
},
onBeforeEventRender: (args) => {
app.renderAppointmentSlot(args);
},
onEventClick: (args) => {
app.editAppointmentSlot(args);
}
});
week.init();
Month view:
const month = new DayPilot.Month("month", {
visible: false,
eventDeleteHandling: "Update",
eventMoveHandling: "Disabled",
eventResizeHandling: "Disabled",
cellHeight: 150,
onCellHeaderClick: args => {
nav.selectMode = "Day";
nav.select(args.start);
},
onEventDelete: args => {
app.deleteAppointmentSlot(args);
},
onBeforeEventRender: args => {
app.renderAppointmentSlot(args);
const locale = DayPilot.Locale.find(month.locale);
const start = new DayPilot.Date(args.data.start).toString(locale.timePattern);
const name = DayPilot.Util.escapeHtml(args.data.patientName || "");
args.data.html = `<span class='month-time'>${start}</span> ${name}`;
},
onTimeRangeSelected: async (args) => {
const params = {
start: args.start.toString(),
end: args.end.toString(),
weekends: true
};
args.control.clearSelection();
const {data} = await DayPilot.Http.post("/api/appointments/create", params);
app.loadEvents();
},
onEventClick: (args) => {
app.editAppointmentSlot(args);
}
});
month.init();
In addition to the calendar views, we also add a date picker which will let doctors change the date easily:
The placeholder is an empty <div>
element:
<div id="nav"></div>
And we need to initialize the date picker using DayPilot.Navigator class:
const nav = new DayPilot.Navigator("nav", {
selectMode: "Week",
showMonths: 3,
skipMonths: 3,
onTimeRangeSelected: (args) => {
app.loadEvents(args.day);
}
});
nav.init();
The app.loadEvents()
method is important, as it loads the appointment slots from the server. It checks the current view type (using nav.selectMode
) and loads the data.
const app = {
async loadEvents(date) {
const start = nav.visibleStart();
const end = nav.visibleEnd();
const {data} = await DayPilot.Http.get(`/api/appointments?start=${start}&end=${end}`);
const options = {
visible: true,
events: data
};
if (date) {
options.startDate = date;
}
day.hide();
week.hide();
month.hide();
const active = app.active();
active.update(options);
nav.update({
events: data
});
},
};
Note that it loads the appointment data for the full date range visible in the date picker (which is three months). This way, the date picker can highlight days with appointments. This data is reused for the currently-visible calendar component (the calendar date range is always a subset of the full range visible in the date picker).
The server-side part is a standard ASP.NET Core API controller generated from the Entity Framework model classes. The controller methods are extended in some cases when we need to perform a specific action (other than a basic create
, update
, delete
, select
operation) or when extra parameters are needed.
This is how the GetAppointments()
method (GET /api/appointments
endpoint) looks:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Project.Models;
using Project.Service;
namespace Project.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AppointmentsController : ControllerBase
{
private readonly DoctorDbContext _context;
public AppointmentsController(DoctorDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<AppointmentSlot>>>
GetAppointments([FromQuery] DateTime start, [FromQuery] DateTime end)
{
return await _context.Appointments.Where(e => !((e.End <= start) ||
(e.Start >= end))).ToListAsync();
}
}
}
Generate Appointment Slots
In each calendar view in the doctor's UI, you can use the "Generate" button to fill the current range with appointment slots:
The button opens a modal dialog where you can check whether you want to generate appointment slots for weekends as well:
In the monthly view, you can also select an arbitrary range using drag and drop:
Selecting a date range will generate slots for the specified days:
It will skip times that would conflict with existing appointment slots.
The slot generation logic is implemented on the server side. The PostAppointmentSlots()
method of the AppointmentController
class uses the Timeline
class helper to generate the slots dates. We will not go into details of the Timeline
class here, but you can check the implementation in the project source code.
The PostAppointmentSlots()
method also selects existing slots for the date range and checks for overlaps before creating the new slot records in the SQL Server database.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Project.Models;
using Project.Service;
namespace Project.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AppointmentsController : ControllerBase
{
private readonly DoctorDbContext _context;
public AppointmentsController(DoctorDbContext context)
{
_context = context;
}
[HttpPost("create")]
public async Task<ActionResult<AppointmentSlot>>
PostAppointmentSlots(AppointmentSlotRange range)
{
var existing = await _context.Appointments.Where(e => !((e.End <= range.Start) ||
(e.Start >= range.End))).ToListAsync();
var slots = Timeline.GenerateSlots(range.Start, range.End, range.Weekends);
slots.ForEach(slot =>
{
var overlaps = existing.Any(e => !((e.End <= slot.Start) ||
(e.Start >= slot.End)));
if (overlaps)
{
return;
}
_context.Appointments.Add(slot);
});
await _context.SaveChangesAsync();
return NoContent();
}
public class AppointmentSlotRange
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public bool Weekends { get; set; }
}
}
}
Appointment Slot Status
Each appointment slot can be marked as "Available", "Waiting", or "Confirmed":
Available:
Waiting for confirmation:
Confirmed:
The slot status is stored in the Status
property of the AppointmentSlot
class. The API controller sends it to the client side as status field of the appointment data object.
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
namespace Project.Models
{
public class AppointmentSlot
{
public int Id { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
public string? PatientName { set; get; }
public string? Text => PatientName;
[JsonPropertyName("patient")]
public string? PatientId { set; get; }
public string Status { get; set; } = "free";
}
}
The DayPilot JavaScript calendar component provides an onBeforeEventRender event handler that lets you customize the appointment appearance before rendering. We will use it to apply a custom color depending on the slot status:
const week = new DayPilot.Calendar("week", {
viewType: "Week",
onBeforeEventRender: (args) => {
switch (args.data.status) {
case "free":
args.data.backColor = app.colors.blue;
args.data.barColor = app.colors.blueDarker;
args.data.borderColor = "darker";
args.data.fontColor = app.colors.text;
args.data.text = "Available";
break;
case "waiting":
args.data.backColor = app.colors.orange;
args.data.barColor = app.colors.orangeDarker;
args.data.borderColor = "darker";
args.data.fontColor = app.colors.text;
args.data.text = args.data.patientName;
break;
case "confirmed":
args.data.backColor = app.colors.green;
args.data.barColor = app.colors.greenDarker;
args.data.borderColor = "darker";
args.data.fontColor = app.colors.text;
args.data.text = args.data.patientName;
break;
}
},
});
How to Run the ASP.NET Core Project
Everything is included:
- The server-side dependencies (Entity Framework) are managed by NuGet. Visual Studio will usually load the NuGet dependencies automatically for you during the first build.
- The client-side dependencies (DayPilot) are in wwwroot/lib folder.
- The database requires SQL Server Express LocalDB instance installed. You can modify the database connection string in appsettings.json file if needed. The initial Entity Framework migrations are included (see the Migrations folder) but you need to run Update-Database in the console to apply them.
History
- 12th January, 2022: Initial release