Overview

  • ASP.NET Core application (.NET 10)

  • JavaScript/HTML5 frontend built using DayPilot visual scheduling components (DayPilot Pro for JavaScript). The UI is based on drag and drop.

  • The frontend communicates with a REST API backend created using Web API.

  • The scheduling logic is divided into three UI screens, one for each role (manager, doctor, patient).

  • The data access layer uses Entity Framework 10 (“code first” model) and SQL Server Express (LocalDB) on Windows, with SQLite on Linux and macOS. The database is initialized automatically on the first start of the application.

The three role screens demonstrate the scheduling workflow without authentication. The browser’s patient identifier is not a secure identity. Add authentication, role-based authorization and server-side booking conflict checks before using the application with real patient data.

License

Licensed for testing and evaluation purposes. Please see the license agreement included in the sample project. You can use the source code of the tutorial if you are a licensed user of DayPilot Pro for JavaScript. Buy a license.

1. Manager Defines Appointment Slots in Advance

shift planning

Under the “Manager” tab, you can see a shift planning dashboard with all available doctors and their appointment slots.

Here you can define new appointment slots using drag and drop.

  • Use the mouse to select a time range — the application will automatically create appointment slots for the selected area.

  • It uses a predefined slot size (60 minutes) and working hours (morning shift from 9am to 1pm and afternoon shift from 2pm to 6pm).

  • You can switch between hour and shift view — this zooms the Scheduler so you can get a better picture of the assigned appointment slots.

  • You can hide non-business hours using a checkbox.

  • Delete unwanted appointment slots using a delete [x] icon.

  • Editing of appointment details is not accessible here.

This is how you can create slots using drag and drop — select a time range:

create slots

The application will automatically fill the selected range with appointment slots:

slots created

2. Patients Can Select a Slot and Request an Appointment

patient available slots

The patients can access a calendar view that displays all available appointment slots:

  • It shows slots for all doctors (there can be multiple slots displayed at the same date and time).

  • Free slots and the current patient’s pending or confirmed appointments are visible.

  • Appointment slots in the past are hidden.

  • Once a patient selects a slot, the slot status is changed to “waiting” while it is waiting for doctor confirmation. Pending requests use an orange color.

  • As soon as the doctor confirms the appointment request, the status changes to “confirmed” and the color changes to green.

This is a pending appointment request:

request waiting

Confirmed appointment:

request confirmed

3. Doctors Can Confirm Appointment Request and Edit Appointments

manage

Doctors see all appointment slots with full details (status, patient name).

  • Doctors can edit and delete appointment details.

  • It is possible to move an appointment slot to a different date and time using drag and drop (it can be outside of standard working hours).

  • To confirm a pending request, change the appointment status from “waiting” to “confirmed”.

ASP.NET Core Implementation of the Doctor Appointment Scheduling Application

This tutorial covers the specifics of doctor appointment scheduling. For an introduction to using the scheduling components in ASP.NET Core, please see the following tutorials:

These tutorials explain the basics — how to install and configure the components, how to load data from the server, and process UI actions (e.g., event drag and drop) using a REST API.

How to Implement Appointment Slot Generation (Manager.cshtml)

The slot creation logic is implemented in the manager’s view (Manager.cshtml).

This view displays a timeline for all doctors using the JavaScript Scheduler component. Each doctor is displayed in a separate row and the time is displayed on the horizontal axis.

timeline view

The MVC view includes JavaScript code that initializes the Scheduler component:

const scheduler = new DayPilot.Scheduler("scheduler", {
    visible: false, // will be displayed after loading the resources
    scale: "Manual",
    timeline: app.getTimeline(),
    timeHeaders: app.getTimeHeaders(),
    useEventBoxes: "Never",
    eventDeleteHandling: "Update",
    eventClickHandling: "Disabled",
    eventMoveHandling: "Disabled",
    eventResizeHandling: "Disabled",
    allowEventOverlap: false,
    // ...
    onTimeRangeSelected: async (args) => {
        const dp = scheduler;
        const scale = app.elements.scaleChecked.value;

        const params = {
            start: args.start.toString(),
            end: args.end.toString(),
            resource: args.resource,
            scale: scale
        };

        dp.clearSelection();

        const {data} = await DayPilot.Http.post("/api/appointments/create", params);
        app.loadEvents();
        //dp.message(data.message);
    }
});
scheduler.init();

Most of the configuration is omitted for brevity. We will take a look at the key functionality, which is generation of the appointment slots.

It is implemented using the onTimeRangeSelected event which is fired when users select a time range using drag and drop.

slot generation

Our event handler calls the /api/appointments/create API endpoint which generates the slots for the provided range.

