Javascript Appointment Scheduler Web App (Free Download)

Welcome to a tutorial on how to create an appointment scheduler web app with pure Javascript. Yep, there are a lot of “appointment schedulers” on the Internet. So here is a slightly different one, an offline-capable and installable web app – Read on!

ⓘ I have included a zip file with all the source code at the start of this tutorial, so you don’t have to copy-paste everything… Or if you just want to dive straight in.

 

 

TABLE OF CONTENTS

 

DOWNLOAD & NOTES

Firstly, here is the download link to the example code as promised.

 

QUICK NOTES

  • Download and unzip into your HTTP folder.
  • Access 1-js-appo.html in the browser.
  • You must access the app with https:// for “installable” and “offline-capable” to work properly. http://localhost is an exception for testing.
If you spot a bug, feel free to comment below. I try to answer short questions too, but it is one person versus the entire world… If you need answers urgently, please check out my list of websites to get help with programming.

 

SCREENSHOT

 

EXAMPLE CODE DOWNLOAD

Click here to download all the example source code, I have released it under the MIT license, so feel free to build on top of it or use it in your own project.

 

 

JAVASCRIPT APPOINTMENT SCHEDULER

All right, let us now get into more details on how the Javascript appointment scheduler works.

 

PART 1) HTML PAGE

1-js-appo.html
<!-- (A) APPOINTMENTS -->
<div id="appoWrap">
  <!-- (A1) HEADER -->
  <div id="appoHead">
    <div id="appoLast" class="mi" onclick="appo.cycle(false)">
      arrow_back_ios
    </div>
    <div id="appoNow"></div>
    <div id="appoAdd" class="mi" onclick="appo.toggle(true)">
      add
    </div>
    <div id="appoNext" class="mi" onclick="appo.cycle(true)">
      arrow_forward_ios
    </div>
  </div>
 
  <!-- (A2) APPOINTMENTS LIST -->
  <div id="appoList"></div>
</div>
 
<!-- (B) APPOINTMENT EVENT FORM -->
<div id="appoFormWrap"><form id="appoForm" onsubmit="return appo.save()">
  <div id="formClose" class="mi" onclick="appo.toggle(false)">
    close
  </div>
  <input type="hidden" id="formID"/>
  <label>From</label>
  <input type="datetime-local" id="formFrom" required/>
  <label>To</label>
  <input type="datetime-local" id="formTo" required/>
  <label>Details</label>
  <input type="text" id="formTxt" required/>
  <label>Text Color</label>
  <input type="color" id="formTC" required value="#ffffff"/>
  <label>Background Color</label>
  <input type="color" id="formBC" required value="#000000"/>
  <input type="submit" value="Save"/>
</form></div>

The HTML page looks rather confusing at first, but it is actually very straightforward.

  • <div id="appoWrap"> The “main page interface”.
    • <div id="appoHead"> The page header. With the currently selected month/year, last/next month, and “add event” buttons.
    • <div id="appoList"> We will generate the list of appointment events in this container using Javascript.
  • <div id="appoFormWrap"><form id="appoForm"> A hidden add/edit appointment event form.

 

 

PART 2) THE JAVASCRIPT

2A) PROPERTIES

2-js-appo.js
var appo = {
  // (A) PROPERTIES
  iName : "jsAppo", // idb name
  iDB : null, iTX : null, // idb object & transaction
  hNow : null, // html current year month
  hList : null, // html appointment list
  hForm : null, // html appointment form
  fID : null, fFrom : null, fTo : null, // html form
  fTxt : null, fTC : null, fBC : null, // html form
  month : null, // current month
  year : null, // current year
  days : null, // days in month for current month/year
  // ...
};

Captain Obvious to the rescue, var appo is an object that contains all the mechanics. Right off the bat, we have a whole bunch of properties. To simplify things, I will generalize them into 4 groups.

  • iName iDB iTX For working with the indexed database.
  • hNow hList hForm Reference to the HTML elements.
  • fID fForm fTo fTxt fTC fBC Reference to the HTML form fields.
  • month year days The currently selected period.

 

2B) INIT

