← Calendar results

Calendar

ox alpha · full

Initial view of Calendar

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 57,267 bytes · SHA-256 08bbd3673ac7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Event Planner</title>
<style>
/* ============================================================
   THEME & LAYOUT
   ============================================================ */
:root{
  --bg:#ffffff; --bg-soft:#f8f9fa; --line:#e0e0e0; --line-soft:#f1f3f4;
  --text:#202124; --text-dim:#5f6368; --text-faint:#9aa0a6;
  --accent:#1a73e8; --accent-soft:#e8f0fe;
  --today:#1a73e8;
  --radius:8px;
  font-size:15px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
  font-family:"Google Sans",Roboto,-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;
  background:var(--bg); color:var(--text); display:flex; flex-direction:column;
  overflow:hidden;
}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
input,select,textarea{font:inherit}

/* ---------- top bar ---------- */
#topbar{
  display:flex;align-items:center;gap:10px;padding:10px 16px;
  border-bottom:1px solid var(--line-soft);flex-wrap:wrap;
}
#brand{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:500;color:var(--accent)}
#brand svg{display:block}
#todayBtn{
  border:1px solid var(--line);border-radius:var(--radius);padding:6px 14px;
  font-weight:500;color:var(--text-dim);
}
#todayBtn:hover{background:var(--bg-soft)}
.navArrow{
  width:34px;height:34px;border-radius:50%;color:var(--text-dim);
  display:inline-flex;align-items:center;justify-content:center;
}
.navArrow:hover{background:var(--bg-soft)}
#calTitle{font-size:1.35rem;font-weight:400;margin-left:6px;min-width:230px}
#viewTabs{margin-left:auto;display:flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
#viewTabs button{padding:7px 18px;color:var(--text-dim);font-weight:500;background:var(--bg)}
#viewTabs button + button{border-left:1px solid var(--line)}
#viewTabs button.active{background:var(--accent-soft);color:var(--accent)}
#settingsBtn{width:38px;height:38px;border-radius:50%;color:var(--text-dim);position:relative}
#settingsBtn:hover{background:var(--bg-soft)}

/* settings dropdown */
#settingsMenu{
  position:absolute;top:52px;right:16px;z-index:60;background:var(--bg);
  border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,.15);
  padding:12px 16px;display:none;min-width:220px;
}
#settingsMenu.open{display:block}
#settingsMenu h4{font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-faint);margin-bottom:8px}
.segRow{display:flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;margin-bottom:12px}
.segRow button{flex:1;padding:6px 10px;color:var(--text-dim)}
.segRow button.active{background:var(--accent-soft);color:var(--accent)}
.segRow button + button{border-left:1px solid var(--line)}

/* ---------- main area ---------- */
#main{flex:1;overflow:hidden;position:relative}
.viewAnim{animation:viewIn .22s ease}
@keyframes viewIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}

/* ---------- MONTH VIEW ---------- */
.monthWrap{height:100%;display:flex;flex-direction:column}
.monthHead,.weekHeadStrip{
  display:grid;border-bottom:1px solid var(--line);
}
.monthHead{grid-template-columns:repeat(7,1fr)}
.monthHead div{padding:8px 4px;text-align:center;font-size:.72rem;font-weight:500;
  color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em}
#monthGrid{
  flex:1;display:grid;
  grid-template-columns:repeat(7,1fr);grid-template-rows:repeat(6,1fr);
  overflow:hidden}
