← Calendar results

Calendar

Claude haiku 5.5 · full

Initial view of Calendar

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 4d3f1ca269
# Task: Build a Calendar & Event Planner

Build a calendar and event planner (vanilla JS, no external assets, no CDN, no libraries). The top priority is **correct date math** — month lengths, week alignment, leap years, and recurrence rules are where calendar implementations silently go wrong.

## Core Specification

**Views & Navigation**
- **Month view:** classic 6-row grid, Monday or Sunday start (settings toggle), correct leading/trailing days from adjacent months (shown dimmed), today highlighted, "Today" button, prev/next month and year jump
- **Week view:** 7 columns × hourly rows (e.g. 6:00–22:00), events rendered as positioned blocks with correct proportional height/offset; all-day events in a top strip
- **Agenda view:** chronological list of upcoming events grouped by day
- Smooth animated transitions between months/views; current time indicator line in week view when viewing the current week

**Date Engine (write it yourself — no Date-library shortcuts, native `Date` only)**
- Correct month/day grid computation for any year (including leap years, February, century rules)
- Week start configurable (Monday/Sunday) with correct week numbering either way
- All date arithmetic done on year/month/day components — never "add 86400000 ms per day" hacks that break across DST transitions

**Events**
- Full CRUD via a clean modal editor: title, date, start/end time (or all-day), color (from a fixed palette), optional notes
- Validation: end time must be after start time (or all-day); clear inline errors, never silent failure
- Click a day cell to create an event on that date; click an event to view/edit; delete with confirm or undo toast
- Drag-to-reschedule in month view (drag an event chip onto another day — updates its date) and in week view (drag vertically to change time in 15-minute snaps)

**Recurring Events**
- Recurrence rules: daily, weekly (choose weekdays), monthly (same day-of-month; `monthly on the 31st` must skip months without a 31st), yearly
- Editing a recurring event asks: this event only / this and following / entire series — each behaving correctly (exception instance, split series, edit master)
- Deleting one occurrence of a series must not delete the rest

**Persistence**
- Events (including recurrence rules and per-occurrence exceptions), settings, and current view serialize to `localStorage` and restore exactly on reload

## Visual Style
- Polished Google-Calendar-adjacent aesthetic: clean typography, soft event chips with color-coded left border or fill, subtle gridlines, today pill accent
- Month cells show up to ~3 event chips plus a "+2 more" overflow that opens a day popover
- Responsive: usable at laptop width and down to ~tablet width without breaking the grid

## Technical Requirements
- Vanilla JS; all code organized with clear section comments
- Clean separation: a date-util module (pure functions: daysInMonth, addDays, addMonths, weekStart, recurrence expansion) fully decoupled from rendering
- Recurrence expansion computed for visible date ranges only — never materialize infinite series
- Tunable constants block at the top (colors, hour range, snap interval, week start default)

## Quality Bar (must hit all)
- February renders correctly in 2024 (29 days), 2025 (28), and 2100 (28 — century rule); month grids align weekdays correctly for any year you navigate to
- A monthly event on Jan 31 appears Feb… nowhere, and reappears Mar 31
- Navigating 24 months forward then back lands on exactly the original month
- Drag-rescheduling an event across a month boundary keeps the correct date
- Editing "this event only" of a weekly series changes one occurrence and leaves all others untouched

