Javascript Calendar, there sure are a ton of these all over the Internet. So I figured to do something a little different. This is a small experiment of mine to create a Javascript Events Calendar progressive web app – That’s right, this is a serverless, installable web app that works even when the user is offline. 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 & REQUIREMENTS
- This is not a “newbie-friendly” open-source project and example. It involves the use of service workers, cache storage, and an indexed database.
- “Grade A” browser required, alongside a
https://
server.http://localhost
is fine for testing too.
SCREENSHOT
LICENSE & DOWNLOAD
This project is released under the MIT License. You are free to use it for your own personal and commercial projects, and modify it as you see fit. On the condition that there the software is provided “as-is”. There are no warranties provided and “no strings attached”. Code Boxx and the authors are not liable for any claims, damages, or liabilities.
HOW IT WORKS
Not going to explain everything line-by-line (will take forever), but here’s a quick walkthrough of the Javascript Calendar PWA.
PART 1) SINGLE PAGE HTML
<!-- (A) PERIOD SELECTOR -->
<div id="calHead">
<div id="calPeriod">
<input id="calBack" type="button" class="mi" value="navigate_before">
<select id="calMonth"></select>
<input id="calYear" type="number">
<input id="calNext" type="button" class="mi" value="navigate_next">
</div>
<input id="calAdd" type="button" class="mi" value="add">
</div>
<!-- (B) CALENDAR WRAPPER -->
<div id="calWrap">
<div id="calDays"></div>
<div id="calBody"></div>
</div>
<!-- (C) EVENT FORM -->
<dialog id="calForm"><form method="dialog">
<div id="evtCX" class="mi">clear</div>
<h2 class="evt100">CALENDAR EVENT</h2>
<div class="evt50">
<label>Start</label>
<input id="evtStart" type="datetime-local" required>
</div>
<div class="evt50">
<label>End</label>
<input id="evtEnd" type="datetime-local" required>
</div>
<div class="evt50">
<label>Text Color</label>
<input id="evtColor" type="color" value="#000000" required>
</div>
<div class="evt50">
<label>Background Color</label>
<input id="evtBG" type="color" value="#ffdbdb" required>
</div>
<div class="evt100">
<label>Event</label>
<input id="evtTxt" type="text" required>
</div>
<div class="evt100">
<input type="hidden" id="evtID">
<input type="button" id="evtDel" class="mi" value="delete">
<input type="submit" id="evtSave" class="mi" value="save">
</div>
</form></dialog>
Look no further, this is the only HTML page in the entire project.
<div id="calHead">
The header bar.<div id="calPeriod">
Month and year selectors.<input id="calAdd">
Add new event button.
<div id="calWrap">
The calendar will be generated here with Javascript.<dialog id="calForm">
Popup calendar event form.
PART 2) JAVASCRIPT
2A) INDEXED DATABASE
// (A) INDEXED DB
const IDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
var calDB = {
// (B) INITIALIZE DATABASE
db : null,
init : () => new Promise((resolve, reject) => {
// (B1) OPEN CALENDAR DATABASE
calDB.db = IDB.open("JSCalendar", 1);
// (B2) CREATE CALENDAR DATABASE
calDB.db.onupgradeneeded = e => {
// (B2-1) CALENDAR DATABASE
calDB.db = e.target.result;
// (B2-2) IDB UPGRADE ERROR
calDB.db.onerror = e => {
alert("Indexed DB upgrade error - " + evt.message);
console.error(e);
reject(e.target.error);
};
// (B2-3) EVENTS STORE
if (e.oldVersion < 1) {
let store = calDB.db.createObjectStore("events", {
keyPath: "id",
autoIncrement: true
});
store.createIndex("s", "s", { unique: false });
store.createIndex("e", "e", { unique: false });
}
};
// (B3) IDB OPEN OK
calDB.db.onsuccess = e => {
calDB.db = e.target.result;
resolve(true);
};
// (B4) IDB OPEN ERROR
calDB.db.onerror = e => {
alert("Indexed DB init error - " + e.message);
console.error(e)
reject(e.target.error);
};
}),
// (C) TRANSACTION "MULTI-TOOL"
tx : (action, store, data, idx) => new Promise((resolve, reject) => {
// (C1) GET OBJECT STORE
let req, tx = calDB.db.transaction(store, "readwrite").objectStore(store);
// (C2) PROCESS ACTION
switch (action) {
// (C2-1) NADA
default: reject("Invalid database action"); break;
// (C2-2) ADD
case "add":
req = tx.add(data);
req.onsuccess = e => resolve(true);
break;
// (C2-3) PUT
case "put":
req = tx.put(data);
req.onsuccess = e => resolve(true);
break;
// (C2-4) DELETE
case "del":
req = tx.delete(data);
req.onsuccess = e => resolve(true);
break;
// (C2-5) GET
case "get":
req = tx.get(data);
req.onsuccess = e => resolve(e.target.result);
break;
// (C2-6) GET ALL
case "getAll":
req = tx.getAll(data);
req.onsuccess = e => resolve(e.target.result);
break;
// (C2-7) CURSOR
case "cursor":
if (idx) { resolve(tx.index(idx).openCursor(data)); }
else { resolve(tx.openCursor(data)); }
break;
}
req.onerror = e => reject(e.target.error);
})
};
The indexed database is a huge component of this web app. If you have not heard of it, an “indexed database” is pretty much a simplified database stored in the browser.
calDB.init()
We will run this on page load. This pretty much creates aJSCalendar
database with only one store (table) –events
.calDB.tx()
A database “transaction helper function” – Add, update, delete, get, etc…
2B) PROPERTIES & HELPERS
var cal = {
// (A) PROPERTIES
// (A1) FLAGS & DATA
mon : false, // monday first
events : null, // events data for current month/year
sMth : 0, // selected month
sYear : 0, // selected year
sDIM : 0, // number of days in selected month
sF : 0, // first date of the selected month (yyyymmddhhmm)
sL : 0, // last date of the selected month (yyyymmddhhmm)
sFD : 0, // first day of the selected month (mon-sun)
sLD : 0, // last day of the selected month (mon-sun)
ready : 0, // to track loading
// (A2) HTML ELEMENTS
hMth : null, hYear : null, // month & year
hCD : null, hCB : null, // calendar days & body
hFormWrap : null, hForm : null, // event form
hfID : null, hfStart : null, // event form fields
hfEnd : null, hfTxt : null,
hfColor : null, hfBG : null,
hfDel : null,
// (A3) HELPER FUNCTIONS
toDate : date => parseInt(date.replace(/-|T|:/g, "")),
toISODate : date => {
date = String(date);
yr = date.slice(0,4); mth = date.slice(4,6); day = date.slice(6,8);
hr = date.slice(8,10); min = date.slice(10);
return `${yr}-${mth}-${day}T${hr}:${min}`;
},
//...
};
All the calendar mechanics are contained within var cal = {}
. There are a lot of settings, flags, and data… Not going to go through all of them one by one, just take note of mon : false
– Change this if you want the week to start on Monday.
2C) INIT
// (B) INIT
init : () => {
// (B1) REQUIREMENTS CHECK - INDEXED DB
if (!IDB) {
alert("Your browser does not support indexed database.");
return;
}
// (B2) REQUIREMENTS CHECK - STORAGE CACHE
if (!"caches" in window) {
alert("Your browser does not support cache storage.");
return;
}
// (B3) REGISTER SERVICE WORKER
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("CB-worker.js");
}
// (B4) DATABASE + INTERFACE SETUP
if (await calDB.init()) {
// (B4-1) GET HTML ELEMENTS
cal.hMth = document.getElementById("calMonth");
cal.hYear = document.getElementById("calYear");
cal.hCD = document.getElementById("calDays");
cal.hCB = document.getElementById("calBody");
cal.hFormWrap = document.getElementById("calForm");
cal.hForm = cal.hFormWrap.querySelector("form");
cal.hfID = document.getElementById("evtID");
cal.hfStart = document.getElementById("evtStart");
cal.hfEnd = document.getElementById("evtEnd");
cal.hfTxt = document.getElementById("evtTxt");
cal.hfColor = document.getElementById("evtColor");
cal.hfBG = document.getElementById("evtBG");
cal.hfDel = document.getElementById("evtDel");
// (B4-2) MONTH & YEAR SELECTOR
let now = new Date(), nowMth = now.getMonth() + 1;
for (let [i,n] of Object.entries({
1 : "January", 2 : "Febuary", 3 : "March", 4 : "April",
5 : "May", 6 : "June", 7 : "July", 8 : "August",
9 : "September", 10 : "October", 11 : "November", 12 : "December"
})) {
let opt = document.createElement("option");
opt.value = i;
opt.innerHTML = n;
if (i==nowMth) { opt.selected = true; }
cal.hMth.appendChild(opt);
}
cal.hYear.value = parseInt(now.getFullYear());
// (B4-3) ATTACH CONTROLS
cal.hMth.onchange = cal.load;
cal.hYear.onchange = cal.load;
document.getElementById("calBack").onclick = () => cal.pshift();
document.getElementById("calNext").onclick = () => cal.pshift(1);
document.getElementById("calAdd").onclick = () => cal.show();
cal.hForm.onsubmit = () => cal.save();
document.getElementById("evtCX").onclick = () => cal.hFormWrap.close();
cal.hfDel.onclick = cal.del;
// (B4-4) DRAW DAY NAMES
let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
if (cal.mon) { days.push("Sun"); } else { days.unshift("Sun"); }
for (let d of days) {
let cell = document.createElement("div");
cell.className = "calCell";
cell.innerHTML = d;
cal.hCD.appendChild(cell);
}
// (B4-5) LOAD & DRAW CALENDAR
cal.load();
} else {
alert("ERROR OPENING INDEXED DB!");
return;
}
},
// ...
window.onload = cal.init;
On page load, cal.init()
will run. Looks pretty “hardcore”, but all this does is:
- Checks if the browser supports
indexedDB
andcaches
. - Register a service worker, which is a requirement to enable an “installable web app”.
- Initialize and create the calendar
indexedDB
. - Set up the HTML interface.
2D) PERIOD SHIFT
// (C) SHIFT CURRENT PERIOD BY 1 MONTH
pshift : forward => {
cal.sMth = parseInt(cal.hMth.value);
cal.sYear = parseInt(cal.hYear.value);
if (forward) { cal.sMth++; } else { cal.sMth--; }
if (cal.sMth > 12) { cal.sMth = 1; cal.sYear++; }
if (cal.sMth < 1) { cal.sMth = 12; cal.sYear--; }
cal.hMth.value = cal.sMth;
cal.hYear.value = cal.sYear;
cal.load();
},
This is just a small “convenience function” to help shift the current period by one month.
2E) LOAD EVENTS
// (D) LOAD EVENTS DATA FOR MONTH/YEAR
load : () => {
// (D1) SET SELECTED PERIOD
cal.sMth = parseInt(cal.hMth.value);
cal.sYear = parseInt(cal.hYear.value);
cal.sDIM = new Date(cal.sYear, cal.sMth, 0).getDate();
cal.sFD = new Date(cal.sYear, cal.sMth-1, 1).getDay();
cal.sLD = new Date(cal.sYear, cal.sMth-1, cal.sDIM).getDay();
let m = cal.sMth;
if (m < 10) { m = "0" + m; }
cal.sF = parseInt(String(cal.sYear) + String(m) + "010000");
cal.sL = parseInt(String(cal.sYear) + String(m) + String(cal.sDIM) + "2359");
// (D2) EVENTS DATA INIT
cal.hCB.innerHTML = "";
cal.ready = 0;
cal.events = {};
// (D3) GET EVENTS
// inefficient. but no other ways to do complex search in idb.
let collector = e => e.onsuccess = e => {
let cur = e.target.result;
if (cur) {
if (!cal.events[cur.value.id]) { cal.events[cur.value.id] = cur.value; }
cur.continue();
} else { cal.loading(); }
};
// (D3-1) GET ALL START DATE THAT FALLS INSIDE MONTH/YEAR
calDB.tx("cursor", "events", IDBKeyRange.bound(cal.sF, cal.sL), "s").then(collector);
// (D3-2) GET ALL END DATE THAT FALLS INSIDE MONTH/YEAR
calDB.tx("cursor", "events", IDBKeyRange.bound(cal.sF, cal.sL), "e").then(collector);
// (D3-3) END DATE AFTER SELECTED MONTH/YEAR, BUT START IS BEFORE
calDB.tx("cursor", "events", IDBKeyRange.lowerBound(cal.sL, true), "e").then(collector);
},
// (E) LOADING CHECK
loading : () => {
cal.ready++;
if (cal.ready==3) { cal.draw(); }
},
cal.load()
handles the loading of calendar events data from the indexedDB
… But as you can see, this is not SQL.
- There is literally no way to do a
SELECT * FROM `EVENTS` WHERE (`START_DATE` BETWEEN X AND Y) OR (`END_DATE` BETWEEN X AND Y) OR (`START_DATE`<X AND `END_DATE`>Y)
. - The only way is to run 3 different “queries” in
cal.load()
to fetch the different time frames and combine the results. cal.loading()
will make sure that all the entries are ready before drawing the HTML calendar.
2F) DRAW CALENDAR
// (F) DRAW CALENDAR
draw : () => {
// (F1) CALCULATE DAY MONTH YEAR
// note - jan is 0 & dec is 11 in js
// note - sun is 0 & sat is 6 in js
let now = new Date(), // current date
nowMth = now.getMonth()+1, // current month
nowYear = parseInt(now.getFullYear()), // current year
nowDay = cal.sMth==nowMth && cal.sYear==nowYear ? now.getDate() : null ;
// (F2) DRAW CALENDAR ROWS & CELLS
// (F2-1) INIT + HELPER FUNCTIONS
let rowA, rowB, rowC, rowMap = {}, rowNum = 1,
cell, cellNum = 1,
rower = () => {
rowA = document.createElement("div");
rowB = document.createElement("div");
rowC = document.createElement("div");
rowA.className = "calRow";
rowA.id = "calRow" + rowNum;
rowB.className = "calRowHead";
rowC.className = "calRowBack";
cal.hCB.appendChild(rowA);
rowA.appendChild(rowB);
rowA.appendChild(rowC);
},
celler = day => {
cell = document.createElement("div");
cell.className = "calCell";
if (day) { cell.innerHTML = day; }
rowB.appendChild(cell);
cell = document.createElement("div");
cell.className = "calCell";
if (day===undefined) { cell.classList.add("calBlank"); }
if (day!==undefined && day==nowDay) { cell.classList.add("calToday"); }
rowC.appendChild(cell);
};
cal.hCB.innerHTML = ""; rower();
// (F2-2) BLANK CELLS BEFORE START OF MONTH
if (cal.mon && cal.sFD != 1) {
let blanks = cal.sFD==0 ? 7 : cal.sFD ;
for (let i=1; i<blanks; i++) { celler(); cellNum++; }
}
if (!cal.mon && cal.sFD != 0) {
for (let i=0; i<cal.sFD; i++) { celler(); cellNum++; }
}
// (F2-3) DAYS OF THE MONTH
for (let i=1; i<=cal.sDIM; i++) {
rowMap[i] = { r : rowNum, c : cellNum };
celler(i);
if (cellNum%7==0 && i!=cal.sDIM) { rowNum++; rower(); }
cellNum++;
}
// (F2-4) BLANK CELLS AFTER END OF MONTH
if (cal.mon && cal.sLD != 0) {
let blanks = cal.sLD==6 ? 1 : 7-cal.sLD;
for (let i=0; i<blanks; i++) { celler(); cellNum++; }
}
if (!cal.mon && cal.sLD != 6) {
let blanks = cal.sLD==0 ? 6 : 6-cal.sLD;
for (let i=0; i<blanks; i++) { celler(); cellNum++; }
}
// (F3) DRAW EVENTS
if (Object.keys(cal.events).length > 0) { for (let [id,evt] of Object.entries(cal.events)) {
// (F3-1) EVENT START & END DAY
let sd = new Date(cal.toISODate(evt.s)),
ed = new Date(cal.toISODate(evt.e));
if (sd.getFullYear() < cal.sYear) { sd = 1; }
else { sd = sd.getMonth()+1 < cal.sMth ? 1 : sd.getDate(); }
if (ed.getFullYear() > cal.sYear) { ed = cal.sDIM; }
else { ed = ed.getMonth()+1 > cal.sMth ? cal.sDIM : ed.getDate(); }
// (F3-2) "MAP" ONTO HTML CALENDAR
cell = {}; rowNum = 0;
for (let i=sd; i<=ed; i++) {
if (rowNum!=rowMap[i]["r"]) {
cell[rowMap[i]["r"]] = { s:rowMap[i]["c"], e:0 };
rowNum = rowMap[i]["r"];
}
if (cell[rowNum]) { cell[rowNum]["e"] = rowMap[i]["c"]; }
}
// (F3-3) DRAW HTML EVENT ROW
for (let [r,c] of Object.entries(cell)) {
let o = c.s - 1 - ((r-1) * 7), // event cell offset
w = c.e - c.s + 1; // event cell width
rowA = document.getElementById("calRow"+r);
rowB = document.createElement("div");
rowB.className = "calRowEvt";
rowB.innerHTML = cal.events[id]["t"];
rowB.style.color = cal.events[id]["c"];
rowB.style.backgroundColor = cal.events[id]["b"];
rowB.classList.add("w"+w);
if (o!=0) { rowB.classList.add("o"+o); }
rowB.onclick = () => cal.show(id);
rowA.appendChild(rowB);
}
}}
},
cal.draw()
Generates the HTML calendar. Quite a bit of calculation… Run through these in your own free time if you want.
2G) SHOW EVENT FORM
// (G) SHOW EVENT FORM
show : id => {
if (id) {
cal.hfID.value = id;
cal.hfStart.value = cal.toISODate(cal.events[id]["s"]);
cal.hfEnd.value = cal.toISODate(cal.events[id]["e"]);
cal.hfTxt.value = cal.events[id]["t"];
cal.hfColor.value = cal.events[id]["c"];
cal.hfBG.value = cal.events[id]["b"];
cal.hfDel.style.display = "inline-block";
} else {
cal.hForm.reset();
cal.hfID.value = "";
cal.hfDel.style.display = "none";
}
cal.hFormWrap.show();
},
cal.show()
To display the hidden popup event form.
id===NUMBER
When the user clicks on an event to edit it. Load the event fromindexDB
and populate the form fields.id===ANYTHING
When the user clicks on “add”. Just show an empty event form.
2H) SAVE & DELETE EVENTS
// (H) SAVE EVENT
save : () => {
// (H1) COLLECT DATA
// s & e : start & end date
// c & b : text & background color
// t : event text
var data = {
s : cal.toDate(cal.hfStart.value),
e : cal.toDate(cal.hfEnd.value),
t : cal.hfTxt.value,
c : cal.hfColor.value,
b : cal.hfBG.value
};
if (cal.hfID.value != "") { data.id = parseInt(cal.hfID.value); }
console.log(data);
// (H2) DATE CHECK
if (new Date(data.s) > new Date(data.e)) {
alert("Start date cannot be later than end date!");
return false;
}
// (H3) SAVE
if (data.id) { calDB.tx("put", "events", data); }
else { calDB.tx("add", "events", data); }
cal.hFormWrap.close();
cal.load();
return false;
},
// (I) DELETE EVENT
del : () => { if (confirm("Delete Event?")) {
calDB.tx("del", "events", +cal.hfID.value);
cal.hFormWrap.close();
cal.load();
}}
Pretty self-explanatory.
cal.save()
gets the data from the HTML form and saves the event into theindexedDB
.cal.del()
removes the specified event from theindexedDB
.
PART 3) PROGRESSIVE WEB APP
3A) HTML META HEADERS
<!-- WEB APP & ICONS -->
<link rel="icon" href="assets/favicon.png" type="image/png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="white">
<link rel="apple-touch-icon" href="assets/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="JS Calendar">
<meta name="msapplication-TileImage" content="assets/icon-512.png">
<meta name="msapplication-TileColor" content="#ffffff">
<!-- WEB APP MANIFEST -->
<!-- https://web.dev/add-manifest/ -->
<link rel="manifest" href="CB-manifest.json">
The calendar is pretty much already complete. But to create a PWA, there are 3 things to address:
- Add the above HTML meta headers to specify the icons/app name.
- Register a web manifest.
- Register a service worker.
3B) WEB MANIFEST
{
"short_name": "JS Calendar",
"name": "JS Calendar",
"icons": [{
"src": "assets/favicon.png",
"sizes": "64x64",
"type": "image/png"
}, {
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}],
"start_url": "js-calendar.html",
"scope": "/",
"background_color": "white",
"theme_color": "white",
"display": "standalone"
}
The manifest file is what it is… A file to indicate the app icons, name, settings, and more.
3C) SERVICE WORKER
// (A) CREATE/INSTALL CACHE
self.addEventListener("install", evt => {
self.skipWaiting();
evt.waitUntil(
caches.open("JSCalendar")
.then(cache => cache.addAll([
"assets/favicon.png",
"assets/icon-512.png",
"assets/head-pwa-calendar.webp",
"assets/maticon.woff2",
"CB-manifest.json",
"assets/js-calendar.css",
"assets/js-calendar-db.js",
"assets/js-calendar.js",
"js-calendar.html"
]))
.catch(err => console.error(err))
);
});
// (B) CLAIM CONTROL INSTANTLY
self.addEventListener("activate", evt => self.clients.claim());
// (C) LOAD FROM CACHE FIRST, FALLBACK TO NETWORK IF NOT FOUND
self.addEventListener("fetch", evt => evt.respondWith(
caches.match(evt.request).then(res => res || fetch(evt.request))
));
For those who are new, a service worker is just a piece of Javascript that runs in the background. For this worker:
- (A) Create a new storage cache and save the project files.
- (C) “Hijack” the fetch requests. If the requested file is in the cache, use it. If not, load from the network.
In short, this worker will enable the web app to work in “offline mode”.
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
- Arrow Functions – CanIUse
- Service Workers – CanIUse
- Cache Storage – CanIUse
- Indexed Database – CanIUse
- Add To Home Screen – CanIUse
Most of the required features are already well-supported on modern “Grade A” browsers.
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!
Hello!
How i set in europe date form, when the monday is first day of the week?
Tanks for the answer.
To locale date string – https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString
Hi,
Thank you for this nice project.
Unfortunately on iPhone and iPad (with Safari, Firefox or Brave), any 1 day events are displayed as running from the first to the last day of the month.
Any suggestion to correct that behaviour ?
Jean
Sorry, I don’t have half-eaten apple devices to verify that… Works as intended on Chrome, Edge, FF, and Opera though – Start 4/4/2023 12:00 AM, end 4/4/2023 11:59 PM shows exactly as one day.
I would like to pass a department ID into the calendar class to view events by department, but not having much luck. I have a department drop down which passes a link to the calendar through the url ( ?dept=23 ). I would like to grab that and use it in the SQL in the calendar class but I’m struggling getting that value into the class. Any help you could give me would be greatly appreciated
1) Create multiple object stores for different departments.
2) On changing dept dropdown – Fetch data from the selected object store and redraw the HTML. The end.
Throw the ideas of “server-side” GET, POST, SQL database out of the window. They don’t exist here, this is a serverless offline app.
i am learning about pwa and tried uploading this to a website to use it online and it works on chrome, but it does not seem to work on edge or safari. would you know why?
See “compatibility checks” above, and do your own debugging. Otherwise:
https://code-boxx.com/faq/#notwork “Don’t seem to work”
https://code-boxx.com/faq/#help “Unclear question”
This mini project is valuable for studies, and I would like a light on how I could make the offline push notification starting from the calendar event, will it be possible?
Yes, it is possible with a scheduled Push Notification.
https://developer.mozilla.org/en-US/docs/Web/API/notification
https://css-tricks.com/creating-scheduled-push-notifications/