Last active
April 3, 2026 05:02
-
-
Save johnweldon/d2db6bd179b7b95a2db5b29cfc9cae2c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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