Skip to content

Instantly share code, notes, and snippets.

@ldijkman
Last active October 18, 2025 06:30
Show Gist options
  • Select an option

  • Save ldijkman/c39727aae09d6f703dd18fbc2faa91bf to your computer and use it in GitHub Desktop.

Select an option

Save ldijkman/c39727aae09d6f703dd18fbc2faa91bf to your computer and use it in GitHub Desktop.
Swipe
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>24/7 Circular TimeSlots Timer</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #111827;
color: #eee;
font-family: sans-serif;
padding: 20px;
max-width: 500px;
margin: 0 auto;
}
h1 {
font-size: 18px;
margin-bottom: 10px;
text-align: center;
}
/* Day Navigation */
.day-navigation {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
gap: 10px;
}
.nav-btn {
background: #374151;
color: #eee;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.day-slider {
display: flex;
overflow-x: auto;
gap: 8px;
padding: 10px 0;
scrollbar-width: none;
}
.day-slider::-webkit-scrollbar {
display: none;
}
.day-tab {
padding: 8px 12px;
background: #374151;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.day-tab.active {
background: #06b6d4;
color: #111;
font-weight: bold;
}
.current-day-indicator {
text-align: center;
margin-bottom: 10px;
font-size: 14px;
color: #fbbf24;
}
/* Canvas Wrapper for Swiping */
.canvas-container-outer {
position: relative;
width: 320px;
height: 320px;
margin: 0 auto 20px auto;
overflow: hidden;
border-radius: 50%;
}
.canvas-wrapper {
display: flex;
width: 100%;
height: 100%;
overflow-x: scroll;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: #111;
border-radius: 50%;
}
.canvas-wrapper::-webkit-scrollbar {
display: none;
}
.day-canvas-item {
flex-shrink: 0;
width: 320px;
height: 320px;
display: flex;
justify-content: center;
align-items: center;
scroll-snap-align: center;
position: relative;
}
.day-canvas-item canvas {
width: 320px;
height: 320px;
border-radius: 50%;
display: block;
}
/* Central overlay elements */
.circle-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.time {
font-size: 20px;
font-weight: bold;
color: #06b6d4;
text-align: center;
}
.dateInfo {
font-size: 12px;
color: #fbbf24;
text-align: center;
line-height: 1.3;
margin-top: 5px;
}
.moon {
font-size: 26px;
margin-bottom: -5px;
}
.moonText {
font-size: 12px;
color: #fbbf24;
}
#sunTimes {
margin-top: 8px;
text-align: center;
font-size: 14px;
color: #fbbf24;
}
#geoMsg {
margin-top: 4px;
text-align: center;
font-size: 13px;
color: #ef4444;
}
form {
margin-top: 15px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
input[type="time"] {
padding: 6px;
border-radius: 6px;
border: none;
}
button {
padding: 6px 10px;
border-radius: 6px;
border: none;
cursor: pointer;
}
.addBtn {
flex-basis: 100%;
max-width: 150px;
margin-top: 5px;
background: #06b6d4;
color: #111;
}
ul {
list-style: none;
padding: 0;
margin-top: 15px;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin-bottom: 6px;
background: #1f2937;
border-radius: 6px;
flex-wrap: wrap;
gap: 6px;
}
.controls {
display: flex;
gap: 6px;
}
.btn {
padding: 4px 8px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
}
.delBtn {
background: #ef4444;
color: white;
}
.editBtn {
background: #f59e0b;
color: white;
}
.toggleBtn {
background: #06b6d4;
color: #111;
}
.daysContainer {
display: flex;
gap: 2px;
margin: 4px 0;
}
.dayBtn {
width: 20px;
height: 20px;
border-radius: 3px;
border: none;
cursor: pointer;
font-size: 11px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.dayBtn.on {
background: #10b981;
color: white;
}
.dayBtn.off {
background: #6b7280;
color: #9ca3af;
}
.slotInfo {
display: flex;
flex-direction: column;
}
.sun-based-indicator {
color: #fbbf24;
font-size: 11px;
margin-top: 2px;
}
/* New styles for sunrise/sunset form */
.sun-form {
margin-top: 15px;
padding: 12px;
background: #1f2937;
border-radius: 8px;
}
.sun-form h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #fbbf24;
text-align: center;
}
.sun-form-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.sun-form-row label {
flex: 1;
min-width: 120px;
}
.sun-form-row select {
flex: 1;
min-width: 120px;
padding: 6px;
border-radius: 6px;
border: none;
background: #374151;
color: #eee;
}
.sun-form-row .addBtn {
margin-top: 5px;
}
/* Export section */
.export-section {
margin-top: 20px;
padding: 12px;
background: #1f2937;
border-radius: 8px;
}
.export-section h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #fbbf24;
text-align: center;
}
.export-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
.export-btn {
background: #10b981;
color: white;
}
.import-btn {
background: #f59e0b;
color: white;
}
/* Footer */
footer {
margin-top: 30px;
text-align: center;
font-size: 12px;
color: #6b7280;
}
/* Location button */
.location-btn {
background: #8b5cf6;
color: white;
margin-top: 10px;
}
</style>
</head>
<body>
<h1>24/7 Circular TimeSlots Timer</h1>
<div class="day-navigation">
<button class="nav-btn" id="prevDay">←</button>
<div class="day-slider" id="daySlider"></div>
<button class="nav-btn" id="nextDay">β†’</button>
</div>
<div class="current-day-indicator" id="currentDayIndicator"></div>
<div class="canvas-container-outer">
<div class="canvas-wrapper" id="canvasWrapper"></div>
<div class="circle-overlay">
<div class="moon" id="moonIcon">πŸŒ•</div>
<div class="moonText" id="moonText">Volle Maan</div>
<div class="time" id="centerTime">--:--</div>
<div class="dateInfo" id="dateInfo">--</div>
</div>
</div>
<div id="sunTimes">Zonsopgang: --:-- | Zonsondergang: --:--</div>
<div id="geoMsg"></div>
<button class="location-btn" id="getLocationBtn">Gebruik mijn locatie voor zonsopgang/ondergang</button>
<div class="sun-form">
<h3>Voeg tijdslot toe op basis van zonsopgang/ondergang</h3>
<div class="sun-form-row">
<label for="sunEvent">Gebeurtenis:</label>
<select id="sunEvent">
<option value="sunrise">Zonsopgang</option>
<option value="sunset">Zonsondergang</option>
</select>
</div>
<div class="sun-form-row">
<label for="sunOffset">Offset:</label>
<select id="sunOffset">
<option value="-120">2 uur voor</option>
<option value="-90">1,5 uur voor</option>
<option value="-60">1 uur voor</option>
<option value="-45">45 minuten voor</option>
<option value="-30">30 minuten voor</option>
<option value="-15">15 minuten voor</option>
<option value="0">Precies</option>
<option value="15">15 minuten na</option>
<option value="30">30 minuten na</option>
<option value="45">45 minuten na</option>
<option value="60">1 uur na</option>
<option value="90">1,5 uur na</option>
<option value="120">2 uur na</option>
</select>
</div>
<div class="sun-form-row">
<label for="sunDuration">Duur:</label>
<select id="sunDuration">
<option value="15">15 minuten</option>
<option value="30">30 minuten</option>
<option value="45">45 minuten</option>
<option value="60">1 uur</option>
<option value="90">1,5 uur</option>
<option value="120">2 uur</option>
<option value="180">3 uur</option>
<option value="240">4 uur</option>
</select>
</div>
<div class="daysContainer">
<button type="button" class="dayBtn on" data-day="0">M</button>
<button type="button" class="dayBtn on" data-day="1">T</button>
<button type="button" class="dayBtn on" data-day="2">W</button>
<button type="button" class="dayBtn on" data-day="3">T</button>
<button type="button" class="dayBtn on" data-day="4">F</button>
<button type="button" class="dayBtn on" data-day="5">S</button>
<button type="button" class="dayBtn on" data-day="6">S</button>
</div>
<button type="button" class="addBtn" id="addSunSlot">Voeg SunSlot toe</button>
</div>
<form id="addForm">
<h3>Voeg tijdslot toe op basis van timepicker</h3>
<input type="time" id="startTime" required>
<input type="time" id="endTime" required>
<div class="daysContainer">
<button type="button" class="dayBtn on" data-day="0">M</button>
<button type="button" class="dayBtn on" data-day="1">T</button>
<button type="button" class="dayBtn on" data-day="2">W</button>
<button type="button" class="dayBtn on" data-day="3">T</button>
<button type="button" class="dayBtn on" data-day="4">F</button>
<button type="button" class="dayBtn on" data-day="5">S</button>
<button type="button" class="dayBtn on" data-day="6">S</button>
</div>
<button type="submit" class="addBtn">Add TimeSlot</button>
</form>
<ul id="slotList"></ul>
<div class="export-section">
<h3>Exporteer/Importeer Schema</h3>
<div class="export-buttons">
<button class="export-btn" id="exportBtn">Export naar JSON</button>
<button class="import-btn" id="importBtn">Importeer van JSON</button>
</div>
<textarea id="jsonData" placeholder="JSON data komt hier..." style="width:100%; height:100px; margin-top:10px; display:none; background:#374151; color:#eee; border:none; border-radius:6px; padding:8px;"></textarea>
</div>
<footer>
<p>Copyright 2025, Dirk Luberth Dijkman Bangert 30 1619GJ Andijk The Netherlands</p>
<p>Voor dagelijks weektijdschema voor bewatering of verlichting</p>
https://m.facebook.com/luberth.dijkman/
<br><br>
https://codepen.io/ldijkman/pens/public
</footer>
<script>
// Constants for canvas dimensions
const CANVAS_WIDTH = 320;
const CANVAS_HEIGHT = 320;
const RADIUS = CANVAS_WIDTH / 2 - 30;
const CENTER_X = CANVAS_WIDTH / 2;
const CENTER_Y = CANVAS_HEIGHT / 2;
const STORAGE_KEY = "dagcirkel_slots_v13"; // Incremented version for new features
const dayLetters = ["M", "T", "W", "T", "F", "S", "S"];
const dayNames = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const dayNamesNL = ["Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag", "Zondag"];
// Application state
let slots = [];
let sunrise = "07:00", sunset = "19:00";
let currentViewDay = new Date().getDay(); // 0 (Sunday) to 6 (Saturday)
let scrollTimeout;
const canvasWrapper = document.getElementById("canvasWrapper");
// Utility functions
const TimeUtils = {
toMin: function(t) {
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
},
toHHMM: function(mins) {
const h = String(Math.floor(mins / 60)).padStart(2, "0");
const m = String(mins % 60).padStart(2, "0");
return `${h}:${m}`;
},
isInSlot: function(mins, slot) {
const s = this.toMin(slot.start), e = this.toMin(slot.end);
return s < e ? (mins >= s && mins < e) : (mins >= s || mins < e);
},
toLocalHHMM: function(d) {
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
},
formatTimeDiff: function(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return (h > 0 ? h + "h " : "") + m + "m";
},
getISOWeek: function(date) {
const tmp = new Date(date.getTime());
tmp.setHours(0, 0, 0, 0);
tmp.setDate(tmp.getDate() + 3 - (tmp.getDay() + 6) % 7);
const week1 = new Date(tmp.getFullYear(), 0, 4);
return 1 + Math.round(((tmp - week1) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
}
};
// Data management functions
const DataManager = {
save: function() {
slots.sort((a, b) => TimeUtils.toMin(a.start) - TimeUtils.toMin(b.start));
localStorage.setItem(STORAGE_KEY, JSON.stringify(slots));
},
load: function() {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
slots = JSON.parse(data);
// Ensure all slots have days array (for backward compatibility)
slots.forEach(slot => {
if (!slot.days) {
slot.days = [true, true, true, true, true, true, true];
}
});
} else {
// Default slots
slots = [
{id: 1, start: "06:00", end: "08:00", enabled: true, days: [true, true, true, true, true, true, true]},
{id: 2, start: "12:00", end: "13:30", enabled: true, days: [true, true, true, true, true, true, true]},
{id: 3, start: "19:00", end: "22:00", enabled: true, days: [true, true, true, true, true, true, true]}
];
this.save();
}
},
exportToJson: function() {
const jsonData = JSON.stringify(slots, null, 2);
const textarea = document.getElementById("jsonData");
textarea.value = jsonData;
textarea.style.display = "block";
// Select the text for easy copying
textarea.select();
textarea.setSelectionRange(0, 99999); // For mobile devices
// Copy to clipboard
document.execCommand("copy");
alert("Schema gekopieerd naar klembord!");
},
importFromJson: function() {
const textarea = document.getElementById("jsonData");
const jsonData = textarea.value.trim();
if (!jsonData) {
alert("Voer JSON data in om te importeren");
return;
}
try {
const importedSlots = JSON.parse(jsonData);
// Validate the imported data structure
if (Array.isArray(importedSlots) && importedSlots.every(s => s.id && s.start && s.end)) {
slots = importedSlots;
this.save();
renderCanvases();
renderList();
textarea.style.display = "none";
alert("Schema succesvol geΓ―mporteerd!");
} else {
alert("Ongeldige JSON structuur");
}
} catch (e) {
alert("Ongeldige JSON: " + e.message);
}
}
};
// Canvas rendering functions
const CanvasRenderer = {
drawMarkers: function(ctx) {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Draw all tickmarks first (quarters, halves, hours)
for (let mins = 0; mins < 1440; mins += 15) {
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2;
let inner, outer, lineWidth;
if (mins % 60 === 0) {
// Hour marks
inner = RADIUS - 10;
outer = RADIUS + 10;
lineWidth = 2;
} else if (mins % 30 === 0) {
// Half-hour marks
inner = RADIUS - 10;
outer = RADIUS + 4;
lineWidth = 1;
} else {
// Quarter-hour marks (15 and 45 minutes)
inner = RADIUS - 10;
outer = RADIUS + 0;
lineWidth = .5;
}
const x1 = CENTER_X + Math.cos(angle) * inner, y1 = CENTER_Y + Math.sin(angle) * inner;
const x2 = CENTER_X + Math.cos(angle) * outer, y2 = CENTER_Y + Math.sin(angle) * outer;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = "#aaa";
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// Draw hour labels
for (let hour = 0; hour < 24; hour++) {
const mins = hour * 60;
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2;
ctx.fillStyle = "#ccc";
ctx.font = "12px sans-serif";
const labelX = CENTER_X + Math.cos(angle) * (RADIUS + 20),
labelY = CENTER_Y + Math.sin(angle) * (RADIUS + 20);
ctx.fillText(hour.toString(), labelX, labelY);
}
},
drawDayNight: function(ctx) {
const srMin = TimeUtils.toMin(sunrise), ssMin = TimeUtils.toMin(sunset);
const srAngle = srMin / 1440 * 2 * Math.PI - Math.PI / 2;
const ssAngle = ssMin / 1440 * 2 * Math.PI - Math.PI / 2;
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, 0, 2 * Math.PI);
ctx.strokeStyle = "#1e3a8a";
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
if (srMin < ssMin) {
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, srAngle, ssAngle);
} else {
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, srAngle, 1.5 * Math.PI);
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, -Math.PI / 2, ssAngle);
}
ctx.strokeStyle = "#fbbf24";
ctx.lineWidth = 12;
ctx.stroke();
},
drawCircularText: function(ctx, text, radius, centerAngle) {
ctx.save();
ctx.translate(CENTER_X, CENTER_Y);
ctx.rotate(centerAngle);
ctx.fillStyle = "#ccc";
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const chars = [...text];
const totalWidth = chars.reduce((w, ch) => w + ctx.measureText(ch).width, 0);
let offset = -(totalWidth / radius) / 2;
chars.forEach(char => {
const w = ctx.measureText(char).width;
const angle = w / radius;
ctx.rotate(offset + angle / 2);
ctx.save();
ctx.translate(0, -radius);
ctx.fillText(char, 0, 0);
ctx.restore();
offset = angle / 2;
});
ctx.restore();
},
drawCircle: function(canvasEl, dayIndexToDraw) {
const ctx = canvasEl.getContext("2d");
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, 2 * Math.PI);
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = 24;
ctx.stroke();
this.drawDayNight(ctx);
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
let stateColor = "grey";
let inAnySlot = false;
slots.forEach(slot => {
// Only draw slots for the specified dayIndexToDraw
if (!isSlotActiveOnDay(slot, dayIndexToDraw)) return;
const sMin = TimeUtils.toMin(slot.start), eMin = TimeUtils.toMin(slot.end);
// If the canvas being drawn is the *actual* current day, highlight active slots
const isActualCurrentDay = (dayIndexToDraw === now.getDay());
const active = isActualCurrentDay && TimeUtils.isInSlot(mins, slot);
if (active) inAnySlot = true;
const color = active ? "limegreen" : "red";
const startAngle = sMin / 1440 * 2 * Math.PI - Math.PI / 2;
const endAngle = eMin / 1440 * 2 * Math.PI - Math.PI / 2;
ctx.beginPath();
if (sMin < eMin) {
ctx.arc(CENTER_X, CENTER_Y, RADIUS, startAngle, endAngle);
} else {
ctx.arc(CENTER_X, CENTER_Y, RADIUS, startAngle, 1.5 * Math.PI);
ctx.arc(CENTER_X, CENTER_Y, RADIUS, -Math.PI / 2, endAngle);
}
ctx.strokeStyle = color;
ctx.lineWidth = 24;
ctx.stroke();
});
this.drawMarkers(ctx);
// Draw current time indicator only if this is the actual current day
if (dayIndexToDraw === now.getDay()) {
if (slots.some(s => isSlotActiveOnDay(s, dayIndexToDraw))) {
stateColor = inAnySlot ? "limegreen" : "red";
}
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2;
const innerR = 40;
ctx.beginPath();
ctx.moveTo(CENTER_X + Math.cos(angle) * innerR, CENTER_Y + Math.sin(angle) * innerR);
ctx.lineTo(CENTER_X + Math.cos(angle) * RADIUS, CENTER_Y + Math.sin(angle) * RADIUS);
ctx.strokeStyle = stateColor;
ctx.lineWidth = 4;
ctx.stroke();
}
this.drawCircularText(ctx, "Evening", RADIUS - 20, -Math.PI / 4);
this.drawCircularText(ctx, "Night", RADIUS - 20, Math.PI / 4);
this.drawCircularText(ctx, "Morning", RADIUS - 20, 3 * Math.PI / 4);
this.drawCircularText(ctx, "Afternoon", RADIUS - 20, -3 * Math.PI / 4);
}
};
// Helper functions
function isSlotActiveOnDay(slot, dayIndex) {
if (!slot.enabled) return false;
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun)
const ourDayIndex = (dayIndex === 0) ? 6 : dayIndex - 1;
return slot.days[ourDayIndex];
}
function getNextStateChangeForDay(now, dayIndex) {
const minsNow = now.getHours() * 60 + now.getMinutes();
let nextChange = null;
slots.forEach(slot => {
if (!slot.enabled) return;
if (!isSlotActiveOnDay(slot, dayIndex)) return;
const s = TimeUtils.toMin(slot.start), e = TimeUtils.toMin(slot.end);
const sDelta = (s - minsNow + 1440) % 1440;
const eDelta = (e - minsNow + 1440) % 1440;
if (nextChange === null || sDelta < nextChange.mins) {
nextChange = {mins: sDelta, type: 'ON', time: slot.start};
}
if (nextChange === null || eDelta < nextChange.mins) {
nextChange = {mins: eDelta, type: 'OFF', time: slot.end};
}
});
return nextChange ? `In ${TimeUtils.formatTimeDiff(nextChange.mins)} -> ${nextChange.type} at ${nextChange.time}` : "--";
}
function updateCenterOverlay(displayDayIndex) {
const now = new Date();
const currentDayOfWeek = now.getDay();
const mins = now.getHours() * 60 + now.getMinutes();
document.getElementById("centerTime").textContent = TimeUtils.toHHMM(mins);
const isToday = (displayDayIndex === currentDayOfWeek);
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun) for dayNamesNL
const dayName = dayNamesNL[(displayDayIndex === 0) ? 6 : displayDayIndex - 1];
const options = { day: 'numeric', month: 'long', year: 'numeric' };
let dateStr;
let weekNum;
if (isToday) {
dateStr = now.toLocaleDateString('nl-NL', options);
weekNum = TimeUtils.getISOWeek(now);
} else {
// For other days, calculate the date for that day
const targetDate = new Date(now);
const diff = displayDayIndex - currentDayOfWeek;
targetDate.setDate(now.getDate() + diff);
dateStr = targetDate.toLocaleDateString('nl-NL', options);
weekNum = TimeUtils.getISOWeek(targetDate);
}
const nextChangeText = getNextStateChangeForDay(now, displayDayIndex);
document.getElementById("dateInfo").innerHTML =
`${dayName}${isToday ? ' (Vandaag)' : ''}<br>${dateStr}<br>Week ${weekNum}<br>Next: ${nextChangeText}`;
document.getElementById("sunTimes").textContent = `SunRise: ${sunrise} | SunSet: ${sunset}`;
}
// This function creates and draws the canvases for the previous, current, and next day
function renderCanvases() {
canvasWrapper.innerHTML = '';
canvasWrapper.style.scrollBehavior = 'auto';
const daysToRender = [];
// Calculate previous, current, and next day indices (0-6)
const prevDay = (currentViewDay - 1 + 7) % 7;
const nextDay = (currentViewDay + 1) % 7;
daysToRender.push({ dayIndex: prevDay, isCurrent: false });
daysToRender.push({ dayIndex: currentViewDay, isCurrent: true });
daysToRender.push({ dayIndex: nextDay, isCurrent: false });
daysToRender.forEach((dayData, index) => {
const dayCanvasItem = document.createElement('div');
dayCanvasItem.className = 'day-canvas-item';
dayCanvasItem.dataset.dayIndex = dayData.dayIndex;
const canvasEl = document.createElement('canvas');
canvasEl.width = CANVAS_WIDTH;
canvasEl.height = CANVAS_HEIGHT;
dayCanvasItem.appendChild(canvasEl);
canvasWrapper.appendChild(dayCanvasItem);
CanvasRenderer.drawCircle(canvasEl, dayData.dayIndex);
});
// Scroll to the middle canvas immediately after creation
canvasWrapper.scrollLeft = CANVAS_WIDTH;
canvasWrapper.style.scrollBehavior = 'smooth';
updateCenterOverlay(currentViewDay);
}
function renderDayNavigation() {
const daySlider = document.getElementById("daySlider");
daySlider.innerHTML = "";
const today = new Date().getDay();
for (let i = 0; i < 7; i++) {
const dayTab = document.createElement("div");
dayTab.className = `day-tab ${i === currentViewDay ? 'active' : ''}`;
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun) for display
dayTab.textContent = dayNamesNL[(i === 0) ? 6 : i - 1];
dayTab.onclick = (function(index) {
return function() {
currentViewDay = index;
renderDayNavigation();
renderCanvases();
renderList();
};
})(i);
daySlider.appendChild(dayTab);
}
// Update current day indicator
const isToday = currentViewDay === today;
document.getElementById("currentDayIndicator").textContent =
`Bekijken: ${dayNamesNL[(currentViewDay === 0) ? 6 : currentViewDay - 1]}${isToday ? ' (Vandaag)' : ''}`;
// Auto-scroll to center the current day tab
const activeTab = daySlider.querySelector('.day-tab.active');
if (activeTab) {
const scrollLeft = activeTab.offsetLeft - (daySlider.offsetWidth / 2) + (activeTab.offsetWidth / 2);
daySlider.scrollLeft = scrollLeft;
}
}
function renderList() {
const ul = document.getElementById("slotList");
ul.innerHTML = "";
slots.sort((a, b) => TimeUtils.toMin(a.start) - TimeUtils.toMin(b.start));
// Convert currentViewDay to our day index (0=Monday, 6=Sunday)
const viewDayArrayIndex = (currentViewDay === 0) ? 6 : currentViewDay - 1;
slots.forEach(slot => {
const li = document.createElement("li");
const slotInfo = document.createElement("div");
slotInfo.className = "slotInfo";
const timeSpan = document.createElement("span");
timeSpan.textContent = `${slot.start} -> ${slot.end}`;
timeSpan.style.color = slot.enabled ? "#eee" : "#666";
slotInfo.appendChild(timeSpan);
// Show sun-based indicator if applicable
if (slot.sunBased) {
const sunIndicator = document.createElement("div");
sunIndicator.className = "sun-based-indicator";
const eventName = slot.sunEvent === "sunrise" ? "Zonsopgang" : "Zonsondergang";
const offsetText = slot.sunOffset > 0 ? `${slot.sunOffset} min na` :
slot.sunOffset < 0 ? `${Math.abs(slot.sunOffset)} min voor` : "Precies";
sunIndicator.textContent = `${eventName} ${offsetText} (${slot.sunDuration} min)`;
slotInfo.appendChild(sunIndicator);
}
// Days toggle buttons
const daysContainer = document.createElement("div");
daysContainer.className = "daysContainer";
dayLetters.forEach((letter, index) => {
const dayBtn = document.createElement("button");
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`;
dayBtn.textContent = letter;
dayBtn.disabled = !slot.enabled;
dayBtn.onclick = () => {
slot.days[index] = !slot.days[index];
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`;
DataManager.save();
renderCanvases();
};
daysContainer.appendChild(dayBtn);
});
slotInfo.appendChild(daysContainer);
li.appendChild(slotInfo);
const ctr = document.createElement("div");
ctr.className = "controls";
const tgl = document.createElement("button");
tgl.textContent = "Toggle";
tgl.className = "btn toggleBtn";
tgl.onclick = () => {
slot.enabled = !slot.enabled;
DataManager.save();
renderList();
renderCanvases();
};
const edit = document.createElement("button");
edit.textContent = "Edit";
edit.className = "btn editBtn";
edit.onclick = () => {
li.innerHTML = "";
const startInput = document.createElement("input");
startInput.type = "time";
startInput.value = slot.start;
const endInput = document.createElement("input");
endInput.type = "time";
endInput.value = slot.end;
// Days toggle buttons in edit mode
const editDaysContainer = document.createElement("div");
editDaysContainer.className = "daysContainer";
dayLetters.forEach((letter, index) => {
const dayBtn = document.createElement("button");
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`;
dayBtn.textContent = letter;
dayBtn.onclick = () => {
slot.days[index] = !slot.days[index];
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`;
};
editDaysContainer.appendChild(dayBtn);
});
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save";
saveBtn.className = "btn toggleBtn";
saveBtn.onclick = () => {
slot.start = startInput.value;
slot.end = endInput.value;
// Clear sun-based properties when manually editing
delete slot.sunBased;
delete slot.sunEvent;
delete slot.sunOffset;
delete slot.sunDuration;
DataManager.save();
renderList();
renderCanvases();
};
li.appendChild(startInput);
li.appendChild(endInput);
li.appendChild(editDaysContainer);
li.appendChild(saveBtn);
};
const del = document.createElement("button");
del.textContent = "Delete";
del.className = "btn delBtn";
del.onclick = () => {
slots = slots.filter(s => s.id !== slot.id);
DataManager.save();
renderList();
renderCanvases();
};
ctr.appendChild(tgl);
ctr.appendChild(edit);
ctr.appendChild(del);
li.appendChild(ctr);
ul.appendChild(li);
});
}
// Initialize form day buttons
function initFormDays() {
const formDays = document.querySelectorAll('#addForm .dayBtn, .sun-form .dayBtn');
formDays.forEach(btn => {
btn.classList.add('on');
btn.onclick = () => {
btn.classList.toggle('on');
btn.classList.toggle('off');
};
});
}
// Function to recalculate sun-based slots
function recalculateSunBasedSlots() {
slots.forEach(slot => {
if (slot.sunBased) {
// Calculate start time based on sunrise/sunset and offset
const baseTime = slot.sunEvent === "sunrise" ? sunrise : sunset;
const baseMins = TimeUtils.toMin(baseTime);
const startMins = (baseMins + slot.sunOffset + 1440) % 1440;
const endMins = (startMins + slot.sunDuration) % 1440;
slot.start = TimeUtils.toHHMM(startMins);
slot.end = TimeUtils.toHHMM(endMins);
}
});
DataManager.save();
renderCanvases();
renderList();
}
// Get user location for sunrise/sunset data
function getUserLocation() {
if (!navigator.geolocation) {
document.getElementById("geoMsg").textContent = "Geolocatie wordt niet ondersteund door deze browser.";
return;
}
document.getElementById("geoMsg").textContent = "Locatie ophalen...";
navigator.geolocation.getCurrentPosition(
position => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
fetchSunTimes(lat, lon);
},
error => {
let message = "Kan locatie niet ophalen: ";
switch(error.code) {
case error.PERMISSION_DENIED:
message += "Gebruiker heeft locatie geweigerd.";
break;
case error.POSITION_UNAVAILABLE:
message += "Locatie informatie is niet beschikbaar.";
break;
case error.TIMEOUT:
message += "Locatie aanvraag is verlopen.";
break;
default:
message += "Onbekende fout.";
}
document.getElementById("geoMsg").textContent = message;
}
);
}
// Add sunrise/sunset based slot
document.getElementById("addSunSlot").addEventListener("click", () => {
const event = document.getElementById("sunEvent").value;
const offset = parseInt(document.getElementById("sunOffset").value);
const duration = parseInt(document.getElementById("sunDuration").value);
// Get selected days from form
const formDays = document.querySelectorAll('.sun-form .dayBtn');
const days = Array.from(formDays).map(btn => btn.classList.contains('on'));
// Calculate start time based on sunrise/sunset and offset
const baseTime = event === "sunrise" ? sunrise : sunset;
const baseMins = TimeUtils.toMin(baseTime);
const startMins = (baseMins + offset + 1440) % 1440;
const endMins = (startMins + duration) % 1440;
const startTime = TimeUtils.toHHMM(startMins);
const endTime = TimeUtils.toHHMM(endMins);
slots.push({
id: Date.now(),
start: startTime,
end: endTime,
enabled: true,
days: days,
sunBased: true,
sunEvent: event,
sunOffset: offset,
sunDuration: duration
});
DataManager.save();
renderList();
renderCanvases();
});
// Original form submission
document.getElementById("addForm").addEventListener("submit", e => {
e.preventDefault();
const st = document.getElementById("startTime").value;
const ed = document.getElementById("endTime").value;
if (!st || !ed) return;
// Get selected days from form
const formDays = document.querySelectorAll('#addForm .dayBtn');
const days = Array.from(formDays).map(btn => btn.classList.contains('on'));
slots.push({id: Date.now(), start: st, end: ed, enabled: true, days: days});
DataManager.save();
renderList();
renderCanvases();
e.target.reset();
// Reset form days to all on
formDays.forEach(btn => {
btn.classList.add('on');
btn.classList.remove('off');
});
});
function fetchSunTimes(lat, lon) {
fetch(`https://api.sunrise-sunset.org/json?lat=${lat}&lng=${lon}&formatted=0`)
.then(res => res.json())
.then(data => {
if (data.status === "OK") {
sunrise = TimeUtils.toLocalHHMM(new Date(data.results.sunrise));
sunset = TimeUtils.toLocalHHMM(new Date(data.results.sunset));
document.getElementById("geoMsg").textContent = "";
// Recalculate all sun-based slots when sun times change
recalculateSunBasedSlots();
}
})
.catch(() => {
document.getElementById("geoMsg").textContent = "Kon sunrise/sunset niet ophalen.";
});
}
function updateMoonPhase() {
const phases = ["πŸŒ‘","πŸŒ’","πŸŒ“","πŸŒ”","πŸŒ•","πŸŒ–","πŸŒ—","🌘"];
const names = ["Nieuwe Maan","Wassende Sikkel","Eerste Kwartier","Wassende Maan",
"Volle Maan","Afnemende Maan","Laatste Kwartier","Afnemende Sikkel"];
const now = new Date();
const lp = new Date(Date.UTC(2000, 0, 6, 18, 14));
const diff = now - lp;
const days = diff / 1000 / 60 / 60 / 24;
const lunations = days / 29.53058867;
const index = Math.floor((lunations - Math.floor(lunations)) * 8 + 0.5) % 8;
document.getElementById("moonIcon").textContent = phases[index];
document.getElementById("moonText").textContent = names[index];
}
// Navigation button handlers for endless scrolling
document.getElementById("prevDay").addEventListener("click", () => {
currentViewDay = (currentViewDay - 1 + 7) % 7;
renderDayNavigation();
renderCanvases();
renderList();
});
document.getElementById("nextDay").addEventListener("click", () => {
currentViewDay = (currentViewDay + 1) % 7;
renderDayNavigation();
renderCanvases();
renderList();
});
// Handle canvas wrapper scrolling/swiping
canvasWrapper.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrolledToDayIndex = Math.round(canvasWrapper.scrollLeft / CANVAS_WIDTH);
if (scrolledToDayIndex === 0) {
currentViewDay = (currentViewDay - 1 + 7) % 7;
} else if (scrolledToDayIndex === 2) {
currentViewDay = (currentViewDay + 1) % 7;
}
// If currentViewDay changed, re-render to ensure correct neighbors and list are shown
if (scrolledToDayIndex !== 1) {
renderDayNavigation();
renderCanvases();
renderList();
}
// Always update overlay as it might show different day's info
updateCenterOverlay(currentViewDay);
}, 150);
});
// Export/Import functionality
document.getElementById("exportBtn").addEventListener("click", () => {
DataManager.exportToJson();
});
document.getElementById("importBtn").addEventListener("click", () => {
const textarea = document.getElementById("jsonData");
if (textarea.style.display === "none") {
textarea.style.display = "block";
textarea.value = "";
textarea.focus();
} else {
DataManager.importFromJson();
}
});
// Location button handler
document.getElementById("getLocationBtn").addEventListener("click", getUserLocation);
// Initial setup
DataManager.load();
initFormDays();
renderDayNavigation();
renderCanvases();
renderList();
updateMoonPhase();
// Update current time pointer and moon phase every 15 seconds
setInterval(() => {
const currentCanvas = canvasWrapper.querySelector(`.day-canvas-item[data-day-index="${currentViewDay}"] canvas`);
if (currentCanvas) {
CanvasRenderer.drawCircle(currentCanvas, currentViewDay);
}
updateCenterOverlay(currentViewDay);
updateMoonPhase();
}, 15000);
</script>
</body>
</html>

