Features

  • Multiple tennis courts, grouped by location (indoor, outdoor)

  • Public interface for visitors: see available time slots, book a time slot

  • Admin interface for staff: see and manage all reservations

  • Showing different prices depending on time of day

  • Prevents creating reservations in the past

  • PHP source code

  • MySQL database

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.

JavaScript Scheduler Introduction

This project uses DayPilot JavaScript Scheduler component for the reservation UI. For introduction to using the scheduler component please see HTML5 Scheduler Tutorial - it shows how to set up the scheduler, load events and resources, enable drag and drop, configure scrolling and time headers.

Hours Available for Reservation

php tennis court reservation html5 javascript mysql available hours

The Scheduler is able to display non-continuous timeline. We will use this feature and limit the Scheduler view to the available hours (from 6 am to 11 pm). 

We will hide the non business hours using showNonBusiness property. By default, all weekends are defined as non-business hours so we need to override this setting manually. In this configuration, the Scheduler will skip the non-business hours and display a special separator (a black vertical line) to indicate a break in the timeline. You can modify the appearance of the time break using CSS if needed (it is marked using scheduler_default_matrix_vertical_break CSS class for the default theme).

dp.businessBeginsHour = 6;
dp.businessEndsHour = 23;
dp.businessWeekends = true;
dp.showNonBusiness = false;

Tennis Court Hourly Rates

php tennis court reservation html5 javascript mysql hourly rate

Our web application uses different prices for the time slots, depending on the time of day - peak times are more expensive. The slot prices are defined in JavaScript:

const slotPrices = {
  "06:00": 12,
  "07:00": 15,
  "08:00": 15,
  "09:00": 15,
  "10:00": 15,
  "11:00": 12,
  "12:00": 10,
  "13:00": 10,
  "14:00": 12,
  "15:00": 12,
  "16:00": 15,
  "17:00": 15,
  "18:00": 15,
  "19:00": 15,
  "20:00": 15,
  "21:00": 12,
  "22:00": 10,
};

We will customize the grid cells using onBeforeCellRender event handler. It will display the price directly in the time slot. The color of the free slots is set to green, lighter colors are used for cheaper slots. Time slots in the past are left with the default appearance (white).

dp.onBeforeCellRender = args => {

  if (args.cell.isParent) {
    return;
  }

  if (args.cell.start < new DayPilot.Date()) {  // past
    return;
  }

  var color = "green";

  var slotId = args.cell.start.toString("HH:mm");
  var price = slotPrices[slotId];

  var min = 5;
  var max = 15;
  var opacity = (price - min) / max;
  var text = "$" + price;
  args.cell.areas = [
    {
      left: 0,
      right: 0,
      top: 0,
      bottom: 0,
      backColor: color,
      fontColor: "white",
      html: text,
      style: "display: flex; justify-content: center; align-items: center; opacity: " + opacity
    }
  ]
};

Loading Tennis Courts

php tennis court reservation html5 javascript mysql loading courts

Our application displays the reservation schedule for multiple tennis courts. We will display the courts on the Y axis on the left side of the Scheduler. The tennis courts will be arranged in a tree hierarchy. There will be two parent nodes (Indoor, Outdoor) that will display the courts grouped by location.

dp.treeEnabled = true;

function loadResources() {
  dp.rows.load("backend_resources.php");
}

PHP

<?php
require_once '_db.php';

$scheduler_groups = $db->query('SELECT * FROM groups ORDER BY name');

class Group {}
class Resource {}

$groups = array();

foreach($scheduler_groups as $group) {
  $g = new Group();
  $g->id = "group_".$group['id'];
  $g->name = $group['name'];
  $g->expanded = true;
  $g->children = array();
  $groups[] = $g;

  $stmt = $db->prepare('SELECT * FROM resources WHERE group_id = :group ORDER BY name');
  $stmt->bindParam(':group', $group['id']);
  $stmt->execute();
  $scheduler_resources = $stmt->fetchAll();

  foreach($scheduler_resources as $resource) {
    $r = new Resource();
    $r->id = $resource['id'];
    $r->name = $resource['name'];
    $g->children[] = $r;
  }
}

header('Content-Type: application/json');
echo json_encode($groups);

Sample JSON Response

[
  {"id":"group_1","name":"Indoor","expanded":true,"children":[
    {"id":"1","name":"Court 1"},
    {"id":"2","name":"Court 2"},
    {"id":"3","name":"Court 3"},
    {"id":"4","name":"Court 4"}]},
  {"id":"group_2","name":"Outdoor","expanded":true,"children":[
    {"id":"11","name":"Court 5"},
    {"id":"12","name":"Court 6"},
    {"id":"13","name":"Court 7"},
    {"id":"14","name":"Court 8"}]}
]

Prevent Concurrent Reservations

php tennis court reservation html5 javascript mysql concurrent