You can find the backend implementation in the AppointmentsController.cs file:

namespace Project.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AppointmentsController : ControllerBase
    {
        // ...
        [HttpPost("create")]
        public async Task<ActionResult<AppointmentSlot>> PostAppointmentSlots(AppointmentSlotRange range)
        {

            var doctor = await _context.Doctors.FindAsync(range.Resource);

            if (doctor == null)
            {
                return BadRequest();
            }

            var slots = Timeline.GenerateSlots(range.Start, range.End, range.Scale);
            slots.ForEach(slot => {
                slot.Doctor = doctor;
                _context.Appointments.Add(slot);
            });

            await _context.SaveChangesAsync();

            return NoContent();
        }

        // ...
        public class AppointmentSlotRange
        {
            public DateTime Start { get; set; }
            public DateTime End { get; set; }
            public int Resource { get; set; }

            public string Scale { get; set; }
        }

        // ...
    }
}

The Timeline.GenerateSlots() takes two steps:

1. First, it generates the shifts for the provided range using GenerateTimeline() method.

There are two shifts per day - the morning shift from 9am to 1pm and the afternoon shift from 2pm to 6pm. In this tutorial, the shift start and end times are hardcoded (MorningShiftStarts, MorningShiftEnds, AfternoonShiftStarts, AfternoonShiftEnds properties). The shifts are the same for each doctor. In your application, you may need to change this and allow modification of the times, as well as providing a custom schedule for each of the doctors.

2. In the next step, it fills the shifts with appointment slots.

The appointment slot is defined using SlotDurationMinutes and in our application it is set to 1 hour (60 minutes).

The GenerateSlots() method saves the slots in a List<AppointmentSlot> object and returns it.

using Project.Models;

namespace Project.Service
{
    public class Timeline
    {

        public static int SlotDurationMinutes = 60;

        public static int MorningShiftStarts = 9;
        public static int MorningShiftEnds = 13;

        public static int AfternoonShiftStarts = 14;
        public static int AfternoonShiftEnds = 18;


        public static List<AppointmentSlot> GenerateSlots(DateTime start, DateTime end, string scale)
        {
            var result = new List<AppointmentSlot>();
            var timeline = GenerateTimeline(start, end, scale);

            foreach (var cell in timeline)
            {
                if (start <= cell.Start && cell.End <= end)
                {
                    for (var slotStart = cell.Start; slotStart < cell.End; slotStart = slotStart.AddMinutes(SlotDurationMinutes))
                    {
                        var slotEnd = slotStart.AddMinutes(SlotDurationMinutes);

                        var slot = new AppointmentSlot();
                        slot.Start = slotStart;
                        slot.End = slotEnd;
                        slot.Status = "free";

                        result.Add(slot);

                    }
                }
            }

            return result;
        }


        private static List<TimeCell> GenerateTimeline(DateTime start, DateTime end, string scale)
        {
            var result = new List<TimeCell>();


            var incrementMorning = 1;
            var incrementAfternoon = 1;

            var days = (end.Date - start.Date).TotalDays;

            if (end > end.Date)
            {
                days += 1;
            }

            if (scale == "shifts")
            {
                incrementMorning = MorningShiftEnds - MorningShiftStarts;
                incrementAfternoon = AfternoonShiftEnds - AfternoonShiftStarts;
            }

            for (var i = 0; i < days; i++)
            {
                var day = start.Date.AddDays(i);
                for (var x = MorningShiftStarts; x < MorningShiftEnds; x += incrementMorning)
                {
                    var cell = new TimeCell();
                    cell.Start = day.AddHours(x);
                    cell.End = day.AddHours(x + incrementMorning);

                    result.Add(cell);
                }
                for (var x = AfternoonShiftStarts; x < AfternoonShiftEnds; x += incrementAfternoon)
                {
                    var cell = new TimeCell();
                    cell.Start = day.AddHours(x);
                    cell.End = day.AddHours(x + incrementAfternoon);

                    result.Add(cell);
                }
            }


            return result;
        }

      
    }

    public class TimeCell
    {
        public DateTime Start;
        public DateTime End;
    }

}

As soon as the PostAppointmentSlots() method of the AppointmentsController class receives the list of slots, it simply creates a new record for each of them.

var slots = Timeline.GenerateSlots(range.Start, range.End, range.Scale);
slots.ForEach(slot => {
    slot.Doctor = doctor;
    _context.Appointments.Add(slot);
});

Now we are back at the client side (Manager.cshtml) where we reload the events using app.loadEvents() method to show the new slots:

const {data} = await DayPilot.Http.post("/api/appointments/create", params);
app.loadEvents();