.mcell{
  border-right:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft);
  padding:3px 4px;overflow:hidden;cursor:pointer;position:relative;min-height:0;
  display:flex;flex-direction:column;
}
.mcell:nth-child(7n){border-right:none}
.mcell.other{background:var(--bg-soft)}
.mcell.dropTarget{background:var(--accent-soft);box-shadow:inset 0 0 0 2px var(--accent)}
.mcell.dragOver{background:var(--accent-soft)}
.daynum{
  width:24px;height:24px;line-height:24px;text-align:center;border-radius:50%;
  font-size:.78rem;color:var(--text-dim);align-self:flex-end;margin:1px 2px 2px 0;flex:none;
}
.mcell.other .daynum{color:var(--text-faint)}
.mcell.today .daynum{background:var(--today);color:#fff;font-weight:600}
.chip{
  display:flex;align-items:center;font-size:.74rem;line-height:1.25;
  padding:1px 4px 1px 6px;border-radius:4px;margin-bottom:2px;cursor:grab;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none;
}
.chip .dot{width:7px;height:7px;border-radius:50%;margin-right:5px;flex:none}
.chip.filled{border-radius:4px;color:#fff;padding-left:6px}
.chip.time{color:var(--text-dim)}
.moreLink{font-size:.72rem;color:var(--text-dim);padding:1px 4px;border-radius:4px;align-self:flex-start}
.moreLink:hover{background:var(--line-soft)}

/* day popover */
#dayPop{
  position:absolute;z-index:70;background:var(--bg);border-radius:12px;
  box-shadow:0 6px 24px rgba(0,0,0,.22);width:300px;max-height:380px;display:none;
  flex-direction:column;overflow:hidden;
}
#dayPop.open{display:flex}
#dayPop header{padding:14px 16px 8px;font-size:.85rem;color:var(--text-dim);display:flex;justify-content:space-between}
#dayPop header b{color:var(--text);font-size:1rem;font-weight:500}
#dayPopList{overflow:auto;padding:0 8px 8px}
.popEvent{display:flex;gap:10px;padding:8px;border-radius:8px;cursor:pointer;align-items:center}
.popEvent:hover{background:var(--bg-soft)}
.popEvent .bar{width:4px;align-self:stretch;border-radius:2px;flex:none}
.popEvent .t{font-size:.78rem;color:var(--text-dim);white-space:nowrap}
.popEvent .n{font-size:.85rem}

/* ---------- WEEK VIEW ---------- */
.weekWrap{height:100%;display:flex;flex-direction:column}
.weekHeadStrip{grid-template-columns:56px repeat(7,1fr);background:var(--bg)}
.wkHeadCell{padding:6px 2px;text-align:center;border-left:1px solid var(--line-soft)}
.wkHeadCell .dn{font-size:.68rem;text-transform:uppercase;color:var(--text-dim);letter-spacing:.05em}
.wkHeadCell .num{
  width:30px;height:30px;line-height:30px;border-radius:50%;margin:2px auto 0;
  font-size:1.05rem;font-weight:500;
}
.wkHeadCell.today .num{background:var(--today);color:#fff}
.allDayRow{
  display:grid;grid-template-columns:56px repeat(7,1fr);
  border-bottom:1px solid var(--line);min-height:26px;
}
.allDayRow .lbl{font-size:.66rem;color:var(--text-faint);text-align:right;padding:4px 6px 0 0}
.adCell{border-left:1px solid var(--line-soft);padding:1px;position:relative;min-width:0}
.adChip{font-size:.72rem;border-radius:4px;padding:1px 5px;margin-bottom:1px;
  color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:grab}
#weekScroll{flex:1;overflow-y:auto;position:relative}
.weekGrid{display:grid;grid-template-columns:56px repeat(7,1fr);position:relative}
.gutterCol{position:relative}
.hourLbl{height:48px;font-size:.66rem;color:var(--text-faint);text-align:right;padding-right:6px;transform:translateY(-6px)}
.dayCol{border-left:1px solid var(--line-soft);position:relative}
.hourLine{height:48px;border-top:1px solid var(--line-soft)}
.evBlock{
  position:absolute;left:2px;right:4px;border-radius:5px;color:#fff;
  font-size:.72rem;padding:2px 5px;overflow:hidden;cursor:grab;z-index:2;
  box-shadow:0 1px 2px rgba(0,0,0,.15);
}
.evBlock .et{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.evBlock .en{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#nowLine{position:absolute;left:0;right:0;height:0;border-top:2px solid #ea4335;z-index:5;pointer-events:none}
#nowLine::before{content:"";position:absolute;left:-1px;top:-5px;width:9px;height:9px;border-radius:50%;background:#ea4335}

/* ---------- AGENDA ---------- */
.agendaWrap{height:100%;overflow-y:auto;padding:8px 20px 40px}
.agDay{max-width:720px;margin:0 auto}
.agDayHead{display:flex;align-items:baseline;gap:12px;padding:14px 4px 6px;border-bottom:1px solid var(--line-soft)}
.agDayHead .dow{font-size:.78rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;width:88px}
.agDayHead .dnum{font-size:1.5rem;font-weight:400}
.agDayHead.today .dnum,.agDayHead.today .dow{color:var(--accent)}
.agEvents{padding:4px 0 10px}
.agEvent{display:flex;gap:14px;padding:9px 8px;border-radius:10px;cursor:pointer;align-items:center}
.agEvent:hover{background:var(--bg-soft)}
.agEvent .bar{width:5px;align-self:stretch;border-radius:3px;flex:none}
.agEvent .time{width:130px;font-size:.8rem;color:var(--text-dim);flex:none}
.agEvent .body b{font-weight:500;display:block}
.agEvent .body span{font-size:.8rem;color:var(--text-dim)}
.agEmpty{text-align:center;color:var(--text-faint);padding:60px 20px}

/* ---------- MODAL EDITOR ---------- */
#modalBack{
  position:fixed;inset:0;background:rgba(32,33,36,.5);z-index:90;
  display:none;align-items:center;justify-content:center;padding:16px;
}
#modalBack.open{display:flex}
#modal{
  background:var(--bg);border-radius:14px;width:430px;max-width:100%;
  max-height:92vh;overflow-y:auto;box-shadow:0 10px 40px rgba(0,0,0,.3);
  animation:popIn .18s ease;
}
@keyframes popIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
#modal header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px 4px}
#modalTitleInp{
  border:none;outline:none;font-size:1.25rem;font-weight:500;flex:1;min-width:0;
  color:var(--text);background:transparent;
}
#modalTitleInp::placeholder{color:var(--text-faint)}
.iconBtn{width:34px;height:34px;border-radius:50%;color:var(--text-dim);flex:none;
  display:inline-flex;align-items:center;justify-content:center}
.iconBtn:hover{background:var(--bg-soft)}
.mBody{padding:6px 20px 8px}
.mRow{display:flex;align-items:flex-start;gap:12px;padding:8px 0}
.mRow .mic{color:var(--text-dim);margin-top:8px;flex:none;width:20px;text-align:center}
.mFields{flex:1;min-width:0}
.mInline{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.inp{
  border:1px solid var(--line);border-radius:6px;padding:7px 10px;background:var(--bg);
  outline:none;color:var(--text);
}
.inp:focus{border-color:var(--accent)}
.err{color:#d93025;font-size:.76rem;margin-top:3px;display:none}
.err.show{display:block}
.inp.bad{border-color:#d93025}
.chkRow{display:flex;align-items:center;gap:8px;font-size:.9rem;color:var(--text-dim);padding:4px 0}
.palette{display:flex;gap:8px;flex-wrap:wrap}
.swatch{width:26px;height:26px;border-radius:50%;cursor:pointer;border:2px solid transparent;position:relative}
.swatch.sel{border-color:var(--text);box-shadow:inset 0 0 0 2px #fff}
textarea.notes{width:100%;border:1px solid var(--line);border-radius:6px;padding:8px 10px;
  resize:vertical;min-height:64px;outline:none}
textarea.notes:focus{border-color:var(--accent)}
.wdPicker{display:flex;gap:5px}
.wdPicker button{width:32px;height:32px;border-radius:50%;border:1px solid var(--line);
  font-size:.72rem;color:var(--text-dim)}
.wdPicker button.on{background:var(--accent);border-color:var(--accent);color:#fff}
.mFoot{display:flex;justify-content:space-between;padding:10px 16px 16px}
.btnPrimary{
  background:var(--accent);color:#fff;border-radius:20px;padding:9px 24px;font-weight:500;
}
.btnPrimary:hover{box-shadow:0 1px 4px rgba(26,115,232,.5)}
.btnText{color:var(--accent);font-weight:500;padding:9px 14px;border-radius:6px}
.btnText:hover{background:var(--accent-soft)}
.btnDanger{color:#d93025;font-weight:500;padding:9px 14px;border-radius:6px}
.btnDanger:hover{background:#fce8e6}

/* recurring-choice mini dialog */
#recAsk{
  position:fixed;inset:0;background:rgba(32,33,36,.45);z-index:95;
  display:none;align-items:center;justify-content:center;padding:16px;
}
#recAsk.open{display:flex}
#recAskCard{background:var(--bg);border-radius:14px;padding:22px 24px;width:360px;max-width:100%;
  box-shadow:0 10px 40px rgba(0,0,0,.3)}
#recAskCard h3{font-weight:500;margin-bottom:6px}
#recAskCard p{font-size:.86rem;color:var(--text-dim);margin-bottom:14px}
#recAskCard button{display:block;width:100%;text-align:left;padding:11px 12px;border-radius:8px;color:var(--text)}
#recAskCard button:hover{background:var(--bg-soft)}
#recAskCard button small{display:block;color:var(--text-faint);font-size:.76rem}

/* toast */
#toast{
  position:fixed;left:50%;bottom:28px;transform:translate(-50%,80px);
  background:#323639;color:#fff;border-radius:6px;padding:13px 18px;z-index:99;
  display:flex;gap:22px;align-items:center;font-size:.9rem;transition:transform .25s ease;
  box-shadow:0 4px 14px rgba(0,0,0,.3);
}
#toast.show{transform:translate(-50%,0)}
#toast button{color:#8ab4f8;font-weight:600}

@media(max-width:900px){
  #calTitle{font-size:1.1rem;min-width:0}
  #brand span{display:none}
  #viewTabs button{padding:7px 10px}
  .chip{font-size:.66rem}
  .evBlock{font-size:.64rem}
}
</style>
</head>
<body>

<!-- ================= TOP BAR ================= -->
<div id="topbar">
  <div id="brand">
    <svg width="26" height="26" viewBox="0 0 24 24"><path fill="#1a73e8" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 15H5V10h14zM5 8V6h14v2z"/></svg>
    <span>Calendar</span>
  </div>
  <button id="todayBtn">Today</button>
  <button class="navArrow" id="prevBtn" aria-label="Previous">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.4 7.4 14 6l-6 6 6 6 1.4-1.4L10.8 12z"/></svg>
  </button>
  <button class="navArrow" id="nextBtn" aria-label="Next">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M9.6 16.6 11 18l6-6-6-6-1.4 1.4 4.6 4.6z"/></svg>
  </button>
  <div id="calTitle"></div>
  <div id="viewTabs">
    <button data-view="month">Month</button>
    <button data-view="week">Week</button>
    <button data-view="agenda">Agenda</button>
  </div>
  <button id="settingsBtn" aria-label="Settings">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2.1-1.7a.5.5 0 0 0 .1-.6l-2-3.5a.5.5 0 0 0-.6-.2l-2.5 1a7.3 7.3 0 0 0-1.7-1l-.4-2.6A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.4l-.4 2.7a7.3 7.3 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.5a.5.5 0 0 0 .1.6L4.6 11a7.6 7.6 0 0 0 0 2l-2.2 1.7a.5.5 0 0 0-.1.6l2 3.5c.1.2.4.3.6.2l2.5-1a7.3 7.3 0 0 0 1.7 1l.4 2.6c0 .3.2.4.5.4h4c.2 0 .4-.2.5-.4l.4-2.7a7.3 7.3 0 0 0 1.7-1l2.5 1c.2.1.5 0 .6-.2l2-3.5a.5.5 0 0 0-.1-.6zM12 15.5A3.5 3.5 0 1 1 15.5 12 3.5 3.5 0 0 1 12 15.5z"/></svg>
  </button>
</div>

<div id="settingsMenu">
  <h4>Week starts on</h4>
  <div class="segRow" id="weekStartSeg">
    <button data-ws="1">Monday</button>
    <button data-ws="0">Sunday</button>
  </div>
  <h4>Data</h4>
  <button class="btnDanger" id="clearDataBtn" style="padding:6px 0">Clear all events</button>
</div>

<!-- ================= MAIN ================= -->
<div id="main"></div>

<!-- day popover -->
<div id="dayPop">
  <header><b id="dayPopDate"></b><button class="iconBtn" id="dayPopClose">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
  </button></header>
  <div id="dayPopList"></div>
</div>

<!-- ================= EVENT MODAL ================= -->
<div id="modalBack">
  <div id="modal">
    <header>
      <input id="modalTitleInp" placeholder="Add title">
      <button class="iconBtn" id="modalClose">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
      </button>
    </header>
    <div class="mBody">
      <div class="mRow"><div class="mic">🕐</div><div class="mFields">
        <div class="chkRow"><label><input type="checkbox" id="fAllDay"> All day</label></div>
        <div class="mInline">
          <input type="time" id="fStart" class="inp" step="900">
          <span>–</span>
          <input type="time" id="fEnd" class="inp" step="900">
        </div>
        <div class="err" id="errTime"></div>
      </div></div>
      <div class="mRow"><div class="mic">📅</div><div class="mFields">
        <input type="date" id="fDate" class="inp">
        <div class="err" id="errDate"></div>
      </div></div>
      <div class="mRow"><div class="mic">🔁</div><div class="mFields">
        <select id="fRec" class="inp">
          <option value="">Does not repeat</option>
          <option value="daily">Daily</option>
          <option value="weekly">Weekly on…</option>
          <option value="monthly">Monthly on the same day</option>
          <option value="yearly">Yearly</option>
        </select>
        <div class="wdPicker" id="wdPicker" style="margin-top:8px;display:none"></div>
        <div class="err" id="errRec"></div>
      </div></div>
      <div class="mRow"><div class="mic">🎨</div><div class="mFields"><div class="palette" id="palette"></div></div></div>
      <div class="mRow"><div class="mic">📝</div><div class="mFields">
        <textarea id="fNotes" class="notes" placeholder="Notes"></textarea>
      </div></div>
    </div>
    <div class="mFoot">
      <button class="btnDanger" id="deleteBtn">Delete</button>
      <button class="btnPrimary" id="saveBtn">Save</button>
    </div>
  </div>
</div>

<!-- recurring edit choice -->
<div id="recAsk">
  <div id="recAskCard">
    <h3 id="recAskTitle">Edit recurring event</h3>
    <p>This event repeats. Which events do you want to change?</p>
    <button data-mode="one">This event<small>Only this occurrence changes</small></button>
    <button data-mode="follow">This and following events<small>This one and every event after it</small></button>
    <button data-mode="all">All events in the series<small>The entire repeating series</small></button>
    <button data-mode="cancel" style="color:var(--text-dim)">Cancel</button>
  </div>
</div>

<div id="toast"><span id="toastMsg"></span><button id="toastUndo">Undo</button></div>

<script>
"use strict";
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONST = {
  HOUR_START: 6,            // first hour row in week view
  HOUR_END: 22,             // last hour (exclusive of trailing line)
  HOUR_PX: 48,              // px per hour
  SNAP_MIN: 15,             // drag snap interval (minutes)
  WEEK_START_DEFAULT: 1,    // 0=Sunday, 1=Monday
  MAX_CHIPS: 3,             // chips per month cell before "+N more"
  AGENDA_DAYS: 60,          // agenda horizon
  PALETTE: [
    {id:'blue',   c:'#1a73e8'}, {id:'green', c:'#188038'},
    {id:'purple', c:'#8e24aa'}, {id:'red',   c:'#d50000'},
    {id:'orange', c:'#ef6c00'}, {id:'teal',  c:'#00796b'}
  ],
  LS_KEY: 'calplanner.v1'
};
const DOW_NAMES_SUN = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MONTH_NAMES = ['January','February','March','April','May','June','July',
  'August','September','October','November','December'];

/* ============================================================
   DATE UTIL MODULE — pure functions, no rendering, native Date only.
   All arithmetic works on {y,m,d} components; m is 1-based.
   ============================================================ */
const DU = (() => {
  const isLeap = y => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
  // m: 1..12
  const daysInMonth = (y, m) => {
    switch (m) {
      case 1: case 3: case 5: case 7: case 8: case 10: case 12: return 31;
      case 4: case 6: case 9: case 11: return 30;
      case 2: return isLeap(y) ? 29 : 28;
    }
    throw new Error('bad month ' + m);
  };
  // Construct a real Date at local noon (avoids DST edge cases) purely to get weekday.
  const dow = (y, m, d) => new Date(y, m - 1, d, 12).getDay(); // 0=Sun
  const cmp = (a, b) => a.y !== b.y ? a.y - b.y : a.m !== b.m ? a.m - b.m : a.d - b.d;
  const eq = (a, b) => a.y === b.y && a.m === b.m && a.d === b.d;
  const clone = a => ({ y: a.y, m: a.m, d: a.d });
  // add n days via component-safe walk (no ms math)
  const addDays = (a, n) => {
    let { y, m, d } = a; d += n;
    while (d > daysInMonth(y, m)) { d -= daysInMonth(y, m); if (++m > 12) { m = 1; y++; } }
    while (d < 1) { if (--m < 1) { m = 12; y--; } d += daysInMonth(y, m); }
    return { y, m, d };
  };
  // add n months, clamping day-of-month (Jan 31 + 1mo -> Feb 28/29)
  const addMonths = (a, n) => {
    const total = a.y * 12 + (a.m - 1) + n;
    const y = Math.floor(total / 12), m = total % 12 + 1;
    return { y, m, d: Math.min(a.d, daysInMonth(y, m)) };
  };
  // date of the week-start day on or before `a`
  const weekStartOf = (a, weekStart) => addDays(a, -(dow(a.y, a.m, a.d) - weekStart + 7) % 7);
  // ISO-8601-style week number but respecting configured week start:
  // week 1 is the week containing Jan 4.
  const weekNumber = (a, weekStart) => {
    const target = weekStartOf(a, weekStart);
    const jan4 = { y: a.y, m: 1, d: 4 };
    const w1 = weekStartOf(jan4, weekStart);
    return Math.floor((daysBetween(w1, target)) / 7) + 1;
  };
  const daysBetween = (a, b) => toOrdinal(b) - toOrdinal(a);
  // days since epoch via civil-from-days inverse math (proleptic Gregorian)
  function toOrdinal(a) {
    let y = a.y, m = a.m;
    if (m <= 2) { y -= 1; m += 12; }
    const era = Math.floor(y / 400);
    const yoe = y - era * 400;
    return era * 146097 + Math.floor(yoe * 365.25) - Math.floor(yoe / 100)
      + Math.floor((153 * m - 457) / 5) + a.d - 306;
  }
  const pad = n => String(n).padStart(2, '0');
  const key = a => `${a.y}-${pad(a.m)}-${pad(a.d)}`;
  const parseKey = s => { const [y, m, d] = s.split('-').map(Number); return { y, m, d }; };
  const today = () => { const t = new Date(); return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() }; };
  const fmtLong = a => `${DOW_NAMES_SUN[dow(a.y, a.m, a.d)]}, ${MONTH_NAMES[a.m-1].slice(0,3)} ${a.d}, ${a.y}`;
  const fmtTime = hm => {
    if (!hm) return '';
    let [h, mi] = hm.split(':').map(Number);
    const ap = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
    return `${h}:${pad(mi)} ${ap}`;
  };
  const minsOf = hm => { const [h, mi] = hm.split(':').map(Number); return h * 60 + mi; };
  const hmOf = mins => `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`;
  return { isLeap, daysInMonth, dow, cmp, eq, clone, addDays, addMonths,
           weekStartOf, weekNumber, daysBetween, key, parseKey, today,
           fmtLong, fmtTime, minsOf, hmOf, pad };
})();

/* ============================================================
   RECURRENCE EXPANSION — range-limited, never materializes infinite series
   ============================================================ */
// rec: {freq:'daily'|'weekly'|'monthly'|'yearly', weekdays?:[0..6](Sun=0), until?:'YYYY-MM-DD'}
// Returns occurrence base dates within [from,to] for an event starting at ev.date.
function expandRecurrence(ev, from, to) {
  const out = [];
  const base = DU.parseKey(ev.date);
  const until = ev.rec && ev.rec.until ? DU.parseKey(ev.rec.until) : null;
  const push = (dt) => {
    if (DU.cmp(dt, base) < 0) return false;
    if (until && DU.cmp(dt, until) > 0) return false;
    out.push(dt); return true;
  };
  if (!ev.rec || ev.rec.freq === 'none') { if (DU.cmp(base, from) >= 0 && DU.cmp(base, to) <= 0) out.push(base); return out; }

  if (ev.rec.freq === 'daily') {
    let d = DU.cmp(base, from) >= 0 ? base : from;
    // align to recurrence phase
    const off = DU.daysBetween(base, d);
    if (off > 0) d = DU.addDays(base, Math.ceil(off / 1) * 1);
    for (let cur = d; DU.cmp(cur, to) <= 0; cur = DU.addDays(cur, 1)) push(cur);

  } else if (ev.rec.freq === 'weekly') {
    const wdSet = new Set(ev.rec.weekdays && ev.rec.weekdays.length ? ev.rec.weekdays : [DU.dow(base.y, base.m, base.d)]);
    for (let cur = from; DU.cmp(cur, to) <= 0; cur = DU.addDays(cur, 1)) {
      if (wdSet.has(DU.dow(cur.y, cur.m, cur.d))) push(cur);
    }

  } else if (ev.rec.freq === 'monthly') {
    // same day-of-month; months without that day are naturally skipped
    const day = base.d;
    let y = from.y, m = from.m;
    // start at month containing max(from aligned after base)
    for (;;) {
      if (y * 12 + m > to.y * 12 + to.m) break;
      if (day <= DU.daysInMonth(y, m)) {
        const dt = { y, m, d: day };
        if (DU.cmp(dt, to) <= 0) push(dt);
      }
      if (++m > 12) { m = 1; y++; }
      if (y * 12 + m < base.y * 12 + base.m) { /* skip pre-base months */ }
    }

  } else if (ev.rec.freq === 'yearly') {
    for (let y = from.y; y <= to.y; y++) {
      if (base.d <= DU.daysInMonth(y, base.m)) {
        const dt = { y, m: base.m, d: base.d };
        if (DU.cmp(dt, to) <= 0) push(dt);
      }
    }
  }
  return out;
}

/* ============================================================
   STORE — events, settings, persistence
   ============================================================ */
const Store = {
  state: { view: 'month', cursor: null, weekStart: CONST.WEEK_START_DEFAULT, events: [] },
  load() {
    try {
      const raw = localStorage.getItem(CONST.LS_KEY);
      if (raw) {
        const s = JSON.parse(raw);
        Object.assign(this.state, s);
      }
    } catch (e) { console.warn('load failed', e); }
    if (!this.state.cursor || !this.state.cursor.y) this.state.cursor = DU.today();
    else this.state.cursor = { y: this.state.cursor.y, m: this.state.cursor.m, d: this.state.cursor.d };
  },
  save() {
    try { localStorage.setItem(CONST.LS_KEY, JSON.stringify(this.state)); }
    catch (e) { console.warn('save failed', e); }
  },
  uid() { return 'ev' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); },

  // Expand all visible instances in [from,to]: merges exceptions onto masters.
  instancesInRange(from, to) {
    const out = [];
    for (const ev of this.state.events) {
      const dates = expandRecurrence(ev, from, to);
      for (const dt of dates) {
        const k = DU.key(dt);
        const ex = ev.exceptions && ev.exceptions[k];
        if (ex) {
          if (ex.deleted) continue;
          out.push({ ...ev, ...ex.patch, date: k, _masterId: ev.id, _occKey: k });
        } else {
          out.push({ ...ev, date: k, _masterId: ev.id, _occKey: ev.rec ? k : null });
        }
      }
    }
    out.sort((a, b) => {
      const dc = DU.cmp(DU.parseKey(a.date), DU.parseKey(b.date));
      if (dc) return dc;
      const aa = a.allDay ? -1 : DU.minsOf(a.start || '00:00');
      const bb = b.allDay ? -1 : DU.minsOf(b.start || '00:00');
      return aa - bb;
    });
    return out;
  },
  getEvent(id) { return this.state.events.find(e => e.id === id); },
  addEvent(ev) { this.state.events.push(ev); this.save(); },
  removeEvent(id) { this.state.events = this.state.events.filter(e => e.id !== id); this.save(); },
  replaceEvent(ev) {
    const i = this.state.events.findIndex(e => e.id === ev.id);
    if (i >= 0) this.state.events[i] = ev; else this.state.events.push(ev);
    this.save();
  },
  clearAll() { this.state.events = []; this.save(); }
};

/* ============================================================
   RECURRING EDIT / DELETE SEMANTICS
   ============================================================ */
// mode: 'one' | 'follow' | 'all'
// patch: full replacement fields for the occurrence/series (without exceptions)
function applyRecurringEdit(masterId, occKey, patch, mode, undoStack) {
  const master = Store.getEvent(masterId);
  if (!master) return;
  undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
  if (mode === 'all') {
    Store.replaceEvent({ ...master, ...patch });
    return;
  }
  if (mode === 'one') {
    if (!master.exceptions) master.exceptions = {};
    if (patch.date && patch.date !== occKey) {
      // moved: delete this occurrence, create standalone event at new date
      master.exceptions[occKey] = { deleted: true };
      const { _masterId, _occKey, ...fields } = { ...master, ...patch };
      delete fields.rec; delete fields.exceptions;
      delete fields.id; delete fields.date;
      Store.addEvent({ id: Store.uid(), ...fields, date: patch.date, exceptions: {} });
    } else {
      master.exceptions[occKey] = { patch: { ...patch, date: undefined } };
      // clean undefined date
      delete master.exceptions[occKey].patch.date;
    }
    Store.replaceEvent(master);
    return;
  }
  if (mode === 'follow') {
    // truncate master before occKey, spawn new series at occKey with patch
    const occ = DU.parseKey(occKey);
    const lastDay = DU.addDays(occ, -1);
    const truncated = { ...master, rec: { ...master.rec, until: DU.key(lastDay) } };
    // drop now-empty weekly weekday entries? keep as-is (until bounds it)
    const { _masterId, _occKey, ...fields } = { ...master, ...patch };
    delete fields.id; delete fields.exceptions;
    const newSeries = { ...fields, date: occKey, exceptions: {} };
    Store.replaceEvent(truncated);
    Store.addEvent(newSeries);
  }
}
function applyRecurringDelete(masterId, occKey, mode, undoStack) {
  const master = Store.getEvent(masterId);
  if (!master) return;
  undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
  if (mode === 'all') { Store.removeEvent(masterId); return; }
  if (mode === 'one') {
    if (!master.exceptions) master.exceptions = {};
    master.exceptions[occKey] = { deleted: true };
    Store.replaceEvent(master);
    return;
  }
  if (mode === 'follow') {
    const occ = DU.parseKey(occKey);
    const truncated = { ...master, rec: { ...master.rec, until: DU.key(DU.addDays(occ, -1)) } };
    Store.replaceEvent(truncated);
  }
}
</script>
<script>
"use strict";
/* ============================================================
   RENDERING
   ============================================================ */
const App = {
  undoStack: [],
  main: document.getElementById('main'),
};

function colorOf(ev) { return (CONST.PALETTE.find(p => p.id === ev.color) || CONST.PALETTE[0]).c; }

function render() {
  const s = Store.state;
  document.querySelectorAll('#viewTabs button').forEach(b =>
    b.classList.toggle('active', b.dataset.view === s.view));
  document.querySelectorAll('#weekStartSeg button').forEach(b =>
    b.classList.toggle('active', Number(b.dataset.ws) === s.weekStart));
  closeDayPop();
  const wrap = document.createElement('div');
  wrap.className = 'viewAnim';
  wrap.style.height = '100%';
  if (s.view === 'month') { renderTitle(); renderMonth(wrap); }
  else if (s.view === 'week') { renderTitle(); renderWeek(wrap); }
  else { renderTitle(); renderAgenda(wrap); }
  App.main.innerHTML = '';
  App.main.appendChild(wrap);
  if (s.view === 'week') startNowLine();
}

function rangeForView() {
  const s = Store.state;
  if (s.view === 'month') {
    const first = { y: s.cursor.y, m: s.cursor.m, d: 1 };
    return { from: DU.weekStartOf(first, s.weekStart), to: DU.addDays(DU.addMonths(first, 1), -1) };
  }
  if (s.view === 'week') {
    const from = DU.weekStartOf(s.cursor, s.weekStart);
    return { from, to: DU.addDays(from, 6) };
  }
  const t = DU.today();
  return { from: t, to: DU.addDays(t, CONST.AGENDA_DAYS) };
}

function renderTitle() {
  const s = Store.state, c = s.cursor;
  let t;
  if (s.view === 'month') t = `${MONTH_NAMES[c.m - 1]} ${c.y}`;
  else if (s.view === 'week') {
    const f = DU.weekStartOf(c, s.weekStart), l = DU.addDays(f, 6);
    t = `${MONTH_NAMES[f.m-1].slice(0,3)} ${f.d} – ${MONTH_NAMES[l.m-1].slice(0,3)} ${l.d}, ${l.y}`;
  } else t = 'Agenda';
  document.getElementById('calTitle').textContent = t;
}

/* ---------------- MONTH VIEW ---------------- */
function renderMonth(root) {
  const s = Store.state;
  const wrap = document.createElement('div'); wrap.className = 'monthWrap';
  const head = document.createElement('div'); head.className = 'monthHead';
  for (let i = 0; i < 7; i++) {
    const d = document.createElement('div');
    d.textContent = DOW_NAMES_SUN[(i + s.weekStart) % 7];
    head.appendChild(d);
  }
  wrap.appendChild(head);
  const grid = document.createElement('div'); grid.id = 'monthGrid';
  const first = { y: s.cursor.y, m: s.cursor.m, d: 1 };
  const start = DU.weekStartOf(first, s.weekStart);
  const today = DU.today();
  const insts = Store.instancesInRange(start, DU.addDays(start, 41));
  const byDate = {};
  for (const inst of insts) (byDate[inst.date] = byDate[inst.date] || []).push(inst);

  for (let i = 0; i < 42; i++) {
    const dt = DU.addDays(start, i);
    const cell = document.createElement('div');
    cell.className = 'mcell' + (dt.m !== s.cursor.m ? ' other' : '') +
      (DU.eq(dt, today) ? ' today' : '');
    cell.dataset.date = DU.key(dt);
    const num = document.createElement('div'); num.className = 'daynum'; num.textContent = dt.d;
    cell.appendChild(num);
    const list = byDate[DU.key(dt)] || [];
    list.slice(0, CONST.MAX_CHIPS).forEach(inst => cell.appendChild(makeChip(inst)));
    if (list.length > CONST.MAX_CHIPS) {
      const more = document.createElement('button');
      more.className = 'moreLink';
      more.textContent = `+${list.length - CONST.MAX_CHIPS} more`;
      more.addEventListener('click', e => { e.stopPropagation(); openDayPop(dt, list, more); });
      cell.appendChild(more);
    }
    cell.addEventListener('click', () => openEditor(DU.key(dt), false));
    attachDrop(cell, dt);
    grid.appendChild(cell);
  }
  wrap.appendChild(grid);
  root.appendChild(wrap);
}

function makeChip(inst, filled) {
  const chip = document.createElement('div');
  chip.className = 'chip' + (filled ? ' filled' : '');
  chip.draggable = true;
  chip.dataset.eventId = inst._masterId;
  chip.dataset.occKey = inst._occKey || '';
  chip.dataset.date = inst.date;
  chip.title = `${inst.title}${inst.allDay ? '' : ' · ' + DU.fmtTime(inst.start)}`;
  if (filled) {
    chip.style.background = colorOf(inst);
    chip.textContent = (inst.allDay ? '' : DU.fmtTime(inst.start) + ' ') + inst.title;
  } else {
    const dot = document.createElement('span'); dot.className = 'dot';
    dot.style.background = colorOf(inst);
    chip.appendChild(dot);
    const tx = document.createElement('span');
    tx.textContent = (inst.allDay ? '' : DU.fmtTime(inst.start) + ' ') + inst.title;
    chip.appendChild(tx);
  }
  chip.addEventListener('click', e => { e.stopPropagation(); openEditorFromInstance(inst); });
  attachChipDrag(chip, inst);
  return chip;
}

/* day popover */
let popAnchorBtn = null;
function openDayPop(dt, list, anchor) {
  const pop = document.getElementById('dayPop');
  document.getElementById('dayPopDate').textContent = DU.fmtLong(dt);
  const lst = document.getElementById('dayPopList');
  lst.innerHTML = '';
  list.forEach(inst => {
    const row = document.createElement('div'); row.className = 'popEvent';
    const bar = document.createElement('div'); bar.className = 'bar'; bar.style.background = colorOf(inst);
    const t = document.createElement('div'); t.className = 't';
    t.textContent = inst.allDay ? 'All day' : `${DU.fmtTime(inst.start)}–${DU.fmtTime(inst.end)}`;
    const n = document.createElement('div'); n.className = 'n'; n.textContent = inst.title;
    row.append(bar, t, n);
    row.addEventListener('click', () => { closeDayPop(); openEditorFromInstance(inst); });
    lst.appendChild(row);
  });
  pop.classList.add('open');
  const r = anchor.getBoundingClientRect();
  pop.style.top = Math.min(r.bottom + 4, window.innerHeight - 400) + 'px';
  pop.style.left = Math.max(8, Math.min(r.left - 120, window.innerWidth - 316)) + 'px';
}
function closeDayPop(){ document.getElementById('dayPop').classList.remove('open'); }

/* ---------------- WEEK VIEW ---------------- */
function renderWeek(root) {
  const s = Store.state;
  const wrap = document.createElement('div'); wrap.className = 'weekWrap';
  const from = DU.weekStartOf(s.cursor, s.weekStart);
  const days = Array.from({length:7},(_,i)=>DU.addDays(from,i));
  const today = DU.today();

  // header
  const head = document.createElement('div'); head.className = 'weekHeadStrip';
  head.appendChild(Object.assign(document.createElement('div'),{}));
  days.forEach(dt=>{
    const c=document.createElement('div'); c.className='wkHeadCell'+(DU.eq(dt,today)?' today':'');
    c.innerHTML=`<div class="dn">${DOW_NAMES_SUN[DU.dow(dt.y,dt.m,dt.d)]}</div><div class="num">${dt.d}</div>`;
    head.appendChild(c);
  });
  wrap.appendChild(head);

  const allInsts = Store.instancesInRange(from, DU.addDays(from,6));
  const timed = allInsts.filter(e=>!e.allDay);
  const allDay = allInsts.filter(e=>e.allDay);

  // all-day strip
  const adRow = document.createElement('div'); adRow.className='allDayRow';
  adRow.appendChild(Object.assign(document.createElement('div'),{className:'lbl',textContent:'All day'}));
  days.forEach(dt=>{
    const cell=document.createElement('div'); cell.className='adCell'; cell.dataset.date=DU.key(dt);
    const k=DU.key(dt);
    allDay.filter(e=>e.date===k).forEach(e=>{
      const c=makeChip(e,true);c.classList.add('adChip');c.classList.remove('filled');
      cell.appendChild(c);
    });
    cell.addEventListener('click',()=>openEditor(k,false));
    attachDrop(cell,dt);
    adRow.appendChild(cell);
  });
  wrap.appendChild(adRow);

  // scrollable hour grid
  const scroll=document.createElement('div'); scroll.id='weekScroll';
  const grid=document.createElement('div'); grid.className='weekGrid';
  const gutter=document.createElement('div'); gutter.className='gutterCol';
  for(let h=CONST.HOUR_START;h<=CONST.HOUR_END;h++){
    const l=document.createElement('div'); l.className='hourLbl';
    l.textContent=h===CONST.HOUR_START?'':DU.fmtTime(DU.pad(h)+':00');
    gutter.appendChild(l);
  }
  grid.appendChild(gutter);
  days.forEach(dt=>{
    const col=document.createElement('div'); col.className='dayCol'; col.dataset.date=DU.key(dt);
    for(let h=CONST.HOUR_START;h<=CONST.HOUR_END;h++){
      const line=document.createElement('div'); line.className='hourLine'; col.appendChild(line);
    }
    attachWeekDrop(col,dt);
    grid.appendChild(col);
  });

  // positioned blocks — simple side-by-side overlap layout
  days.forEach((dt,idx)=>{
    const k=DU.key(dt);
    const evs=timed.filter(e=>e.date===k)
      .map(e=>({...e,s:DU.minsOf(e.start||'00:00'),en:DU.minsOf(e.end||'23:59')}))
      .filter(e=>e.en>DU.minsOf(DU.pad(CONST.HOUR_START)+':00') && e.s<DU.minsOf(DU.pad(CONST.HOUR_END+1)+':00'))
      .sort((a,b)=>a.s-b.s||b.en-a.en);
    // cluster overlaps
    let cluster=[],clusterEnd=-1;
    const flush=()=>{
      if(!cluster.length)return;
      const n=cluster.length;
      cluster.forEach((e,i)=>{
        const top=(Math.max(e.s,CONST.HOUR_START*60)-CONST.HOUR_START*60)/60*CONST.HOUR_PX;
        const hgt=Math.max(14,(Math.min(e.en,(CONST.HOUR_END+1)*60)-Math.max(e.s,CONST.HOUR_START*60))/60*CONST.HOUR_PX);
        const b=document.createElement('div'); b.className='evBlock';
        b.style.top=top+'px'; b.style.height=hgt+'px';
        b.style.left=`calc(56px + (100% - 56px) * ${(idx/7).toFixed(5)} + 2px)`;
        b.style.width=`calc((100% - 56px) * ${(1/7).toFixed(5)} - 6px)`;
        b.style.background=colorOf(e);
        b.innerHTML=`<div class="et">${DU.fmtTime(e.start)}${hgt>28?' – '+DU.fmtTime(e.end):''}</div><div class="en">${e.title}</div>`;
        b.dataset.eventId=e._masterId;b.dataset.occKey=e._occKey||'';b.dataset.date=e.date;
        b.addEventListener('click',ev=>{if(!b.dataset.dragged){ev.stopPropagation();openEditorFromInstance(e);}});
        attachWeekDrag(b,e);
        grid.appendChild(b);
      });
      cluster=[];clusterEnd=-1;
    };
    evs.forEach(e=>{
      if(cluster.length&&e.s>=clusterEnd)flush();
      cluster.push(e);clusterEnd=Math.max(clusterEnd,e.en);
    });
    flush();
  });

  scroll.appendChild(grid);
  wrap.appendChild(scroll);
  root.appendChild(wrap);
  scroll.scrollTop = Math.max(0,(new Date().getHours()-CONST.HOUR_START-1))*CONST.HOUR_PX;

  function startNowLine(){
    const old=document.getElementById('nowLine'); if(old)old.remove();
    const now=new Date();
    const k=DU.key({y:now.getFullYear(),m:now.getMonth()+1,d:now.getDate()});
    const col=grid.querySelector(`.dayCol[data-date="${k}"]`);
    if(!col)return;
    const mins=now.getHours()*60+now.getMinutes();
    if(mins<CONST.HOUR_START*60||mins>(CONST.HOUR_END+1)*60)return;
    const line=document.createElement('div');line.id='nowLine';
    line.style.top=((mins-CONST.HOUR_START*60)/60*CONST.HOUR_PX)+'px';
    line.style.left=`calc(56px + (100% - 56px) * ${(days.findIndex(d=>DU.key(d)===k)/7).toFixed(5)})`;
    line.style.width=`calc((100% - 56px) * ${(1/7).toFixed(5)})`;
    grid.appendChild(line);
  }
  App._startNowLine=startNowLine;
}
function startNowLine(){ if(App._startNowLine)App._startNowLine(); }
setInterval(()=>{ if(Store.state.view==='week'&&document.getElementById('nowLine'))startNowLine(); },60000);

/* ---------------- AGENDA VIEW ---------------- */
function renderAgenda(root){
  const wrap=document.createElement('div');wrap.className='agendaWrap';
  const {from,to}=rangeForView();
  const insts=Store.instancesInRange(from,to);
  if(!insts.length){
    wrap.innerHTML='<div class="agEmpty">No upcoming events.<br>Create one by clicking a day in Month view.</div>';
    root.appendChild(wrap);return;
  }
  const today=DU.today();
  let lastKey='';
  insts.forEach(inst=>{
    const k=inst.date;
    if(k!==lastKey){
      lastKey=k;
      const dt=DU.parseKey(k);
      const dh=document.createElement('div');
      dh.className='agDayHead'+(DU.eq(dt,today)?' today':'');
      dh.innerHTML=`<span class="dow">${DOW_NAMES_SUN[DU.dow(dt.y,dt.m,dt.d)]}</span><span class="dnum">${dt.d}</span>`;
      const mo=document.createElement('span');mo.className='dow';mo.textContent=`${MONTH_NAMES[dt.m-1].slice(0,3)} ${dt.y}`;
      dh.appendChild(mo);
      const dayBox=document.createElement('div');dayBox.className='agDay';
      dayBox.appendChild(dh);
      const evs=document.createElement('div');evs.className='agEvents';
      dayBox.appendChild(evs);
      wrap.appendChild(dayBox);
      inst._agEvents=evs;
    }
    const row=document.createElement('div');row.className='agEvent';
    const bar=document.createElement('div');bar.className='bar';bar.style.background=colorOf(inst);
    const time=document.createElement('div');time.className='time';
    time.textContent=inst.allDay?'All day':`${DU.fmtTime(inst.start)} – ${DU.fmtTime(inst.end)}`;
    const body=document.createElement('div');body.className='body';
    body.innerHTML=`<b>${esc(inst.title)}</b>${inst.notes?`<span>${esc(inst.notes)}</span>`:''}`;
    row.append(bar,time,body);
    row.addEventListener('click',()=>openEditorFromInstance(inst));
    inst._agEvents.appendChild(row);
  });
  root.appendChild(wrap);
}
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

/* ============================================================
   NAVIGATION
   ============================================================ */
function navigate(dir){
  const s=Store.state;
  if(s.view==='month') s.cursor=DU.addMonths(s.cursor,dir);
  else if(s.view==='week') s.cursor=DU.addDays(s.cursor,7*dir);
  else s.cursor=DU.addDays(s.cursor,30*dir);
  Store.save();render();
}
function goToday(){Store.state.cursor=DU.today();Store.save();render();}

/* ============================================================
   EVENT EDITOR MODAL
   ============================================================ */
const Modal={
  editing:null,   // {masterId, occKey} or null for new
  master:null,
  recMode:null,   // chosen scope when editing recurring
  isNew:false,
};
function openEditor(dateKey, allDayDefault){
  Modal.editing=null;Modal.master=null;Modal.isNew=true;
  fillModal({
    title:'',date:dateKey||DU.key(DU.today()),
    allDay:!!allDayDefault,start:allDayDefault?'':'09:00',end:allDayDefault?'':'10:00',
    color:CONST.PALETTE[Math.floor(Math.random()*CONST.PALETTE.length)].id,
    notes:'',rec:{freq:''}
  }, false);
  showModal();
}
function openEditorFromInstance(inst){
  const master=Store.getEvent(inst._masterId);
  Modal.editing={masterId:inst._masterId,occKey:inst._occKey};
  Modal.master=master;Modal.isNew=false;
  fillModal(inst,master&&!!master.rec&&master.rec.freq!=='none');
  showModal();
}
function fillModal(ev,isRecurring){
  document.getElementById('modalTitleInp').value=ev.title||'';
  document.getElementById('fDate').value=ev.date;
  document.getElementById('fAllDay').checked=!!ev.allDay;
  document.getElementById('fStart').value=ev.allDay?'':(ev.start||'09:00');
  document.getElementById('fEnd').value=ev.allDay?'':(ev.end||'10:00');
  document.getElementById('fNotes').value=ev.notes||'';
  const recSel=document.getElementById('fRec');
  recSel.value=(isRecurring&&Modal.editing)?(Modal.master.rec.freq):'';
  recSel.disabled=isRecurring&&!!Modal.editing;
  buildWdPicker(Modal.editing?(Modal.master.rec.weekdays||[]):[]);
  document.getElementById('wdPicker').style.display=recSel.value==='weekly'&&!recSel.disabled?'flex':'none';
  buildPalette(ev.color||'blue');
  document.getElementById('deleteBtn').style.display=Modal.isNew?'none':'inline-block';
  clearErrors();
}
function buildPalette(sel){
  const pal=document.getElementById('palette');pal.innerHTML='';
  CONST.PALETTE.forEach(p=>{
    const sw=document.createElement('div');sw.className='swatch'+(p.id===sel?' sel':'');
    sw.style.background=p.c;sw.dataset.color=p.id;sw.title=p.id;
    sw.addEventListener('click',()=>{pal.querySelectorAll('.swatch').forEach(x=>x.classList.remove('sel'));sw.classList.add('sel');});
    pal.appendChild(sw);
  });
}
function buildWdPicker(selected){
  const wp=document.getElementById('wdPicker');wp.innerHTML='';
  for(let i=0;i<7;i++){
    const b=document.createElement('button');
    b.textContent=DOW_NAMES_SUN[i][0];
    b.dataset.wd=i;
    if(selected.includes(i))b.classList.add('on');
    b.addEventListener('click',()=>b.classList.toggle('on'));
    wp.appendChild(b);
  }
}
function showModal(){document.getElementById('modalBack').classList.add('open');
  setTimeout(()=>document.getElementById('modalTitleInp').focus(),50);}
function closeModal(){document.getElementById('modalBack').classList.remove('open');}
function showErr(id,msg){const e=document.getElementById(id);e.textContent=msg;e.classList.add('show');}
function clearErrors(){['errTime','errDate','errRec'].forEach(i=>showErrHide(i));}
function showErrHide(id){const e=document.getElementById(id);e.classList.remove('show');}

function readModalForm(){
  const title=document.getElementById('modalTitleInp').value.trim();
  const date=document.getElementById('fDate').value;
  const allDay=document.getElementById('fAllDay').checked;
  const start=document.getElementById('fStart').value;
  const end=document.getElementById('fEnd').value;
  const notes=document.getElementById('fNotes').value.trim();
  const colorSel=document.querySelector('#palette .swatch.sel');
  const color=colorSel?colorSel.dataset.color:'blue';
  let ok=true;
  if(!title){showErr('errTime','Please give the event a title.');ok=false;}
  if(!/^\d{4}-\d{2}-\d{2}$/.test(date)){showErr('errDate','Pick a valid date.');ok=false;}
  if(!allDay){
    if(!start||!end){showErr('errTime','Start and end times are required.');ok=false;}
    else if(DU.minsOf(end)<=DU.minsOf(start)){showErr('errTime','End time must be after start time.');ok=false;}
  }
  if(!ok)return null;
  return {title,date,allDay,start:allDay?'':start,end:allDay?'':end,notes,color};
}

function saveModal(){
  const fields=readModalForm();
  if(!fields)return;
  const freq=document.getElementById('fRec').value;
  const wd=[...document.querySelectorAll('#wdPicker button.on')].map(b=>Number(b.dataset.wd));
  if(freq==='weekly'&&wd.length===0){showErr('errRec','Pick at least one weekday.');return;}

  if(Modal.isNew){
    const ev={id:Store.uid(),...fields,exceptions:{}};
    if(freq==='weekly'){if(fields.date&&wd.length===0){showErr('errRec','Pick at least one weekday.');return;}ev.rec={freq,weekdays:wd};}
    else if(freq)ev.rec={freq};
    Store.addEvent(ev);
  }else{
    const isRec=Modal.master&&Modal.master.rec&&Modal.master.rec.freq!=='none';
    if(isRec){
      askRecScope(mode=>{
        if(mode==='cancel')return;
        applyRecurringEdit(Modal.editing.masterId,Modal.editing.occKey,fields,mode,App.undoStack);
        render();
      });
      return;
    }
    const master=Store.getEvent(Modal.editing.masterId);
    App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
    Store.replaceEvent({...master,...fields});
  }
  closeModal();render();
}

function deleteCurrent(){
  if(!Modal.editing){closeModal();return;}
  const master=Store.getEvent(Modal.editing.masterId);
  const isRec=master&&master.rec&&master.rec.freq!=='none';
  if(isRec){
    askRecScope(mode=>{
      if(mode==='cancel')return;
      applyRecurringDelete(Modal.editing.masterId,Modal.editing.occKey,mode,App.undoStack);
      closeModal();render();
      showToast('Event deleted');
    });
    return;
  }
  App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
  Store.removeEvent(Modal.editing.masterId);
  closeModal();render();
  showToast('Event deleted');
}

function askRecScope(cb){
  const dlg=document.getElementById('recAsk');
  const isDelete=arguments.length===1;
  document.getElementById('recAskTitle').textContent=isDelete?'Delete recurring event':'Edit recurring event';
  dlg.classList.add('open');
  dlg.onclick=e=>{
    const b=e.target.closest('button[data-mode]');
    if(!b)return;
    dlg.classList.remove('open');dlg.onclick=null;
    cb(b.dataset.mode);
  };
}

/* ============================================================
   TOAST / UNDO
   ============================================================ */
let toastTimer=null;
function showToast(msg){
  const t=document.getElementById('toast');
  document.getElementById('toastMsg').textContent=msg;
  t.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer=setTimeout(()=>t.classList.remove('show'),5000);
}
document.getElementById('toastUndo').addEventListener('click',()=>{
  const prev=App.undoStack.pop();
  if(prev){Store.state.events=prev;Store.save();render();}
  document.getElementById('toast').classList.remove('show');
});

/* ============================================================
   DRAG & DROP
   ============================================================ */
// --- month view: HTML5 drag chips onto day cells ---
function attachChipDrag(chip,inst){
  chip.addEventListener('dragstart',e=>{
    e.dataTransfer.setData('text/plain',JSON.stringify({eventId:inst._masterId,occKey:inst._occKey}));
    e.dataTransfer.effectAllowed='move';
  });
}
function attachDrop(cell,dt){
  cell.addEventListener('dragover',e=>{e.preventDefault();cell.classList.add('dragOver');});
  cell.addEventListener('dragleave',()=>cell.classList.remove('dragOver'));
  cell.addEventListener('drop',e=>{
    e.preventDefault();e.stopPropagation();cell.classList.remove('dragOver');
    let data;try{data=JSON.parse(e.dataTransfer.getData('text/plain'));}catch(_){return;}
    reschedule(data.eventId,data.occKey,DU.key(dt));
  });
}
function reschedule(masterId,occKey,newDateKey){
  const master=Store.getEvent(masterId);
  if(!master)return;
  if(occKey){ // occurrence of a series -> exception override with new date
    App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
    if(!master.exceptions)master.exceptions={};
    master.exceptions[occKey]={deleted:true};
    const{_masterId,_occKey,id,rec,exceptions,...fields}=master;
    Store.addEvent({id:Store.uid(),...fields,date:newDateKey,exceptions:{}});
    Store.replaceEvent(master);
  }else{
    App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
    master.date=newDateKey;
    Store.replaceEvent(master);
  }
  render();
  showToast(`Moved to ${DU.fmtLong(DU.parseKey(newDateKey))}`);
}

// --- week view: vertical pointer drag with snap ---
function attachWeekDrag(block,inst){
  block.addEventListener('pointerdown',e=>{
    if(e.button!==0)return;
    e.preventDefault();
    const startY=e.clientY,startX=e.clientX;
    const sMin=DU.minsOf(inst.start),dur=DU.minsOf(inst.end)-sMin;
    let moved=false;
    block.setPointerCapture(e.pointerId);
    block.style.zIndex=20;block.style.opacity=.85;
    block.dataset.dragged='';
    const onMove=ev=>{
      const dy=ev.clientY-startY,dx=ev.clientX-startX;
      if(Math.abs(dy)>4||Math.abs(dx)>12)moved=true;
      if(!moved)return;
      const delta=Math.round(dy/CONST.HOUR_PX*60/CONST.SNAP_MIN)*CONST.SNAP_MIN;
      block.style.transform=`translateY(${delta/60*CONST.HOUR_PX}px)`;
      block.dataset.delta=delta;
    };
    const onUp=ev=>{
      block.removeEventListener('pointermove',onMove);
      block.removeEventListener('pointerup',onUp);
      const delta=Number(block.dataset.delta||0);
      block.style.transform='';block.style.opacity='';block.style.zIndex='';
      if(!moved||delta===0){setTimeout(()=>delete block.dataset.dragged,0);return;}
      block.dataset.dragged='1';setTimeout(()=>delete block.dataset.dragged,0);
      const newStart=Math.max(0,sMin+delta);
      const master=Store.getEvent(inst._masterId);
      if(!master)return;
      const patch={start:DU.hmOf(newStart),end:DU.hmOf(newStart+dur)};
      App.undoStack.push(JSON.parse(JSON.stringify(Store.state.events)));
      if(inst._occKey){
        if(!master.exceptions)master.exceptions={};
        master.exceptions[inst._occKey]={patch};
        Store.replaceEvent(master);
      }else{
        Object.assign(master,patch);Store.replaceEvent(master);
      }
      render();
      showToast(`Rescheduled to ${DU.fmtTime(patch.start)}`);
    };
    block.addEventListener('pointermove',onMove);
    block.addEventListener('pointerup',onUp);
  });
}
// week view column drop target (accepts month-style drags too)
function attachWeekDrop(col,dt){
  col.addEventListener('dragover',e=>e.preventDefault());
  col.addEventListener('drop',e=>{
    e.preventDefault();
    let data;try{data=JSON.parse(e.dataTransfer.getData('text/plain'));}catch(_){return;}
    reschedule(data.eventId,data.occKey,DU.key(dt));
  });
}

/* ============================================================
   INIT / WIRING
   ============================================================ */
document.getElementById('prevBtn').addEventListener('click',()=>navigate(-1));
document.getElementById('nextBtn').addEventListener('click',()=>navigate(1));
document.getElementById('todayBtn').addEventListener('click',goToday);
document.querySelectorAll('#viewTabs button').forEach(b=>
  b.addEventListener('click',()=>{Store.state.view=b.dataset.view;Store.save();render();}));
document.querySelectorAll('#weekStartSeg button').forEach(b=>
  b.addEventListener('click',()=>{Store.state.weekStart=Number(b.dataset.ws);Store.save();render();}));
document.getElementById('settingsBtn').addEventListener('click',e=>{
  e.stopPropagation();
  document.getElementById('settingsMenu').classList.toggle('open');
});
document.addEventListener('click',e=>{
  const m=document.getElementById('settingsMenu');
  if(m.classList.contains('open')&&!m.contains(e.target)&&e.target.id!=='settingsBtn')
    m.classList.remove('open');
  const pop=document.getElementById('dayPop');
  if(pop.classList.contains('open')&&!pop.contains(e.target))closeDayPop();
});
document.getElementById('dayPopClose').addEventListener('click',closeDayPop);
document.getElementById('clearDataBtn').addEventListener('click',()=>{
  if(confirm('Delete ALL events? This cannot be undone.')){Store.clearAll();render();}
});
document.getElementById('modalClose').addEventListener('click',closeModal);
document.getElementById('saveBtn').addEventListener('click',saveModal);
document.getElementById('deleteBtn').addEventListener('click',deleteCurrent);
document.getElementById('fAllDay').addEventListener('change',e=>{
  document.getElementById('fStart').disabled=e.target.checked;
  document.getElementById('fEnd').disabled=e.target.checked;
  if(e.target.checked){document.getElementById('fStart').value='';document.getElementById('fEnd').value='';}
  else{document.getElementById('fStart').value='09:00';document.getElementById('fEnd').value='10:00';}
});
document.getElementById('fRec').addEventListener('change',e=>{
  document.getElementById('wdPicker').style.display=e.target.value==='weekly'?'flex':'none';
});
document.getElementById('modalBack').addEventListener('mousedown',e=>{
  if(e.target===e.currentTarget)closeModal();
});
document.addEventListener('keydown',e=>{
  if(e.key==='Escape'){
    closeModal();
    document.getElementById('recAsk').classList.remove('open');
    closeDayPop();
  }
});

Store.load();
render();

/* expose for testing/probing */
window.__cal={DU,Store,expandRecurrence,applyRecurringEdit,applyRecurringDelete,navigate,reschedule,
  get cursor(){return Store.state.cursor},set cursor(v){Store.state.cursor=v}};
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"d73946f4-54cb-4799-bd45-8c1d90c14043","tokensIn":1995094,"tokensOut":28247,"tokensTotal":2023341,"cost":0,"turns":41,"toolCalls":42,"failedToolCalls":0,"timestamp":"2026-08-21T20:28:33.801Z"} -->