Timekeeper for DM
// ==UserScript==
// @name Roll20 Timekeeper DM
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Timekeeper for DM
// @author @TheMerryTavern
// @match https://app.roll20.net/editor/*
// @grant none
// @license MIT
// ==/UserScript==
/*
MIT License
Copyright (c) 2026 The Merry Tavern
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
(function() {
'use strict';
if (location.pathname !== "/editor/") return;
const STORAGE_KEY_POS = 'r20_clock_position';
const STORAGE_KEY_TIME = 'r20_clock_time';
const STORAGE_KEY_DATE = 'r20_clock_date';
const STORAGE_KEY_NOTES = 'r20_clock_notes';
// ────────────────────────────────
// BAR WITH TIME
// ────────────────────────────────
const bar = document.createElement("div");
bar.style.position = "fixed";
bar.style.top = "6px";
bar.style.left = "50%";
bar.style.transform = "translateX(-50%)";
bar.style.width = "260px";
bar.style.height = "36px";
bar.style.background = "rgba(0,0,0,0.7)";
bar.style.color = "white";
bar.style.fontSize = "18px";
bar.style.fontWeight = "700";
bar.style.fontFamily = "monospace";
bar.style.display = "flex";
bar.style.alignItems = "center";
bar.style.justifyContent = "center";
bar.style.padding = "0 10px";
bar.style.borderRadius = "6px";
bar.style.zIndex = "2147483647";
bar.style.boxShadow = "0 2px 8px rgba(0,0,0,0.6)";
bar.style.cursor = "move";
document.body.appendChild(bar);
const savedPos = JSON.parse(localStorage.getItem(STORAGE_KEY_POS) || "null");
if (savedPos && savedPos.top && savedPos.left) {
bar.style.top = savedPos.top;
bar.style.left = savedPos.left;
bar.style.transform = "";
}
const timeBox = document.createElement("div");
timeBox.style.fontSize = "18px";
timeBox.style.fontWeight = "900";
timeBox.style.letterSpacing = "1px";
timeBox.textContent = "12:00";
timeBox.style.cursor = "pointer";
timeBox.title = "Click to set time";
timeBox.style.position = "absolute";
timeBox.style.left = "50%";
timeBox.style.transform = "translateX(-50%)";
bar.appendChild(timeBox);
const dayEmoji = document.createElement("span");
dayEmoji.style.fontSize = "20px";
dayEmoji.style.position = "absolute";
dayEmoji.style.left = "calc(50% + 50px)";
dayEmoji.style.transform = "translateX(-50%)";
dayEmoji.style.transition = "opacity 0.3s ease";
dayEmoji.style.opacity = "1";
dayEmoji.textContent = "☀️";
bar.appendChild(dayEmoji);
function getDayEmoji(hour) {
if (hour >= 5 && hour < 8) return "🌅";
if (hour >= 8 && hour < 12) return "☀️";
if (hour >= 12 && hour < 14) return "🌞";
if (hour >= 14 && hour < 18) return "☀️";
if (hour >= 18 && hour < 21) return "🌇";
return "🌙";
}
let time = JSON.parse(localStorage.getItem(STORAGE_KEY_TIME) || '{"h":12,"m":0}');
let calendar = JSON.parse(localStorage.getItem(STORAGE_KEY_DATE) || '{"day":1,"month":1,"year":1492,"season":"Deepwinter"}');
let notes = JSON.parse(localStorage.getItem(STORAGE_KEY_NOTES) || "{}");
let lastEmoji = "";
function updateTimeDisplay() {
timeBox.textContent = String(time.h).padStart(2, '0') + ":" + String(time.m).padStart(2, '0');
const emoji = getDayEmoji(time.h);
if (emoji !== lastEmoji) {
dayEmoji.style.opacity = "0";
setTimeout(() => { dayEmoji.textContent = emoji; dayEmoji.style.opacity = "1"; }, 160);
lastEmoji = emoji;
}
}
updateTimeDisplay();
function sendChatCommand(cmd) {
const chatInput = document.querySelector('#textchat-input textarea');
const sendBtn = document.querySelector('#textchat-input .btn');
if (chatInput && sendBtn) {
const oldValue = chatInput.value;
chatInput.value = cmd;
sendBtn.click();
setTimeout(() => { chatInput.value = oldValue; }, 50);
}
}
function adjustTime(minutes) {
const oldTotal = time.h * 60 + time.m;
let totalMinutes = oldTotal + minutes;
time.h = Math.floor(totalMinutes / 60) % 24;
time.m = totalMinutes % 60;
if (time.m < 0) {
time.m += 60;
time.h = (time.h - 1 + 24) % 24;
}
const newTotal = time.h * 60 + time.m;
let dayChanged = false;
if (minutes > 0 && newTotal < oldTotal) {
calendar.day++;
dayChanged = true;
}
if (dayChanged) {
if (calendar.day > 30) {
calendar.day = 1;
calendar.month++;
if (calendar.month > 12) {
calendar.month = 1;
calendar.year++;
}
}
calendar.season = computeSeason(calendar.month);
localStorage.setItem(STORAGE_KEY_DATE, JSON.stringify(calendar));
sendChatCommand(`date ${calendar.day}/${calendar.month}/${calendar.year}`);
}
updateTimeDisplay();
localStorage.setItem(STORAGE_KEY_TIME, JSON.stringify(time));
sendChatCommand(`time ${timeBox.textContent}`);
}
function showTimeAdjustMenu() {
let menu = bar.querySelector('.time-adjust-menu');
if (menu) {
menu.remove();
return;
}
menu = document.createElement('div');
menu.className = 'time-adjust-menu';
menu.style.position = 'absolute';
menu.style.top = '44px';
menu.style.left = '50%';
menu.style.transform = 'translateX(-50%)';
menu.style.width = '260px';
menu.style.display = 'flex';
menu.style.justifyContent = 'space-between';
menu.style.background = 'rgba(0,0,0,0.7)';
menu.style.borderRadius = '6px';
menu.style.padding = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.6)';
menu.style.zIndex = '2147483648';
menu.style.opacity = '0';
menu.style.transform = 'translate(-50%, -10px)';
menu.style.transition = 'all 0.22s ease';
const buttons = [
{text: '+15 min', min: 15},
{text: '+1 h', min: 60},
{text: '+8 h', min: 480},
{text: 'Custom', min: null}
];
buttons.forEach(b => {
const btn = document.createElement('button');
btn.textContent = b.text;
btn.style.flex = '1';
btn.style.margin = '0 4px';
btn.style.padding = '8px 0';
btn.style.background = 'rgba(255,255,255,0.08)';
btn.style.color = 'white';
btn.style.border = '1px solid rgba(255,255,255,0.15)';
btn.style.borderRadius = '6px';
btn.style.fontWeight = '700';
btn.style.cursor = 'pointer';
btn.style.transition = 'all 0.15s ease';
btn.style.fontSize = '14px';
btn.addEventListener('mouseover', () => {
btn.style.background = 'rgba(255,255,255,0.15)';
});
btn.addEventListener('mouseout', () => {
btn.style.background = 'rgba(255,255,255,0.08)';
});
btn.addEventListener('click', () => {
if (b.min !== null) {
adjustTime(b.min);
} else {
const input = prompt("Set time (HH:MM)", timeBox.textContent);
if (input) {
const parts = input.split(":").map(s => parseInt(s.trim(), 10));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
const newTotal = parts[0] * 60 + parts[1];
const oldTotal = time.h * 60 + time.m;
adjustTime(newTotal - oldTotal);
} else {
alert("Invalid format.");
}
}
}
menu.remove();
});
menu.appendChild(btn);
});
bar.appendChild(menu);
requestAnimationFrame(() => {
menu.style.opacity = '1';
menu.style.transform = 'translate(-50%, 0)';
});
}
timeBox.addEventListener('click', showTimeAdjustMenu);
function promptSetDateAndSend() {
const input = prompt("Set date (DD/MM/YYYY)", `${calendar.day}/${calendar.month}/${calendar.year}`);
if (!input) return;
const parts = input.split("/").map(s => parseInt(s.trim(), 10));
if (parts.length === 3 && parts.every(n => !isNaN(n))) {
calendar.day = parts[0];
calendar.month = parts[1];
calendar.year = parts[2];
calendar.season = computeSeason(calendar.month);
localStorage.setItem(STORAGE_KEY_DATE, JSON.stringify(calendar));
sendChatCommand(`date ${calendar.day}/${calendar.month}/${calendar.year}`);
} else {
alert("Invalid format (DD/MM/YYYY).");
}
}
function computeSeason(m) {
if ([1,2,12].includes(m)) return "Deepwinter";
if ([3,4,5].includes(m)) return "Spring";
if ([6,7,8].includes(m)) return "Summer";
if ([9,10,11].includes(m)) return "Autumn";
return "Unknown";
}
// ────────────────────────────────
// CALENDAR ICON + DRAGGING
// ────────────────────────────────
const calendarIcon = document.createElement("div");
calendarIcon.textContent = "📅";
calendarIcon.style.position = "absolute";
calendarIcon.style.left = "35px";
calendarIcon.style.cursor = "pointer";
calendarIcon.title = "Click to open calendar";
bar.appendChild(calendarIcon);
const calendarPanel = document.createElement("div");
calendarPanel.style.position = "absolute";
calendarPanel.style.width = "320px";
calendarPanel.style.maxWidth = "90vw";
calendarPanel.style.background = "rgba(0,0,0,0.7)";
calendarPanel.style.border = "1px solid rgba(255,255,255,0.06)";
calendarPanel.style.borderRadius = "8px";
calendarPanel.style.padding = "8px";
calendarPanel.style.display = "none";
calendarPanel.style.color = "white";
calendarPanel.style.zIndex = "2147483646";
calendarPanel.style.boxShadow = "0 6px 24px rgba(0,0,0,0.6)";
calendarPanel.style.transition = "opacity 0.18s ease, transform 0.18s ease";
document.body.appendChild(calendarPanel);
const calendarGear = document.createElement("div");
calendarGear.textContent = "⚙️";
calendarGear.title = "Set date manually";
calendarGear.style.position = "absolute";
calendarGear.style.cursor = "pointer";
calendarGear.style.fontSize = "18px";
calendarGear.style.background = "rgba(0,0,0,0.45)";
calendarGear.style.padding = "4px 6px";
calendarGear.style.borderRadius = "6px";
calendarGear.style.color = "white";
calendarGear.style.boxShadow = "0 4px 10px rgba(0,0,0,0.6)";
let viewDate = { ...calendar };
let viewIsSynced = true;
function cloneDate(src) {
return { day: Number(src.day||1), month: Number(src.month||1), year: Number(src.year||1492), season: src.season || "" };
}
function renderCalendar() {
const months = ["Hammer","Alturiak","Ches","Tarsakh","Mirtul","Kythorn","Flamerule","Eleasis","Eleint","Marpenoth","Uktar","Nightal"];
const daysOfWeek = ["Starday","Sunday","Moonday","Lunesday","Trewsday","Thurnday","Fryday"];
if (viewDate.day < 1) viewDate.day = 1;
if (viewDate.day > 30) viewDate.day = 30;
const daysHeader = daysOfWeek.map(n => `<div style="font-size:11px;color:#bbb;padding:2px 0;">${n.slice(0,3)}</div>`).join("");
const daysGrid = Array.from({length:30}, (_,i) => i+1).map(day => {
const key = `${viewDate.year}-${viewDate.month}-${day}`;
const hasNote = !!notes[key];
let dot = "";
if (viewDate.month === calendar.month && viewDate.year === calendar.year && day === calendar.day) {
dot = '<span style="display:inline-block;width:8px;height:8px;background:gold;border-radius:50%;margin-left:6px;vertical-align:middle;"></span>';
} else if (hasNote) {
dot = '<span style="display:inline-block;width:8px;height:8px;background:deepskyblue;border-radius:50%;margin-left:6px;vertical-align:middle;"></span>';
}
return `<div data-day="${day}" style="padding:6px;cursor:pointer;border-radius:6px;min-height:28px;display:flex;align-items:center;justify-content:center;">${day}${dot}</div>`;
}).join("");
calendarPanel.innerHTML = `<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:6px;font-size:13px;text-align:center;">${daysHeader}${daysGrid}</div>`;
const headerDiv = document.createElement('div');
headerDiv.style.display = 'flex';
headerDiv.style.justifyContent = 'center';
headerDiv.style.alignItems = 'center';
headerDiv.style.marginBottom = '6px';
headerDiv.style.position = 'relative';
headerDiv.style.padding = '0 20px';
const prevBtn = document.createElement('button');
prevBtn.textContent = '◀';
prevBtn.style.position = 'absolute';
prevBtn.style.left = '8px';
prevBtn.style.background = 'transparent';
prevBtn.style.border = 'none';
prevBtn.style.color = 'white';
prevBtn.style.cursor = 'pointer';
prevBtn.addEventListener('click', ev => {
ev.stopPropagation();
viewDate.month--;
if (viewDate.month < 1) { viewDate.month = 12; viewDate.year--; }
viewDate.season = computeSeason(viewDate.month);
viewIsSynced = false;
renderCalendar();
});
const nextBtn = document.createElement('button');
nextBtn.textContent = '▶';
nextBtn.style.position = 'absolute';
nextBtn.style.right = '8px';
nextBtn.style.background = 'transparent';
nextBtn.style.border = 'none';
nextBtn.style.color = 'white';
nextBtn.style.cursor = 'pointer';
nextBtn.addEventListener('click', ev => {
ev.stopPropagation();
viewDate.month++;
if (viewDate.month > 12) { viewDate.month = 1; viewDate.year++; }
viewDate.season = computeSeason(viewDate.month);
viewIsSynced = false;
renderCalendar();
});
const titleSpan = document.createElement('span');
titleSpan.style.fontWeight = '700';
titleSpan.style.textAlign = 'center';
titleSpan.style.whiteSpace = 'nowrap';
const monthName = (months[viewDate.month - 1] || '???');
const seasonName = viewDate.season || computeSeason(viewDate.month);
titleSpan.textContent = `📅 ${monthName} ${viewDate.year} (${seasonName})`;
headerDiv.appendChild(prevBtn);
headerDiv.appendChild(titleSpan);
headerDiv.appendChild(nextBtn);
calendarPanel.insertBefore(headerDiv, calendarPanel.firstChild);
calendarPanel.querySelectorAll('[data-day]').forEach(el => {
el.addEventListener('click', () => { openNoteWindow(parseInt(el.getAttribute('data-day'), 10)); });
});
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.justifyContent = 'space-between';
footer.style.marginTop = '12px';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export Notes';
exportBtn.style.padding = '6px 12px';
exportBtn.style.background = '#444';
exportBtn.style.color = 'white';
exportBtn.style.border = 'none';
exportBtn.style.borderRadius = '4px';
exportBtn.style.cursor = 'pointer';
exportBtn.addEventListener('click', () => {
const json = JSON.stringify(notes, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'calendar_notes.json';
a.click();
});
const importFileBtn = document.createElement('button');
importFileBtn.textContent = 'Import from file';
importFileBtn.style.padding = '6px 12px';
importFileBtn.style.background = '#444';
importFileBtn.style.color = 'white';
importFileBtn.style.border = 'none';
importFileBtn.style.borderRadius = '4px';
importFileBtn.style.cursor = 'pointer';
importFileBtn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
notes = JSON.parse(event.target.result);
localStorage.setItem(STORAGE_KEY_NOTES, JSON.stringify(notes));
alert("Notes imported from file!");
renderCalendar();
} catch (err) {
alert("Invalid JSON file.");
}
};
reader.readAsText(file);
});
input.click();
});
footer.appendChild(exportBtn);
footer.appendChild(importFileBtn);
calendarPanel.appendChild(footer);
calendarGear.style.position = "absolute";
calendarGear.style.right = "-50px";
calendarGear.style.top = "0px";
headerDiv.appendChild(calendarGear);
}
calendarIcon.addEventListener('click', () => {
if (calendarPanel.style.display === 'none' || calendarPanel.style.display === '') {
const rect = bar.getBoundingClientRect();
viewDate = cloneDate(calendar);
viewIsSynced = true;
calendarPanel.style.display = "block";
calendarPanel.style.opacity = '0';
calendarPanel.style.transform = 'translateY(-6px)';
renderCalendar();
requestAnimationFrame(() => {
const panelWidth = calendarPanel.offsetWidth || 320;
const left = rect.left + (rect.width / 2) - (panelWidth / 2);
calendarPanel.style.left = Math.max(4, Math.min(left, window.innerWidth - panelWidth - 4)) + "px";
calendarPanel.style.top = (rect.bottom + 6) + "px";
calendarPanel.style.opacity = '1';
calendarPanel.style.transform = 'translateY(0)';
});
} else {
calendarPanel.style.display = "none";
}
});
calendarGear.addEventListener('click', (ev) => {
ev.stopPropagation();
promptSetDateAndSend();
renderCalendar();
});
function openNoteWindow(day) {
const key = `${viewDate.year}-${viewDate.month}-${day}`;
const existing = notes[key] || "";
if (document.querySelector(`[data-note-key="${key}"]`)) {
const existingWin = document.querySelector(`[data-note-key="${key}"]`);
const ta = existingWin.querySelector('textarea');
if (ta) ta.focus();
return;
}
const noteWin = document.createElement('div');
noteWin.setAttribute('data-note-key', key);
noteWin.style.position = 'fixed';
noteWin.style.top = '120px';
noteWin.style.left = '120px';
noteWin.style.width = '360px';
noteWin.style.minWidth = '220px';
noteWin.style.height = '260px';
noteWin.style.background = '#0b0b0b';
noteWin.style.color = 'white';
noteWin.style.border = '2px solid white';
noteWin.style.borderRadius = '8px';
noteWin.style.zIndex = '2147483650';
noteWin.style.display = 'flex';
noteWin.style.flexDirection = 'column';
noteWin.style.resize = 'both';
noteWin.style.overflow = 'hidden';
noteWin.style.boxShadow = '0 10px 30px rgba(0,0,0,0.6)';
const header = document.createElement('div');
header.style.padding = '6px 8px';
header.style.cursor = 'grab';
header.style.background = 'linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0))';
header.style.borderBottom = '1px solid rgba(255,255,255,0.03)';
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
const title = document.createElement('div');
title.textContent = `Note: ${day}/${viewDate.month}/${viewDate.year}`;
title.style.fontSize = '13px';
title.style.color = 'white';
title.style.fontWeight = '700';
const closeBtn = document.createElement('button');
closeBtn.textContent = '✖';
closeBtn.title = 'Close without saving';
closeBtn.style.background = 'transparent';
closeBtn.style.border = 'none';
closeBtn.style.color = 'white';
closeBtn.style.cursor = 'pointer';
closeBtn.style.fontSize = '14px';
header.appendChild(title);
header.appendChild(closeBtn);
noteWin.appendChild(header);
const ta = document.createElement('textarea');
ta.value = existing;
ta.style.flex = '1';
ta.style.width = '100%';
ta.style.background = '#0f0f0f';
ta.style.color = 'white';
ta.style.border = 'none';
ta.style.outline = 'none';
ta.style.padding = '8px';
ta.style.resize = 'none';
ta.style.fontFamily = 'monospace';
noteWin.appendChild(ta);
const footer = document.createElement('div');
footer.style.padding = '8px';
footer.style.background = 'rgba(255,255,255,0.05)';
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.style.padding = '6px 16px';
saveBtn.style.background = '#2a2';
saveBtn.style.color = 'white';
saveBtn.style.border = 'none';
saveBtn.style.borderRadius = '4px';
saveBtn.style.cursor = 'pointer';
saveBtn.style.fontWeight = '700';
footer.appendChild(saveBtn);
noteWin.appendChild(footer);
document.body.appendChild(noteWin);
ta.focus();
let dragging = false, dx = 0, dy = 0;
header.addEventListener('mousedown', (e) => {
dragging = true;
header.style.cursor = 'grabbing';
dx = e.clientX - noteWin.getBoundingClientRect().left;
dy = e.clientY - noteWin.getBoundingClientRect().top;
e.preventDefault();
});
const onMove = (e) => {
if (!dragging) return;
let nx = e.clientX - dx;
let ny = e.clientY - dy;
nx = Math.max(4, Math.min(nx, window.innerWidth - noteWin.offsetWidth - 4));
ny = Math.max(4, Math.min(ny, window.innerHeight - noteWin.offsetHeight - 4));
noteWin.style.left = nx + 'px';
noteWin.style.top = ny + 'px';
};
const onUp = () => { dragging = false; header.style.cursor = 'grab'; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
const saveAndClose = () => {
notes[key] = ta.value;
localStorage.setItem(STORAGE_KEY_NOTES, JSON.stringify(notes));
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
if (noteWin.parentNode) noteWin.parentNode.removeChild(noteWin);
renderCalendar();
};
const closeWithoutSave = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
if (noteWin.parentNode) noteWin.parentNode.removeChild(noteWin);
};
saveBtn.addEventListener('click', saveAndClose);
closeBtn.addEventListener('click', closeWithoutSave);
setTimeout(() => {
function onDocDown(ev) {
if (!noteWin.contains(ev.target)) {
closeWithoutSave();
document.removeEventListener('mousedown', onDocDown);
}
}
document.addEventListener('mousedown', onDocDown);
}, 0);
}
let isDragging = false, dragOffX = 0, dragOffY = 0;
bar.addEventListener('mousedown', ev => {
isDragging = true;
dragOffX = ev.clientX - bar.getBoundingClientRect().left;
dragOffY = ev.clientY - bar.getBoundingClientRect().top;
bar.style.transition = "none";
});
window.addEventListener('mousemove', ev => {
if (!isDragging) return;
let nx = ev.clientX - dragOffX;
let ny = ev.clientY - dragOffY;
nx = Math.max(0, Math.min(nx, window.innerWidth - bar.offsetWidth));
ny = Math.max(0, Math.min(ny, window.innerHeight - bar.offsetHeight));
bar.style.left = nx + "px";
bar.style.top = ny + "px";
bar.style.transform = "";
if (calendarPanel.style.display === 'block') {
const panelWidth = calendarPanel.offsetWidth || 320;
const left = nx + (bar.offsetWidth / 2) - (panelWidth / 2);
calendarPanel.style.left = Math.max(4, Math.min(left, window.innerWidth - panelWidth - 4)) + "px";
calendarPanel.style.top = (ny + bar.offsetHeight + 6) + "px";
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
localStorage.setItem(STORAGE_KEY_POS, JSON.stringify({ top: bar.style.top, left: bar.style.left }));
}
});
})();