2-js-appo.js
// (B) INIT
init : () => {
  // (B1) IDB SUPPORT CHECK
  window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
  if (!window.indexedDB) {
    alert("Your browser does not support indexed database.");
    return false;
  }

  // (B2) OPEN "MYLIB" DATABASE
  let req = window.indexedDB.open(appo.iName, 1);

  // (B3) ON DATABASE ERROR
  req.onerror = (evt) => {
    alert("Indexed DB init error - " + evt.message);
    console.error(evt);
  };

  // (B4) UPGRADE NEEDED
  req.onupgradeneeded = (evt) => {
    // (B4-1) INIT UPGRADE
    appo.iDB = evt.target.result;
    appo.iDB.onerror = (evt) => {
      alert("Indexed DB upgrade error - " + evt.message);
      console.error(evt);
    };

    // (B4-2) VERSION 1
    if (evt.oldVersion < 1) {
      let store = appo.iDB.createObjectStore(appo.iName, {
        keyPath: "id",
        autoIncrement: true
      });
      store.createIndex("from", "from");
      store.createIndex("to", "to");
    }
  };

  // (B5) OPEN DATABASE OK
  req.onsuccess = (evt) => {
    // (B5-1) REGISTER IDB OBJECTS
    appo.iDB = evt.target.result;
    appo.iTX = () => {
      return appo.iDB
      .transaction(appo.iName, "readwrite")
      .objectStore(appo.iName);
    };

    // (B5-2) GET HTML ELEMENTS
    appo.hNow = document.getElementById("appoNow");
    appo.hList = document.getElementById("appoList");
    appo.hForm = document.getElementById("appoFormWrap");
    appo.fID = document.getElementById("formID");
    appo.fFrom = document.getElementById("formFrom");
    appo.fTo = document.getElementById("formTo");
    appo.fTxt = document.getElementById("formTxt");
    appo.fTC = document.getElementById("formTC");
    appo.fBC = document.getElementById("formBC");

    // (B5-3) CURRENT DATE
    let today = new Date();
    appo.month = today.getMonth() + 1; // jan is 0!
    appo.year = today.getFullYear();

    // (B5-4) DRAW APPOINTMENTS
    appo.draw();
  };
},
// ...
window.onload = appo.init;

On window load, appo.init() will initialize the app, I shall leave it as it is… It is just long-winded, and the basics are quite essentially:

  • (B1 to B4) Set up the indexed database.
  • (B5) Get the HTML elements and set the default to the current month/year.

 

 

2C) DATA STRUCTURE

Before we proceed, here is a “quick table structure” of the jsAppo indexed database object-store.

{
  id :   PRIMARY KEY,
  from : DATE/TIME FROM - INDEXED UNIX TIMESTAMP,
  to :   DATE/TIME TO - INDEXED UNIX TIMESTAMP,
  txt :  EVENT DETAILS,
  tc :   TEXT COLOR,
  bc :   BACKGROUND COLOR
}

Feel free to change the HTML form and save() below to capture whatever fields you need.

 

2D) SUPPORT FUNCTIONS

2-js-appo.js
// (C) HELPER FUNCTION - DATE TO UNIX TIMESTAMP
dtu : (date) => {
  return Math.floor((+new Date(date)) / 1000);
},
 
// (D) HELPER FUNCTION - UNIX TIMESTAMP TO DATE
utd : (unix) => {
  return new Date(unix*1000).toISOString().substring(0,16);
},

Next, we have 2 support functions. Don’t think these need much explanation. One turns a date into a Unix timestamp, the other does the vice-versa.

 

2E) DRAW HTML APPOINTMENTS