Note that the scheduler configuration disables all other user actions (such as drag and drop moving or resizing) except deleting.

eventDeleteHandling: "Update",
eventClickHandling: "Disabled",
eventMoveHandling: "Disabled",
eventResizeHandling: "Disabled",

In this example, the manager’s view is simplified and it only handles the slot generation. All other management actions (reservation moving, editing, etc.) are handled in the doctor’s view. In your implementation, these two roles can be merged and implemented in the same view.

How to Implement Public Patient User Interface (Index.cshtml)

In our application, patients can access a public view which lists all available slots. Slots in the past and slots that were already requested by some other patient are not visible.

The patients can browse through the available slots and choose one that they would like to book.

The user interface displays a weekly calendar.

patient interface available slots

Next to the calendar, there is also a date picker that they can use to switch to a different week. Days with available slots are displayed in bold.

patient interface days

The weekly calendar is implemented using the JavaScript Calendar component (Index.cshtml):

const calendar = new DayPilot.Calendar("calendar", {
    viewType: "Week",
    timeRangeSelectedHandling: "Disabled",
    eventMoveHandling: "Disabled",
    eventResizeHandling: "Disabled",
    eventArrangement: "SideBySide",
    onBeforeEventRender: (args) => {
        switch (args.data.status) {
          case "free":
            args.data.backColor = "#3d85c6";  // blue
            args.data.barHidden = true;
            args.data.borderColor = "darker";
            args.data.fontColor = "white";
            args.data.html = `Available<br/>${args.data.doctorName}`;
            args.data.toolTip = "Click to request this time slot";
            break;
          case "waiting":
            args.data.backColor = "#e69138";  // orange
            args.data.barHidden = true;
            args.data.borderColor = "darker";
            args.data.fontColor = "white";
            args.data.html = "Your appointment, waiting for confirmation";
            break;
          case "confirmed":
            args.data.backColor = "#6aa84f";  // green
            args.data.barHidden = true;
            args.data.borderColor = "darker";
            args.data.fontColor = "white";
            args.data.html = "Your appointment, confirmed";
            break;
        }
    },
    onEventClick: async (args) => {
        if (args.e.data.status !== "free") {
          calendar.message("Please use a free slot to request an appointment.");
          return;
        }

        const form = [
          {name: "Request an Appointment"},
          {name: "From", id: "start", dateFormat: "MMMM d, yyyy h:mm tt", disabled: true},
          {name: "To", id: "end", dateFormat: "MMMM d, yyyy h:mm tt", disabled: true},
          {name: "Name", id: "name"},
        ];

        const data = {
          id: args.e.id(),
          start: args.e.start(),
          end: args.e.end(),
          patient: app.patientId
        };

        const options = {
          focus: "name"
        };

        const modal = await DayPilot.Modal.form(form, data, options);
        if (modal.canceled) {
          return;
        }

        await DayPilot.Http.put(`/api/appointments/${data.id}/request`, modal.result);

        args.e.data.status = "waiting";
        calendar.events.update(args.e.data);

    }
});
calendar.init();

As you can see, the calendar is read-only — it disables the drag and drop operations. We don’t want patients to be able to move the appointments to a different date or time, or create appointment requests at arbitrary positions.

timeRangeSelectedHandling: "Disabled",
eventMoveHandling: "Disabled",
eventResizeHandling: "Disabled",

The main logic is implemented using the onEventClick event handler, which is fired (as the name suggests) when patients click an existing available slot.

The calendar displays a modal dialog with appointment slot details where the patients can enter their details. In our application, we only require the patient name.

request

After confirmation, we submit the request to /api/appointments/${data.id}/request. This is an API endpoint that updates the slot details in the configured database:

[HttpPut("{id}/request")]
public async Task<IActionResult> PutAppointmentSlotRequest(int id, AppointmentSlotRequest slotRequest)
{
    var appointmentSlot = await _context.Appointments.FindAsync(id);
    if (appointmentSlot == null)
    {
        return NotFound();
    }

    appointmentSlot.PatientName = slotRequest.Name;
    appointmentSlot.PatientId = slotRequest.Patient;
    appointmentSlot.Status = "waiting";

    _context.Appointments.Update(appointmentSlot);
    await _context.SaveChangesAsync();

    return NoContent();
}

In the PutAppointmentSlotRequest() method, we change the status to waiting, save the provided patient name, and link the appointment slot to the patient ID.

In your application, you’d use the patient ID from the database of users. Here, we use a client-side generated random ID that is stored in the localStorage object. This will let us identify the patient after reloading the page and highlight their requests. In a real application, this needs to be replaced with a proper login.

You can see that we send the user ID with the request that loads free appointments:

