Calendar
ox alpha · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 57,267 bytes · SHA-256 08bbd3673ac7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Event Planner</title>
<style>
/* ============================================================
THEME & LAYOUT
============================================================ */
:root{
--bg:#ffffff; --bg-soft:#f8f9fa; --line:#e0e0e0; --line-soft:#f1f3f4;
--text:#202124; --text-dim:#5f6368; --text-faint:#9aa0a6;
--accent:#1a73e8; --accent-soft:#e8f0fe;
--today:#1a73e8;
--radius:8px;
font-size:15px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:"Google Sans",Roboto,-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--text); display:flex; flex-direction:column;
overflow:hidden;
}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
input,select,textarea{font:inherit}
/* ---------- top bar ---------- */
#topbar{
display:flex;align-items:center;gap:10px;padding:10px 16px;
border-bottom:1px solid var(--line-soft);flex-wrap:wrap;
}
#brand{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:500;color:var(--accent)}
#brand svg{display:block}
#todayBtn{
border:1px solid var(--line);border-radius:var(--radius);padding:6px 14px;
font-weight:500;color:var(--text-dim);
}
#todayBtn:hover{background:var(--bg-soft)}
.navArrow{
width:34px;height:34px;border-radius:50%;color:var(--text-dim);
display:inline-flex;align-items:center;justify-content:center;
}
.navArrow:hover{background:var(--bg-soft)}
#calTitle{font-size:1.35rem;font-weight:400;margin-left:6px;min-width:230px}
#viewTabs{margin-left:auto;display:flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
#viewTabs button{padding:7px 18px;color:var(--text-dim);font-weight:500;background:var(--bg)}
#viewTabs button + button{border-left:1px solid var(--line)}
#viewTabs button.active{background:var(--accent-soft);color:var(--accent)}
#settingsBtn{width:38px;height:38px;border-radius:50%;color:var(--text-dim);position:relative}
#settingsBtn:hover{background:var(--bg-soft)}
/* settings dropdown */
#settingsMenu{
position:absolute;top:52px;right:16px;z-index:60;background:var(--bg);
border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.15);
padding:12px 16px;display:none;min-width:220px;
}
#settingsMenu.open{display:block}
#settingsMenu h4{font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-faint);margin-bottom:8px}
.segRow{display:flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;margin-bottom:12px}
.segRow button{flex:1;padding:6px 10px;color:var(--text-dim)}
.segRow button.active{background:var(--accent-soft);color:var(--accent)}
.segRow button + button{border-left:1px solid var(--line)}
/* ---------- main area ---------- */
#main{flex:1;overflow:hidden;position:relative}
.viewAnim{animation:viewIn .22s ease}
@keyframes viewIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
/* ---------- MONTH VIEW ---------- */
.monthWrap{height:100%;display:flex;flex-direction:column}
.monthHead,.weekHeadStrip{
display:grid;border-bottom:1px solid var(--line);
}
.monthHead{grid-template-columns:repeat(7,1fr)}
.monthHead div{padding:8px 4px;text-align:center;font-size:.72rem;font-weight:500;
color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em}
#monthGrid{
flex:1;display:grid;
grid-template-columns:repeat(7,1fr);grid-template-rows:repeat(6,1fr);
overflow:hidden}
.mcell{
border-right:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft);
padding:3px 4px;overflow:hidden;cursor:pointer;position:relative;min-height:0;
display:flex;flex-direction:column;
}
.mcell:nth-child(7n){border-right:none}
.mcell.other{background:var(--bg-soft)}
.mcell.dropTarget{background:var(--accent-soft);box-shadow:inset 0 0 0 2px var(--accent)}
.mcell.dragOver{background:var(--accent-soft)}
.daynum{
width:24px;height:24px;line-height:24px;text-align:center;border-radius:50%;
font-size:.78rem;color:var(--text-dim);align-self:flex-end;margin:1px 2px 2px 0;flex:none;
}
.mcell.other .daynum{color:var(--text-faint)}
.mcell.today .daynum{background:var(--today);color:#fff;font-weight:600}
.chip{
display:flex;align-items:center;font-size:.74rem;line-height:1.25;
padding:1px 4px 1px 6px;border-radius:4px;margin-bottom:2px;cursor:grab;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none;
}
.chip .dot{width:7px;height:7px;border-radius:50%;margin-right:5px;flex:none}
.chip.filled{border-radius:4px;color:#fff;padding-left:6px}
.chip.time{color:var(--text-dim)}
.moreLink{font-size:.72rem;color:var(--text-dim);padding:1px 4px;border-radius:4px;align-self:flex-start}
.moreLink:hover{background:var(--line-soft)}
/* day popover */
#dayPop{
position:absolute;z-index:70;background:var(--bg);border-radius:12px;
box-shadow:0 6px 24px rgba(0,0,0,.22);width:300px;max-height:380px;display:none;
flex-direction:column;overflow:hidden;
}
#dayPop.open{display:flex}
#dayPop header{padding:14px 16px 8px;font-size:.85rem;color:var(--text-dim);display:flex;justify-content:space-between}
#dayPop header b{color:var(--text);font-size:1rem;font-weight:500}
#dayPopList{overflow:auto;padding:0 8px 8px}
.popEvent{display:flex;gap:10px;padding:8px;border-radius:8px;cursor:pointer;align-items:center}
.popEvent:hover{background:var(--bg-soft)}
.popEvent .bar{width:4px;align-self:stretch;border-radius:2px;flex:none}
.popEvent .t{font-size:.78rem;color:var(--text-dim);white-space:nowrap}
.popEvent .n{font-size:.85rem}
/* ---------- WEEK VIEW ---------- */
.weekWrap{height:100%;display:flex;flex-direction:column}
.weekHeadStrip{grid-template-columns:56px repeat(7,1fr);background:var(--bg)}
.wkHeadCell{padding:6px 2px;text-align:center;border-left:1px solid var(--line-soft)}
.wkHeadCell .dn{font-size:.68rem;text-transform:uppercase;color:var(--text-dim);letter-spacing:.05em}
.wkHeadCell .num{
width:30px;height:30px;line-height:30px;border-radius:50%;margin:2px auto 0;
font-size:1.05rem;font-weight:500;
}
.wkHeadCell.today .num{background:var(--today);color:#fff}
.allDayRow{
display:grid;grid-template-columns:56px repeat(7,1fr);
border-bottom:1px solid var(--line);min-height:26px;
}
.allDayRow .lbl{font-size:.66rem;color:var(--text-faint);text-align:right;padding:4px 6px 0 0}
.adCell{border-left:1px solid var(--line-soft);padding:1px;position:relative;min-width:0}
.adChip{font-size:.72rem;border-radius:4px;padding:1px 5px;margin-bottom:1px;
color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:grab}
#weekScroll{flex:1;overflow-y:auto;position:relative}
.weekGrid{display:grid;grid-template-columns:56px repeat(7,1fr);position:relative}
.gutterCol{position:relative}
.hourLbl{height:48px;font-size:.66rem;color:var(--text-faint);text-align:right;padding-right:6px;transform:translateY(-6px)}
.dayCol{border-left:1px solid var(--line-soft);position:relative}
.hourLine{height:48px;border-top:1px solid var(--line-soft)}
.evBlock{
position:absolute;left:2px;right:4px;border-radius:5px;color:#fff;
font-size:.72rem;padding:2px 5px;overflow:hidden;cursor:grab;z-index:2;
box-shadow:0 1px 2px rgba(0,0,0,.15);
}
.evBlock .et{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.evBlock .en{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#nowLine{position:absolute;left:0;right:0;height:0;border-top:2px solid #ea4335;z-index:5;pointer-events:none}
#nowLine::before{content:"";position:absolute;left:-1px;top:-5px;width:9px;height:9px;border-radius:50%;background:#ea4335}
/* ---------- AGENDA ---------- */
.agendaWrap{height:100%;overflow-y:auto;padding:8px 20px 40px}
.agDay{max-width:720px;margin:0 auto}
.agDayHead{display:flex;align-items:baseline;gap:12px;padding:14px 4px 6px;border-bottom:1px solid var(--line-soft)}
.agDayHead .dow{font-size:.78rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;width:88px}
.agDayHead .dnum{font-size:1.5rem;font-weight:400}
.agDayHead.today .dnum,.agDayHead.today .dow{color:var(--accent)}
.agEvents{padding:4px 0 10px}
.agEvent{display:flex;gap:14px;padding:9px 8px;border-radius:10px;cursor:pointer;align-items:center}
.agEvent:hover{background:var(--bg-soft)}
.agEvent .bar{width:5px;align-self:stretch;border-radius:3px;flex:none}
.agEvent .time{width:130px;font-size:.8rem;color:var(--text-dim);flex:none}
.agEvent .body b{font-weight:500;display:block}
.agEvent .body span{font-size:.8rem;color:var(--text-dim)}
.agEmpty{text-align:center;color:var(--text-faint);padding:60px 20px}
/* ---------- MODAL EDITOR ---------- */
#modalBack{
position:fixed;inset:0;background:rgba(32,33,36,.5);z-index:90;
display:none;align-items:center;justify-content:center;padding:16px;
}
#modalBack.open{display:flex}
#modal{
background:var(--bg);border-radius:14px;width:430px;max-width:100%;
max-height:92vh;overflow-y:auto;box-shadow:0 10px 40px rgba(0,0,0,.3);
animation:popIn .18s ease;
}
@keyframes popIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
#modal header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px 4px}
#modalTitleInp{
border:none;outline:none;font-size:1.25rem;font-weight:500;flex:1;min-width:0;
color:var(--text);background:transparent;
}
#modalTitleInp::placeholder{color:var(--text-faint)}
.iconBtn{width:34px;height:34px;border-radius:50%;color:var(--text-dim);flex:none;
display:inline-flex;align-items:center;justify-content:center}
.iconBtn:hover{background:var(--bg-soft)}
.mBody{padding:6px 20px 8px}
.mRow{display:flex;align-items:flex-start;gap:12px;padding:8px 0}
.mRow .mic{color:var(--text-dim);margin-top:8px;flex:none;width:20px;text-align:center}
.mFields{flex:1;min-width:0}
.mInline{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.inp{
border:1px solid var(--line);border-radius:6px;padding:7px 10px;background:var(--bg);
outline:none;color:var(--text);
}
.inp:focus{border-color:var(--accent)}
.err{color:#d93025;font-size:.76rem;margin-top:3px;display:none}
.err.show{display:block}
.inp.bad{border-color:#d93025}
.chkRow{display:flex;align-items:center;gap:8px;font-size:.9rem;color:var(--text-dim);padding:4px 0}
.palette{display:flex;gap:8px;flex-wrap:wrap}
.swatch{width:26px;height:26px;border-radius:50%;cursor:pointer;border:2px solid transparent;position:relative}
.swatch.sel{border-color:var(--text);box-shadow:inset 0 0 0 2px #fff}
textarea.notes{width:100%;border:1px solid var(--line);border-radius:6px;padding:8px 10px;
resize:vertical;min-height:64px;outline:none}
textarea.notes:focus{border-color:var(--accent)}
.wdPicker{display:flex;gap:5px}
.wdPicker button{width:32px;height:32px;border-radius:50%;border:1px solid var(--line);
font-size:.72rem;color:var(--text-dim)}
.wdPicker button.on{background:var(--accent);border-color:var(--accent);color:#fff}
.mFoot{display:flex;justify-content:space-between;padding:10px 16px 16px}
.btnPrimary{
background:var(--accent);color:#fff;border-radius:20px;padding:9px 24px;font-weight:500;
}
.btnPrimary:hover{box-shadow:0 1px 4px rgba(26,115,232,.5)}
.btnText{color:var(--accent);font-weight:500;padding:9px 14px;border-radius:6px}
.btnText:hover{background:var(--accent-soft)}
.btnDanger{color:#d93025;font-weight:500;padding:9px 14px;border-radius:6px}
.btnDanger:hover{background:#fce8e6}
/* recurring-choice mini dialog */
#recAsk{
position:fixed;inset:0;background:rgba(32,33,36,.45);z-index:95;
display:none;align-items:center;justify-content:center;padding:16px;
}
#recAsk.open{display:flex}
#recAskCard{background:var(--bg);border-radius:14px;padding:22px 24px;width:360px;max-width:100%;
box-shadow:0 10px 40px rgba(0,0,0,.3)}
#recAskCard h3{font-weight:500;margin-bottom:6px}
#recAskCard p{font-size:.86rem;color:var(--text-dim);margin-bottom:14px}
#recAskCard button{display:block;width:100%;text-align:left;padding:11px 12px;border-radius:8px;color:var(--text)}
#recAskCard button:hover{background:var(--bg-soft)}
#recAskCard button small{display:block;color:var(--text-faint);font-size:.76rem}
/* toast */
#toast{
position:fixed;left:50%;bottom:28px;transform:translate(-50%,80px);
background:#323639;color:#fff;border-radius:6px;padding:13px 18px;z-index:99;
display:flex;gap:22px;align-items:center;font-size:.9rem;transition:transform .25s ease;
box-shadow:0 4px 14px rgba(0,0,0,.3);
}
#toast.show{transform:translate(-50%,0)}
#toast button{color:#8ab4f8;font-weight:600}
@media(max-width:900px){
#calTitle{font-size:1.1rem;min-width:0}
#brand span{display:none}
#viewTabs button{padding:7px 10px}
.chip{font-size:.66rem}
.evBlock{font-size:.64rem}
}
</style>
</head>
<body>
<!-- ================= TOP BAR ================= -->
<div id="topbar">
<div id="brand">
<svg width="26" height="26" viewBox="0 0 24 24"><path fill="#1a73e8" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 15H5V10h14zM5 8V6h14v2z"/></svg>
<span>Calendar</span>
</div>
<button id="todayBtn">Today</button>
<button class="navArrow" id="prevBtn" aria-label="Previous">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.4 7.4 14 6l-6 6 6 6 1.4-1.4L10.8 12z"/></svg>
</button>
<button class="navArrow" id="nextBtn" aria-label="Next">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M9.6 16.6 11 18l6-6-6-6-1.4 1.4 4.6 4.6z"/></svg>
</button>
<div id="calTitle"></div>
<div id="viewTabs">
<button data-view="month">Month</button>
<button data-view="week">Week</button>
<button data-view="agenda">Agenda</button>
</div>
<button id="settingsBtn" aria-label="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2.1-1.7a.5.5 0 0 0 .1-.6l-2-3.5a.5.5 0 0 0-.6-.2l-2.5 1a7.3 7.3 0 0 0-1.7-1l-.4-2.6A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.4l-.4 2.7a7.3 7.3 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.5a.5.5 0 0 0 .1.6L4.6 11a7.6 7.6 0 0 0 0 2l-2.2 1.7a.5.5 0 0 0-.1.6l2 3.5c.1.2.4.3.6.2l2.5-1a7.3 7.3 0 0 0 1.7 1l.4 2.6c0 .3.2.4.5.4h4c.2 0 .4-.2.5-.4l.4-2.7a7.3 7.3 0 0 0 1.7-1l2.5 1c.2.1.5 0 .6-.2l2-3.5a.5.5 0 0 0-.1-.6zM12 15.5A3.5 3.5 0 1 1 15.5 12 3.5 3.5 0 0 1 12 15.5z"/></svg>
</button>
</div>
<div id="settingsMenu">
<h4>Week starts on</h4>
<div class="segRow" id="weekStartSeg">
<button data-ws="1">Monday</button>
<button data-ws="0">Sunday</button>
</div>
<h4>Data</h4>
<button class="btnDanger" id="clearDataBtn" style="padding:6px 0">Clear all events</button>
</div>
<!-- ================= MAIN ================= -->
<div id="main"></div>
<!-- day popover -->
<div id="dayPop">
<header><b id="dayPopDate"></b><button class="iconBtn" id="dayPopClose">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
</button></header>
<div id="dayPopList"></div>
</div>
<!-- ================= EVENT MODAL ================= -->
<div id="modalBack">
<div id="modal">
<header>
<input id="modalTitleInp" placeholder="Add title">
<button class="iconBtn" id="modalClose">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
</button>
</header>
<div class="mBody">
<div class="mRow"><div class="mic">🕐</div><div class="mFields">
<div class="chkRow"><label><input type="checkbox" id="fAllDay"> All day</label></div>
<div class="mInline">
<input type="time" id="fStart" class="inp" step="900">
<span>–</span>
<input type="time" id="fEnd" class="inp" step="900">
</div>
<div class="err" id="errTime"></div>
</div></div>
<div class="mRow"><div class="mic">📅</div><div class="mFields">
<input type="date" id="fDate" class="inp">
<div class="err" id="errDate"></div>
</div></div>
<div class="mRow"><div class="mic">🔁</div><div class="mFields">
<select id="fRec" class="inp">
<option value="">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly on…</option>
<option value="monthly">Monthly on the same day</option>
<option value="yearly">Yearly</option>
</select>
<div class="wdPicker" id="wdPicker" style="margin-top:8px;display:none"></div>
<div class="err" id="errRec"></div>
</div></div>
<div class="mRow"><div class="mic">🎨</div><div class="mFields"><div class="palette" id="palette"></div></div></div>
<div class="mRow"><div class="mic">📝</div><div class="mFields">
<textarea id="fNotes" class="notes" placeholder="Notes"></textarea>
</div></div>
</div>
<div class="mFoot">
<button class="btnDanger" id="deleteBtn">Delete</button>
<button class="btnPrimary" id="saveBtn">Save</button>
</div>
</div>
</div>
<!-- recurring edit choice -->
<div id="recAsk">
<div id="recAskCard">
<h3 id="recAskTitle">Edit recurring event</h3>
<p>This event repeats. Which events do you want to change?</p>
<button data-mode="one">This event<small>Only this occurrence changes</small></button>
<button data-mode="follow">This and following events<small>This one and every event after it</small></button>
<button data-mode="all">All events in the series<small>The entire repeating series</small></button>
<button data-mode="cancel" style="color:var(--text-dim)">Cancel</button>
</div>
</div>
<div id="toast"><span id="toastMsg"></span><button id="toastUndo">Undo</button></div>
<script>
"use strict";
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONST = {
HOUR_START: 6, // first hour row in week view
HOUR_END: 22, // last hour (exclusive of trailing line)
HOUR_PX: 48, // px per hour
SNAP_MIN: 15, // drag snap interval (minutes)
WEEK_START_DEFAULT: 1, // 0=Sunday, 1=Monday
MAX_CHIPS: 3, // chips per month cell before "+N more"
AGENDA_DAYS: 60, // agenda horizon
PALETTE: [
{id:'blue', c:'#1a73e8'}, {id:'green', c:'#188038'},
{id:'purple', c:'#8e24aa'}, {id:'red', c:'#d50000'},
{id:'orange', c:'#ef6c00'}, {id:'teal', c:'#00796b'}
],
LS_KEY: 'calplanner.v1'
};
const DOW_NAMES_SUN = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MONTH_NAMES = ['January','February','March','April','May','June','July',
'August','September','October','November','December'];
/* ============================================================
DATE UTIL MODULE — pure functions, no rendering, native Date only.
All arithmetic works on {y,m,d} components; m is 1-based.
============================================================ */
const DU = (() => {
const isLeap = y => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
// m: 1..12
const daysInMonth = (y, m) => {
switch (m) {
case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31;
case 4: case 6: case 9: case 11: return 30;
case 2: return isLeap(y) ? 29 : 28;
}
throw new Error('bad month ' + m);
};
// Construct a real Date at local noon (avoids DST edge cases) purely to get weekday.
const dow = (y, m, d) => new Date(y, m - 1, d, 12).getDay(); // 0=Sun
const cmp = (a, b) => a.y !== b.y ? a.y - b.y : a.m !== b.m ? a.m - b.m : a.d - b.d;
const eq = (a, b) => a.y === b.y && a.m === b.m && a.d === b.d;
const clone = a => ({ y: a.y, m: a.m, d: a.d });
// add n days via component-safe walk (no ms math)
const addDays = (a, n) => {
let { y, m, d } = a; d += n;
while (d > daysInMonth(y, m)) { d -= daysInMonth(y, m); if (++m > 12) { m = 1; y++; } }
while (d < 1) { if (--m < 1) { m = 12; y--; } d += daysInMonth(y, m); }
return { y, m, d };
};
// add n months, clamping day-of-month (Jan 31 + 1mo -> Feb 28/29)
const addMonths = (a, n) => {
const total = a.y * 12 + (a.m - 1) + n;
const y = Math.floor(total / 12), m = total % 12 + 1;
return { y, m, d: Math.min(a.d, daysInMonth(y, m)) };
};
// date of the week-start day on or before `a`
const weekStartOf = (a, weekStart) => addDays(a, -(dow(a.y, a.m, a.d) - weekStart + 7) % 7);
// ISO-8601-style week number but respecting configured week start:
// week 1 is the week containing Jan 4.
const weekNumber = (a, weekStart) => {
const target = weekStartOf(a, weekStart);
const jan4 = { y: a.y, m: 1, d: 4 };
const w1 = weekStartOf(jan4, weekStart);
return Math.floor((daysBetween(w1, target)) / 7) + 1;
};
const daysBetween = (a, b) => toOrdinal(b) - toOrdinal(a);
// days since epoch via civil-from-days inverse math (proleptic Gregorian)
function toOrdinal(a) {
let y = a.y, m = a.m;
if (m <= 2) { y -= 1; m += 12; }
const era = Math.floor(y / 400);
const yoe = y - era * 400;
return era * 146097 + Math.floor(yoe * 365.25) - Math.floor(yoe / 100)
+ Math.floor((153 * m - 457) / 5) + a.d - 306;
}
const pad = n => String(n).padStart(2, '0');
const key = a => `${a.y}-${pad(a.m)}-${pad(a.d)}`;
const parseKey = s => { const [y, m, d] = s.split('-').map(Number); return { y, m, d }; };
const today = () => { const t = new Date(); return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() }; };
const fmtLong = a => `${DOW_NAMES_SUN[dow(a.y, a.m, a.d)]}, ${MONTH_NAMES[a.m-1].slice(0,3)} ${a.d}, ${a.y}`;
const fmtTime = hm => {
if (!hm) return '';
let [h, mi] = hm.split(':').map(Number);
const ap = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
return `${h}:${pad(mi)} ${ap}`;
};
const minsOf = hm => { const [h, mi] = hm.split(':').map(Number); return h * 60 + mi; };
const hmOf = mins => `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`;
return { isLeap, daysInMonth, dow, cmp, eq, clone, addDays, addMonths,
weekStartOf, weekNumber, daysBetween, key, parseKey, today,
fmtLong, fmtTime, minsOf, hmOf, pad };
})();
/* ============================================================
RECURRENCE EXPANSION — range-limited, never materializes infinite series
============================================================ */
// rec: {freq:'daily'|'weekly'|'monthly'|'yearly', weekdays?:[0..6](Sun=0), until?:'YYYY-MM-DD'}
// Returns occurrence base dates within [from,to] for an event starting at ev.date.
function expandRecurrence(ev, from, to) {
const out = [];
const base = DU.parseKey(ev.date);
const until = ev.rec && ev.rec.until ? DU.parseKey(ev.rec.until) : null;
const push = (dt) => {
if (DU.cmp(dt, base) < 0) return false;
if (until && DU.cmp(dt, until) > 0) return false;
out.push(dt); return true;
};
if (!ev.rec || ev.rec.freq === 'none') { if (DU.cmp(base, from) >= 0 && DU.cmp(base, to) <= 0) out.push(base); return out; }
if (ev.rec.freq === 'daily') {
let d = DU.cmp(base, from) >= 0 ? base : from;
// align to recurrence phase
const off = DU.daysBetween(base, d);
if (off > 0) d = DU.addDays(base, Math.ceil(off / 1) * 1);
for (let cur = d; DU.cmp(cur, to) <= 0; cur = DU.addDays(cur, 1)) push(cur);
} else if (ev.rec.freq === 'weekly') {
const wdSet = new Set(ev.rec.weekdays && ev.rec.weekdays.length ? ev.rec.weekdays : [DU.dow(base.y, base.m, base.d)]);
for (let cur = from; DU.cmp(cur, to) <= 0; cur = DU.addDays(cur, 1)) {
if (wdSet.has(DU.dow(cur.y, cur.m, cur.d))) push(cur);
}
} else if (ev.rec.freq === 'monthly') {
// same day-of-month; months without that day are naturally skipped
const day = base.d;
let y = from.y, m = from.m;
// start at month containing max(from aligned after base)
for (;;) {
if (y * 12 + m > to.y * 12 + to.m) break;
if (day <= DU.daysInMonth(y, m)) {
const dt = { y, m, d: day };
if (DU.cmp(dt, to) <= 0) push(dt);
}
if (++m > 12) { m = 1; y++; }
if (y * 12 + m < base.y * 12 + base.m) { /* skip pre-base months */ }
}
} else if (ev.rec.freq === 'yearly') {
for (let y = from.y; y <= to.y; y++) {
if (base.d <= DU.daysInMonth(y, base.m)) {
const dt = { y, m: base.m, d: base.d };
if (DU.cmp(dt, to) <= 0) push(dt);
}
}
}
return out;
}
/* ============================================================
STORE — events, settings, persistence
============================================================ */
const Store = {
state: { view: 'month', cursor: null, weekStart: CONST.WEEK_START_DEFAULT, events: [] },
load() {
try {
const raw = localStorage.getItem(CONST.LS_KEY);
if (raw) {
const s = JSON.parse(raw);
Object.assign(this.state, s);
}
} catch (e) { console.warn('load failed', e); }
if (!this.state.cursor || !this.state.cursor.y) this.state.cursor = DU.today();
else this.state.cursor = { y: this.state.cursor.y, m: this.state.cursor.m, d: this.state.cursor.d };
},
save() {
try { localStorage.setItem(CONST.LS_KEY, JSON.stringify(this.state)); }
catch (e) { console.warn('save failed', e); }
},
uid() { return 'ev' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); },
// Expand all visible instances in [from,to]: merges exceptions onto masters.
instancesInRange(from, to) {
const out = [];
for (const ev of this.state.events) {
const dates = expandRecurrence(ev, from, to);
for (const dt of dates) {
const k = DU.key(dt);
const ex = ev.exceptions && ev.exceptions[k];
if (ex) {
if (ex.deleted) continue;
out.push({ ...ev, ...ex.patch, date: k, _masterId: ev.id, _occKey: k });
} else {
out.push({ ...ev, date: k, _masterId: ev.id, _occKey: ev.rec ? k : null });
}
}
}
out.sort((a, b) => {
const dc = DU.cmp(DU.parseKey(a.date), DU.parseKey(b.date));
if (dc) return dc;
const aa = a.allDay ? -1 : DU.minsOf(a.start || '00:00');
const bb = b.allDay ? -1 : DU.minsOf(b.start || '00:00');
return aa - bb;
});
return out;
},
getEvent(id) { return this.state.events.find(e => e.id === id); },
addEvent(ev) { this.state.events.push(ev); this.save(); },
removeEvent(id) { this.state.events = this.state.events.filter(e => e.id !== id); this.save(); },
replaceEvent(ev) {
const i = this.state.events.findIndex(e => e.id === ev.id);
if (i >= 0) this.state.events[i] = ev; else this.state.events.push(ev);
this.save();
},
clearAll() { this.state.events = []; this.save(); }
};
/* ============================================================
RECURRING EDIT / DELETE SEMANTICS
============================================================ */
// mode: 'one' | 'follow' | 'all'
// patch: full replacement fields for the occurrence/series (without exceptions)
function applyRecurringEdit(masterId, occKey, patch, mode, undoStack) {
const master = Store.getEvent(masterId);
if (!master) return;
undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
if (mode === 'all') {
Store.replaceEvent({ ...master, ...patch });
return;
}
if (mode === 'one') {
if (!master.exceptions) master.exceptions = {};
if (patch.date && patch.date !== occKey) {
// moved: delete this occurrence, create standalone event at new date
master.exceptions[occKey] = { deleted: true };
const { _masterId, _occKey, ...fields } = { ...master, ...patch };
delete fields.rec; delete fields.exceptions;
delete fields.id; delete fields.date;
Store.addEvent({ id: Store.uid(), ...fields, date: patch.date, exceptions: {} });
} else {
master.exceptions[occKey] = { patch: { ...patch, date: undefined } };
// clean undefined date
delete master.exceptions[occKey].patch.date;
}
Store.replaceEvent(master);
return;
}
if (mode === 'follow') {
// truncate master before occKey, spawn new series at occKey with patch
const occ = DU.parseKey(occKey);
const lastDay = DU.addDays(occ, -1);
const truncated = { ...master, rec: { ...master.rec, until: DU.key(lastDay) } };
// drop now-empty weekly weekday entries? keep as-is (until bounds it)
const { _masterId, _occKey, ...fields } = { ...master, ...patch };
delete fields.id; delete fields.exceptions;
const newSeries = { ...fields, date: occKey, exceptions: {} };
Store.replaceEvent(truncated);
Store.addEvent(newSeries);
}
}
function applyRecurringDelete(masterId, occKey, mode, undoStack) {
const master = Store.getEvent(masterId);
if (!master) return;
undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
if (mode === 'all') { Store.removeEvent(masterId); return; }
if (mode === 'one') {
if (!master.exceptions) master.exceptions = {};
master.exceptions[occKey] = { deleted: true };
Store.replaceEvent(master);
return;
}
if (mode === 'follow') {
const occ = DU.parseKey(occKey);
const truncated = { ...master, rec: { ...master.rec, until: DU.key(DU.addDays(occ, -1)) } };
Store.replaceEvent(truncated);
}
}
</script>
<script>
"use strict";
/* ============================================================
RENDERING
============================================================ */
const App = {
undoStack: [],
main: document.getElementById('main'),
};
function colorOf(ev) { return (CONST.PALETTE.find(p => p.id === ev.color) || CONST.PALETTE[0]).c; }
function render() {
const s = Store.state;
document.querySelectorAll('#viewTabs button').forEach(b =>
b.classList.toggle('active', b.dataset.view === s.view));
document.querySelectorAll('#weekStartSeg button').forEach(b =>
b.classList.toggle('active', Number(b.dataset.ws) === s.weekStart));
closeDayPop();
const wrap = document.createElement('div');
wrap.className = 'viewAnim';
wrap.style.height = '100%';
if (s.view === 'month') { renderTitle(); renderMonth(wrap); }
else if (s.view === 'week') { renderTitle(); renderWeek(wrap); }
else { renderTitle(); renderAgenda(wrap); }
App.main.innerHTML = '';
App.main.appendChild(wrap);
if (s.view === 'week') startNowLine();
}
function rangeForView() {
const s = Store.state;
if (s.view === 'month') {
const first = { y: s.cursor.y, m: s.cursor.m, d: 1 };
return { from: DU.weekStartOf(first, s.weekStart), to: DU.addDays(DU.addMonths(first, 1), -1) };
}
if (s.view === 'week') {
const from = DU.weekStartOf(s.cursor, s.weekStart);
return { from, to: DU.addDays(from, 6) };
}
const t = DU.today();
return { from: t, to: DU.addDays(t, CONST.AGENDA_DAYS) };
}
function renderTitle() {
const s = Store.state, c = s.cursor;
let t;
if (s.view === 'month') t = `${MONTH_NAMES[c.m - 1]} ${c.y}`;
else if (s.view === 'week') {
const f = DU.weekStartOf(c, s.weekStart), l = DU.addDays(f, 6);
t = `${MONTH_NAMES[f.m-1].slice(0,3)} ${f.d} – ${MONTH_NAMES[l.m-1].slice(0,3)} ${l.d}, ${l.y}`;
} else t = 'Agenda';
document.getElementById('calTitle').textContent = t;
}
/* ---------------- MONTH VIEW ---------------- */
function renderMonth(root) {
const s = Store.state;
const wrap = document.createElement('div'); wrap.className = 'monthWrap';
const head = document.createElement('div'); head.className = 'monthHead';
for (let i = 0; i < 7; i++) {
const d = document.createElement('div');
d.textContent = DOW_NAMES_SUN[(i + s.weekStart) % 7];
head.appendChild(d);
}
wrap.appendChild(head);
const grid = document.createElement('div'); grid.id = 'monthGrid';
const first = { y: s.cursor.y, m: s.cursor.m, d: 1 };
const start = DU.weekStartOf(first, s.weekStart);
const today = DU.today();
const insts = Store.instancesInRange(start, DU.addDays(start, 41));
const byDate = {};
for (const inst of insts) (byDate[inst.date] = byDate[inst.date] || []).push(inst);
for (let i = 0; i < 42; i++) {
const dt = DU.addDays(start, i);
const cell = document.createElement('div');
cell.className = 'mcell' + (dt.m !== s.cursor.m ? ' other' : '') +
(DU.eq(dt, today) ? ' today' : '');
cell.dataset.date = DU.key(dt);
const num = document.createElement('div'); num.className = 'daynum'; num.textContent = dt.d;
cell.appendChild(num);
const list = byDate[DU.key(dt)] || [];
list.slice(0, CONST.MAX_CHIPS).forEach(inst => cell.appendChild(makeChip(inst)));
if (list.length > CONST.MAX_CHIPS) {
const more = document.createElement('button');
more.className = 'moreLink';
more.textContent = `+${list.length - CONST.MAX_CHIPS} more`;
more.addEventListener('click', e => { e.stopPropagation(); openDayPop(dt, list, more); });
cell.appendChild(more);
}
cell.addEventListener('click', () => openEditor(DU.key(dt), false));
attachDrop(cell, dt);
grid.appendChild(cell);
}
wrap.appendChild(grid);
root.appendChild(wrap);
}
function makeChip(inst, filled) {
const chip = document.createElement('div');
chip.className = 'chip' + (filled ? ' filled' : '');
chip.draggable = true;
chip.dataset.eventId = inst._masterId;
chip.dataset.occKey = inst._occKey || '';
chip.dataset.date = inst.date;
chip.title = `${inst.title}${inst.allDay ? '' : ' · ' + DU.fmtTime(inst.start)}`;
if (filled) {
chip.style.background = colorOf(inst);
chip.textContent = (inst.allDay ? '' : DU.fmtTime(inst.start) + ' ') + inst.title;
} else {
const dot = document.createElement('span'); dot.className = 'dot';
dot.style.background = colorOf(inst);
chip.appendChild(dot);
const tx = document.createElement('span');
tx.textContent = (inst.allDay ? '' : DU.fmtTime(inst.start) + ' ') + inst.title;
chip.appendChild(tx);
}
chip.addEventListener('click', e => { e.stopPropagation(); openEditorFromInstance(inst); });
attachChipDrag(chip, inst);
return chip;
}
/* day popover */
let popAnchorBtn = null;
function openDayPop(dt, list, anchor) {
const pop = document.getElementById('dayPop');
document.getElementById('dayPopDate').textContent = DU.fmtLong(dt);
const lst = document.getElementById('dayPopList');
lst.innerHTML = '';
list.forEach(inst => {
const row = document.createElement('div'); row.className = 'popEvent';
const bar = document.createElement('div'); bar.className = 'bar'; bar.style.background = colorOf(inst);
const t = document.createElement('div'); t.className = 't';
t.textContent = inst.allDay ? 'All day' : `${DU.fmtTime(inst.start)}–${DU.fmtTime(inst.end)}`;
const n = document.createElement('div'); n.className = 'n'; n.textContent = inst.title;
row.append(bar, t, n);
row.addEventListener('click', () => { closeDayPop(); openEditorFromInstance(inst); });
lst.appendChild(row);
});
pop.classList.add('open');
const r = anchor.getBoundingClientRect();
pop.style.top = Math.min(r.bottom + 4, window.innerHeight - 400) + 'px';
pop.style.left = Math.max(8, Math.min(r.left - 120, window.innerWidth - 316)) + 'px';
}
function closeDayPop(){ document.getElementById('dayPop').classList.remove('open'); }
/* ---------------- WEEK VIEW ---------------- */
function renderWeek(root) {
const s = Store.state;
const wrap = document.createElement('div'); wrap.className = 'weekWrap';
const from = DU.weekStartOf(s.cursor, s.weekStart);
const days = Array.from({length:7},(_,i)=>DU.addDays(from,i));
const today = DU.today();
// header
const head = document.createElement('div'); head.className = 'weekHeadStrip';
head.appendChild(Object.assign(document.createElement('div'),{}));
days.forEach(dt=>{
const c=document.createElement('div'); c.className='wkHeadCell'+(DU.eq(dt,today)?' today':'');
c.innerHTML=`<div class="dn">${DOW_NAMES_SUN[DU.dow(dt.y,dt.m,dt.d)]}</div><div class="num">${dt.d}</div>`;
head.appendChild(c);
});
wrap.appendChild(head);
const allInsts = Store.instancesInRange(from, DU.addDays(from,6));
const timed = allInsts.filter(e=>!e.allDay);
const allDay = allInsts.filter(e=>e.allDay);
// all-day strip
const adRow = document.createElement('div'); adRow.className='allDayRow';
adRow.appendChild(Object.assign(document.createElement('div'),{className:'lbl',textContent:'All day'}));
days.forEach(dt=>{
const cell=document.createElement('div'); cell.className='adCell'; cell.dataset.date=DU.key(dt);
const k=DU.key(dt);
allDay.filter(e=>e.date===k).forEach(e=>{
const c=makeChip(e,true);c.classList.add('adChip');c.classList.remove('filled');
cell.appendChild(c);
});
cell.addEventListener('click',()=>openEditor(k,false));
attachDrop(cell,dt);
adRow.appendChild(cell);
});
wrap.appendChild(adRow);
// scrollable hour grid
const scroll=document.createElement('div'); scroll.id='weekScroll';
const grid=document.createElement('div'); grid.className='weekGrid';
const gutter=document.createElement('div'); gutter.className='gutterCol';
for(let h=CONST.HOUR_START;h<=CONST.HOUR_END;h++){
const l=document.createElement('div'); l.className='hourLbl';
l.textContent=h===CONST.HOUR_START?'':DU.fmtTime(DU.pad(h)+':00');
gutter.appendChild(l);
}
grid.appendChild(gutter);
days.forEach(dt=>{
const col=document.createElement('div'); col.className='dayCol'; col.dataset.date=DU.key(dt);
for(let h=CONST.HOUR_START;h<=CONST.HOUR_END;h++){
const line=document.createElement('div'); line.className='hourLine'; col.appendChild(line);
}
attachWeekDrop(col,dt);
grid.appendChild(col);
});
// positioned blocks — simple side-by-side overlap layout
days.forEach((dt,idx)=>{
const k=DU.key(dt);
const evs=timed.filter(e=>e.date===k)
.map(e=>({...e,s:DU.minsOf(e.start||'00:00'),en:DU.minsOf(e.end||'23:59')}))
.filter(e=>e.en>DU.minsOf(DU.pad(CONST.HOUR_START)+':00') && e.s<DU.minsOf(DU.pad(CONST.HOUR_END+1)+':00'))
.sort((a,b)=>a.s-b.s||b.en-a.en);
// cluster overlaps
let cluster=[],clusterEnd=-1;
const flush=()=>{
if(!cluster.length)return;
const n=cluster.length;
cluster.forEach((e,i)=>{
const top=(Math.max(e.s,CONST.HOUR_START*60)-CONST.HOUR_START*60)/60*CONST.HOUR_PX;
const hgt=Math.max(14,(Math.min(e.en,(CONST.HOUR_END+1)*60)-Math.max(e.s,CONST.HOUR_START*60))/60*CONST.HOUR_PX);
const b=document.createElement('div'); b.className='evBlock';
b.style.top=top+'px'; b.style.height=hgt+'px';
b.style.left=`calc(56px + (100% - 56px) * ${(idx/7).toFixed(5)} + 2px)`;
b.style.width=`calc((100% - 56px) * ${(1/7).toFixed(5)} - 6px)`;
b.style.background=colorOf(e);
b.innerHTML=`<div class="et">${DU.fmtTime(e.start)}${hgt>28?' – '+DU.fmtTime(e.end):''}</div><div class="en">${e.title}</div>`;
b.dataset.eventId=e._masterId;b.dataset.occKey=e._occKey||'';b.dataset.date=e.date;
b.addEventListener('click',ev=>{if(!b.dataset.dragged){ev.stopPropagation();openEditorFromInstance(e);}});
attachWeekDrag(b,e);
grid.appendChild(b);
});
cluster=[];clusterEnd=-1;
};
evs.forEach(e=>{
if(cluster.length&&e.s>=clusterEnd)flush();
cluster.push(e);clusterEnd=Math.max(clusterEnd,e.en);
});
flush();
});
scroll.appendChild(grid);
wrap.appendChild(scroll);
root.appendChild(wrap);
scroll.scrollTop = Math.max(0,(new Date().getHours()-CONST.HOUR_START-1))*CONST.HOUR_PX;
function startNowLine(){
const old=document.getElementById('nowLine'); if(old)old.remove();
const now=new Date();
const k=DU.key({y:now.getFullYear(),m:now.getMonth()+1,d:now.getDate()});
const col=grid.querySelector(`.dayCol[data-date="${k}"]`);
if(!col)return;
const mins=now.getHours()*60+now.getMinutes();
if(mins<CONST.HOUR_START*60||mins>(CONST.HOUR_END+1)*60)return;
const line=document.createElement('div');line.id='nowLine';
line.style.top=((mins-CONST.HOUR_START*60)/60*CONST.HOUR_PX)+'px';
line.style.left=`calc(56px + (100% - 56px) * ${(days.findIndex(d=>DU.key(d)===k)/7).toFixed(5)})`;
line.style.width=`calc((100% - 56px) * ${(1/7).toFixed(5)})`;
grid.appendChild(line);
}
App._startNowLine=startNowLine;
}
function startNowLine(){ if(App._startNowLine)App._startNowLine(); }
setInterval(()=>{ if(Store.state.view==='week'&&document.getElementById('nowLine'))startNowLine(); },60000);
/* ---------------- AGENDA VIEW ---------------- */
function renderAgenda(root){
const wrap=document.createElement('div');wrap.className='agendaWrap';
const {from,to}=rangeForView();
const insts=Store.instancesInRange(from,to);
if(!insts.length){
wrap.innerHTML='<div class="agEmpty">No upcoming events.<br>Create one by clicking a day in Month view.</div>';
root.appendChild(wrap);return;
}
const today=DU.today();
let lastKey='';
insts.forEach(inst=>{
const k=inst.date;
if(k!==lastKey){
lastKey=k;
const dt=DU.parseKey(k);
const dh=document.createElement('div');
dh.className='agDayHead'+(DU.eq(dt,today)?' today':'');
dh.innerHTML=`<span class="dow">${DOW_NAMES_SUN[DU.dow(dt.y,dt.m,dt.d)]}</span><span class="dnum">${dt.d}</span>`;
const mo=document.createElement('span');mo.className='dow';mo.textContent=`${MONTH_NAMES[dt.m-1].slice(0,3)} ${dt.y}`;
dh.appendChild(mo);
const dayBox=document.createElement('div');dayBox.className='agDay';
dayBox.appendChild(dh);
const evs=document.createElement('div');evs.className='agEvents';
dayBox.appendChild(evs);
wrap.appendChild(dayBox);
inst._agEvents=evs;
}
const row=document.createElement('div');row.className='agEvent';
const bar=document.createElement('div');bar.className='bar';bar.style.background=colorOf(inst);
const time=document.createElement('div');time.className='time';
time.textContent=inst.allDay?'All day':`${DU.fmtTime(inst.start)} – ${DU.fmtTime(inst.end)}`;
const body=document.createElement('div');body.className='body';
body.innerHTML=`<b>${esc(inst.title)}</b>${inst.notes?`<span>${esc(inst.notes)}</span>`:''}`;
row.append(bar,time,body);
row.addEventListener('click',()=>openEditorFromInstance(inst));
inst._agEvents.appendChild(row);
});
root.appendChild(wrap);
}
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
/* ============================================================
NAVIGATION
============================================================ */
function navigate(dir){
const s=Store.state;
if(s.view==='month') s.cursor=DU.addMonths(s.cursor,dir);
else if(s.view==='week') s.cursor=DU.addDays(s.cursor,7*dir);
else s.cursor=DU.addDays(s.cursor,30*dir);
Store.save();render();
}
function goToday(){Store.state.cursor=DU.today();Store.save();render();}
/* ============================================================
EVENT EDITOR MODAL
============================================================ */
const Modal={
editing:null, // {masterId, occKey} or null for new
master:null,
recMode:null, // chosen scope when editing recurring
isNew:false,
};
function openEditor(dateKey, allDayDefault){
Modal.editing=null;Modal.master=null;Modal.isNew=true;
fillModal({
title:'',date:dateKey||DU.key(DU.today()),
allDay:!!allDayDefault,start:allDayDefault?'':'09:00',end:allDayDefault?'':'10:00',
color:CONST.PALETTE[Math.floor(Math.random()*CONST.PALETTE.length)].id,
notes:'',rec:{freq:''}
}, false);
showModal();
}
function openEditorFromInstance(inst){
const master=Store.getEvent(inst._masterId);
Modal.editing={masterId:inst._masterId,occKey:inst._occKey};
Modal.master=master;Modal.isNew=false;
fillModal(inst,master&&!!master.rec&&master.rec.freq!=='none');
showModal();
}
function fillModal(ev,isRecurring){
document.getElementById('modalTitleInp').value=ev.title||'';
document.getElementById('fDate').value=ev.date;
document.getElementById('fAllDay').checked=!!ev.allDay;
document.getElementById('fStart').value=ev.allDay?'':(ev.start||'09:00');
document.getElementById('fEnd').value=ev.allDay?'':(ev.end||'10:00');
document.getElementById('fNotes').value=ev.notes||'';
const recSel=document.getElementById('fRec');
recSel.value=(isRecurring&&Modal.editing)?(Modal.master.rec.freq):'';
recSel.disabled=isRecurring&&!!Modal.editing;
buildWdPicker(Modal.editing?(Modal.master.rec.weekdays||[]):[]);
document.getElementById('wdPicker').style.display=recSel.value==='weekly'&&!recSel.disabled?'flex':'none';
buildPalette(ev.color||'blue');
document.getElementById('deleteBtn').style.display=Modal.isNew?'none':'inline-block';
clearErrors();
}
function buildPalette(sel){
const pal=document.getElementById('palette');pal.innerHTML='';
CONST.PALETTE.forEach(p=>{
const sw=document.createElement('div');sw.className='swatch'+(p.id===sel?' sel':'');
sw.style.background=p.c;sw.dataset.color=p.id;sw.title=p.id;
sw.addEventListener('click',()=>{pal.querySelectorAll('.swatch').forEach(x=>x.classList.remove('sel'));sw.classList.add('sel');});
pal.appendChild(sw);
});
}
function buildWdPicker(selected){
const wp=document.getElementById('wdPicker');wp.innerHTML='';
for(let i=0;i<7;i++){
const b=document.createElement('button');
b.textContent=DOW_NAMES_SUN[i][0];
b.dataset.wd=i;
if(selected.includes(i))b.classList.add('on');
b.addEventListener('click',()=>b.classList.toggle('on'));
wp.appendChild(b);
}
}
function showModal(){document.getElementById('modalBack').classList.add('open');
setTimeout(()=>document.getElementById('modalTitleInp').focus(),50);}
function closeModal(){document.getElementById('modalBack').classList.remove('open');}
function showErr(id,msg){const e=document.getElementById(id);e.textContent=msg;e.classList.add('show');}
function clearErrors(){['errTime','errDate','errRec'].forEach(i=>showErrHide(i));}
function showErrHide(id){const e=document.getElementById(id);e.classList.remove('show');}
function readModalForm(){
const title=document.getElementById('modalTitleInp').value.trim();
const date=document.getElementById('fDate').value;
const allDay=document.getElementById('fAllDay').checked;
const start=document.getElementById('fStart').value;
const end=document.getElementById('fEnd').value;
const notes=document.getElementById('fNotes').value.trim();
const colorSel=document.querySelector('#palette .swatch.sel');
const color=colorSel?colorSel.dataset.color:'blue';
let ok=true;
if(!title){showErr('errTime','Please give the event a title.');ok=false;}
if(!/^\d{4}-\d{2}-\d{2}$/.test(date)){showErr('errDate','Pick a valid date.');ok=false;}
if(!allDay){
if(!start||!end){showErr('errTime','Start and end times are required.');ok=false;}
else if(DU.minsOf(end)<=DU.minsOf(start)){showErr('errTime','End time must be after start time.');ok=false;}
}
if(!ok)return null;
return {title,date,allDay,start:allDay?'':start,end:allDay?'':end,notes,color};
}
function saveModal(){
const fields=readModalForm();
if(!fields)return;
const freq=document.getElementById('fRec').value;
const wd=[...document.querySelectorAll('#wdPicker button.on')].map(b=>Number(b.dataset.wd));
if(freq==='weekly'&&wd.length===0){showErr('errRec','Pick at least one weekday.');return;}
if(Modal.isNew){
const ev={id:Store.uid(),...fields,exceptions:{}};
if(freq==='weekly'){if(fields.date&&wd.length===0){showErr('errRec','Pick at least one weekday.');return;}ev.rec={freq,weekdays:wd};}
else if(freq)ev.rec={freq};
Store.addEvent(ev);
}else{
const isRec=Modal.master&&Modal.master.rec&&Modal.master.rec.freq!=='none';
if(isRec){
askRecScope(mode=>{
if(mode==='cancel')return;
applyRecurringEdit(Modal.editing.masterId,Modal.editing.occKey,fields,mode,App.undoStack);
render();
});
return;
}
const master=Store.getEvent(Modal.editing.masterId);
App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
Store.replaceEvent({...master,...fields});
}
closeModal();render();
}
function deleteCurrent(){
if(!Modal.editing){closeModal();return;}
const master=Store.getEvent(Modal.editing.masterId);
const isRec=master&&master.rec&&master.rec.freq!=='none';
if(isRec){
askRecScope(mode=>{
if(mode==='cancel')return;
applyRecurringDelete(Modal.editing.masterId,Modal.editing.occKey,mode,App.undoStack);
closeModal();render();
showToast('Event deleted');
});
return;
}
App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
Store.removeEvent(Modal.editing.masterId);
closeModal();render();
showToast('Event deleted');
}
function askRecScope(cb){
const dlg=document.getElementById('recAsk');
const isDelete=arguments.length===1;
document.getElementById('recAskTitle').textContent=isDelete?'Delete recurring event':'Edit recurring event';
dlg.classList.add('open');
dlg.onclick=e=>{
const b=e.target.closest('button[data-mode]');
if(!b)return;
dlg.classList.remove('open');dlg.onclick=null;
cb(b.dataset.mode);
};
}
/* ============================================================
TOAST / UNDO
============================================================ */
let toastTimer=null;
function showToast(msg){
const t=document.getElementById('toast');
document.getElementById('toastMsg').textContent=msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove('show'),5000);
}
document.getElementById('toastUndo').addEventListener('click',()=>{
const prev=App.undoStack.pop();
if(prev){Store.state.events=prev;Store.save();render();}
document.getElementById('toast').classList.remove('show');
});
/* ============================================================
DRAG & DROP
============================================================ */
// --- month view: HTML5 drag chips onto day cells ---
function attachChipDrag(chip,inst){
chip.addEventListener('dragstart',e=>{
e.dataTransfer.setData('text/plain',JSON.stringify({eventId:inst._masterId,occKey:inst._occKey}));
e.dataTransfer.effectAllowed='move';
});
}
function attachDrop(cell,dt){
cell.addEventListener('dragover',e=>{e.preventDefault();cell.classList.add('dragOver');});
cell.addEventListener('dragleave',()=>cell.classList.remove('dragOver'));
cell.addEventListener('drop',e=>{
e.preventDefault();e.stopPropagation();cell.classList.remove('dragOver');
let data;try{data=JSON.parse(e.dataTransfer.getData('text/plain'));}catch(_){return;}
reschedule(data.eventId,data.occKey,DU.key(dt));
});
}
function reschedule(masterId,occKey,newDateKey){
const master=Store.getEvent(masterId);
if(!master)return;
if(occKey){ // occurrence of a series -> exception override with new date
App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
if(!master.exceptions)master.exceptions={};
master.exceptions[occKey]={deleted:true};
const{_masterId,_occKey,id,rec,exceptions,...fields}=master;
Store.addEvent({id:Store.uid(),...fields,date:newDateKey,exceptions:{}});
Store.replaceEvent(master);
}else{
App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
master.date=newDateKey;
Store.replaceEvent(master);
}
render();
showToast(`Moved to ${DU.fmtLong(DU.parseKey(newDateKey))}`);
}
// --- week view: vertical pointer drag with snap ---
function attachWeekDrag(block,inst){
block.addEventListener('pointerdown',e=>{
if(e.button!==0)return;
e.preventDefault();
const startY=e.clientY,startX=e.clientX;
const sMin=DU.minsOf(inst.start),dur=DU.minsOf(inst.end)-sMin;
let moved=false;
block.setPointerCapture(e.pointerId);
block.style.zIndex=20;block.style.opacity=.85;
block.dataset.dragged='';
const onMove=ev=>{
const dy=ev.clientY-startY,dx=ev.clientX-startX;
if(Math.abs(dy)>4||Math.abs(dx)>12)moved=true;
if(!moved)return;
const delta=Math.round(dy/CONST.HOUR_PX*60/CONST.SNAP_MIN)*CONST.SNAP_MIN;
block.style.transform=`translateY(${delta/60*CONST.HOUR_PX}px)`;
block.dataset.delta=delta;
};
const onUp=ev=>{
block.removeEventListener('pointermove',onMove);
block.removeEventListener('pointerup',onUp);
const delta=Number(block.dataset.delta||0);
block.style.transform='';block.style.opacity='';block.style.zIndex='';
if(!moved||delta===0){setTimeout(()=>delete block.dataset.dragged,0);return;}
block.dataset.dragged='1';setTimeout(()=>delete block.dataset.dragged,0);
const newStart=Math.max(0,sMin+delta);
const master=Store.getEvent(inst._masterId);
if(!master)return;
const patch={start:DU.hmOf(newStart),end:DU.hmOf(newStart+dur)};
App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
if(inst._occKey){
if(!master.exceptions)master.exceptions={};
master.exceptions[inst._occKey]={patch};
Store.replaceEvent(master);
}else{
Object.assign(master,patch);Store.replaceEvent(master);
}
render();
showToast(`Rescheduled to ${DU.fmtTime(patch.start)}`);
};
block.addEventListener('pointermove',onMove);
block.addEventListener('pointerup',onUp);
});
}
// week view column drop target (accepts month-style drags too)
function attachWeekDrop(col,dt){
col.addEventListener('dragover',e=>e.preventDefault());
col.addEventListener('drop',e=>{
e.preventDefault();
let data;try{data=JSON.parse(e.dataTransfer.getData('text/plain'));}catch(_){return;}
reschedule(data.eventId,data.occKey,DU.key(dt));
});
}
/* ============================================================
INIT / WIRING
============================================================ */
document.getElementById('prevBtn').addEventListener('click',()=>navigate(-1));
document.getElementById('nextBtn').addEventListener('click',()=>navigate(1));
document.getElementById('todayBtn').addEventListener('click',goToday);
document.querySelectorAll('#viewTabs button').forEach(b=>
b.addEventListener('click',()=>{Store.state.view=b.dataset.view;Store.save();render();}));
document.querySelectorAll('#weekStartSeg button').forEach(b=>
b.addEventListener('click',()=>{Store.state.weekStart=Number(b.dataset.ws);Store.save();render();}));
document.getElementById('settingsBtn').addEventListener('click',e=>{
e.stopPropagation();
document.getElementById('settingsMenu').classList.toggle('open');
});
document.addEventListener('click',e=>{
const m=document.getElementById('settingsMenu');
if(m.classList.contains('open')&&!m.contains(e.target)&&e.target.id!=='settingsBtn')
m.classList.remove('open');
const pop=document.getElementById('dayPop');
if(pop.classList.contains('open')&&!pop.contains(e.target))closeDayPop();
});
document.getElementById('dayPopClose').addEventListener('click',closeDayPop);
document.getElementById('clearDataBtn').addEventListener('click',()=>{
if(confirm('Delete ALL events? This cannot be undone.')){Store.clearAll();render();}
});
document.getElementById('modalClose').addEventListener('click',closeModal);
document.getElementById('saveBtn').addEventListener('click',saveModal);
document.getElementById('deleteBtn').addEventListener('click',deleteCurrent);
document.getElementById('fAllDay').addEventListener('change',e=>{
document.getElementById('fStart').disabled=e.target.checked;
document.getElementById('fEnd').disabled=e.target.checked;
if(e.target.checked){document.getElementById('fStart').value='';document.getElementById('fEnd').value='';}
else{document.getElementById('fStart').value='09:00';document.getElementById('fEnd').value='10:00';}
});
document.getElementById('fRec').addEventListener('change',e=>{
document.getElementById('wdPicker').style.display=e.target.value==='weekly'?'flex':'none';
});
document.getElementById('modalBack').addEventListener('mousedown',e=>{
if(e.target===e.currentTarget)closeModal();
});
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){
closeModal();
document.getElementById('recAsk').classList.remove('open');
closeDayPop();
}
});
Store.load();
render();
/* expose for testing/probing */
window.__cal={DU,Store,expandRecurrence,applyRecurringEdit,applyRecurringDelete,navigate,reschedule,
get cursor(){return Store.state.cursor},set cursor(v){Store.state.cursor=v}};
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"d73946f4-54cb-4799-bd45-8c1d90c14043","tokensIn":1995094,"tokensOut":28247,"tokensTotal":2023341,"cost":0,"turns":41,"toolCalls":42,"failedToolCalls":0,"timestamp":"2026-08-21T20:28:33.801Z"} -->