2-js-appo.js
// (E) DRAW APPOINTMENTS FOR SELECTED MONTH
draw : () => {
  // (E1) CURRENT MONTH YEAR
  let months = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
                "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  appo.hNow.innerHTML = `${months[appo.month]} ${appo.year}`;
  delete months;
 
  // (E2) FIRST ROW - DAYS IN MONTH
  let cell;
  appo.days = new Date(appo.year, appo.month, 0).getDate();
  appo.hList.style.gridTemplateColumns = `repeat(${appo.days}, 100px)`;
  appo.hList.innerHTML = "";
  for (let d=1; d<=appo.days; d++) {
    cell = document.createElement("div");
    cell.className = "cell head";
    cell.innerHTML = d;
    appo.hList.append(cell);
  }
 
  // (E3) FOLLOWING ROWS - APPOINTMENT EVENTS
  // (E3-1) FLAGS & VARS
  let first = appo.dtu(`${appo.year}-${appo.month<10?"0"+appo.month:appo.month}-01T00:00`),
      last = appo.dtu(`${appo.year}-${appo.month<10?"0"+appo.month:appo.month}-${appo.days}T00:00`),
      idx = null, more = true, drawn = {}, row = 2;
 
  // (E3-2) HELPER FUNCTION - DRAW APPOINTMENT EVENT
  let rower = (evt) => {
    const cursor = evt.target.result;
    if (cursor) {
      if (drawn[cursor.value.id]==undefined) {
        let cx = cursor.value.from < first ? 1 : new Date(cursor.value.from * 1000).getDate(),
            cy = cursor.value.to > last ? appo.days + 1 : new Date(cursor.value.to * 1000).getDate() + 1;
 
        cell = document.createElement("div");
        cell.className = "row";
        cell.style.gridRow = row;
        cell.style.gridColumn = `${cx}/${cy}`;
        cell.style.color = cursor.value.tc;
        cell.style.background = cursor.value.bc;
        cell.innerHTML = `<div class="mi del" onclick="appo.del(${cursor.value.id});">clear</div>
        <div class="txt" onclick="appo.toggle(${cursor.value.id});">${cursor.value.txt}</div>`;
        appo.hList.append(cell);
        drawn[cursor.value.id] = 1; row++;
      }
      cursor.continue();
    } else { if (more) {
      more = false;
      idx = appo.iTX().index("to").openCursor(range = IDBKeyRange.bound(first, last));
      idx.onsuccess = rower;
    }}
  };
 
  // (E3-3) GET & DRAW ENTRIES
  idx = appo.iTX().index("from").openCursor(range = IDBKeyRange.bound(first, last));
  idx.onsuccess = rower;
},

Once again, this is just long-winded. All this does is get the event entries from the indexed database and draw the HTML.

 

 

2F) HTML INTERFACE

2-js-appo.js
// (F) CYCLE MONTH
cycle : (next) => {
  if (next) { appo.month++; } else { appo.month--; }
  if (appo.month==13) { appo.month = 1; appo.year++; }
  if (appo.month==0) { appo.month = 12; appo.year--; }
  appo.draw();
},
 
// (G) TOGGLE APPOINTMENT FORM
toggle : (id) => {
  // (G1) HIDE
  if (id == false) {
    appo.hForm.classList.remove("show");
    appo.fID.value = "";
    appo.fFrom.value = "";
    appo.fTo.value = "";
    appo.fTxt.value = "";
    appo.fTC.value = "#ffffff";
    appo.fBC.value = "#000000";
  }
 
  // (G2) SHOW
  else {
    if (Number.isInteger(id)) {
      let req = appo.iTX().get(id);
      req.onsuccess = (evt) => {
        appo.fID.value = req.result.id;
        appo.fFrom.value = appo.utd(req.result.from);
        appo.fTo.value = appo.utd(req.result.to);
        appo.fTxt.value = req.result.txt;
        appo.fTC.value = req.result.tc;
        appo.fBC.value = req.result.bc;
      };
    }
    appo.hForm.classList.add("show");
  }
},
  • (F) Remember the last/next month buttons in the header? This deals with it. Literally, just increment/decrement month year and redraw the HTML.
  • (G) Remember the hidden HTML form? This will toggle it, take note of id though.
    • false Hide the form and reset it.
    • true Show the form.
    • NUM Indicates “edit mode”. Get the specified entry from the database, set the fields, and show the form.

 

2G) SAVE & DELETE APPOINTMENT EVENTS