## Self-Check Before Finishing
Verify with the browser tools (probe the date-util functions and the rendered grid directly):
- Assert day-of-week alignment for several known dates (e.g. 2026-01-01 is a Thursday — it must land in the Thursday column for both week-start settings)
- Create a weekly recurring event, edit one occurrence ("this event only"), reload, and assert exactly one changed instance plus the intact series
- Drag an event from Jan 30 to Feb 2 (crossing the month boundary) and assert its stored date is correct
- Navigate forward/back repeatedly and assert the grid, header, and today highlight stay consistent
Inspect original source 57,341 bytes · SHA-256 393526ca25bb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar &amp; Event Planner</title>
<style>
:root{
  --bg:#fff;--fg:#3c4043;--muted:#70757a;--line:#e0e2e6;--accent:#1a73e8;--hover:#f1f3f4;
  --shadow:0 6px 24px rgba(60,64,67,.28);--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  --now:#ea4335;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;font-family:var(--font);color:var(--fg);background:var(--bg);font-size:14px}
button,input,select,textarea{font:inherit;color:inherit}
button{cursor:pointer;background:#fff;border:1px solid var(--line);border-radius:6px;padding:6px 12px;white-space:nowrap}
button:hover{background:var(--hover)}
button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
button.primary:hover{background:#1765cc}
button.danger{color:#d93025}
input,select,textarea{border:1px solid #c4c7c5;border-radius:6px;padding:6px 8px;background:#fff;min-width:0}
input:focus,select:focus,textarea:focus{outline:2px solid #aecbfa;border-color:var(--accent)}
input.bad,select.bad{border-color:#d93025!important;background:#fff5f5}
#app{display:flex;flex-direction:column;height:100%}
header.top{display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.title{font-size:20px;font-weight:500;min-width:200px;white-space:nowrap}
.nav{display:flex;gap:4px;align-items:center}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.seg button{border:0;border-radius:0;border-right:1px solid var(--line)}
.seg button:last-child{border-right:0}
.seg button.on{background:#e8f0fe;color:var(--accent);font-weight:600}
.spacer{flex:1}
.main{flex:1;overflow:auto;position:relative;min-height:0}
.view{animation-duration:.28s;animation-timing-function:ease-out}
.anim-next{animation-name:inR}
.anim-prev{animation-name:inL}
.anim-fade{animation-name:fadeIn}
@keyframes inR{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:none}}
@keyframes inL{from{opacity:0;transform:translateX(-24px)}to{opacity:1;transform:none}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes pop{from{opacity:0;transform:scale(.96) translateY(6px)}to{opacity:1;transform:none}}

/* Month view */
.mhead{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:3}
.mhead div{padding:8px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;text-align:center}
.mgrid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}
.cell{border-right:1px solid var(--line);border-bottom:1px solid var(--line);padding:4px 4px 6px;min-height:112px;display:flex;flex-direction:column;gap:2px;cursor:pointer;min-width:0}
.cell:hover{background:#fafbfc}
.cell.other{background:#fafafa;color:#9aa0a6}
.cell.other .dnum{color:#9aa0a6}
.cell.drop{background:#e8f0fe!important;box-shadow:inset 0 0 0 2px var(--accent)}
.dnum{align-self:flex-start;font-size:13px;min-width:26px;height:26px;padding:0 6px;display:flex;align-items:center;justify-content:center;border-radius:13px;margin-bottom:2px}
.cell.today .dnum{background:var(--accent);color:#fff;font-weight:600}
.chip{--c:#4285f4;background:color-mix(in srgb,var(--c) 22%,#fff);border-left:3px solid var(--c);border-radius:4px;padding:2px 6px;font-size:12px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:grab;user-select:none;touch-action:none;flex-shrink:0}
.chip:hover{filter:brightness(.96)}
.chip.dragging{box-shadow:var(--shadow);cursor:grabbing;opacity:.92;position:relative;z-index:30}
.chip .t{color:var(--muted);margin-right:4px}
.more{font-size:12px;color:var(--accent);padding:2px 6px;border-radius:4px;font-weight:500;flex-shrink:0}
.more:hover{background:#e8f0fe}

/* Week view */
.wk{display:grid;grid-template-columns:56px repeat(7,minmax(96px,1fr));min-width:720px}
.wk-h{position:sticky;top:0;z-index:4;background:#fff;text-align:center;padding:6px 4px 4px;font-size:11px;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--line)}
.wk-h .num{display:inline-flex;align-items:center;justify-content:center;font-size:20px;color:var(--fg);min-width:36px;height:36px;border-radius:18px;text-transform:none;margin-top:2px}
.wk-h.today .num{background:var(--accent);color:#fff;font-weight:600}
.wk-corner{position:sticky;top:0;z-index:4;background:#fff;border-bottom:1px solid var(--line)}
.wk-ag-g{font-size:10px;color:var(--muted);text-align:right;padding:6px 6px 0 0;border-bottom:1px solid var(--line);background:#fff;position:sticky;left:0;z-index:2}
.wk-allday{border-bottom:1px solid var(--line);border-right:1px solid var(--line);min-height:34px;padding:3px 2px;display:flex;flex-direction:column;gap:2px;cursor:pointer}
.wk-allday:hover{background:#fafbfc}
.wk-allday .chip{font-size:11px}
.wk-gutter{position:sticky;left:0;z-index:2;background:#fff;font-size:11px;color:var(--muted);text-align:right;position:relative}
.wk-gutter span{position:absolute;right:6px;transform:translateY(-7px)}
.wk-col{position:relative;border-right:1px solid var(--line);cursor:cell;min-width:0}
.wk-col.drop{background:#f3f8ff}
.hline{position:absolute;left:0;right:0;border-top:1px solid var(--line);pointer-events:none}
.hline.half{border-top:1px dashed #eceef1}
.blk{position:absolute;left:3px;right:3px;border-radius:6px;padding:3px 6px;font-size:12px;line-height:1.3;background:color-mix(in srgb,var(--c) 24%,#fff);border-left:3px solid var(--c);overflow:hidden;cursor:grab;user-select:none;touch-action:none;z-index:1;box-shadow:0 1px 2px rgba(0,0,0,.08)}
.blk:hover{filter:brightness(.97)}
.blk{display:flex;align-items:baseline;gap:6px;white-space:nowrap;padding-top:2px}
.blk .tm{font-size:11px;color:var(--muted);flex-shrink:0}
.blk>div:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis}
.blk.dragging{box-shadow:var(--shadow);z-index:20;opacity:.92;cursor:grabbing}
.now{position:absolute;left:0;right:0;border-top:2px solid var(--now);z-index:5;pointer-events:none}
.now::before{content:"";position:absolute;left:-5px;top:-6px;width:10px;height:10px;border-radius:50%;background:var(--now)}
.snapmark{position:absolute;left:4px;right:4px;background:#e8f0fe;border-radius:4px;font-size:11px;color:var(--accent);padding:2px 6px;z-index:21;pointer-events:none;text-align:center}

/* Agenda */
.ag{max-width:820px;margin:0 auto;padding:12px 20px 40px}
.ag-day{margin-top:14px}
.ag-day h3{font-size:13px;font-weight:600;margin:0 0 6px;color:var(--muted);padding-bottom:4px;border-bottom:1px solid var(--line)}
.ag-day h3.today{color:var(--accent)}
.ag-item{display:flex;gap:12px;align-items:center;padding:8px 10px;border-radius:8px;cursor:pointer;border-left:4px solid var(--c);background:color-mix(in srgb,var(--c) 10%,#fff);margin-bottom:6px}
.ag-item:hover{filter:brightness(.97)}
.ag-item .tm{width:120px;color:var(--muted);font-size:12px;flex-shrink:0}
.ag-item .nt{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ag-empty{color:var(--muted);text-align:center;padding:60px 0}

/* Overlays / modal */
.overlay{position:fixed;inset:0;background:rgba(32,33,36,.35);display:flex;align-items:center;justify-content:center;z-index:50;animation:fadeIn .15s}
.modal{background:#fff;border-radius:12px;box-shadow:var(--shadow);width:min(540px,94vw);max-height:92vh;overflow:auto;padding:20px 22px;animation:pop .18s}
.modal h2{font-size:18px;font-weight:500;margin:0 0 14px}
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
.field .lb{font-size:12px;color:var(--muted)}
.row{display:flex;gap:10px;align-items:flex-end}
.row>*{flex:1}
.row>.fixed{flex:0 0 auto}
.err{color:#d93025;font-size:12px}
.err:empty{display:none}
.swatches{display:flex;gap:8px;flex-wrap:wrap}
.swatches button{width:26px;height:26px;border-radius:50%;border:2px solid #fff;padding:0;background:var(--c);box-shadow:0 0 0 1px #dadce0}
.swatches button.on{box-shadow:0 0 0 2px #fff,0 0 0 4px var(--c)}
.wdays{display:flex;gap:4px;flex-wrap:wrap}
.wdays label{display:inline-flex;align-items:center;gap:4px;border:1px solid #c4c7c5;border-radius:16px;padding:4px 9px;cursor:pointer;font-size:12px}
.wdays input{margin:0}
.wdays label.on{background:#e8f0fe;border-color:var(--accent);color:var(--accent)}
.hide{display:none!important}
.modal .foot{display:flex;gap:8px;align-items:center;margin-top:16px;flex-wrap:wrap}
.scope{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:12px;background:#f8f9fa;display:flex;gap:14px;flex-wrap:wrap;font-size:13px}
.scope label{display:flex;gap:4px;align-items:center;cursor:pointer}
.pop{position:fixed;background:#fff;box-shadow:var(--shadow);border-radius:10px;padding:10px;width:270px;z-index:40;animation:pop .14s;max-height:70vh;overflow:auto}
.pop h4{margin:2px 4px 8px;font-size:15px;font-weight:500}
.pop .chip{display:block;margin-bottom:4px;white-space:normal;cursor:pointer}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#323232;color:#fff;padding:10px 14px 10px 18px;border-radius:8px;display:flex;gap:14px;align-items:center;z-index:60;box-shadow:var(--shadow);animation:pop .2s}
.toast button{background:transparent;border:0;color:#8ab4f8;font-weight:600;padding:4px 6px}
.toast button:hover{background:rgba(255,255,255,.08)}
.hint{font-size:11px;color:var(--muted)}
@media (max-width:900px){
  .title{min-width:0;font-size:17px}
  .cell{min-height:92px;padding:3px}
  .chip{font-size:11px;padding:1px 4px}
  .dnum{min-width:22px;height:22px;font-size:12px}
  .mhead div{font-size:10px;letter-spacing:0}
  .ag-item .tm{width:90px}
}
</style>
</head>
<body>
<div id="app">
  <header class="top">
    <div class="title" id="title"></div>
    <div class="nav">
      <button data-act="yprev" title="Previous year">&laquo;</button>
      <button data-act="prev" title="Previous">&lsaquo;</button>
      <button data-act="today">Today</button>
      <button data-act="next" title="Next">&rsaquo;</button>
      <button data-act="ynext" title="Next year">&raquo;</button>
    </div>
    <div class="seg" id="modes">
      <button data-mode="month">Month</button><button data-mode="week">Week</button><button data-mode="agenda">Agenda</button>
    </div>
    <div class="spacer"></div>
    <button data-act="settings" title="Settings">Settings</button>
    <button class="primary" data-act="new">+ New event</button>
  </header>
  <div class="main" id="main"></div>
</div>
<div id="layer"></div>
<script>
'use strict';
/* ==================================================================
   1. TUNABLE CONSTANTS
   ================================================================== */
const CONFIG = {
  COLORS: ['#4285f4', '#0b8043', '#d50000', '#f6bf26', '#8e24aa', '#039be5', '#e67c73', '#616161'],
  HOUR_START: 6,          // first hour row in week view
  HOUR_END: 22,           // last hour boundary in week view
  SNAP_MIN: 15,           // drag snap interval (minutes)
  HOUR_PX: 48,            // px per hour in week view
  WEEK_START_DEFAULT: 1,  // 0 = Sunday, 1 = Monday
  MAX_CHIPS: 3,           // chips shown per month cell before "+N more"
  AGENDA_DAYS: 60,        // agenda look-ahead window
  DEFAULT_DURATION: 60,   // minutes, for click-to-create in week view
  STORAGE_KEY: 'calendar-planner.v1',
  UNDO_MS: 7000,
};

/* ==================================================================
   2. DATE UTILITIES (pure functions, native arithmetic only)
      Dates are handled as integer "serials" (days since 1970-01-01)
      and as {y, m, d} components (m is 1-12). Never adds ms offsets.
   ================================================================== */
const DateUtil = (() => {
  const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const isLeap = y => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
  const daysInMonth = (y, m) => m === 2 ? (isLeap(y) ? 29 : 28)
    : (m === 4 || m === 6 || m === 9 || m === 11) ? 30 : 31;
  // Howard Hinnant's days_from_civil, proleptic Gregorian
  const toSerial = (y, m, d) => {
    const yy = m <= 2 ? y - 1 : y;
    const era = Math.floor(yy / 400);
    const yoe = yy - era * 400;
    const mp = (m + 9) % 12;
    const doy = Math.floor((153 * mp + 2) / 5) + d - 1;
    const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
    return era * 146097 + doe - 719468;
  };
  // Inverse (civil_from_days)
  const fromSerial = z => {
    const zz = z + 719468;
    const era = Math.floor(zz / 146097);
    const doe = zz - era * 146097;
    const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
    const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
    const mp = Math.floor((5 * doy + 2) / 153);
    const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
    const m = mp < 10 ? mp + 3 : mp - 9;
    const y = yoe + era * 400 + (m <= 2 ? 1 : 0);
    return { y, m, d };
  };
  const dow = z => ((z + 4) % 7 + 7) % 7;               // 0 = Sunday (1970-01-01 was Thursday)
  const p2 = n => String(n).padStart(2, '0');
  const keyOf = z => { const { y, m, d } = fromSerial(z); return String(y).padStart(4, '0') + '-' + p2(m) + '-' + p2(d); };
  // Parses 'YYYY-MM-DD' strictly; returns serial or null if invalid (e.g. 2025-02-29)
  const parseKey = s => {
    const mt = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || '');
    if (!mt) return null;
    const y = +mt[1], m = +mt[2], d = +mt[3];
    if (m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m)) return null;
    return toSerial(y, m, d);
  };
  const addDays = (z, n) => z + n;
  // Month arithmetic on components: returns {y, m} (m 1-12), handles year wrap
  const addMonths = (y, m, n) => {
    const t = y * 12 + (m - 1) + n;
    return { y: Math.floor(t / 12), m: ((t % 12) + 12) % 12 + 1 };
  };
  const weekStart = (z, ws) => z - ((dow(z) - ws + 7) % 7);
  // ISO-8601 when weeks start Monday; US-style (week 1 contains Jan 1) when Sunday
  const weekNumber = (z, ws) => {
    if (ws === 1) {
      const thu = z - ((dow(z) + 6) % 7) + 3;   // Thursday of this ISO week
      const { y } = fromSerial(thu);
      return Math.floor((thu - toSerial(y, 1, 1)) / 7) + 1;
    }
    const { y } = fromSerial(z);
    const sw = weekStart(z, 0);
    if (sw >= weekStart(toSerial(y + 1, 1, 1), 0)) return 1;
    return Math.floor((sw - weekStart(toSerial(y, 1, 1), 0)) / 7) + 1;
  };
  // "now" is the only place native Date is used (reading the wall clock)
  const today = () => { const n = new Date(); return toSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()); };
  const nowMinutes = () => { const n = new Date(); return n.getHours() * 60 + n.getMinutes(); };
  return { MONTHS, DAYS, isLeap, daysInMonth, toSerial, fromSerial, dow, keyOf, parseKey,
           addDays, addMonths, weekStart, weekNumber, today, nowMinutes, p2 };
})();
window.DateUtil = DateUtil;

/* ==================================================================
   3. RECURRENCE ENGINE (pure; expands only the requested range)
      Event shape: {id,title,date,allDay,start,end,color,notes,
                    rule:null|{freq,days?,until?}, ex:{ [origKey]: {deleted:true} | {title,...,date} }}
   ================================================================== */
const Recur = (() => {
  const { parseKey, keyOf, fromSerial, dow, daysInMonth } = DateUtil;

  // Does master event `ev`'s rule place an occurrence on serial z?
  function occursOn(ev, z) {
    const s = parseKey(ev.date);
    if (z < s) return false;
    if (!ev.rule) return z === s;
    if (ev.rule.until && z > parseKey(ev.rule.until)) return false;
    const sd = fromSerial(s), zd = fromSerial(z);
    switch (ev.rule.freq) {
      case 'daily':   return true;
      case 'weekly':  return (ev.rule.days || []).includes(dow(z));
      case 'monthly': return zd.d === sd.d;                 // Jan 31 -> skips months without 31
      case 'yearly':  return zd.m === sd.m && zd.d === sd.d; // Feb 29 -> skips non-leap years
    }
    return false;
  }

  function makeInst(ev, z, origKey, e) {
    const src = e || ev;
    return {
      mid: ev.id, iid: ev.id + '@' + origKey, origKey, z, date: keyOf(z),
      title: src.title, allDay: !!src.allDay, start: src.start || '', end: src.end || '',
      color: src.color, notes: src.notes || '', recurring: !!ev.rule, overridden: !!e,
    };
  }

  // All instances whose DISPLAYED date falls in [a,b] (serials, inclusive).
  function instancesBetween(events, a, b) {
    const out = [];
    for (const ev of events) {
      const s = parseKey(ev.date);
      if (!ev.rule) {
        if (s >= a && s <= b) out.push(makeInst(ev, s, ev.date, null));
        continue;
      }
      const lo = Math.max(a, s), ex = ev.ex || {};
      for (let z = lo; z <= b; z++) {          // bounded by visible range only
        if (!occursOn(ev, z)) continue;
        const k = keyOf(z), e = ex[k];
        if (e && e.deleted) continue;
        if (e) {
          const dz = parseKey(e.date);
          if (dz >= a && dz <= b) out.push(makeInst(ev, dz, k, e));
        } else out.push(makeInst(ev, z, k, null));
      }
      // Exceptions moved INTO the range whose original slot lies outside the scan
      for (const k of Object.keys(ex)) {
        const e = ex[k];
        if (!e || e.deleted) continue;
        const oz = parseKey(k);
        if (oz >= lo && oz <= b) continue;
        const dz = parseKey(e.date);
        if (dz >= a && dz <= b) out.push(makeInst(ev, dz, k, e));
      }
    }
    return out;
  }

  return { occursOn, instancesBetween };
})();
window.Recur = Recur;

/* ==================================================================
   4. STATE & PERSISTENCE
   ================================================================== */
function newId() { return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }

function seedEvents() {
  const t = DateUtil.today(), K = DateUtil.keyOf, c = fromComponents(t);
  const mon = DateUtil.weekStart(t, 1);
  return [
    { id: newId(), title: 'Team standup', date: K(mon), allDay: false, start: '09:00', end: '09:30',
      color: CONFIG.COLORS[0], notes: 'Daily sync. Bring blockers.', rule: { freq: 'weekly', days: [1, 3, 5], until: null }, ex: {} },
    { id: newId(), title: 'Design review', date: K(mon + 2), allDay: false, start: '14:00', end: '15:30',
      color: CONFIG.COLORS[4], notes: '', rule: null, ex: {} },
    { id: newId(), title: 'Product launch', date: K(t + 3), allDay: true, start: '', end: '',
      color: CONFIG.COLORS[2], notes: '', rule: null, ex: {} },
    { id: newId(), title: 'Rent due', date: K(DateUtil.toSerial(c.y, c.m, 1)), allDay: true, start: '', end: '',
      color: CONFIG.COLORS[3], notes: 'Monthly on the 1st', rule: { freq: 'monthly', days: null, until: null }, ex: {} },
  ];
}
function fromComponents(z) { return DateUtil.fromSerial(z); }

const Store = {
  load() {
    try {
      const d = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY));
      if (d && Array.isArray(d.events)) return d;
    } catch (e) { /* fall through to defaults */ }
    return null;
  },
  save() {
    try { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(state)); }
    catch (e) { showToast('Could not save changes (storage unavailable).'); }
  },
};

let state = (() => {
  const d = Store.load();
  if (d) {
    d.settings = Object.assign({ weekStart: CONFIG.WEEK_START_DEFAULT }, d.settings);
    d.view = Object.assign({ mode: 'month', anchor: DateUtil.keyOf(DateUtil.today()) }, d.view);
    return d;
  }
  return {
    events: seedEvents(),
    settings: { weekStart: CONFIG.WEEK_START_DEFAULT },
    view: { mode: 'month', anchor: DateUtil.keyOf(DateUtil.today()) },
  };
})();

// Run a mutation with undo snapshot, persist and re-render. Returns snapshot.
function mutate(fn) {
  const before = JSON.stringify(state.events);
  fn();
  Store.save();
  render();
  return before;
}

/* ==================================================================
   5. UI HELPERS
   ================================================================== */
const extraCss = document.createElement('style');
extraCss.textContent = `
.cell{position:relative}
.wkn{position:absolute;right:6px;top:6px;font-size:10px;color:#9aa0a6}
.rep{color:var(--muted);font-size:10px}
.wdbtn{padding:4px 9px;border-radius:16px;font-size:12px}
.wdbtn.on{background:#e8f0fe;border-color:var(--accent);color:var(--accent)}
.wdbtn:disabled{opacity:.45;cursor:default}
.drag-ghost{font-size:12px;line-height:1.35;border-radius:4px;padding:2px 6px;background:color-mix(in srgb,var(--c) 22%,#fff);border-left:3px solid var(--c);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wk-corner{text-align:center;font-size:10px;color:var(--muted);padding-top:12px}
.blk.dragging .tm{color:var(--accent);font-weight:600}
`;
document.head.appendChild(extraCss);

const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MON_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const { p2, keyOf, parseKey, fromSerial, toSerial } = DateUtil;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hmToMin = hm => { const [h, m] = hm.split(':').map(Number); return h * 60 + m; };
const minToHM = m => p2(Math.floor(m / 60)) + ':' + p2(m % 60);
const fmtTime = hm => {
  if (!hm) return '';
  const [h, m] = hm.split(':').map(Number);
  return ((h + 11) % 12 + 1) + (m ? ':' + p2(m) : '') + (h < 12 ? 'am' : 'pm');
};
const fmtHour = h => ((h + 11) % 12 + 1) + (h < 12 ? 'am' : 'pm');
const ord = n => n + (n % 10 === 1 && n % 100 !== 11 ? 'st' : n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th');
const mainEl = $('#main'), titleEl = $('#title'), layer = $('#layer');

let IDX = new Map();   // iid -> instance, rebuilt on each render (drag/click lookups)
function findMaster(id) { return state.events.find(e => e.id === id); }

// Visible instances in [a,b], sorted by day, all-day first, then start time
function instancesFor(a, b) {
  const list = Recur.instancesBetween(state.events, a, b);
  list.sort((x, y) => x.z - y.z || (x.allDay === y.allDay ? 0 : x.allDay ? -1 : 1) ||
    (x.start || '').localeCompare(y.start || '') || x.title.localeCompare(y.title));
  return list;
}
function groupByDay(list) {
  const m = new Map();
  for (const i of list) {
    IDX.set(i.iid, i);
    if (!m.has(i.z)) m.set(i.z, []);
    m.get(i.z).push(i);
  }
  return m;
}
function chipHTML(i) {
  const t = !i.allDay && i.start ? `<span class="t">${fmtTime(i.start)}</span>` : '';
  return `<div class="chip" data-iid="${esc(i.iid)}" style="--c:${i.color}" title="${esc(i.title)}">${t}${esc(i.title)}${i.recurring ? ' <span class="rep">↻</span>' : ''}</div>`;
}

/* ==================================================================
   6. VIEWS (month / week / agenda) — render from computed ranges only
   ================================================================== */
function renderMonth(anim) {
  const ws = state.settings.weekStart, td = DateUtil.today();
  const a = fromSerial(parseKey(state.view.anchor));
  const first = toSerial(a.y, a.m, 1), start = DateUtil.weekStart(first, ws), end = start + 41;
  const byDay = groupByDay(instancesFor(start, end));
  let h = '<div class="mhead">';
  for (let i = 0; i < 7; i++) h += `<div>${DAY_SHORT[(ws + i) % 7]}</div>`;
  h += '</div><div class="mgrid">';
  for (let r = 0; r < 6; r++) for (let c = 0; c < 7; c++) {
    const z = start + r * 7 + c, d = fromSerial(z), key = keyOf(z);
    const items = byDay.get(z) || [];
    const cls = ['cell', d.m !== a.m ? 'other' : '', z === td ? 'today' : ''].join(' ');
    const label = d.d === 1 ? MON_SHORT[d.m - 1] + ' 1' : String(d.d);
    h += `<div class="${cls}" data-key="${key}">`;
    if (c === 0) h += `<span class="wkn">W${DateUtil.weekNumber(z, ws)}</span>`;
    h += `<div class="dnum">${label}</div>`;
    items.slice(0, CONFIG.MAX_CHIPS).forEach(i => h += chipHTML(i));
    if (items.length > CONFIG.MAX_CHIPS) h += `<div class="more" data-more="${key}">+${items.length - CONFIG.MAX_CHIPS} more</div>`;
    h += '</div>';
  }
  h += '</div>';
  mainEl.innerHTML = `<div class="view ${anim || ''}">${h}</div>`;
  titleEl.textContent = `${DateUtil.MONTHS[a.m - 1]} ${a.y}`;
}

// Lays out timed items in a day column: greedy lane packing for overlaps
function packLanes(items) {
  const base = CONFIG.HOUR_START * 60, top = CONFIG.HOUR_END * 60;
  const arr = [];
  for (const i of items) {
    const s0 = hmToMin(i.start), e0 = i.end ? hmToMin(i.end) : s0 + CONFIG.DEFAULT_DURATION;
    const s = Math.max(s0, base), e = Math.min(Math.max(e0, s0 + CONFIG.SNAP_MIN), top);
    if (e <= base || s >= top) continue;   // outside visible hours
    arr.push({ i, s, e, lane: 0 });
  }
  arr.sort((p, q) => p.s - q.s || q.e - p.e);
  const laneEnds = [];
  for (const it of arr) {
    let l = laneEnds.findIndex(x => x <= it.s);
    if (l < 0) { l = laneEnds.length; laneEnds.push(it.e); } else laneEnds[l] = it.e;
    it.lane = l;
  }
  const n = Math.max(1, laneEnds.length);
  return arr.map(it => Object.assign(it, { n }));
}

function weekTitle(s, e, ws) {
  const a = fromSerial(s), b = fromSerial(e), wn = DateUtil.weekNumber(s, ws);
  let range;
  if (a.m === b.m) range = `${MON_SHORT[a.m - 1]} ${a.d} – ${b.d}, ${b.y}`;
  else if (a.y === b.y) range = `${MON_SHORT[a.m - 1]} ${a.d} – ${MON_SHORT[b.m - 1]} ${b.d}, ${b.y}`;
  else range = `${MON_SHORT[a.m - 1]} ${a.d}, ${a.y} – ${MON_SHORT[b.m - 1]} ${b.d}, ${b.y}`;
  return `${range} · Week ${wn}`;
}

function renderWeek(anim) {
  const ws = state.settings.weekStart, td = DateUtil.today(), H = CONFIG.HOUR_PX;
  const start = DateUtil.weekStart(parseKey(state.view.anchor), ws), end = start + 6;
  const byDay = groupByDay(instancesFor(start, end));
  const bodyH = (CONFIG.HOUR_END - CONFIG.HOUR_START) * H;
  const baseMin = CONFIG.HOUR_START * 60, endMin = CONFIG.HOUR_END * 60;
  const nowMin = DateUtil.nowMinutes();
  let h = '<div class="wk"><div class="wk-corner">W' + DateUtil.weekNumber(start, ws) + '</div>';
  for (let i = 0; i < 7; i++) {
    const z = start + i, d = fromSerial(z);
    h += `<div class="wk-h${z === td ? ' today' : ''}">${DAY_SHORT[DateUtil.dow(z)]}<br><span class="num">${d.d}</span></div>`;
  }
  h += '<div class="wk-ag-g">all-day</div>';
  for (let i = 0; i < 7; i++) {
    const z = start + i;
    const items = (byDay.get(z) || []).filter(x => x.allDay);
    h += `<div class="wk-allday" data-key="${keyOf(z)}">${items.map(chipHTML).join('')}</div>`;
  }
  h += `<div class="wk-gutter" style="height:${bodyH}px">`;
  for (let hr = CONFIG.HOUR_START; hr <= CONFIG.HOUR_END; hr++) {
    h += `<span style="top:${(hr - CONFIG.HOUR_START) * H}px">${fmtHour(hr)}</span>`;
  }
  h += '</div>';
  for (let i = 0; i < 7; i++) {
    const z = start + i, key = keyOf(z);
    const timed = (byDay.get(z) || []).filter(x => !x.allDay);
    h += `<div class="wk-col" data-key="${key}" style="height:${bodyH}px">`;
    for (let hr = CONFIG.HOUR_START; hr < CONFIG.HOUR_END; hr++) {
      const top = (hr - CONFIG.HOUR_START) * H;
      h += `<div class="hline" style="top:${top}px"></div><div class="hline half" style="top:${top + H / 2}px"></div>`;
    }
    for (const it of packLanes(timed)) {
      const top = (it.s - baseMin) / 60 * H, height = (it.e - it.s) / 60 * H - 2;
      h += `<div class="blk" data-iid="${esc(it.i.iid)}" style="--c:${it.i.color};top:${top}px;height:${Math.max(height, 14)}px;` +
        `left:calc(${it.lane * 100 / it.n}% + 2px);width:calc(${100 / it.n}% - 4px);right:auto" title="${esc(it.i.title)}">` +
        `<div class="tm">${fmtTime(it.i.start)} – ${fmtTime(it.i.end)}</div><div>${esc(it.i.title)}${it.i.recurring ? ' <span class="rep">↻</span>' : ''}</div></div>`;
    }
    if (z === td && nowMin >= baseMin && nowMin <= endMin) {
      h += `<div class="now" style="top:${(nowMin - baseMin) / 60 * H}px"></div>`;
    }
    h += '</div>';
  }
  h += '</div>';
  mainEl.innerHTML = `<div class="view ${anim || ''}">${h}</div>`;
  titleEl.textContent = weekTitle(start, end, ws);
}

function renderAgenda(anim) {
  const td = DateUtil.today(), a = parseKey(state.view.anchor), end = a + CONFIG.AGENDA_DAYS - 1;
  const byDay = groupByDay(instancesFor(a, end));
  let h = '<div class="ag">', any = false;
  for (let z = a; z <= end; z++) {
    const items = byDay.get(z);
    if (!items) continue;
    any = true;
    const d = fromSerial(z);
    h += `<div class="ag-day"><h3 class="${z === td ? 'today' : ''}">${z === td ? 'Today · ' : ''}${DAY_SHORT[DateUtil.dow(z)]}, ${MON_SHORT[d.m - 1]} ${d.d}, ${d.y}</h3>`;
    for (const i of items) {
      h += `<div class="ag-item" data-iid="${esc(i.iid)}" style="--c:${i.color}"><div class="tm">${i.allDay ? 'All day' : fmtTime(i.start) + ' – ' + fmtTime(i.end)}</div>` +
        `<div style="min-width:0;flex:1"><div>${esc(i.title)}${i.recurring ? ' <span class="rep">↻</span>' : ''}</div>${i.notes ? `<div class="nt">${esc(i.notes)}</div>` : ''}</div></div>`;
    }
    h += '</div>';
  }
  if (!any) h += `<div class="ag-empty">No events in the next ${CONFIG.AGENDA_DAYS} days.</div>`;
  h += '</div>';
  mainEl.innerHTML = `<div class="view ${anim || ''}">${h}</div>`;
  const d = fromSerial(a);
  titleEl.textContent = `Agenda from ${MON_SHORT[d.m - 1]} ${d.d}, ${d.y}`;
}

let lastMode = null;
function render(anim) {
  IDX = new Map();
  closePop();
  const mode = state.view.mode, st = mainEl.scrollTop;
  document.querySelectorAll('#modes button').forEach(b => b.classList.toggle('on', b.dataset.mode === mode));
  if (mode === 'week') renderWeek(anim);
  else if (mode === 'agenda') renderAgenda(anim);
  else renderMonth(anim);
  if (lastMode !== mode) {
    mainEl.scrollTop = mode === 'week' ? (8 - CONFIG.HOUR_START) * CONFIG.HOUR_PX : 0;
  } else mainEl.scrollTop = st;
  lastMode = mode;
}

/* ==================================================================
   7. NAVIGATION
   ================================================================== */
// Shift a date by whole months on components; day is clamped to the target month length
function shiftMonths(z, n) {
  const c = fromSerial(z), t = DateUtil.addMonths(c.y, c.m, n);
  return toSerial(t.y, t.m, Math.min(c.d, DateUtil.daysInMonth(t.y, t.m)));
}
function navigate(dir, unit) {
  const z = parseKey(state.view.anchor), mode = state.view.mode;
  let nz;
  if (unit === 'year') nz = shiftMonths(z, 12 * dir);
  else if (mode === 'month') nz = shiftMonths(z, dir);
  else nz = z + 7 * dir;
  state.view.anchor = keyOf(nz);
  Store.save();
  render(dir > 0 ? 'anim-next' : 'anim-prev');
}
function goToday() {
  const cur = parseKey(state.view.anchor), t = DateUtil.today();
  state.view.anchor = keyOf(t);
  Store.save();
  const anim = t > cur ? 'anim-next' : t < cur ? 'anim-prev' : 'anim-fade';
  render(anim);
}
function setMode(mode) {
  if (state.view.mode === mode) return;
  state.view.mode = mode;
  Store.save();
  render('anim-fade');
}

/* ==================================================================
   8. TOAST, POPOVER, DRAG-AND-DROP
   ================================================================== */
let toastTimer = null;
function showToast(msg, undo) {
  document.querySelectorAll('.toast').forEach(t => t.remove());
  clearTimeout(toastTimer);
  const el = document.createElement('div');
  el.className = 'toast';
  el.innerHTML = `<span>${esc(msg)}</span>` + (undo ? '<button type="button">Undo</button>' : '');
  if (undo) el.querySelector('button').onclick = () => { el.remove(); undo(); };
  layer.appendChild(el);
  toastTimer = setTimeout(() => el.remove(), CONFIG.UNDO_MS);
}

let popEl = null, popCleanup = null;
function closePop() {
  if (popEl) { popEl.remove(); popEl = null; }
  if (popCleanup) { popCleanup(); popCleanup = null; }
}
function openDayPop(key, anchorEl) {
  closePop();
  const z = parseKey(key), items = instancesFor(z, z), d = fromSerial(z);
  const el = document.createElement('div');
  el.className = 'pop';
  el.innerHTML = `<h4>${DAY_SHORT[DateUtil.dow(z)]}, ${MON_SHORT[d.m - 1]} ${d.d}</h4>` +
    items.map(chipHTML).join('') +
    `<button type="button" class="primary" style="margin-top:6px;width:100%">+ Add event on this day</button>`;
  document.body.appendChild(el);
  popEl = el;
  const r = anchorEl.getBoundingClientRect(), pw = el.offsetWidth, ph = el.offsetHeight;
  let left = clamp(r.left, 8, innerWidth - pw - 8), top = r.bottom + 6;
  if (top + ph > innerHeight - 8) top = Math.max(8, r.top - ph - 6);
  el.style.left = left + 'px'; el.style.top = top + 'px';
  el.addEventListener('click', e => {
    const c = e.target.closest('.chip');
    if (c) { const inst = IDX.get(c.dataset.iid); closePop(); if (inst) openEditor({ inst }); return; }
    if (e.target.closest('button')) { closePop(); openEditor({ date: key }); }
  });
  const down = e => { if (!el.contains(e.target) && !e.target.closest('.more')) closePop(); };
  const key2 = e => { if (e.key === 'Escape') closePop(); };
  setTimeout(() => document.addEventListener('pointerdown', down), 0);
  document.addEventListener('keydown', key2);
  popCleanup = () => { document.removeEventListener('pointerdown', down); document.removeEventListener('keydown', key2); };
}

// Pointer-driven drag. Month: move chip between days. Week: move block across days + time (snap).
let drag = null, suppressClick = false;
mainEl.addEventListener('pointerdown', e => {
  if (e.button !== 0) return;
  const el = e.target.closest('.chip, .blk');
  if (!el) return;
  const inst = IDX.get(el.dataset.iid);
  if (!inst) return;
  const r = el.getBoundingClientRect();
  drag = { el, inst, sx: e.clientX, sy: e.clientY, grab: (e.clientY - r.top), active: false, ghost: null, target: null, moved: false };
  window.addEventListener('pointermove', onDragMove);
  window.addEventListener('pointerup', onDragUp);
});

function onDragMove(e) {
  if (!drag) return;
  if (!drag.active) {
    if (Math.abs(e.clientX - drag.sx) + Math.abs(e.clientY - drag.sy) < 5) return;
    drag.active = true;
    closePop();
    const g = drag.el.cloneNode(true);
    g.classList.add('dragging');
    g.style.position = 'fixed'; g.style.pointerEvents = 'none'; g.style.zIndex = 100;
    g.style.width = Math.max(120, drag.el.offsetWidth) + 'px';
    g.style.left = (e.clientX - 20) + 'px'; g.style.top = (e.clientY - 10) + 'px';
    g.style.margin = '0'; g.style.right = 'auto';
    g.style.height = drag.el.classList.contains('blk') ? drag.el.offsetHeight + 'px' : '';
    document.body.appendChild(g);
    drag.ghost = g;
    drag.el.style.opacity = '.35';
  }
  drag.ghost.style.left = (e.clientX - 20) + 'px';
  drag.ghost.style.top = (e.clientY - 10) + 'px';
  drag.moved = true;
  // clear previous hints
  document.querySelectorAll('.cell.drop,.wk-col.drop,.wk-allday.drop').forEach(n => n.classList.remove('drop'));
  document.querySelectorAll('.snapmark').forEach(n => n.remove());
  drag.target = null;
  const under = document.elementFromPoint(e.clientX, e.clientY);
  if (!under) return;
  if (state.view.mode === 'month') {
    const cell = under.closest('.cell');
    if (cell) { cell.classList.add('drop'); drag.target = { key: cell.dataset.key }; }
  } else if (state.view.mode === 'week') {
    const strip = under.closest('.wk-allday');
    const col = under.closest('.wk-col');
    if (strip) { strip.classList.add('drop'); drag.target = { key: strip.dataset.key, allDayStrip: true }; }
    else if (col) {
      col.classList.add('drop');
      const dur = drag.inst.allDay ? 0 : (hmToMin(drag.inst.end || minToHM(hmToMin(drag.inst.start) + CONFIG.DEFAULT_DURATION)) - hmToMin(drag.inst.start));
      const rect = col.getBoundingClientRect();
      const H = CONFIG.HOUR_PX, baseMin = CONFIG.HOUR_START * 60, endMin = CONFIG.HOUR_END * 60;
      const pointerMin = baseMin + (e.clientY - rect.top) / H * 60;
      const snapStart = Math.round((pointerMin - drag.grab / H * 60) / CONFIG.SNAP_MIN) * CONFIG.SNAP_MIN;
      const st = drag.inst.allDay ? 0 : clamp(snapStart, baseMin, Math.max(baseMin, endMin - Math.max(dur, CONFIG.SNAP_MIN)));
      drag.target = { key: col.dataset.key, start: drag.inst.allDay ? '' : minToHM(st), end: drag.inst.allDay ? '' : minToHM(st + dur), colEl: col, minute: st };
      if (!drag.inst.allDay) {
        const mk = document.createElement('div');
        mk.className = 'snapmark';
        mk.style.top = ((st - baseMin) / 60 * H) + 'px';
        mk.textContent = fmtTime(minToHM(st)) + ' – ' + fmtTime(minToHM(st + dur));
        col.appendChild(mk);
      }
    }
  }
}

function onDragUp() {
  window.removeEventListener('pointermove', onDragMove);
  window.removeEventListener('pointerup', onDragUp);
  const d = drag; drag = null;
  if (!d) return;
  if (d.ghost) d.ghost.remove();
  document.querySelectorAll('.snapmark').forEach(n => n.remove());
  if (!d.active) return;             // plain click: let click handler open editor
  suppressClick = true; setTimeout(() => suppressClick = false, 0);
  d.el.style.opacity = '';
  const t = d.target;
  if (!t) { render(); return; }
  const inst = d.inst, nz = parseKey(t.key);
  const patch = { z: nz };
  if (state.view.mode === 'week' && !inst.allDay && !t.allDayStrip) { patch.start = t.start; patch.end = t.end; }
  if (!patch.start && nz === inst.z) { render(); return; }
  if (inst.allDay && nz === inst.z) { render(); return; }
  if (patch.start !== undefined && patch.start === inst.start && nz === inst.z) { render(); return; }
  const before = mutate(() => writeOccurrence(inst, patch));
  showToast('Event moved', () => { state.events = JSON.parse(before); Store.save(); render(); });
}
// Week timed drag: if target is the all-day strip, convert to date-only move (timed events keep time)

/* ==================================================================
   9. EVENT EDITING — scope-aware CRUD (this / following / all)
      A recurring series is one master record. "This event" = an
      exception keyed by the ORIGINAL slot date. "Following" splits the
      series (old master gets rule.until; new master takes the rest).
   ================================================================== */
const ALL_FIELDS = ['title', 'allDay', 'start', 'end', 'color', 'notes'];

// f: {title, allDay, start, end, color, notes, dateKey, rule?}  (rule undefined = keep)
function applyEdit(inst, scope, f) {
  const master = findMaster(inst.mid);
  if (!master) return;
  const fields = { title: f.title, allDay: !!f.allDay, start: f.allDay ? '' : (f.start || ''),
                   end: f.allDay ? '' : (f.end || ''), color: f.color, notes: f.notes || '' };
  const dateKey = f.dateKey;
  const newRule = f.rule === undefined ? master.rule : f.rule;

  if (!master.rule) {                                   // one-off event (or becoming recurring)
    Object.assign(master, fields, { date: dateKey, rule: newRule || null, ex: {} });
    return;
  }
  if (scope === 'this') {                               // exception for this occurrence only
    master.ex[inst.origKey] = Object.assign({}, fields, { date: dateKey });
    return;
  }
  if (scope === 'following' && inst.origKey !== master.date) {   // split series
    const origZ = parseKey(inst.origKey);
    const oldUntil = master.rule.until;
    master.rule = Object.assign({}, master.rule, { until: keyOf(origZ - 1) });
    const ne = Object.assign({}, fields, { id: newId(), date: dateKey,
      rule: Object.assign({}, newRule, { until: newRule ? newRule.until : oldUntil }), ex: {} });
    if (!newRule) { ne.rule = null; }
    for (const k of Object.keys(master.ex)) {
      if (k >= inst.origKey) { ne.ex[k] = master.ex[k]; delete master.ex[k]; }
    }
    if (newRule === undefined) ne.rule = null;
    state.events.push(ne);
    // drop the old master if nothing is left before the split point
    if (master.rule.until < master.date) state.events = state.events.filter(e => e !== master);
    return;
  }
  // whole series: shift exception keys by any anchor-date delta so overrides stay on their occurrences
  const delta = parseKey(dateKey) - parseKey(master.date);
  const ex = {};
  if (newRule) {
    for (const k of Object.keys(master.ex)) {
      const e = master.ex[k];
      const nk = keyOf(parseKey(k) + delta);
      ex[nk] = e.deleted ? e : Object.assign({}, e, { date: keyOf(parseKey(e.date) + delta) });
    }
  }
  Object.assign(master, fields, { date: dateKey, rule: newRule || null, ex });
}

// Delete one instance under a scope. Never touches other occurrences unless scope says so.
function deleteInst(inst, scope) {
  const master = findMaster(inst.mid);
  if (!master) return;
  if (!master.rule || scope === 'all' || (scope === 'following' && inst.origKey === master.date)) {
    state.events = state.events.filter(e => e !== master);
    return;
  }
  if (scope === 'this') { master.ex[inst.origKey] = { deleted: true }; return; }
  // following: end the series the day before this occurrence
  master.rule = Object.assign({}, master.rule, { until: keyOf(parseKey(inst.origKey) - 1) });
  for (const k of Object.keys(master.ex)) if (k >= inst.origKey) delete master.ex[k];
}

// Drag/resize helper: move/retime a single occurrence (exception for recurring events)
function writeOccurrence(inst, patch) {
  applyEdit(inst, 'this', {
    title: inst.title, allDay: inst.allDay,
    start: patch.start !== undefined ? patch.start : inst.start,
    end: patch.end !== undefined ? patch.end : inst.end,
    color: inst.color, notes: inst.notes, dateKey: keyOf(patch.z),
  });
}

/* ==================================================================
   10. EVENT EDITOR MODAL
   ================================================================== */
let modalEl = null;
function closeModal() {
  if (modalEl) { modalEl.remove(); modalEl = null; }
  document.removeEventListener('keydown', modalKey);
}
function modalKey(e) { if (e.key === 'Escape') closeModal(); }
function openModalEl(html, cls) {
  closeModal(); closePop();
  modalEl = document.createElement('div');
  modalEl.className = 'overlay';
  modalEl.innerHTML = html;
  document.body.appendChild(modalEl);
  modalEl.addEventListener('pointerdown', e => { if (e.target === modalEl) closeModal(); });
  document.addEventListener('keydown', modalKey);
  return modalEl;
}

function openEditor(opts) {
  const inst = opts.inst || null;
  const master = inst ? findMaster(inst.mid) : null;
  const ws = state.settings.weekStart;
  let init;
  if (inst) {
    init = { title: inst.title, date: inst.date, allDay: inst.allDay, start: inst.start, end: inst.end,
             color: inst.color, notes: inst.notes, rule: master && master.rule ? master.rule : null };
  } else {
    const dk = opts.date || keyOf(DateUtil.today());
    const st = opts.start || '09:00';
    const en = minToHM(Math.min(hmToMin(st) + CONFIG.DEFAULT_DURATION, 23 * 60 + 59));
    init = { title: '', date: dk, allDay: !!opts.allDay, start: opts.allDay ? '' : st,
             end: opts.allDay ? '' : en, color: CONFIG.COLORS[0], notes: '', rule: null };
  }
  const rule = init.rule;
  const repeat = rule ? rule.freq : 'none';
  const anchorDow = DateUtil.dow(parseKey(init.date));
  const days = rule && rule.days ? rule.days.slice() : [anchorDow];
  const order = [0, 1, 2, 3, 4, 5, 6].map(i => (ws + i) % 7);
  const recurring = !!(inst && inst.recurring);

  const el = openModalEl(`
  <form class="modal" novalidate role="dialog" aria-modal="true">
    <h2>${inst ? 'Edit event' : 'New event'}</h2>
    ${recurring ? `<div class="scope"><span style="color:var(--muted)">Apply changes to:</span>
      <label><input type="radio" name="scope" value="this" checked> This event</label>
      <label><input type="radio" name="scope" value="following"> This &amp; following</label>
      <label><input type="radio" name="scope" value="all"> Entire series</label></div>` : ''}
    <div class="field"><label class="lb" for="f-title">Title</label>
      <input id="f-title" maxlength="120" autocomplete="off" value="${esc(init.title)}"><div class="err" data-err="title"></div></div>
    <div class="row">
      <div class="field"><label class="lb" for="f-date">Date</label>
        <input type="date" id="f-date" value="${init.date}"><div class="err" data-err="date"></div></div>
      <div class="field fixed"><label style="display:flex;gap:6px;align-items:center;height:34px;cursor:pointer">
        <input type="checkbox" id="f-allday" ${init.allDay ? 'checked' : ''}> All day</label></div>
    </div>
    <div class="row" id="f-timerow">
      <div class="field"><label class="lb" for="f-start">Start</label><input type="time" id="f-start" step="900" value="${init.start}"></div>
      <div class="field"><label class="lb" for="f-end">End</label><input type="time" id="f-end" step="900" value="${init.end}"></div>
    </div>
    <div class="err" data-err="time"></div>
    <div class="field"><span class="lb">Color</span>
      <div class="swatches" id="f-colors">${CONFIG.COLORS.map(c => `<button type="button" data-c="${c}" style="--c:${c}" class="${c === init.color ? 'on' : ''}" aria-label="Color ${c}"></button>`).join('')}</div></div>
    <div class="field"><label class="lb" for="f-repeat">Repeat</label>
      <select id="f-repeat">
        <option value="none">Does not repeat</option><option value="daily">Daily</option>
        <option value="weekly">Weekly</option><option value="monthly">Monthly (same day of month)</option>
        <option value="yearly">Yearly</option>
      </select><div class="err" data-err="rule"></div></div>
    <div class="field" id="f-weekrow"><span class="lb">Repeat on</span>
      <div class="wdays" id="f-days">${order.map(d => `<button type="button" class="wdbtn ${days.includes(d) ? 'on' : ''}" data-d="${d}">${DAY_SHORT[d]}</button>`).join('')}</div></div>
    <div class="field" id="f-untilrow"><label class="lb" for="f-until">Ends on (optional)</label>
      <input type="date" id="f-until" value="${rule && rule.until ? rule.until : ''}"><div class="err" data-err="until"></div></div>
    <div class="field"><label class="lb" for="f-notes">Notes</label><textarea id="f-notes" rows="3">${esc(init.notes)}</textarea></div>
    <div class="foot">
      ${inst ? '<button type="button" class="danger" data-x="del">Delete</button>' : ''}
      <span class="spacer"></span>
      <button type="button" data-x="cancel">Cancel</button>
      <button type="submit" class="primary">Save</button>
    </div>
  </form>`);

  const q = s => el.querySelector(s);
  q('#f-repeat').value = repeat;
  let color = init.color;
  const scopeVal = () => { const r = el.querySelector('input[name=scope]:checked'); return r ? r.value : 'all'; };

  function syncUI() {
    const allDay = q('#f-allday').checked;
    q('#f-timerow').classList.toggle('hide', allDay);
    const thisOnly = recurring && scopeVal() === 'this';
    const rep = q('#f-repeat');
    rep.disabled = thisOnly;
    q('#f-until').disabled = thisOnly;
    el.querySelectorAll('#f-days .wdbtn').forEach(b => b.disabled = thisOnly);
    q('#f-weekrow').classList.toggle('hide', rep.value !== 'weekly');
    q('#f-untilrow').classList.toggle('hide', rep.value === 'none');
    if (thisOnly) q('#f-repeat').title = 'Change the repeat rule using "This & following" or "Entire series".';
  }
  el.querySelectorAll('input[name=scope]').forEach(r => r.addEventListener('change', syncUI));
  q('#f-allday').addEventListener('change', syncUI);
  q('#f-repeat').addEventListener('change', () => {
    if (q('#f-repeat').value === 'weekly' && !el.querySelector('#f-days .on')) {
      el.querySelector(`#f-days .wdbtn[data-d="${anchorFromForm()}"]`).classList.add('on');
    }
    syncUI();
  });
  q('#f-date').addEventListener('change', () => {
    const z = parseKey(q('#f-date').value);
    if (z != null && q('#f-repeat').value === 'weekly') {
      // keep the anchor date's weekday included so the first occurrence is visible
      const dd = DateUtil.dow(z);
      el.querySelector(`#f-days .wdbtn[data-d="${dd}"]`).classList.add('on');
    }
  });
  function anchorFromForm() { const z = parseKey(q('#f-date').value); return z == null ? anchorDow : DateUtil.dow(z); }
  el.querySelectorAll('#f-days .wdbtn').forEach(b => b.addEventListener('click', () => {
    if (b.classList.contains('on') && el.querySelectorAll('#f-days .on').length === 1) {
      setErr('rule', 'Pick at least one weekday.'); return;
    }
    b.classList.toggle('on'); setErr('rule', '');
  }));
  el.querySelectorAll('#f-colors button').forEach(b => b.addEventListener('click', () => {
    color = b.dataset.c;
    el.querySelectorAll('#f-colors button').forEach(x => x.classList.toggle('on', x === b));
  }));
  el.querySelector('[data-x=cancel]').onclick = closeModal;
  const del = el.querySelector('[data-x=del]');
  if (del) del.onclick = () => {
    const scope = recurring ? scopeVal() : 'all';
    const before = JSON.stringify(state.events);
    closeModal();
    mutate(() => deleteInst(inst, scope));
    showToast(recurring && scope === 'this' ? 'Occurrence deleted' : 'Event deleted', () => { state.events = JSON.parse(before); Store.save(); render(); });
  };

  function setErr(key, msg) {
    const n = el.querySelector(`[data-err="${key}"]`);
    if (n) n.textContent = msg;
    const map = { title: '#f-title', date: '#f-date', time: '#f-end', until: '#f-until', rule: '#f-repeat' };
    const inp = map[key] && el.querySelector(map[key]);
    if (inp) inp.classList.toggle('bad', !!msg);
  }

  el.querySelector('form').addEventListener('submit', e => {
    e.preventDefault();
    ['title', 'date', 'time', 'until', 'rule'].forEach(k => setErr(k, ''));
    const title = q('#f-title').value.trim();
    const dateStr = q('#f-date').value;
    const dz = parseKey(dateStr);
    const allDay = q('#f-allday').checked;
    const start = q('#f-start').value, end = q('#f-end').value;
    const thisOnly = recurring && scopeVal() === 'this';
    const repVal = q('#f-repeat').value;
    const until = q('#f-until').value;
    let ok = true;
    if (!title) { setErr('title', 'Title is required.'); ok = false; }
    if (dz == null) { setErr('date', 'Enter a valid date (YYYY-MM-DD).'); ok = false; }
    if (!allDay) {
      if (!start || !end) { setErr('time', 'Start and end times are required (or choose All day).'); ok = false; }
      else if (hmToMin(end) <= hmToMin(start)) { setErr('time', 'End time must be after start time.'); ok = false; }
    }
    let rule = null;
    if (!thisOnly && repVal !== 'none') {
      let days = null;
      if (repVal === 'weekly') {
        days = [...el.querySelectorAll('#f-days .on')].map(b => +b.dataset.d).sort((a, b) => a - b);
        if (dz != null && !days.includes(DateUtil.dow(dz))) { days.push(DateUtil.dow(dz)); days.sort((a, b) => a - b); }
        if (!days.length) { setErr('rule', 'Pick at least one weekday.'); ok = false; }
      }
      let untilOut = null;
      if (until) {
        const uz = parseKey(until);
        if (uz == null) { setErr('until', 'Enter a valid end date.'); ok = false; }
        else if (dz != null && uz < dz) { setErr('until', 'End date must be on or after the start date.'); ok = false; }
        else untilOut = until;
      }
      rule = { freq: repVal, days, until: untilOut };
    }
    if (!ok) {
      const first = el.querySelector('.err:not(:empty)');
      if (first) { const inp = first.parentElement.querySelector('input,select') || q('#f-end'); inp && inp.focus(); }
      return;
    }
    const fields = { title, allDay, start, end, color, notes: q('#f-notes').value.trim(), dateKey: dateStr };
    if (thisOnly) fields.rule = undefined; else fields.rule = rule;
    if (!inst) {
      state.events.push(Object.assign({ id: newId(), ex: {} }, fields, { date: dateStr, allDay, start: allDay ? '' : start, end: allDay ? '' : end, rule, ex: {} }, { rule }));
      closeModal();
      mutate(() => {});
      showToast('Event created');
    } else {
      const scope = recurring ? scopeVal() : 'all';
      closeModal();
      mutate(() => applyEdit(inst, scope, fields));
      showToast('Event saved');
    }
  });
  syncUI();
  setTimeout(() => q('#f-title').focus(), 30);
}

/* ==================================================================
   11. SETTINGS MODAL
   ================================================================== */
function openSettings() {
  const ws = state.settings.weekStart;
  const el = openModalEl(`
  <div class="modal" role="dialog" aria-modal="true">
    <h2>Settings</h2>
    <div class="field"><span class="lb">Week starts on</span>
      <div class="swatches" style="gap:6px">
        <label class="wdbtn ${ws === 0 ? 'on' : ''}" style="display:inline-flex;align-items:center;gap:6px;border:1px solid #c4c7c5;border-radius:16px;padding:6px 12px;cursor:pointer"><input type="radio" name="ws" value="0" ${ws === 0 ? 'checked' : ''} style="margin:0"> Sunday</label>
        <label class="wdbtn ${ws === 1 ? 'on' : ''}" style="display:inline-flex;align-items:center;gap:6px;border:1px solid #c4c7c5;border-radius:16px;padding:6px 12px;cursor:pointer"><input type="radio" name="ws" value="1" ${ws === 1 ? 'checked' : ''} style="margin:0"> Monday</label>
      </div></div>
    <div class="field"><span class="lb">Data</span>
      <div><button type="button" class="danger" data-x="wipe">Delete all events</button></div></div>
    <div class="foot"><span class="spacer"></span><button type="button" class="primary" data-x="done">Done</button></div>
  </div>`);
  el.querySelectorAll('input[name=ws]').forEach(r => r.addEventListener('change', () => {
    state.settings.weekStart = +r.value; Store.save(); render();
  }));
  el.querySelector('[data-x=done]').onclick = closeModal;
  el.querySelector('[data-x=wipe]').onclick = () => {
    if (!confirm('Delete ALL events? This cannot be undone.')) return;
    const before = JSON.stringify(state.events);
    mutate(() => { state.events = []; });
    showToast('All events deleted', () => { state.events = JSON.parse(before); Store.save(); render(); });
    closeModal();
  };
}

/* ==================================================================
   12. EVENT WIRING (header, click handling, keyboard, live clock)
   ================================================================== */
document.querySelector('header.top').addEventListener('click', e => {
  const b = e.target.closest('button');
  if (!b) return;
  if (b.dataset.mode) return setMode(b.dataset.mode);
  switch (b.dataset.act) {
    case 'prev': return navigate(-1, 'month');
    case 'next': return navigate(1, 'month');
    case 'yprev': return navigate(-1, 'year');
    case 'ynext': return navigate(1, 'year');
    case 'today': return goToday();
    case 'new': return openEditor({ date: keyOf(DateUtil.today()) });
    case 'settings': return openSettings();
  }
});

mainEl.addEventListener('click', e => {
  if (suppressClick) return;
  const t = e.target;
  const chip = t.closest('.chip, .blk, .ag-item');
  if (chip) { const inst = IDX.get(chip.dataset.iid); if (inst) openEditor({ inst }); return; }
  const more = t.closest('.more');
  if (more) return openDayPop(more.dataset.more, more);
  const col = t.closest('.wk-col');
  if (col) {
    const rect = col.getBoundingClientRect();
    const H = CONFIG.HOUR_PX;
    let m = CONFIG.HOUR_START * 60 + (e.clientY - rect.top) / H * 60;
    m = Math.round(m / CONFIG.SNAP_MIN) * CONFIG.SNAP_MIN;
    m = clamp(m, CONFIG.HOUR_START * 60, CONFIG.HOUR_END * 60 - CONFIG.SNAP_MIN);
    return openEditor({ date: col.dataset.key, start: minToHM(m) });
  }
  const strip = t.closest('.wk-allday');
  if (strip) return openEditor({ date: strip.dataset.key, allDay: true });
  const cell = t.closest('.cell');
  if (cell) return openEditor({ date: cell.dataset.key });
});

// Keyboard navigation (ignored while typing or with a modal open)
document.addEventListener('keydown', e => {
  if (modalEl || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return;
  if (e.key === 'ArrowLeft') navigate(-1, 'month');
  else if (e.key === 'ArrowRight') navigate(1, 'month');
  else if (e.key === 't' || e.key === 'T') goToday();
});

// Refresh the current-time line while viewing week view
setInterval(() => {
  if (state.view.mode === 'week' && !drag && !modalEl) render();
}, 30000);

/* ==================================================================
   13. BOOT
   ================================================================== */
Store.save();
render();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"0a33c3c4-b341-4726-94cf-52c0854d2607","tokensIn":524471,"tokensOut":171173,"tokensTotal":695644,"cost":0.09763461000000001,"turns":17,"toolCalls":20,"failedToolCalls":0,"timestamp":"2026-10-08T06:35:49.699Z"} -->