We don't want the users to create multiple reservations for the same time slot so we will disable overlapping events in the Scheduler. This setting will be applied when creating new reservations and also when moving and resizing the reservation (this is only possible in the admin interface).

dp.allowEventOverlap = false;

Slots Unavailable for Reservations

php tennis court reservation html5 javascript mysql unavailable slots

We will load data for existing reservations and display them as blocks of unavailable time. The event/reservation details won't be visible to users. Only the administrator will be able to see the reservation name and other details.

Loading the availability data:

function loadEvents() {
  dp.events.load("backend_events_busy.php");  // GET  request with "start" and "end" query string parameters
}

backend_events_busy.php

<?php
require_once '_db.php';

$json = file_get_contents('php://input');
$params = json_decode($json);

$stmt = $db->prepare('SELECT * FROM events WHERE NOT ((end <= :start) OR (start >= :end))');
$stmt->bindParam(':start', $_GET['start']);
$stmt->bindParam(':end', $_GET['end']);
$stmt->execute();
$result = $stmt->fetchAll();

class Event {}
$events = array();

foreach($result as $row) {
  $e = new Event();
  $e->id = $row['id'];
  $e->text = "";
  $e->start = $row['start'];
  $e->end = $row['end'];
  $e->resource = $row['resource_id'];
  $e->moveDisabled = true;
  $e->resizeDisabled = true;
  $e->clickDisabled = true;
  $e->backColor = "#E69138";   // lighter #F6B26B
  $e->bubbleHtml = "Not Available";

  $events[] = $e;
}

header('Content-Type: application/json');
echo json_encode($events);

The PHP endpoint returns the reservation data. It is a JSON array of event data objects. Each event object includes the required fields:

  • start (reservations start date/time)

  • end (reservation end date/time)

  • id (reservation id)

  • text (reservation text - the public interface uses an empty string)

  • resource (the court id)

We also add a few optional parameters to modify the default behavior:

  • moveDisabled (disables drag and drop reservation moving)

  • resizeDisabled (disables drag and drop reservation resizing)

  • clickDisabled (disables the default reservation click action)

  • backColor (the reservation background color is set to light orange)

  • bubbleHtml (sets the HTML that displays on hover)

Reservation Restrictions

We will implement two custom rules that will apply when creating a new reservation:

  • It's not possible to create reservations in the past.

  • It's not possible to create reservations longer than 4 hours.

Creating Reservations in the Past

php tennis court reservation html5 javascript mysql past

We will use onTimeRangeSelecting event which is fired in real time whenever the selected date range changes. It will let us provide immediate feedback to the users about the selected date range.

In this case, we will forbid the selection if it starts in the past. The selection color will change to red (if we mark it as not allowed). We will also display a custom message that will show details about the rule.

dp.onTimeRangeSelecting = args => {
  if (args.start < new DayPilot.Date()) {
      args.right.enabled = true;
      args.right.html = "Can't book in the past";
      args.allowed = false;                        
  }
  // ...
};

Maximum Reservation Duration

php tennis court reservation html5 javascript mysql maximum length

We will add one more rule that will prevent users from creating reservations longer than 4 hours. We will check the duration property of the args object and display a message describing the problem.

dp.onTimeRangeSelecting = args => {

  // ...

  if (args.duration.totalHours() > 4) {
      args.right.enabled = true;
      args.right.html = "You can only book up to 4 hours";
      args.allowed = false;
  }
}; 

Reservation Scheduler Time Headers

php tennis court reservation html5 javascript mysql time headers

We will display time header with three levels:

  • month, year

  • day

  • hour

We will use a custom format string to customize the date appearance. The Scheduler will use the format string in connection with the current locale to display localized values. By default it is set to "en-us".

You can also customize the time header HTML if you need more control over the content.

dp.timeHeaders = [
  { groupBy: "Month", format: "MMMM yyyy" },
  { groupBy: "Day", format: "dddd, MMMM d"},
  { groupBy: "Hour", format: "h tt"}
];

Scrolling to the Selected Date

php tennis court reservation html5 javascript mysql scroll to date

We will add a Navigator control to allow easy switching of the visible date:

<div style="display: flex">
  <div style="">
    <div id="nav"></div>
  </div>
  <div style="flex-grow: 1; margin-left: 10px;">
    <div id="dp"></div>
  </div>
</div>          

<script>
    const nav = new DayPilot.Navigator("navigator");
    nav.onTimeRangeSelected =  args => {
        const day = args.day;
        
        const start = day.firstDayOfMonth();
        const days = day.daysInMonth();
        dp.startDate = start;
        dp.days = days;
        dp.update();
        loadEvents();
    };
    nav.init();
       
    const dp = new DayPilot.Scheduler("scheduler");
    // ...
    dp.init();
 </script>

The Navigator fires onTimeRangeSelected event whenever the user selects a new date. This event handler switches the Scheduler to display the selected month and loads the reservations.