const app = {
    get patientId() {
        if (!localStorage["patientId"]) {
            localStorage["patientId"] = DayPilot.guid();
        }
        return localStorage["patientId"];
    },
    async loadEvents(day) {
        const start = nav.visibleStart() > DayPilot.Date.now() ? nav.visibleStart() : DayPilot.Date.now();
        const end = nav.visibleEnd();
        const patient = app.patientId;

        const {data} = await DayPilot.Http.get(`/api/appointments/free?start=${start}&end=${end}&patient=${patient}`);

        if (day) {
            calendar.startDate = day;
        }
        calendar.events.list = data;
        calendar.update();

        nav.events.list = data;
        nav.update();
    },
    init() {
        app.loadEvents();
    }
};

On the server side, we use the patient ID to filter out slots that belong to someone else:

[HttpGet("free")]
public async Task<ActionResult<IEnumerable<AppointmentSlot>>> GetAppointments([FromQuery] DateTime start, [FromQuery] DateTime end, [FromQuery] string patient)
{
    return await _context.Appointments.Where(e => (e.Status == "free" || (e.Status != "free" && e.PatientId == patient )) && !((e.End <= start) || (e.Start >= end))).Include(e => e.Doctor).ToListAsync();
}

Entity Framework Model Classes

In this application, we use the Entity Framework code first approach. We define the model classes and use EnsureCreated() to create the database schema on first startup. The same model works with SQL Server LocalDB and SQLite. This sample does not include migrations; use migrations when evolving a database that already contains data.

The Entity Framework also makes it easier to create the Web API controller. We started with the generated versions of the controller and only added the application-specific endpoints.

You can find the model classes in the Models/Data.cs file:

Doctor class:

public class Doctor
{
    public int Id { get; set; }
    public string Name { get; set; }
}

AppointmentSlot class:

public class AppointmentSlot
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
    public DateTime End { get; set; }

    [JsonIgnore()]
    public Doctor Doctor { get; set; }

    [JsonPropertyName("text")]
    public string? PatientName {  set; get; }

    [JsonPropertyName("patient")]
    public string? PatientId { set; get; }

    public string Status { get; set; } = "free";

    [NotMapped]
    public int Resource { get { return Doctor.Id; } }

    [NotMapped]
    public string DoctorName { get { return Doctor.Name; } }

}

Entity Framework Installation and Configuration

The project references the required Entity Framework Core packages. Install the .NET 10 SDK and run dotnet restore followed by dotnet run from the project directory. Open the URL printed by ASP.NET Core. Windows requires SQL Server Express LocalDB; Linux and macOS use a local SQLite database file automatically.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.2" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>

Connection string in appsettings.json:

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "AllowedHosts": "*",
    "ConnectionStrings": {
        "DoctorDbContext": "Server=(localdb)\\mssqllocaldb;Database=DayPilot.TutorialDoctor;Trusted_Connection=True"
    }
}

Configure the DoctorDbContext in Program.cs. The default provider follows the operating system. To use SQLite explicitly, start with dotnet run -- --Database:Provider=Sqlite:

var provider = builder.Configuration["Database:Provider"]
    ?? (OperatingSystem.IsWindows() ? "SqlServer" : "Sqlite");
builder.Services.AddDbContext<DoctorDbContext>(options =>
{
    if (provider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase))
    {
        options.UseSqlite(builder.Configuration.GetConnectionString("Sqlite") ?? "Data Source=doctor.db");
    }
    else
    {
        options.UseSqlServer(builder.Configuration.GetConnectionString("DoctorDbContext"));
    }
});

The ASP.NET Core project does not include database migration classes. The database will be automatically created and initialized when you run the application for the first time.

Program.cs:

using (var serviceScope = app.Services.CreateScope())
{
    var services = serviceScope.ServiceProvider;
    try
    {
        var context = services.GetRequiredService<DoctorDbContext>();
        context.Database.EnsureCreated();
    }
    catch (Exception ex)
    {
        var logger = services.GetRequiredService<ILogger<Program>>();
        logger.LogError(ex, "An error occurred creating the DB.");
    }
}

History

  • October 6, 2026: Refreshed the DayPilot Pro library, added a SQLite fallback for Linux/macOS, updated the navigation and scheduling toolbar, and refreshed the screenshots.

  • March 21, 2026: Upgraded to .NET 10 and Entity Framework 10; upgraded to DayPilot Pro for JavaScript 2026.1.6860

  • November 26, 2023: Upgraded to .NET 8, upgraded to DayPilot Pro for JavaScript 2023.4.5808; automatic database initialization

  • December 3, 2021: Initial release (.NET 6)