2-js-appo.js
// (H) SAVE APPOINTMENT EVENT
save : () => {
  // (H1) FORM DATA
  let data = {
    id : appo.fID.value,
    from : appo.dtu(appo.fFrom.value),
    to : appo.dtu(appo.fTo.value),
    txt : appo.fTxt.value,
    tc : appo.fTC.value,
    bc : appo.fBC.value
  };
 
  // (H2) DATE/TIME CHECK
  if (data.to < data.from) {
    alert("'Date From' cannot be later than 'Date To'");
    return false;
  }
 
  // (H3) SAVE EVENT
  if (data.id == "") { delete data.id; }
  else { data.id = parseInt(data.id); }
  appo.iTX().put(data);
 
  // (H4) UPDATE HTML INTERFACE
  appo.toggle(false);
  appo.draw();
  return false;
},
 
// (I) DELETE APPOINTMENT EVENT
del : (id) => { if (confirm("Delete event?")) {
  appo.iTX().delete(id);
  appo.draw();
}}

Lastly, these 2 functions do exactly what their function name says… Save and delete entries. Not going to explain line-by-line once again, if you want to learn more about indexed databases, I will leave a link to another tutorial below.

 

 

PART 3) PROGRESSIVE WEB APP

 

3A) HTML HEADERS

1-js-appo.html
<!-- ANDROID + CHROME + APPLE + WINDOWS APP -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="white">
<link rel="apple-touch-icon" href="icon-512.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Scheduler">
<meta name="msapplication-TileImage" content="icon-512.png">
<meta name="msapplication-TileColor" content="#ffffff">
 
<!-- WEB APP MANIFEST -->
<!-- https://web.dev/add-manifest/ -->
<link rel="manifest" href="3a-manifest.json">
 
<!-- SERVICE WORKER -->
<script>
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("3b-worker.js");
}
</script>

At this stage, we already have a fully functioning web app. But to level this up and turn it into an “installable offline app”, there are 3 things we need to insert into the HTML head.

  • Meta tags… A real pain because everyone has different meta tags and icon sizes. I figured the laziest way is to just provide a huge 512 X 512 icon, and let them resize it.
  • Add a web manifest file.
  • Register a service worker.

 

3B) WEB MANIFEST

3a-manifest.json
{
  "short_name": "Scheduler",
  "name": "Appointment Scheduler",
  "icons": [{
    "src": "favicon.png",
    "sizes": "64x64",
    "type": "image/png"
  }, {
    "src": "icon-512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "start_url": "1-js-appo.html",
  "scope": "/",
  "background_color": "white",
  "theme_color": "white",
  "display": "standalone"
}

Well, a “web manifest file” is what it is. A file to contain the app name, icons, starting page, theme, and settings.

 

3C) SERVICE WORKER

3b-worker.js
// (A) FILES TO CACHE
const cName = "JSAppo",
cFiles = [
  "favicon.png",
  "icon-512.png",
  "maticon.woff2",
  "1-js-appo.html",
  "2-js-appo.js",
  "2-js-appo.css"
];

// (B) CREATE/INSTALL CACHE
self.addEventListener("install", (evt) => {
  self.skipWaiting();
  evt.waitUntil(
    caches.open(cName)
    .then((cache) => { return cache.addAll(cFiles); })
    .catch((err) => { console.error(err) })
  );
});

// (C) LOAD FROM CACHE, FALLBACK TO NETWORK IF NOT FOUND
self.addEventListener("fetch", (evt) => {
  evt.respondWith(
    caches.match(evt.request)
    .then((res) => { return res || fetch(evt.request); })
  );
});

For you guys who have never heard of “service worker” before, it is basically a piece of Javascript that runs in the background. In this one:

  • (A & B) We create a new browser cache and save all the project files inside.
  • (C) “Hijack” the fetch requests. Serve the requested file from the cache if it is previously saved, and fall back to loading from the network if not found.

In other words, this service worker enables “offline mode” by saving the entire app into a browser cache.

 

EXTRA BITS & LINKS

That’s all for the tutorial, and here is a small section on some extras and links that may be useful to you.

 

COMPATIBILITY CHECKS

This example will only work on modern “Grade A” browsers.

 

LINKS & REFERENCES

 

THE END

Thank you for reading, and we have come to the end. I hope that it has helped you to better understand, and if you want to share anything with this guide, please feel free to comment below. Good luck and happy coding!

Leave a Comment

Your email address will not be published. Required fields are marked *