Swipe

A Pen by Dirk Luberth Dijkman on CodePen.

License.

This HTML file creates a complete, interactive web application called the 24/7 Circular TimeSlots Timer. It's designed to help users create, manage, and visualize daily and weekly repeating schedules, such as for watering systems or lighting control. The application is built with HTML, CSS, and vanilla JavaScript and runs entirely in the browser, saving all data to your device's local storage.

Key Features

The application has several sophisticated features for managing time-based events. Circular Clock Visualization The main interface is a 24-hour circular clock drawn on an HTML5 canvas.

  • Time Slots: Scheduled time slots are shown as colored arcs on the outer ring of the clock. A slot that is currently active is highlighted in green, while inactive slots are red.
  • Day & Night: An inner ring visually represents the cycle of day and night, using the actual sunrise and sunset times.
  • Current Time: A dynamic line points from the center to the current time on the clock.
  • Swipable Days: You can swipe left or right on the clock to navigate between the days of the week, or use the navigation buttons and tabs.

Time Slot Management

Users can manage schedules in two primary ways:

  1. Fixed Time Slots You can add a schedule by specifying an exact start and end time using the time pickers. This is ideal for events that always happen at the same time.
  2. Sun-Based Slots This unique feature allows you to create schedules relative to the day's sunrise ("Zonsopgang") or sunset ("Zonsondergang").
  • Geolocation: The app can use your browser's location to fetch accurate, local sunrise and sunset times from an online API.
  • Relative Timing: You can set an event to occur, for example, "30 minutes before sunset" or "1 hour after sunrise" for a specific duration. These slots will automatically adjust each day based on the changing sun times.

Additional Functionality

  • Daily Scheduling: Each time slot can be configured to run on specific days of the week (e.g., only on weekdays).
  • Information Display: The center of the clock displays the current time, date, week number, and the current moon phase with its name.
  • Data Persistence: All your schedules are automatically saved in the browser's localStorage. This means your settings will be remembered the next time you open the page.
  • Export & Import: You can export your entire schedule as a JSON file (a standard text-based data format) to back it up or share it. You can also import a schedule from a JSON file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment