Calendar
GLM 5.3 Flash · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 59,714 bytes · SHA-256 5a089599e94b
<!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 / CONSTANTS (CSS side) ============ */
:root{
--bg:#ffffff; --bg-soft:#f8f9fa; --border:#e0e0e0; --border-soft:#f0f0f0;
--text:#202124; --text-dim:#70757a; --accent:#1a73e8; --accent-soft:#e8f0fe;
--today:#1a73e8; --now-line:#ea4335;
--radius:8px; --chip-radius:4px;
--header-h:64px;
font-size:15px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:'Segoe UI',system-ui,-apple-system,Roboto,Arial,sans-serif;
color:var(--text); background:var(--bg); overflow:hidden;
-webkit-font-smoothing:antialiased;
}
button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
input,select,textarea{font:inherit;color:inherit}
/* ============ TOP BAR ============ */
#topbar{
height:var(--header-h); display:flex; align-items:center; gap:16px;
padding:0 16px; border-bottom:1px solid var(--border);
}
#brand{display:flex;align-items:center;gap:10px;font-size:1.35rem;color:#5f6368;font-weight:400}
#brand .logo{width:26px;height:26px;border-radius:6px;background:conic-gradient(#4285f4 0 25%,#ea4335 0 50%,#fbbc05 0 75%,#34a853 0)}
#todayBtn{
border:1px solid var(--border); border-radius:var(--radius); padding:7px 16px;
font-size:.9rem; color:#3c4043; transition:background .15s;
}
#todayBtn:hover{background:var(--bg-soft)}
#navBtns{display:flex;gap:2px}
#navBtns button{width:36px;height:36px;border-radius:50%;color:#5f6368;font-size:1.1rem;line-height:1}
#navBtns button:hover{background:var(--bg-soft)}
#title{font-size:1.35rem;font-weight:400;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#title .yr{color:var(--text-dim)}
#topbar .spacer{flex:1}
.viewtabs{display:flex;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.viewtabs button{padding:7px 16px;font-size:.85rem;color:#3c4043;border-right:1px solid var(--border)}
.viewtabs button:last-child{border-right:none}
.viewtabs button.active{background:var(--accent-soft);color:var(--accent);font-weight:500}
#settingsBtn{width:38px;height:38px;border-radius:50%;color:#5f6368;font-size:1.15rem}
#settingsBtn:hover{background:var(--bg-soft)}
/* ============ LAYOUT ============ */
#main{position:absolute;top:var(--header-h);left:0;right:0;bottom:0}
.view{position:absolute;inset:0;display:none;flex-direction:column}
.view.active{display:flex}
.view.anim-in{animation:viewIn .22s ease}
.view.anim-in.rev{animation:viewInRev .22s ease}
@keyframes viewIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:none}}
@keyframes viewInRev{from{opacity:0;transform:translateX(-24px)}to{opacity:1;transform:none}}
/* ============ MONTH VIEW ============ */
#monthView{padding:0}
.mhead,#mgrid{display:grid;grid-template-columns:repeat(7,1fr)}
.mhead{border-bottom:1px solid var(--border)}
.mhead div{padding:8px 8px;text-align:center;font-size:.72rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em}
#mgrid{flex:1;grid-auto-rows:1fr;min-height:0}
.mcell{
border-right:1px solid var(--border-soft);border-bottom:1px solid var(--border-soft);
padding:4px 6px;overflow:hidden;cursor:pointer;position:relative;
display:flex;flex-direction:column;min-height:0;transition:background .12s;
}
.mcell:nth-child(7n){border-right:none}
.mcell:hover{background:var(--bg-soft)}
.mcell.dim .dnum{color:#b9bdc1}
.mcell.dim{background:#fafafa}
.mcell.dragover{background:var(--accent-soft);box-shadow:inset 0 0 0 2px var(--accent)}
.dnum{
width:26px;height:26px;line-height:26px;text-align:center;border-radius:50%;
font-size:.8rem;color:var(--text);align-self:flex-end;margin-bottom:2px;flex:none;
}
.mcell.today .dnum{background:var(--today);color:#fff;font-weight:500}
.chips{display:flex;flex-direction:column;gap:2px;min-height:0;overflow:hidden}
.chip{
font-size:.75rem;padding:1px 4px 1px 8px;border-radius:var(--chip-radius);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;
border-left:3px solid;position:relative;flex:none;
}
.chip .t{font-weight:500}
.chip.allDay{border-left-width:3px}
.chip .dot{display:none}
.more{font-size:.72rem;color:var(--text-dim);padding:1px 4px;border-radius:4px;cursor:pointer;flex:none}
.more:hover{background:var(--border-soft)}
.chip.dragging{opacity:.4}
/* ============ WEEK VIEW ============ */
#wkScroll{flex:1;overflow-y:auto;position:relative}
.wkgrid{display:grid;grid-template-columns:56px repeat(7,1fr);grid-auto-rows:min-content}
.wk-sticky{position:sticky;top:0;z-index:5;background:var(--bg)}
.wk-dayhead{border-bottom:1px solid var(--border);border-left:1px solid var(--border-soft);padding:6px 4px;text-align:center}
.wk-dayhead .dn{font-size:.68rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em}
.wk-dayhead .dd{
width:34px;height:34px;line-height:34px;margin:2px auto 0;border-radius:50%;
font-size:1.15rem;font-weight:400;
}
.wk-dayhead.today .dd{background:var(--today);color:#fff}
.wk-dayhead.today .dn{color:var(--today)}
.wk-corner{border-bottom:1px solid var(--border)}
.wk-alldayrow{display:grid;grid-template-columns:56px repeat(7,1fr);border-bottom:1px solid var(--border);background:var(--bg)}
.wk-alldayrow .lbl{font-size:.65rem;color:var(--text-dim);text-align:right;padding:4px 6px 0 0}
.wk-adcell{border-left:1px solid var(--border-soft);padding:2px;min-height:26px}
.wk-body{display:grid;grid-template-columns:56px repeat(7,1fr);position:relative}
.wk-hours{position:relative}
.wk-hourlbl{height:var(--hour-h);font-size:.65rem;color:var(--text-dim);text-align:right;padding-right:8px;transform:translateY(-6px)}
.wk-col{border-left:1px solid var(--border-soft);position:relative;height:calc(var(--hour-h) * var(--hours))}
.wk-col.dropok{background:var(--accent-soft)}
.wk-col .hl{position:absolute;left:0;right:0;height:var(--hour-h);border-bottom:1px solid var(--border-soft);pointer-events:none}
.wk-col .hl:first-child{border-top:1px solid var(--border-soft)}
.evblock{
position:absolute;left:2px;right:4px;border-radius:var(--chip-radius);
padding:2px 4px 2px 6px;font-size:.72rem;overflow:hidden;cursor:grab;
border-left:3px solid;z-index:2;
}
.evblock .t{font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.evblock .tm{font-size:.65rem;opacity:.85;display:block;white-space:nowrap}
.evblock.allDay{border-left-width:3px}
.nowline{position:absolute;left:0;right:0;height:0;border-top:2px solid var(--now-line);z-index:6;pointer-events:none}
.nowline::before{
content:'';position:absolute;left:-6px;top:-5px;width:8px;height:8px;
border-radius:50%;background:var(--now-line);
}
/* ============ AGENDA VIEW ============ */
#agendaView{overflow-y:auto;padding:16px 0}
.ag-day{max-width:720px;margin:0 auto;padding:0 16px}
.ag-dayhead{display:flex;align-items:baseline;gap:10px;padding:14px 4px 8px;border-bottom:1px solid var(--border)}
.ag-dayhead .dd{
width:40px;height:40px;line-height:40px;border-radius:50%;text-align:center;
font-size:1.3rem;font-weight:400;flex:none;
}
.ag-dayhead.today .dd{background:var(--today);color:#fff}
.ag-dayhead .dw{font-size:.8rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em}
.ag-ev{display:flex;gap:12px;padding:10px 4px;border-bottom:1px solid var(--border-soft);cursor:pointer;border-radius:6px}
.ag-ev:hover{background:var(--bg-soft)}
.ag-ev .bar{width:4px;border-radius:2px;flex:none}
.ag-ev .tt{font-weight:500}
.ag-ev .meta{font-size:.8rem;color:var(--text-dim)}
.ag-empty{max-width:720px;margin:40px auto;text-align:center;color:var(--text-dim)}
/* ============ MODAL ============ */
#overlay{
position:fixed;inset:0;background:rgba(32,33,36,.5);display:none;
align-items:center;justify-content:center;z-index:100;
}
#overlay.open{display:flex}
#modal{
background:var(--bg);border-radius:var(--radius);width:min(520px,94vw);
max-height:92vh;overflow-y:auto;padding:22px 24px;
box-shadow:0 8px 32px rgba(0,0,0,.28);animation:pop .18s ease;
}
@keyframes pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
#modal h2{font-size:1.15rem;font-weight:500;margin-bottom:18px}
.frow{margin-bottom:14px}
.frow label{display:block;font-size:.75rem;color:var(--text-dim);margin-bottom:4px;font-weight:500}
.frow input[type=text],.frow input[type=date],.frow input[type=time],.frow select,.frow textarea{
width:100%;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--bg);
}
.frow input:focus,.frow select:focus,.frow textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.frow textarea{resize:vertical;min-height:60px}
.err{color:#d93025;font-size:.75rem;margin-top:4px;display:none}
.err.show{display:block}
input.invalid,select.invalid{border-color:#d93025 !important}
.timegrid{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:center}
.timegrid .dash{text-align:center;color:var(--text-dim)}
.pal{display:flex;gap:10px}
.pal button{width:26px;height:26px;border-radius:50%;border:2px solid transparent;position:relative}
.pal button.sel{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
.chk{display:flex;align-items:center;gap:8px;font-size:.9rem}
.chk input{width:16px;height:16px;accent-color:var(--accent)}
#recOpts{margin-top:10px;padding:10px 12px;background:var(--bg-soft);border-radius:6px;display:none}
#recOpts.open{display:block}
#recOpts .frow{margin-bottom:8px}
.wdgroup{display:flex;gap:4px;flex-wrap:wrap}
.wdgroup button{
width:34px;height:30px;border:1px solid var(--border);border-radius:6px;font-size:.72rem;
}
.wdgroup button.sel{background:var(--accent);color:#fff;border-color:var(--accent)}
.mbtns{display:flex;justify-content:space-between;margin-top:20px}
.mbtns .right{display:flex;gap:8px}
.btn{padding:8px 18px;border-radius:6px;font-size:.9rem;font-weight:500}
.btn.primary{background:var(--accent);color:#fff}
.btn.primary:hover{background:#1765cc}
.btn.ghost{color:var(--accent)}
.btn.ghost:hover{background:var(--accent-soft)}
.btn.danger{color:#d93025}
.btn.danger:hover{background:#fce8e6}
/* ============ RECURRENCE SCOPE DIALOG ============ */
#scopeModal{width:min(420px,94vw)}
#scopeModal .opts{display:flex;flex-direction:column;gap:6px;margin:14px 0 18px}
#scopeModal .opts button{
text-align:left;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-size:.9rem;
}
#scopeModal .opts button:hover{background:var(--accent-soft);border-color:var(--accent)}
/* ============ POPOVER (day overflow / event peek) ============ */
#popover{
position:fixed;background:var(--bg);border-radius:var(--radius);z-index:90;display:none;
width:280px;max-height:340px;overflow-y:auto;padding:12px 14px;
box-shadow:0 4px 20px rgba(0,0,0,.22);border:1px solid var(--border);
}
#popover h3{font-size:.95rem;font-weight:500;margin-bottom:8px}
#popover .pv-ev{display:flex;gap:8px;padding:6px 4px;border-radius:6px;cursor:pointer;font-size:.85rem;align-items:baseline}
#popover .pv-ev:hover{background:var(--bg-soft)}
#popover .pv-ev .bar{width:8px;height:8px;border-radius:50%;flex:none;align-self:center}
#popover .pv-ev .tm{color:var(--text-dim);font-size:.75rem;flex:none;width:78px}
#popover .addlink{margin-top:6px;font-size:.85rem;color:var(--accent);padding:6px 4px;border-radius:6px;display:block;width:100%;text-align:left}
#popover .addlink:hover{background:var(--accent-soft)}
/* ============ SETTINGS PANEL ============ */
#settings{
position:fixed;top:var(--header-h);right:12px;background:var(--bg);z-index:95;display:none;
border-radius:var(--radius);box-shadow:0 4px 20px rgba(0,0,0,.22);border:1px solid var(--border);
padding:16px 18px;width:250px;
}
#settings.open{display:block;animation:pop .15s ease}
#settings h3{font-size:.95rem;margin-bottom:12px}
#settings .frow{margin-bottom:12px}
/* ============ TOAST ============ */
#toast{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);
background:#323232;color:#fff;padding:12px 18px;border-radius:6px;font-size:.88rem;
display:flex;gap:18px;align-items:center;z-index:120;transition:transform .25s ease;
box-shadow:0 4px 14px rgba(0,0,0,.3);
}
#toast.show{transform:translateX(-50%) translateY(0)}
#toast button{color:#8ab4f8;font-weight:500;text-transform:uppercase;font-size:.8rem}
@media (max-width:900px){
:root{--header-h:56px}
#brand span{display:none}
#title{font-size:1.05rem}
.viewtabs button{padding:7px 10px}
.chip{font-size:.68rem}
.evblock{font-size:.65rem}
}
</style>
</head>
<body>
<!-- ============ TOP BAR ============ -->
<header id="topbar">
<div id="brand"><div class="logo"></div><span>Calendar</span></div>
<button id="todayBtn">Today</button>
<div id="navBtns">
<button id="prevBtn" title="Previous">‹</button>
<button id="nextBtn" title="Next">›</button>
</div>
<div id="title"></div>
<div class="spacer"></div>
<div class="viewtabs">
<button data-view="month">Month</button>
<button data-view="week">Week</button>
<button data-view="agenda">Agenda</button>
</div>
<button id="settingsBtn" title="Settings">⚙</button>
</header>
<!-- ============ MAIN VIEWS ============ -->
<div id="main">
<section id="monthView" class="view">
<div class="mhead" id="mhead"></div>
<div id="mgrid"></div>
</section>
<section id="weekView" class="view">
<div id="wkScroll">
<div class="wkgrid">
<div class="wk-corner wk-sticky"></div>
<div id="wkHeads" class="wk-sticky" style="grid-column:2/9;display:grid;grid-template-columns:repeat(7,1fr)"></div>
</div>
<div class="wk-alldayrow" id="wkAllDayRow"></div>
<div class="wk-body" id="wkBody"></div>
</div>
</section>
<section id="agendaView" class="view">
<div id="agList"></div>
</section>
</div>
<!-- ============ EVENT MODAL ============ -->
<div id="overlay">
<div id="modal" role="dialog">
<h2 id="modalTitle">New event</h2>
<div class="frow">
<label for="fTitle">Title</label>
<input type="text" id="fTitle" placeholder="Add title">
<div class="err" id="eTitle">Title is required</div>
</div>
<div class="frow">
<label for="fDate">Date</label>
<input type="date" id="fDate">
<div class="err" id="eDate">Enter a valid date</div>
</div>
<div class="frow">
<label class="chk"><input type="checkbox" id="fAllDay"> All-day event</label>
</div>
<div class="frow" id="timeRow">
<label>Time</label>
<div class="timegrid">
<input type="time" id="fStart" value="09:00" step="900">
<div class="dash">–</div>
<input type="time" id="fEnd" value="10:00" step="900">
</div>
<div class="err" id="eTime">End time must be after start time</div>
</div>
<div class="frow">
<label>Color</label>
<div class="pal" id="fPalette"></div>
</div>
<div class="frow">
<label class="chk"><input type="checkbox" id="fRec"> Repeat</label>
<div id="recOpts">
<div class="frow">
<select id="fRecType">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div class="frow" id="wdRow" style="display:none">
<label>Repeat on</label>
<div class="wdgroup" id="fWeekdays"></div>
<div class="err" id="eWd">Pick at least one weekday</div>
</div>
</div>
</div>
<div class="frow">
<label for="fNotes">Notes</label>
<textarea id="fNotes" placeholder="Add notes"></textarea>
</div>
<div class="mbtns">
<button class="btn danger" id="deleteBtn" style="display:none">Delete</button>
<div class="right">
<button class="btn ghost" id="cancelBtn">Cancel</button>
<button class="btn primary" id="saveBtn">Save</button>
</div>
</div>
</div>
<!-- recurrence scope chooser -->
<div id="scopeModal" style="display:none" role="dialog">
<h2 id="scopeTitle">Edit recurring event</h2>
<p style="font-size:.85rem;color:var(--text-dim)" id="scopeSub"></p>
<div class="opts">
<button data-scope="one">This event</button>
<button data-scope="follow">This and following events</button>
<button data-scope="all">All events in series</button>
</div>
<div class="mbtns"><div class="right"><button class="btn ghost" id="scopeCancel">Cancel</button></div></div>
</div>
</div>
<!-- ============ POPOVER ============ -->
<div id="popover"></div>
<!-- ============ SETTINGS ============ -->
<div id="settings">
<h3>Settings</h3>
<div class="frow">
<label>Week starts on</label>
<select id="setWeekStart">
<option value="1">Monday</option>
<option value="0">Sunday</option>
</select>
</div>
<div class="frow">
<label>Day starts at</label>
<select id="setHourStart"></select>
</div>
<div class="frow">
<label>Day ends at</label>
<select id="setHourEnd"></select>
</div>
</div>
<!-- ============ TOAST ============ -->
<div id="toast"><span id="toastMsg"></span><button id="toastUndo">Undo</button></div>
<script>
'use strict';
/* =====================================================================
* 1. TUNABLE CONSTANTS
* ===================================================================== */
const CONFIG = {
DEFAULT_WEEK_START: 1, // 1 = Monday, 0 = Sunday
HOUR_START: 6, // week view first hour
HOUR_END: 22, // week view last hour (exclusive end row)
SNAP_MIN: 15, // week-view drag snap in minutes
MONTH_MAX_CHIPS: 3, // chips per month cell before "+N more"
AGENDA_DAYS: 60, // agenda horizon
PALETTE: [
{id:'blue', c:'#1a73e8', bg:'#d3e3fd'},
{id:'green', c:'#188038', bg:'#ceead6'},
{id:'purple', c:'#8430cb', bg:'#d7bdbf'},
{id:'pink', c:'#c5221f', bg:'#fcd8d4'},
{id:'orange', c:'#e8710a', bg:'#fedfc8'},
{id:'teal', c:'#007b83', bg:'#c9eef0'},
],
WEEKDAY_LETTERS_MON: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],
WEEKDAY_LETTERS_SUN: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
STORAGE_KEY: 'calplanner.v1',
};
/* =====================================================================
* 2. DATE UTIL MODULE (pure functions, native Date only, component math)
* All functions take/return plain {y,m,d} or 'YYYY-MM-DD' keys.
* m is 0-based (0 = January). No ms-arithmetic across days.
* ===================================================================== */
const DU = (() => {
const isLeap = y => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
const daysInMonth = (y, m) => {
const d = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return d[m];
};
// day-of-week of a Y/M/D without relying on local timezone parsing
const dow = (y, m, d) => new Date(y, m, d).getDay(); // 0=Sun
const toKey = (y, m, d) =>
y + '-' + String(m + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
const keyOf = dt => toKey(dt.getFullYear(), dt.getMonth(), dt.getDate());
const fromKey = k => { const [y, m, d] = k.split('-').map(Number); return { y, m: m - 1, d }; };
const isValidKey = k => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(k || '')) return false;
const { y, m, d } = fromKey(k);
return m >= 0 && m < 12 && d >= 1 && d <= daysInMonth(y, m);
};
// add n days via components (safe across months/years/DST)
const addDays = (y, m, d, n) => {
let dd = d + n, mm = m, yy = y;
while (dd > daysInMonth(yy, mm)) { dd -= daysInMonth(yy, mm); mm++; if (mm > 11) { mm = 0; yy++; } }
while (dd < 1) { mm--; if (mm < 0) { mm = 11; yy--; } dd += daysInMonth(yy, mm); }
return { y: yy, m: mm, d: dd };
};
// add n months, clamping day-of-month (Jan 31 + 1mo -> Feb 28/29)
const addMonths = (y, m, d, n) => {
const t = y * 12 + m + n;
const yy = Math.floor(t / 12), mm = ((t % 12) + 12) % 12;
return { y: yy, m: mm, d: Math.min(d, daysInMonth(yy, mm)) };
};
// date of the week's first day containing (y,m,d)
const weekStart = (y, m, d, weekStartDay) => {
const back = (dow(y, m, d) - weekStartDay + 7) % 7;
return addDays(y, m, d, -back);
};
// ISO-8601 style week number, correct for either week-start setting
const weekNumber = (y, m, d, weekStartDay) => {
const ws = weekStart(y, m, d, weekStartDay);
// week 1 = week containing the first weekStartDay-thursday... use Jan4 anchor
const jan4 = weekStart(y, 0, 4, weekStartDay);
const diffDays = diff(ws.y, ws.m, ws.d, jan4.y, jan4.m, jan4.d);
return Math.floor(diffDays / 7) + 1;
};
// whole days from (y1,m1,d1) to (y2,m2,d2) — component based
const diff = (y1, m1, d1, y2, m2, d2) => {
// count via day-of-year + year lengths (no ms math)
const doy = (y, m, d) => {
let n = d;
for (let i = 0; i < m; i++) n += daysInMonth(y, i);
return n;
};
if (y1 === y2) return doy(y2, m2, d2) - doy(y1, m1, d1);
let sign = 1;
if (y2 < y1 || (y2 === y1 && (m2 < m1 || (m2 === m1 && d2 < d1)))) { sign = -1; [y1, m1, d1, y2, m2, d2] = [y2, m2, d2, y1, m1, d1]; }
let n = (isLeap(y1) ? 366 : 365) - doy(y1, m1, d1);
for (let yy = y1 + 1; yy < y2; yy++) n += isLeap(yy) ? 366 : 365;
n += doy(y2, m2, d2);
return sign * n;
};
const todayKey = () => keyOf(new Date());
const cmpKey = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const fmtLong = k => {
const { y, m, d } = fromKey(k);
return new Date(y, m, d).toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
};
const fmtMonYear = (y, m) => new Date(y, m, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
const fmtTime = mins => {
const h = Math.floor(mins / 60), mm = mins % 60;
const ap = h < 12 ? 'AM' : 'PM'; let h12 = h % 12; if (h12 === 0) h12 = 12;
return h12 + (mm ? ':' + String(mm).padStart(2, '0') : '') + ' ' + ap;
};
return { isLeap, daysInMonth, dow, toKey, keyOf, fromKey, isValidKey, addDays, addMonths,
weekStart, weekNumber, diff, todayKey, cmpKey, fmtLong, fmtMonYear, fmtTime };
})();
/* =====================================================================
* 3. RECURRENCE ENGINE
* Event shape:
* { id, title, date:'YYYY-MM-DD', allDay, start:'HH:MM'|null, end,
* color, notes, rec:{type:'daily'|'weekly'|'monthly'|'yearly',
* weekdays:[0-6]?, until:'YYYY-MM-DD'|null},
* exdates:[keys], overrides:{key: occurrenceEvent} }
* Expansion is range-bounded: never materializes infinite series.
* ===================================================================== */
const REC = (() => {
// does event occur on the given date key (given its recurrence rule)?
function matchesRule(ev, y, m, d) {
const r = ev.rec; if (!r) return false;
const sd = DU.fromKey(ev.date);
if (DU.cmpKey(DU.toKey(y, m, d), ev.date) < 0) return false;
if (r.until && DU.cmpKey(DU.toKey(y, m, d), r.until) > 0) return false;
switch (r.type) {
case 'daily': return true;
case 'weekly':
// first matching weekday on/after start IS the earliest key >= start
// with a matching dow, so the dow check alone is sufficient
return r.weekdays.includes(DU.dow(y, m, d));
case 'monthly': {
// same day-of-month; months lacking it are skipped naturally
if (d !== sd.d) return false;
const months = (y - sd.y) * 12 + (m - sd.m);
return months >= 0;
}
case 'yearly':
return m === sd.m && d === Math.min(sd.d, DU.daysInMonth(y, m)) && y >= sd.y;
}
return false;
}
// expand events into concrete occurrences within [startKey,endKey] inclusive
// returns [{ev, key, isOverride}] — ev is the effective event object
function expand(events, startKey, endKey) {
const out = [];
for (const ev of events) {
if (!ev.rec) {
if (DU.cmpKey(ev.date, startKey) >= 0 && DU.cmpKey(ev.date, endKey) <= 0)
out.push({ ev, key: ev.date });
continue;
}
const r = ev.rec;
let s = ev.date > startKey ? ev.date : startKey;
let e = endKey;
if (r.until && r.until < e) e = r.until;
if (s > e) continue;
// iterate day by day through the (bounded) window using components
let cur = DU.fromKey(s);
const end = DU.fromKey(e);
let guard = 0;
while (DU.cmpKey(DU.toKey(cur.y, cur.m, cur.d), e) <= 0 && guard++ < 5000) {
const k = DU.toKey(cur.y, cur.m, cur.d);
const ov = ev.overrides && ev.overrides[k];
if (ov) { out.push({ ev: ov, key: k, isOverride: true }); }
else if (matchesRule(ev, cur.y, cur.m, cur.d) && !ev.exdates.includes(k)) {
out.push({ ev, key: k });
}
cur = DU.addDays(cur.y, cur.m, cur.d, 1);
}
}
out.sort((a, b) => {
const t1 = occStart(a.ev), t2 = occStart(b.ev);
if (a.key !== b.key) return DU.cmpKey(a.key, b.key);
return t1 - t2;
});
return out;
}
const toMin = t => { if (!t) return 0; const [h, m] = t.split(':').map(Number); return h * 60 + m; };
const occStart = ev => ev.allDay ? -1 : toMin(ev.start);
return { matchesRule, expand, toMin };
})();
/* =====================================================================
* 4. STATE + PERSISTENCE
* ===================================================================== */
const State = {
events: [], // master event list
view: 'month',
y: new Date().getFullYear(),
m: new Date().getMonth(), // 0-based
weekStart: CONFIG.DEFAULT_WEEK_START,
hourStart: CONFIG.HOUR_START,
hourEnd: CONFIG.HOUR_END,
// undo buffer for toast
_undo: null,
};
const TODAY = DU.todayKey();
function save() {
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
events: State.events, view: State.view, y: State.y, m: State.m,
weekStart: State.weekStart, hourStart: State.hourStart, hourEnd: State.hourEnd,
}));
}
function load() {
try {
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
if (!raw) return;
const d = JSON.parse(raw);
if (Array.isArray(d.events)) State.events = d.events;
if (['month','week','agenda'].includes(d.view)) State.view = d.view;
if (Number.isInteger(d.y)) State.y = d.y;
if (Number.isInteger(d.m) && d.m >= 0 && d.m < 12) State.m = d.m;
if (d.weekStart === 0 || d.weekStart === 1) State.weekStart = d.weekStart;
if (Number.isInteger(d.hourStart)) State.hourStart = d.hourStart;
if (Number.isInteger(d.hourEnd)) State.hourEnd = d.hourEnd;
} catch (e) { console.warn('load failed', e); }
}
const uid = () => 'ev' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const findEv = id => State.events.find(e => e.id === id);
const colorOf = id => (CONFIG.PALETTE.find(p => p.id === id) || CONFIG.PALETTE[0]);
/* =====================================================================
* 5. RENDERING — shared helpers
* ===================================================================== */
const $ = id => document.getElementById(id);
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
function occurrencesInRange(startKey, endKey) {
return REC.expand(State.events, startKey, endKey);
}
function chipHTML(occ, extra) {
const col = colorOf(occ.ev.color);
const time = occ.ev.allDay ? '' : '<span class="t">' + esc(occ.ev.title) + '</span>';
const plain = occ.ev.allDay ? esc(occ.ev.title) : esc(occ.ev.title);
return `<div class="chip ${occ.ev.allDay ? 'allDay' : ''}" ${extra || ''}
data-id="${occ.ev.id}" data-key="${occ.key}"
style="background:${col.bg};border-color:${col.c};color:${col.c}" title="${esc(occ.ev.title)}">${plain}</div>`;
}
/* ---------- MONTH VIEW ---------- */
function renderMonth(dir) {
const { y, m } = State;
const head = document.getElementById('mhead');
const letters = State.weekStart === 1 ? CONFIG.WEEKDAY_LETTERS_MON : CONFIG.WEEKDAY_LETTERS_SUN;
head.innerHTML = letters.map(l => `<div>${l}</div>`).join('');
const grid = document.getElementById('mgrid');
// first cell = weekStart on/before the 1st
const first = DU.weekStart(y, m, 1, State.weekStart);
const cells = [];
let cur = { ...first };
for (let i = 0; i < 42; i++) {
cells.push({ ...cur });
cur = DU.addDays(cur.y, cur.m, cur.d, 1);
}
grid.innerHTML = cells.map(c => {
const k = DU.toKey(c.y, c.m, c.d);
const dim = c.m !== m;
const isToday = k === TODAY;
return `<div class="mcell ${dim ? 'dim' : ''} ${isToday ? 'today' : ''}" data-key="${k}">
<div class="dnum">${c.d}</div><div class="chips" data-slot></div></div>`;
}).join('');
// fill chips
const occs = occurrencesInRange(DU.toKey(cells[0].y, cells[0].m, cells[0].d),
DU.toKey(cells[41].y, cells[41].m, cells[41].d));
const byDay = {};
for (const o of occs) (byDay[o.key] = byDay[o.key] || []).push(o);
grid.querySelectorAll('.mcell').forEach(cell => {
const k = cell.dataset.key;
const slot = cell.querySelector('[data-slot]');
const list = byDay[k] || [];
const max = CONFIG.MONTH_MAX_CHIPS;
list.slice(0, max).forEach(o => slot.insertAdjacentHTML('beforeend', chipHTML(o, 'draggable="true"')));
if (list.length > max) {
const more = document.createElement('div');
more.className = 'more'; more.textContent = '+' + (list.length - max) + ' more';
more.addEventListener('click', e => { e.stopPropagation(); openDayPopover(k, more); });
slot.appendChild(more);
}
});
setTitle();
animateGrid(dir);
}
function animateGrid(dir) {
const v = document.querySelector('.view.active');
if (!v || !dir) return;
v.classList.remove('anim-in', 'rev'); void v.offsetWidth;
v.classList.add('anim-in'); if (dir < 0) v.classList.add('rev');
}
/* ---------- WEEK VIEW ---------- */
function renderWeek(dir) {
const ws = DU.weekStart(State.y, State.m, new Date().getDate(), State.weekStart);
// when in week view, State.y/m track the month of the week being shown
const days = [];
let cur = { ...ws };
for (let i = 0; i < 7; i++) { days.push({ ...cur }); cur = DU.addDays(cur.y, cur.m, cur.d, 1); }
const heads = document.getElementById('wkHeads');
heads.innerHTML = days.map(c => {
const k = DU.toKey(c.y, c.m, c.d);
const isToday = k === TODAY;
const dn = new Date(c.y, c.m, c.d).toLocaleDateString(undefined, { weekday: 'short' });
return `<div class="wk-dayhead ${isToday ? 'today' : ''}" data-key="${k}">
<div class="dn">${dn}</div><div class="dd">${c.d}</div></div>`;
}).join('');
const hs = State.hourStart, he = State.hourEnd;
const hours = he - hs;
document.documentElement.style.setProperty('--hour-h', '48px');
document.documentElement.style.setProperty('--hours', hours);
const body = document.getElementById('wkBody');
let hourHTML = '<div class="wk-hours">';
for (let h = hs; h < he; h++)
hourHTML += `<div class="wk-hourlbl">${DU.fmtTime(h * 60)}</div>`;
hourHTML += '</div>';
let cols = hourHTML;
days.forEach(c => {
let inner = '';
for (let h = hs; h < he; h++) inner += `<div class="hl"></div>`;
cols += `<div class="wk-col" data-key="${DU.toKey(c.y, c.m, c.d)}">${inner}</div>`;
});
body.innerHTML = cols;
body.style.setProperty('--hours', hours);
body.querySelectorAll('.wk-col').forEach(col => {
col.style.height = (hours * 48) + 'px';
col.querySelector('.wk-hours');
});
// hour labels column height
body.querySelector('.wk-hours').style.height = (hours * 48) + 'px';
// all-day strip
const adr = document.getElementById('wkAllDayRow');
let adHTML = '<div class="lbl">All day</div>';
const startK = DU.toKey(days[0].y, days[0].m, days[0].d);
const endK = DU.toKey(days[6].y, days[6].m, days[6].d);
const occs = occurrencesInRange(startK, endK);
days.forEach(c => {
const k = DU.toKey(c.y, c.m, c.d);
const list = occs.filter(o => o.key === k && o.ev.allDay);
adHTML += `<div class="wk-adcell" data-key="${k}">` +
list.map(o => chipHTML(o, 'draggable="true"')).join('') + '</div>';
});
adr.innerHTML = adHTML;
// timed events as positioned blocks
body.querySelectorAll('.wk-col').forEach(col => {
const k = col.dataset.key;
const list = occs.filter(o => o.key === k && !o.ev.allDay);
// simple overlap layout: group overlapping, split width
const laid = layoutOverlaps(list);
laid.forEach(o => {
const col2 = colorOf(o.ev.color);
const s = REC.toMin(o.ev.start), e = REC.toMin(o.ev.end);
const top = ((s - hs * 60) / 60) * 48;
const h = Math.max(16, ((e - s) / 60) * 48);
const w = 100 / o._group, left = (o._idx * 100) / o._group;
col.insertAdjacentHTML('beforeend',
`<div class="evblock ${o.ev.allDay ? 'allDay' : ''}" draggable="true"
data-id="${o.ev.id}" data-key="${k}"
style="top:${top}px;height:${h - 2}px;background:${col2.bg};border-color:${col2.c};color:${col2.c};
left:calc(${left}% + 2px);width:calc(${w}% - 6px)">
<span class="t">${esc(o.ev.title)}</span>
${h > 26 ? `<span class="tm">${DU.fmtTime(s)} – ${DU.fmtTime(e)}</span>` : ''}
</div>`);
});
});
// now line
renderNowLine(days, hs, hours);
setTitle();
animateGrid(dir);
}
// assign _group/_idx for simple side-by-side overlap layout
function layoutOverlaps(list) {
const sorted = [...list].sort((a, b) => REC.toMin(a.ev.start) - REC.toMin(b.ev.start));
let cluster = [];
const clusterEnd = () => Math.max(...cluster.map(x => REC.toMin(x.ev.end)));
const flush = () => {
cluster.forEach((o, i) => { o._group = cluster.length; o._idx = i; });
cluster = [];
};
for (const o of sorted) {
const s = REC.toMin(o.ev.start);
if (cluster.length && s >= clusterEnd()) flush();
cluster.push(o);
}
if (cluster.length) flush();
return sorted;
}
let nowTimer = null;
function renderNowLine(days, hs, hours) {
document.querySelectorAll('.nowline').forEach(n => n.remove());
const inWeek = days.some(c => DU.toKey(c.y, c.m, c.d) === TODAY);
if (!inWeek) return;
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
if (mins < hs * 60 || mins > hours * 60 + hs * 60) return;
const body = document.getElementById('wkBody');
const col0 = body.querySelector('.wk-col');
if (!col0) return;
const line = document.createElement('div');
line.className = 'nowline';
line.style.top = (((mins - hs * 60) / 60) * 48) + 'px';
line.style.left = '56px';
body.appendChild(line);
clearInterval(nowTimer);
nowTimer = setInterval(() => {
const n = new Date();
const mm = n.getHours() * 60 + n.getMinutes();
if (mm >= hs * 60 && mm <= hours * 60 + hs * 60)
line.style.top = (((mm - hs * 60) / 60) * 48) + 'px';
}, 60000);
}
/* ---------- AGENDA VIEW ---------- */
function renderAgenda(dir) {
const list = document.getElementById('agList');
const start = { y: State.y, m: State.m, d: 1 };
let startKey = DU.toKey(start.y, start.m, start.d);
const tk = DU.fromKey(TODAY);
if (DU.cmpKey(startKey, TODAY) < 0) startKey = TODAY;
let cur = DU.fromKey(startKey);
const end = DU.addDays(cur.y, cur.m, cur.d, CONFIG.AGENDA_DAYS);
const endKey = DU.toKey(end.y, end.m, end.d);
const occs = occurrencesInRange(startKey, endKey);
const byDay = {};
for (const o of occs) (byDay[o.key] = byDay[o.key] || []).push(o);
let html = '';
cur = DU.fromKey(startKey);
let shown = 0;
for (let i = 0; i < CONFIG.AGENDA_DAYS && shown < 40; i++) {
const k = DU.toKey(cur.y, cur.m, cur.d);
const list2 = byDay[k];
if (list2 && list2.length) {
shown++;
const dt = new Date(cur.y, cur.m, cur.d);
const dw = dt.toLocaleDateString(undefined, { weekday: 'long' });
const mo = dt.toLocaleDateString(undefined, { month: 'long' });
html += `<div class="ag-day"><div class="ag-dayhead ${k === TODAY ? 'today' : ''}">
<div class="dd">${cur.d}</div>
<div><div style="font-size:.95rem">${mo}</div><div class="dw">${dw}</div></div>
</div>`;
for (const o of list2) {
const col = colorOf(o.ev.color);
const meta = o.ev.allDay ? 'All day'
: `${DU.fmtTime(REC.toMin(o.ev.start))} – ${DU.fmtTime(REC.toMin(o.ev.end))}`;
html += `<div class="ag-ev" data-id="${o.ev.id}" data-key="${k}">
<div class="bar" style="background:${col.c}"></div>
<div><div class="tt">${esc(o.ev.title)}</div>
<div class="meta">${meta}${o.ev.rec ? ' · repeats' : ''}</div></div></div>`;
}
html += '</div>';
}
cur = DU.addDays(cur.y, cur.m, cur.d, 1);
}
if (!html) html = '<div class="ag-empty">No upcoming events in the next ' + CONFIG.AGENDA_DAYS + ' days.</div>';
list.innerHTML = html;
setTitle();
animateGrid(dir);
}
/* ---------- TITLE / NAV ---------- */
function setTitle() {
const t = document.getElementById('title');
if (State.view === 'month') {
t.innerHTML = esc(new Date(State.y, State.m, 1).toLocaleDateString(undefined, { month: 'long' })) +
' <span class="yr">' + State.y + '</span>';
} else if (State.view === 'week') {
const ws = DU.weekStart(State.y, State.m, Math.min(new Date().getDate(), DU.daysInMonth(State.y, State.m)), State.weekStart);
const we = DU.addDays(ws.y, ws.m, ws.d, 6);
const f = (c, withYr) => new Date(c.y, c.m, c.d).toLocaleDateString(undefined,
{ month: 'short', day: 'numeric', ...(withYr ? { year: 'numeric' } : {}) });
const sameM = ws.m === DU.addDays(ws.y, ws.m, ws.d, 6).m && ws.y === DU.addDays(ws.y, ws.m, ws.d, 6).y;
t.innerHTML = sameM
? `${esc(f(ws))} – ${esc(f(DU.addDays(ws.y, ws.m, ws.d, 6), true))}`
: `${esc(f(ws))} – ${esc(f(DU.addDays(ws.y, ws.m, ws.d, 6), true))}`;
} else {
t.innerHTML = 'Agenda <span class="yr">from ' + esc(new Date(State.y, State.m, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })) + '</span>';
}
}
function render(dir) {
if (State.view === 'month') renderMonth(dir);
else if (State.view === 'week') renderWeek(dir);
else renderAgenda(dir);
document.querySelectorAll('.viewtabs button').forEach(b =>
b.classList.toggle('active', b.dataset.view === State.view));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(State.view + 'View').classList.add('active');
save();
}
/* ---------- NAVIGATION ---------- */
function navMonth(n) {
const r = DU.addMonths(State.y, State.m, 1, n);
const dir = n > 0 ? 1 : -1;
State.y = r.y; State.m = r.m;
render(dir);
}
function navWeek(n) {
// move the anchor date by n weeks, keep month tracking consistent
const anchor = DU.weekStart(State.y, State.m, 15, State.weekStart); // mid-month anchor
const moved = DU.addDays(anchor.y, anchor.m, anchor.d, n * 7);
State.y = moved.y; State.m = moved.m;
render(n > 0 ? 1 : -1);
}
function navAgenda(n) {
const r = DU.addMonths(State.y, State.m, 1, n);
State.y = r.y; State.m = r.m;
render(n > 0 ? 1 : -1);
}
function goToday() {
const n = new Date();
const dir = (State.y * 12 + State.m) <= (n.getFullYear() * 12 + n.getMonth()) ? 1 : -1;
State.y = n.getFullYear(); State.m = n.getMonth();
render(dir);
}
/* =====================================================================
* 6. EVENT MODAL (CRUD)
* ===================================================================== */
const Modal = {
editingId: null, // master event id being edited
editingKey: null, // occurrence date key (for recurring)
pendingScope: null, // 'one'|'follow'|'all' chosen for recurring edit
isNew: false,
};
function openModal(dateKey, evId, occKey) {
Modal.editingId = evId || null;
Modal.editingKey = occKey || null;
Modal.pendingScope = null;
Modal.isNew = !evId;
const ev = evId ? findEv(evId) : null;
const eff = (ev && occKey && ev.overrides && ev.overrides[occKey]) || ev;
$('modalTitle').textContent = ev ? 'Edit event' : 'New event';
$('deleteBtn').style.display = ev ? '' : 'none';
$('fTitle').value = eff ? eff.title : '';
$('fDate').value = occKey || dateKey || TODAY;
$('fAllDay').checked = eff ? !!eff.allDay : false;
$('fStart').value = eff && eff.start ? eff.start : '09:00';
$('fEnd').value = eff && eff.end ? eff.end : '10:00';
$('fNotes').value = eff ? (eff.notes || '') : '';
// palette
const pal = $('fPalette');
pal.innerHTML = CONFIG.PALETTE.map(p =>
`<button type="button" data-c="${p.id}" style="background:${p.c}"
class="${(eff ? eff.color : CONFIG.PALETTE[0].id) === p.id ? 'sel' : ''}"></button>`).join('');
pal.querySelectorAll('button').forEach(b => b.onclick = () => {
pal.querySelectorAll('button').forEach(x => x.classList.remove('sel'));
b.classList.add('sel');
});
// recurrence
const rec = eff && eff.rec;
$('fRec').checked = !!rec;
$('recOpts').classList.toggle('open', !!rec);
$('fRecType').value = rec ? rec.type : 'weekly';
buildWeekdayPicker(rec ? rec.weekdays : [new Date().getDay()]);
syncRecUI();
clearErrors();
$('overlay').classList.add('open');
if (!evId) setTimeout(() => $('fTitle').focus(), 50);
}
function closeModal() { $('overlay').classList.remove('open'); }
function buildWeekdayPicker(sel) {
const order = State.weekStart === 1 ? [1,2,3,4,5,6,0] : [0,1,2,3,4,5,6];
const letters = ['S','M','T','W','T','F','S'];
$('fWeekdays').innerHTML = order.map(d =>
`<button type="button" data-d="${d}" class="${sel && sel.includes(d) ? 'sel' : ''}">${letters[d]}</button>`).join('');
$('fWeekdays').querySelectorAll('button').forEach(b => b.onclick = () => b.classList.toggle('sel'));
}
function syncRecUI() {
$('wdRow').style.display = $('fRecType').value === 'weekly' ? '' : 'none';
}
function clearErrors() {
['eTitle','eDate','eTime','eWd'].forEach(id => $(id).classList.remove('show'));
['fTitle','fDate','fStart','fEnd'].forEach(id => $(id).classList.remove('invalid'));
}
function showErr(id, inputId) {
$(id).classList.add('show');
if (inputId) $(inputId).classList.add('invalid');
}
function collectForm() {
clearErrors();
let ok = true;
const title = $('fTitle').value.trim();
const date = $('fDate').value;
const allDay = $('fAllDay').checked;
const start = allDay ? null : $('fStart').value;
const end = allDay ? null : $('fEnd').value;
if (!title) { showErr('eTitle', 'fTitle'); ok = false; }
if (!DU.isValidKey(date)) { showErr('eDate', 'fDate'); ok = false; }
if (!allDay) {
if (!start || !end) { showErr('eTime', 'fEnd'); ok = false; }
else if (REC.toMin(end) <= REC.toMin(start)) { showErr('eTime', 'fEnd'); ok = false; }
}
let rec = null;
if ($('fRec').checked) {
const type = $('fRecType').value;
rec = { type, weekdays: null, until: null };
if (type === 'weekly') {
rec.weekdays = [...$('fWeekdays').querySelectorAll('button.sel')].map(b => +b.dataset.d);
if (!rec.weekdays.length) { showErr('eWd'); ok = false; }
rec.weekdays.sort();
}
}
if (!ok) return null;
return {
title: title.trim(), date, allDay, start, end,
color: $('fPalette button.sel')?.dataset.c || CONFIG.PALETTE[0].id,
notes: $('fNotes').value.trim(), rec,
};
}
function saveEvent() {
const data = collectForm();
if (!data) return; // inline errors shown
if (Modal.isNew) {
State.events.push({ id: uid(), ...data, exdates: [], overrides: {} });
} else {
const ev = findEv(Modal.editingId);
const scope = ev.rec ? (Modal.pendingScope || askScope('save')) : 'all';
if (scope === 'ask') return; // scope dialog open; saveEvent re-called after
applyEdit(ev, Modal.editingKey, scope, data);
}
closeModal();
render();
showToast('Event saved');
}
// apply an edit to (an occurrence of) a recurring event
function applyEdit(ev, occKey, scope, data) {
if (!ev.rec || scope === 'all') {
// edit master (non-recurring always lands here)
Object.assign(ev, data);
if (!data.rec) { ev.rec = null; ev.exdates = []; ev.overrides = {}; }
return;
}
if (scope === 'one') {
// exception instance: store override for this date only
ev.overrides = ev.overrides || {};
ev.overrides[occKey] = { ...data, id: ev.id, exdates: [], overrides: {}, rec: null };
if (!ev.exdates.includes(occKey)) ev.exdates.push(occKey);
// if the occurrence date itself moved, the original slot must be excluded
// (override carries the new date; exdate removes the original slot)
} else if (scope === 'follow') {
// split series: master ends before occKey; new event continues after
const prevDay = DU.addDays(...(d => [d.y, d.m, d.d, -1])(DU.fromKey(occKey)));
ev.rec = { ...ev.rec, until: DU.toKey(prevDay.y, prevDay.m, prevDay.d) };
// drop overrides/exdates beyond the split (they belong to the new series)
const newEv = { id: uid(), ...data, exdates: [], overrides: {} };
State.events.push(newEv);
}
}
function deleteEvent() {
const ev = findEv(Modal.editingId);
if (!ev) { closeModal(); return; }
if (!ev.rec) {
removeAndToast(ev);
} else {
askScope('delete', scope => {
if (scope === 'all') removeAndToast(ev);
else if (scope === 'one') {
// exclude just this occurrence
ev.exdates = ev.exdates || [];
if (!ev.exdates.includes(Modal.editingKey)) ev.exdates.push(Modal.editingKey);
if (ev.overrides) delete ev.overrides[Modal.editingKey];
closeModal(); render(); showToast('Occurrence deleted');
} else if (scope === 'follow') {
const prevDay = DU.addDays(...(d => [d.y, d.m, d.d, -1])(DU.fromKey(Modal.editingKey)));
ev.rec = { ...ev.rec, until: DU.toKey(prevDay.y, prevDay.m, prevDay.d) };
closeModal(); render(); showToast('Series truncated');
}
});
return;
}
}
function removeAndToast(ev) {
State.events = State.events.filter(e => e.id !== ev.id);
closeModal(); render();
showToast('Event deleted', () => { State.events.push(ev); render(); });
}
/* ---------- scope chooser ---------- */
let scopeCb = null;
function askScope(mode) {
// returns 'ask' and shows dialog; callback fires with chosen scope
scopeCb = scope => {
$('scopeModal').style.display = 'none';
if (mode === 'save') { Modal.pendingScope = scope; saveEvent(); }
else scopeCb2 && scopeCb2(scope);
};
$('scopeTitle').textContent = mode === 'delete' ? 'Delete recurring event' : 'Edit recurring event';
$('scopeSub').textContent = mode === 'delete'
? 'Which occurrences should be deleted?' : 'Which occurrences should be changed?';
$('overlay').classList.add('open');
$('modal').style.display = 'none';
$('scopeModal').style.display = '';
return 'ask';
}
let scopeCb2 = null;
function askScopeAsync(cb) {
scopeCb2 = scope => {
$('scopeModal').style.display = 'none';
$('modal').style.display = '';
if (scope) cb(scope);
else { $('overlay').classList.remove('open'); $('modal').style.display = ''; }
};
$('scopeTitle').textContent = 'Delete recurring event';
$('scopeSub').textContent = 'Which occurrences should be deleted?';
$('overlay').classList.add('open');
$('modal').style.display = 'none';
$('scopeModal').style.display = '';
}
// unified: delete flow uses askScopeAsync; save flow uses askScope
function askScopeForDelete(cb) { askScopeAsync(cb); }
/* =====================================================================
* 7. POPOVER (day overflow / event peek)
* ===================================================================== */
function openDayPopover(key, anchor) {
const pop = $('popover');
const occs = occurrencesInRange(key, key);
const dt = DU.fromKey(key);
pop.innerHTML = `<h3>${esc(new Date(dt.y, dt.m, dt.d).toLocaleDateString(undefined,
{ weekday: 'long', month: 'long', day: 'numeric' }))}</h3>` +
occs.map(o => {
const col = colorOf(o.ev.color);
const meta = o.ev.allDay ? 'All day' : `${DU.fmtTime(REC.toMin(o.ev.start))} – ${DU.fmtTime(REC.toMin(o.ev.end))}`;
return `<div class="pv-ev" data-id="${o.ev.id}" data-key="${key}">
<div class="bar" style="background:${col.c}"></div>
<div class="tm">${meta}</div><div>${esc(o.ev.title)}</div></div>`;
}).join('') +
`<button class="addlink" data-add="${key}">+ Create event</button>`;
pop.style.display = 'block';
const r = anchor.getBoundingClientRect();
let x = Math.min(r.left, innerWidth - 296);
let y = r.bottom + 6;
if (y + 340 > innerHeight) y = Math.max(8, r.top - 340);
pop.style.left = x + 'px'; pop.style.top = y + 'px';
pop.querySelectorAll('.pv-ev').forEach(el =>
el.onclick = () => { closePopover(); openModal(null, el.dataset.id, el.dataset.key); });
pop.querySelector('.addlink').onclick = () => { closePopover(); openModal(key); };
}
function closePopover() { $('popover').style.display = 'none'; }
/* =====================================================================
* 8. TOAST (undo)
* ===================================================================== */
let toastTimer = null, toastUndoCb = null;
function showToast(msg, undoCb) {
$('toastMsg').textContent = msg;
toastUndoCb = undoCb || null;
$('toastUndo').style.display = undoCb ? '' : 'none';
$('toast').classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => $('toast').classList.remove('show'), 5000);
}
/* =====================================================================
* 9. DRAG & DROP
* Month view: drag chip -> drop on day cell (change date).
* Week view: drag evblock vertically -> change time (15-min snap).
* Uses pointer-free HTML5 DnD for month; custom pointer drag for week.
* ===================================================================== */
let dragInfo = null;
// --- month view (HTML5 DnD) ---
document.addEventListener('dragstart', e => {
const chip = e.target.closest('.chip, .evblock');
if (!chip) return;
const id = chip.dataset.id, key = chip.dataset.key;
dragInfo = { id, key, fromWeek: chip.classList.contains('evblock') };
chip.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', id); } catch (_) {}
});
document.addEventListener('dragend', e => {
document.querySelectorAll('.dragging').forEach(x => x.classList.remove('dragging'));
document.querySelectorAll('.dragover,.dropok').forEach(x => x.classList.remove('dragover', 'dropok'));
});
document.addEventListener('dragover', e => {
const cell = e.target.closest('.mcell');
const col = e.target.closest('.wk-col');
if (cell) { e.preventDefault(); cell.classList.add('dragover'); }
if (col && dragInfo && dragInfo.fromWeek) { e.preventDefault(); col.classList.add('dropok'); }
});
document.addEventListener('dragleave', e => {
const cell = e.target.closest('.mcell');
if (cell) cell.classList.remove('dragover');
const col = e.target.closest('.wk-col');
if (col) col.classList.remove('dropok');
});
document.addEventListener('drop', e => {
const cell = e.target.closest('.mcell');
if (cell && dragInfo) {
e.preventDefault();
const ev = findEv(dragInfo.id);
if (!ev) return;
const newKey = cell.dataset.key;
if (newKey === dragInfo.key) return;
reschedule(ev, dragInfo.key, newKey);
}
const col = e.target.closest('.wk-col');
if (col && dragInfo && dragInfo.fromWeek) {
e.preventDefault();
// vertical position within column determines new start time
const rect = col.getBoundingClientRect();
const rel = e.clientY - rect.top;
const hs = State.hourStart;
let mins = hs * 60 + Math.round((rel / 48) * 60 / CONFIG.SNAP_MIN) * CONFIG.SNAP_MIN;
const ev = findEv(dragInfo.id);
if (!ev) return;
const dur = REC.toMin(ev.end) - REC.toMin(ev.start);
const dayStart = 0, dayEnd = 24 * 60;
mins = Math.max(0, Math.min(mins, dayEnd - dur));
const nk = col.dataset.key;
const upd = { ...ev, start: fmtHHMM(mins), end: fmtHHMM(mins + dur), date: nk };
rescheduleTime(ev, dragInfo.key, upd);
}
dragInfo = null;
});
const fmtHHMM = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
// move a (possibly recurring) occurrence to another date
function reschedule(ev, fromKey, toKey) {
const doIt = () => {
if (!ev.rec) { ev.date = toKey; }
else {
ev.overrides = ev.overrides || {};
const eff = ev.overrides[fromKey] || ev;
ev.overrides[fromKey] = { ...eff, date: toKey, rec: null, id: ev.id, exdates: [], overrides: {} };
if (!ev.exdates.includes(fromKey)) ev.exdates.push(fromKey);
}
render(); save(); showToast('Moved to ' + DU.fmtLong(toKey), () => undoReschedule(ev, fromKey, toKey));
};
doIt();
}
function undoReschedule(ev, fromKey, toKey) {
if (!ev.rec) { ev.date = fromKey; }
else { delete ev.overrides[fromKey]; ev.exdates = ev.exdates.filter(k => k !== fromKey); }
render(); save();
}
// week-view time change
function rescheduleTime(ev, fromKey, upd) {
if (!ev.rec) { Object.assign(ev, { start: upd.start, end: upd.end, date: upd.date }); }
else {
ev.overrides = ev.overrides || {};
const eff = ev.overrides[fromKey] || ev;
ev.overrides[fromKey] = { ...eff, start: upd.start, end: upd.end, date: upd.date, rec: null, id: ev.id, exdates: [], overrides: {} };
if (!ev.exdates.includes(fromKey)) ev.exdates.push(fromKey);
}
render(); save();
}
/* =====================================================================
* 10. EVENT WIRING
* ===================================================================== */
document.getElementById('prevBtn').onclick = () =>
(State.view === 'month' ? navMonth(-1) : State.view === 'week' ? navWeek(-1) : navAgenda(-1));
document.getElementById('nextBtn').onclick = () =>
(State.view === 'month' ? navMonth(1) : State.view === 'week' ? navWeek(1) : navAgenda(1));
document.getElementById('todayBtn').onclick = goToday;
document.querySelectorAll('.viewtabs button').forEach(b =>
b.onclick = () => { State.view = b.dataset.view; render(1); });
// click day cell -> create; click chip/event -> edit
document.getElementById('mgrid').addEventListener('click', e => {
const chip = e.target.closest('.chip');
if (chip) { openModal(null, chip.dataset.id, chip.dataset.key); return; }
const cell = e.target.closest('.mcell');
if (cell) openModal(cell.dataset.key);
});
document.getElementById('wkBody').addEventListener('click', e => {
const blk = e.target.closest('.evblock');
if (blk) { openModal(null, blk.dataset.id, blk.dataset.key); return; }
const col = e.target.closest('.wk-col');
if (col) {
// create at clicked 15-min slot
const rect = col.getBoundingClientRect();
let mins = State.hourStart * 60 + Math.round(((e.clientY - rect.top) / 48) * 60 / CONFIG.SNAP_MIN) * CONFIG.SNAP_MIN;
mins = Math.max(0, Math.min(mins, 23 * 60 + 45));
openModal(col.dataset.key);
$('fStart').value = fmtHHMM(mins);
$('fEnd').value = fmtHHMM(Math.min(mins + 60, 24 * 60));
}
});
document.getElementById('wkAllDayRow').addEventListener('click', e => {
const chip = e.target.closest('.chip');
if (chip) openModal(null, chip.dataset.id, chip.dataset.key);
});
document.getElementById('agList').addEventListener('click', e => {
const row = e.target.closest('.ag-ev');
if (row) openModal(null, row.dataset.id, row.dataset.key);
});
// modal buttons
$('saveBtn').onclick = saveEvent;
$('cancelBtn').onclick = closeModal;
$('deleteBtn').onclick = () => {
const ev = findEv(Modal.editingId);
if (!ev) { closeModal(); return; }
if (!ev.rec) { removeAndToast(ev); return; }
askScopeForDelete(scope => {
if (scope === 'all') removeAndToast(ev);
else if (scope === 'one') {
ev.exdates = ev.exdates || [];
if (!ev.exdates.includes(Modal.editingKey)) ev.exdates.push(Modal.editingKey);
if (ev.overrides) delete ev.overrides[Modal.editingKey];
closeModal(); $('modal').style.display = ''; render(); showToast('Occurrence deleted');
} else if (scope === 'follow') {
const p = DU.fromKey(Modal.editingKey);
const prev = DU.addDays(p.y, p.m, p.d, -1);
ev.rec = { ...ev.rec, until: DU.toKey(prev.y, prev.m, prev.d) };
closeModal(); $('modal').style.display = ''; render(); showToast('Series ends after this event');
}
});
};
$('scopeCancel').onclick = () => {
$('scopeModal').style.display = 'none';
$('modal').style.display = '';
if (!Modal.editingId) $('overlay').classList.remove('open');
};
document.querySelectorAll('#scopeModal .opts button').forEach(b =>
b.onclick = () => {
const s = b.dataset.scope;
const cb = scopeCb2 || scopeCb;
scopeCb = null; scopeCb2 = null;
cb && cb(s);
});
$('fAllDay').onchange = () => { $('timeRow').style.display = $('fAllDay').checked ? 'none' : ''; };
$('fRec').onchange = () => { $('recOpts').classList.toggle('open', $('fRec').checked); syncRecUI(); };
$('fRecType').onchange = syncRecUI;
$('overlay').addEventListener('mousedown', e => { if (e.target === $('overlay')) closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeModal(); closePopover(); $('settings').classList.remove('open'); }
if (e.key === 'Delete' && !Modal.editingId && !e.target.closest('input,textarea,select')) return;
});
// click-away for popover & settings
document.addEventListener('mousedown', e => {
if (!e.target.closest('#popover') && !e.target.closest('.more')) closePopover();
if (!e.target.closest('#settings') && !e.target.closest('#settingsBtn'))
$('settings').classList.remove('open');
});
// settings
$('settingsBtn').onclick = () => {
const s = $('settings');
s.classList.toggle('open');
if (s.classList.contains('open')) {
$('setWeekStart').value = String(State.weekStart);
$('setWeekStart').onchange = () => {
State.weekStart = +$('setWeekStart').value; save(); render(1);
};
}
};
/* =====================================================================
* 11. INIT
* ===================================================================== */
load();
// sync settings selects
(function initSettings() {
const sel1 = $('setHourStart'), sel2 = $('setHourEnd');
for (let h = 0; h < 24; h++) {
const o = `<option value="${h}">${DU.fmtTime(h * 60)}</option>`;
sel1.insertAdjacentHTML('beforeend', o);
sel2.insertAdjacentHTML('beforeend', o);
}
sel1.value = State.hourStart; sel2.value = State.hourEnd;
sel1.onchange = () => { State.hourStart = Math.min(+sel1.value, State.hourEnd - 4); sel1.value = State.hourStart; save(); if (State.view === 'week') render(); };
sel2.onchange = () => { State.hourEnd = Math.max(+sel2.value, State.hourStart + 4); sel2.value = State.hourEnd; save(); if (State.view === 'week') render(); };
$('setWeekStart').value = String(State.weekStart);
$('setWeekStart').onchange = () => { State.weekStart = +$('setWeekStart').value; save(); render(1); };
})();
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"7146a655-69fe-45cd-8e88-912a662dc105","tokensIn":1946854,"tokensOut":32085,"tokensTotal":1978939,"cost":0.10536411999999999,"turns":43,"toolCalls":42,"failedToolCalls":2,"timestamp":"2026-08-28T04:37:23.779Z"} -->