← Calendar results

Calendar

GLM 5.3 Flash · full

Initial view of Calendar

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">&#x2039;</button>
    <button id="nextBtn" title="Next">&#x203a;</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">&#x2699;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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"} -->