As the next step, we will modify the event handler to scroll to the selected date:

const nav = new DayPilot.Navigator("navigator");
nav.onTimeRangeSelected = args => {
  const day = args.day;
  
  if (dp.visibleStart() <= day && day < dp.visibleEnd()) {
      dp.scrollTo(day, "fast");
  }
  else {
      const start = day.firstDayOfMonth();
      const days = day.daysInMonth();
      dp.startDate = start;
      dp.days = days;
      dp.update();
      dp.scrollTo(day, "fast");
      loadEvents();
  }
};
nav.init();

Administration Interface

php tennis court reservation html5 javascript mysql admin

The admin interface (admin.php) is very similar to the public one. However, it provides full access to the reservations:

  • It displays full reservation details, including the name.

  • All drag and drop operations are allowed. It is possible to move and resize reservations.

  • It's possible to edit the reservations (clicking the reservation opens a modal dialog with reservation details).

The reservations are loaded using backend_events.php which shows the full reservation text and doesn't disable drag and drop:

<?php
require_once '_db.php';
    
$json = file_get_contents('php://input');
$params = json_decode($json);

$stmt = $db->prepare('SELECT * FROM [events] WHERE NOT ((end <= :start) OR (start >= :end))');
$stmt->bindParam(':start', $params->start);
$stmt->bindParam(':end', $params->end);
$stmt->execute();
$result = $stmt->fetchAll();

class Event {}
$events = array();

foreach($result as $row) {
  $e = new Event();
  $e->id = $row['id'];
  $e->text = $row['name'];
  $e->start = $row['start'];
  $e->end = $row['end'];
  $e->resource = $row['resource_id'];
  $e->bubbleHtml = "Event details: <br/>".$e->text;
  $events[] = $e;
}

header('Content-Type: application/json');
echo json_encode($events);

?> 

Reservation moving is enabled using onEventMoved event handler:

dp.onEventMoved = async args => {

  const data = {
    id: args.e.id(),
    newStart: args.newStart,
    newEnd: args.newEnd,
    newResource: args.newResource
  };
  await DayPilot.Http.post("backend_move.php", data);
  dp.message("Moved.");

}; 

Event click is mapped to editing the reservation using a modal dialog:

dp.onEventClicked = async  args => {

  const courts = []
    .concat(dp.resources[0].children)
    .concat(dp.resources[1].children);

  const form = [
    {name: "Name", id: "name"},
    {name: "Start", id: "start", dateFormat: "MMMM d, yyyy h:mm tt"},
    {name: "End", id: "end", dateFormat: "MMMM d, yyyy h:mm tt"},
    {name: "Court", id: "resource", options: courts},
  ];

  const data = {
    id: args.e.data.id,
    name: args.e.data.text,
    start: args.e.data.start,
    end: args.e.data.end,
    resource: args.e.data.resource
  };

  const modal = await DayPilot.Modal.form(form, data);
  dp.clearSelection();
  if (modal.canceled) {
    return;
  }
  await DayPilot.Http.post("backend_update.php", modal.result);

  const e = {
    start: modal.result.start,
    end: modal.result.end,
    id: modal.result.id,
    text: modal.result.name,
    resource: modal.result.resource
  };

  dp.events.update(e);

};

MySQL and SQLite Database

By default, the project uses SQLite database to minimize the setup requirements. You can switch to MySQL by editing the _db.php:

<?php

// use sqlite
// require_once '_db_sqlite.php';

// use MySQL
require_once '_db_mysql.php';

You can configure the MySQL database connection properties (server address, user name, password, database name) in _db_mysql.php:

<?php
$host = "127.0.0.1";
$port = 3306;
$username = "username";
$password = "password";
$database = "tennis-court-reservation";

// ...

The database will be created and initialized automatically if it doesn't exist.

Database Schema (MySQL)

This web application uses a MySQL database with the following schema:

CREATE TABLE `events` (
	`id` INT(11) NOT NULL AUTO_INCREMENT,
	`name` TEXT NULL,
	`start` DATETIME NULL DEFAULT NULL,
	`end` DATETIME NULL DEFAULT NULL,
	`resource_id` VARCHAR(30) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

CREATE TABLE `groups` (
	`id` INT(11) NOT NULL,
	`name` VARCHAR(200) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

CREATE TABLE `resources` (
	`id` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(200) NULL DEFAULT NULL,
	`group_id` INT(11) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

History

  • June 8, 2021: DayPilot Pro 2021.2.5000; ES6 syntax, async/await for promises; using system fonts

  • November 21, 2020: DayPilot Pro 2020.4.4766; jQuery removed; bug fixes

  • July 7, 2020: DayPilot Pro 2020.3.4558

  • Jul 12, 2018: MySQL support added, DayPilot Pro 2018.2.3324

  • Jun 28, 2017: DayPilot Pro updated

  • Dec 31, 2015: Initial release