Roll20 Timekeeper DM

Timekeeper for DM

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 }));
        }
    });
})();