Skip to content

Instantly share code, notes, and snippets.

@johnweldon
Last active April 3, 2026 05:02
Show Gist options
  • Select an option

  • Save johnweldon/d2db6bd179b7b95a2db5b29cfc9cae2c to your computer and use it in GitHub Desktop.

Select an option

Save johnweldon/d2db6bd179b7b95a2db5b29cfc9cae2c to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>BoB Meeting Availability</title>
<style>
body {
background: #1a1a2e;
color: #eee;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 20px;
}
.controls {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
select {
background: #16213e;
color: #eee;
border: 1px solid #444;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
.legend {
display: flex;
gap: 20px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
}
canvas {
border: 1px solid #333;
border-radius: 4px;
}
.note {
color: #888;
font-size: 11px;
margin-top: 8px;
}
h1 {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
}
#tooltip {
position: fixed;
background: #16213e;
border: 1px solid #555;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 100;
max-width: 250px;
line-height: 1.5;
}
</style>
</head>
<body>
<h1>BoB Meeting Availability</h1>
<div class="controls">
<label>Display timezone:</label>
<select id="tzSelect" onchange="render()">
<option value="0" selected>EDT (BH, MB, MD)</option>
<option value="-2">MDT (AG)</option>
<option value="-3">MST (JW)</option>
<option value="7">AST (DR)</option>
</select>
</div>
<div class="legend" id="legend"></div>
<canvas id="grid" width="940" height="520"></canvas>
<div id="tooltip"></div>
<div class="note">Highlighted rows = 3+ people overlap (brighter = 4+, brightest = 5+). Hover for details.</div>
<script>
const PEOPLE = {"AG": {"color": "#e74c3c", "windows": {"Mon": [[450, 540], [1200, 1320]], "Tue": [[450, 540]], "Wed": [[450, 540], [1200, 1320]], "Thu": [[450, 540], [1200, 1320]], "Fri": [[450, 540]], "Sun": [[1140, 1260]]}}, "BH": {"color": "#3498db", "windows": {"Mon": [[1260, 1350]], "Tue": [[1260, 1350]], "Wed": [[1260, 1350]], "Sun": [[1200, 1290]]}}, "DR": {"color": "#2ecc71", "windows": {"Mon": [[0, 120], [540, 720]], "Tue": [[0, 120], [540, 660]], "Wed": [[0, 120], [540, 1080]], "Thu": [[0, 120]], "Sun": [[0, 120], [540, 1080]]}}, "JW": {"color": "#f39c12", "windows": {"Mon": [[0, 120], [1260, 1380]], "Tue": [[0, 120], [480, 720], [1260, 1440]], "Wed": [[0, 120], [480, 690], [900, 1020]], "Thu": [[510, 630], [1320, 1440]], "Fri": [[0, 120], [510, 600], [690, 840]], "Sat": [[540, 660], [750, 840]], "Sun": [[540, 600], [1080, 1440]]}}, "MB": {"color": "#9b59b6", "windows": {"Mon": [[1320, 1470]], "Tue": [[360, 540], [1260, 1470]], "Wed": [[360, 540], [660, 720]], "Thu": [[360, 540], [1320, 1470]], "Sun": [[1200, 1320]]}}, "MD": {"color": "#e67e22", "windows": {"Mon": [[1080, 1380]], "Tue": [[1080, 1380]], "Thu": [[1080, 1380]], "Sat": [[480, 660]], "Sun": [[1080, 1380]]}}};
const NAMES = ["AG", "BH", "DR", "JW", "MB", "MD"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const canvas = document.getElementById("grid");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const LEFT = 65;
const TOP = 30;
const COL_W = 124;
const SLOT_H = 5;
const START_HOUR = 0;
const END_HOUR = 24;
const NUM_SLOTS = (END_HOUR - START_HOUR) * 4;
const GRID_H = NUM_SLOTS * SLOT_H;
const legend = document.getElementById("legend");
for (const name of NAMES) {
const item = document.createElement("div");
item.className = "legend-item";
const swatch = document.createElement("div");
swatch.className = "legend-swatch";
swatch.style.background = PEOPLE[name].color;
item.appendChild(swatch);
item.appendChild(document.createTextNode(name));
legend.appendChild(item);
}
function fmtTime(mins) {
let h = Math.floor(((mins % 1440) + 1440) % 1440 / 60);
let m = ((mins % 60) + 60) % 60;
let ap = h < 12 ? "AM" : "PM";
let dh = h % 12;
if (dh === 0) dh = 12;
return dh + ":" + String(m).padStart(2, "0") + " " + ap;
}
function getAvailableAt(day, edtMin) {
let avail = [];
for (const name of NAMES) {
let wins = PEOPLE[name].windows[day] || [];
for (const [ws, we] of wins) {
if (ws <= edtMin && edtMin + 15 <= we) {
avail.push(name);
break;
}
}
}
return avail;
}
function render() {
const offset = parseInt(document.getElementById("tzSelect").value);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#1a1a2e";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.fillStyle = "#ccc";
for (let di = 0; di < 7; di++) {
ctx.fillText(DAYS[di], LEFT + di * COL_W + COL_W / 2, TOP - 10);
}
ctx.font = "10px sans-serif";
ctx.textAlign = "right";
for (let h = START_HOUR; h <= END_HOUR; h++) {
let displayH = ((h + offset + 24) % 24);
let y = TOP + (h - START_HOUR) * 4 * SLOT_H;
let ap = displayH < 12 ? "AM" : "PM";
let dh = displayH % 12;
if (dh === 0) dh = 12;
if (h < END_HOUR) {
ctx.fillStyle = "#888";
ctx.fillText(dh + " " + ap, LEFT - 4, y + 4);
}
ctx.strokeStyle = h % 6 === 0 ? "#555" : "#2a2a3e";
ctx.lineWidth = h % 6 === 0 ? 0.8 : 0.4;
ctx.beginPath();
ctx.moveTo(LEFT, y);
ctx.lineTo(LEFT + 7 * COL_W, y);
ctx.stroke();
}
ctx.strokeStyle = "#444";
ctx.lineWidth = 0.5;
for (let di = 0; di <= 7; di++) {
ctx.beginPath();
ctx.moveTo(LEFT + di * COL_W, TOP);
ctx.lineTo(LEFT + di * COL_W, TOP + GRID_H);
ctx.stroke();
}
const laneW = COL_W / NAMES.length;
for (let di = 0; di < 7; di++) {
let day = DAYS[di];
for (let pi = 0; pi < NAMES.length; pi++) {
let name = NAMES[pi];
let color = PEOPLE[name].color;
let wins = PEOPLE[name].windows[day] || [];
let laneX = LEFT + di * COL_W + pi * laneW + 1;
let w = laneW - 2;
for (const [ws, we] of wins) {
let yStart = TOP + (ws / 15) * SLOT_H;
let yEnd = TOP + (Math.min(we, END_HOUR * 60) / 15) * SLOT_H;
if (yEnd <= yStart) continue;
ctx.fillStyle = color;
ctx.globalAlpha = 0.75;
ctx.beginPath();
ctx.roundRect(laneX, yStart, w, yEnd - yStart, 2);
ctx.fill();
ctx.globalAlpha = 1.0;
}
}
for (let slot = START_HOUR * 4; slot < END_HOUR * 4; slot++) {
let slotMin = slot * 15;
let avail = getAvailableAt(day, slotMin);
if (avail.length >= 3) {
let y = TOP + slot * SLOT_H;
ctx.fillStyle = "white";
ctx.globalAlpha = avail.length >= 5 ? 0.22 : avail.length >= 4 ? 0.15 : 0.07;
ctx.fillRect(LEFT + di * COL_W, y, COL_W, SLOT_H);
ctx.globalAlpha = 1.0;
}
}
}
}
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const offset = parseInt(document.getElementById("tzSelect").value);
const di = Math.floor((x - LEFT) / COL_W);
const slot = Math.floor((y - TOP) / SLOT_H);
if (di < 0 || di >= 7 || slot < 0 || slot >= NUM_SLOTS) {
tooltip.style.display = "none";
return;
}
const edtMin = slot * 15;
const localMin = edtMin + offset * 60;
const day = DAYS[di];
let avail = [];
let unavail = [];
for (const name of NAMES) {
let wins = PEOPLE[name].windows[day] || [];
let found = false;
for (const [ws, we] of wins) {
if (ws <= edtMin && edtMin + 15 <= we) {
found = true;
break;
}
}
if (found) avail.push(name);
else unavail.push(name);
}
let html = "<b>" + day + " " + fmtTime(localMin) + "</b><br>";
if (avail.length > 0) {
html += '<span style="color:#4ade80">Available (' + avail.length + '):</span> ' + avail.join(", ") + "<br>";
}
if (unavail.length > 0) {
html += '<span style="color:#f87171">Unavailable:</span> ' + unavail.join(", ");
}
tooltip.innerHTML = html;
tooltip.style.display = "block";
tooltip.style.left = (e.clientX + 12) + "px";
tooltip.style.top = (e.clientY + 12) + "px";
});
canvas.addEventListener("mouseleave", () => {
tooltip.style.display = "none";
});
render();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment