← Calendar results

Calendar

Qwen 3.6 27b MTP · composer

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 68,946 bytes · SHA-256 85e7721a9b41
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calendar &amp; Event Planner</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; color: #202124; overflow-x: hidden; }

#header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: #fff; border-bottom: 1px solid #dadce0; position: sticky; top: 0; z-index: 100; min-height: 48px; }
#nav-left { display: flex; align-items: center; gap: 8px; }
#nav-right { display: flex; align-items: center; gap: 12px; }

.nav-btn { background: none; border: 1px solid #dadce0; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 13px; color: #5f6368; transition: all .15s; }
.nav-btn:hover { background: #f1f3f4; border-color: #c0c4cc; }
.nav-arrow { padding: 6px 10px; font-size: 18px; line-height: 1; }

#header-title { font-size: 22px; font-weight: 500; color: #3c4043; margin-left: 8px; min-width: 200px; transition: opacity .2s; }

#year-jump { border: 1px solid #dadce0; border-radius: 4px; padding: 4px 8px; font-size: 13px; background: #fff; cursor: pointer; color: #5f6368; }
#view-switcher { display: flex; gap: 2px; background: #e8eaed; border-radius: 6px; padding: 2px; }
.view-btn { background: none; border: none; padding: 6px 14px; cursor: pointer; font-size: 13px; color: #5f6368; border-radius: 4px; transition: all .15s; }
.view-btn.active { background: #fff; color: #1a73e8; box-shadow: 0 1px 2px rgba(0,0,0,.1); font-weight: 500; }
.view-btn:hover:not(.active) { background: rgba(0,0,0,.04); }

#calendar-container { padding: 16px; max-width: 1400px; margin: 0 auto; transition: opacity .2s ease; }
/* ── css: month-view ── */
.month-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid #dadce0; border-radius: 8px; overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.month-header-cell { padding: 8px; text-align: center; font-size: 11px; font-weight: 600; color: #70757a; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #e8eaed; background: #fafbfc; }
.month-cell { min-height: 90px; padding: 4px; border-right: 1px solid #e8eaed; border-bottom: 1px solid #e8eaed; cursor: pointer; transition: background .1s; position: relative; overflow: hidden; }
.month-cell:nth-child(7n) { border-right: none; }
.month-cell:hover { background: #f5f6f8; }
.month-cell.other-month { background: #fafbfc; }
.month-cell.other-month .cell-day-num { color: #bbb; }
.month-cell.drag-target { background: #e8f0fe !important; box-shadow: inset 0 0 0 2px #1a73e8; }
.cell-day-num { font-size: 12px; font-weight: 500; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; margin-bottom: 2px; color: #3c4043; }
.month-cell.today .cell-day-num { background: #1a73e8; color: #fff; font-weight: 700; }

.event-chip { font-size: 11px; padding: 1px 5px; border-radius: 3px; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: grab; user-select: none; line-height: 18px; }
.event-chip:active { cursor: grabbing; }
.event-chip.dragging { opacity: .4; }

.more-link { font-size: 10px; color: #5f6368; padding: 1px 5px; cursor: pointer; border-radius: 3px; transition: background .1s; }
.more-link:hover { background: rgba(0,0,0,.06); }

/* Day popover */
.day-popover { position: fixed; z-index: 200; background: #fff; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.15); padding: 12px; min-width: 240px; max-height: 320px; overflow-y: auto; }
.day-popover-header { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #3c4043; }
.popover-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: background .1s; font-size: 12px; }
.popover-event:hover { background: #f1f3f4; }
.popover-color-bar { width: 4px; height: 20px; border-radius: 2px; flex-shrink: 0; }
/* ── css: week-view ── */
.week-grid { display: grid; grid-template-columns: 50px repeat(7, 1fr); border: 1px solid #dadce0; border-radius: 8px; overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.week-header-cell { padding: 8px 4px; text-align: center; font-size: 12px; border-bottom: 1px solid #e8eaed; background: #fafbfc; }
.week-header-cell .wh-day-name { font-weight: 600; color: #70757a; text-transform: uppercase; font-size: 10px; letter-spacing: .3px; }
.week-header-cell .wh-day-num { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; margin: 4px auto 0; font-size: 14px; color: #3c4043; }
.week-header-cell.today .wh-day-num { background: #1a73e8; color: #fff; font-weight: 700; }

.week-body { display: grid; grid-template-columns: 50px repeat(7, 1fr); position: relative; }
.time-labels { border-right: 1px solid #e8eaed; }
.time-label { height: 48px; font-size: 10px; color: #9aa0a6; text-align: right; padding-right: 8px; transform: translateY(-7px); }

.week-day-col { border-right: 1px solid #e8eaed; position: relative; min-height: 768px; }
.week-day-col:last-child { border-right: none; }
.hour-slot { height: 48px; border-bottom: 1px solid #f0f0f0; }

.week-event-block { position: absolute; left: 2px; right: 2px; border-radius: 4px; padding: 2px 6px; font-size: 11px; overflow: hidden; cursor: grab; z-index: 5; transition: box-shadow .1s; }
.week-event-block:hover { box-shadow: 0 2px 8px rgba(0,0,0,.15); z-index: 6; }
.week-event-block.dragging { opacity: .5; z-index: 7; }
.week-event-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-time { font-size: 10px; opacity: .8; }

.all-day-strip { display: grid; grid-template-columns: 50px repeat(7, 1fr); border-bottom: 2px solid #dadce0; min-height: 32px; background: #fafbfc; }
.all-day-cell { padding: 4px; border-right: 1px solid #e8eaed; font-size: 11px; }
.all-day-event { display: inline-block; padding: 2px 6px; border-radius: 3px; margin: 1px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; font-size: 11px; }

.current-time-line { position: absolute; left: 0; right: 0; height: 2px; background: #ea4335; z-index: 10; pointer-events: none; }
.current-time-dot { width: 10px; height: 10px; border-radius: 50%; background: #ea4335; position: absolute; left: -5px; top: -4px; }
/* ── css: agenda-view ── */
.agenda-list { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); overflow: hidden; }
.agenda-day-group { padding: 12px 16px; border-bottom: 1px solid #e8eaed; }
.agenda-day-group:last-child { border-bottom: none; }
.agenda-date-header { font-size: 13px; font-weight: 600; color: #70757a; text-transform: uppercase; margin-bottom: 8px; letter-spacing: .3px; }
.agenda-date-header.today-label::after { content: 'TODAY'; background: #1a73e8; color: #fff; font-size: 9px; padding: 2px 6px; border-radius: 3px; margin-left: 8px; vertical-align: middle; }
.agenda-event-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: background .1s; font-size: 13px; }
.agenda-event-item:hover { background: #f1f3f4; }
.agenda-color-bar { width: 4px; height: 28px; border-radius: 2px; flex-shrink: 0; }
.agenda-event-info { flex: 1; min-width: 0; }
.agenda-event-title-text { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agenda-event-time-text { font-size: 12px; color: #70757a; margin-top: 1px; }
.no-events-msg { padding: 40px; text-align: center; color: #9aa0a6; font-size: 14px; }
/* ── css: modal-toast ── */
.hidden { display: none !important; }

#modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 300; display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

#event-modal { background: #fff; border-radius: 12px; width: 420px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.2); animation: slideUp .2s ease; }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

#modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #e8eaed; }
#modal-title-text { font-size: 18px; font-weight: 500; color: #3c4043; }
.close-btn { background: none; border: none; font-size: 22px; cursor: pointer; color: #70757a; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background .1s; }
.close-btn:hover { background: #f1f3f4; }

#event-form { padding: 16px 20px 20px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: #70757a; margin-bottom: 4px; font-weight: 500; }
.form-row { display: flex; gap: 12px; align-items: flex-end; }
.form-row .form-group { flex: 1; }

input[type="text"], input[type="date"], input[type="time"], select, textarea { width: 100%; padding: 8px 10px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px; color: #3c4043; background: #fff; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #1a73e8; box-shadow: 0 0 0 2px rgba(26,115,232,.15); }

.error-msg { font-size: 11px; color: #d93025; margin-top: 2px; display: block; min-height: 14px; }

#color-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.color-swatch { width: 28px; height: 28px; border-radius: 50%; cursor: pointer; border: 3px solid transparent; transition: all .1s; }
.color-swatch.selected { border-color: #3c4043; transform: scale(1.1); }

#weekday-picker { display: flex; gap: 4px; margin-top: 4px; }
.weekday-btn { width: 32px; height: 28px; border-radius: 4px; border: 1px solid #dadce0; background: #fff; cursor: pointer; font-size: 11px; color: #5f6368; transition: all .1s; }
.weekday-btn.selected { background: #e8f0fe; border-color: #1a73e8; color: #1a73e8; font-weight: 600; }

#modal-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; padding-top: 12px; border-top: 1px solid #e8eaed; gap: 8px; flex-wrap: wrap; }
.danger-btn { background: none; border: 1px solid #dadce0; color: #d93025; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all .1s; }
.danger-btn:hover { background: #fce8e6; }
#btn-save-event { background: #1a73e8; color: #fff; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background .1s; }
#btn-save-event:hover { background: #1765cc; }

#delete-series-options { display: flex; gap: 12px; width: 100%; margin-top: 8px; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 12px; color: #5f6368; }
#delete-series-options label { cursor: pointer; display: flex; align-items: center; gap: 4px; }

/* Toast */
#toast-container { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 400; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast { background: #323232; color: #fff; padding: 10px 20px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,.2); animation: toastIn .2s ease; display: flex; align-items: center; gap: 12px; }
.toast-action { color: #8ab4f8; cursor: pointer; font-weight: 600; text-transform: uppercase; font-size: 12px; border: none; background: none; padding: 2px 4px; }
@keyframes toastIn { from { transform: translateY(10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

/* Responsive */
@media (max-width: 768px) {
  #header { flex-wrap: wrap; gap: 8px; padding: 8px; }
  #nav-right { order: 3; width: 100%; justify-content: center; }
  .month-cell { min-height: 60px; }
  .event-chip { font-size: 9px; }
  .week-grid, .week-body, .all-day-strip { grid-template-columns: 35px repeat(7, 1fr); }
}
</style>
</head>
<body>
<header id="header">
  <div id="nav-left">
    <button id="btn-today" class="nav-btn">Today</button>
    <button id="btn-prev" class="nav-btn nav-arrow">‹</button>
    <button id="btn-next" class="nav-btn nav-arrow">›</button>
    <span id="header-title"></span>
  </div>
  <button id="btn-settings" class="nav-btn" title="Settings">⚙</button><div id="nav-right">
    <select id="year-jump"></select>
    <div id="view-switcher">
      <button class="view-btn active" data-view="month">Month</button>
      <button class="view-btn" data-view="week">Week</button>
      <button class="view-btn" data-view="agenda">Agenda</button>
    </div>
  </div>
</header>
<main id="calendar-container"></main>
<div id="modal-overlay" class="hidden">
  <div id="event-modal">
    <div id="modal-header">
      <h3 id="modal-title-text">New Event</h3>
      <button id="btn-close-modal" class="close-btn">×</button>
    </div>
    <form id="event-form">
      <div class="form-group">
        <label for="evt-title">Title</label>
        <input type="text" id="evt-title" required="" placeholder="Event title">
        <span class="error-msg" id="err-title"></span>
      </div>
      <div class="form-row">
        <div class="form-group">
          <label for="evt-date">Date</label>
          <input type="date" id="evt-date" required="">
        </div>
        <div class="form-group">
          <label><input type="checkbox" id="evt-allday"> All day</label>
        </div>
      </div>
      <div class="form-row time-row" id="time-fields">
        <div class="form-group">
          <label for="evt-start">Start</label>
          <input type="time" id="evt-start" value="09:00">
        </div>
        <div class="form-group">
          <label for="evt-end">End</label>
          <input type="time" id="evt-end" value="10:00">
          <span class="error-msg" id="err-time"></span>
        </div>
      </div>
      <div class="form-group">
        <label for="evt-color">Color</label>
        <div id="color-picker"></div>
      </div>
      <div class="form-group">
        <label for="evt-recurrence">Recurrence</label>
        <select id="evt-recurrence">
          <option value="none">None</option>
          <option value="daily">Daily</option>
          <option value="weekly">Weekly</option>
          <option value="monthly">Monthly</option>
          <option value="yearly">Yearly</option>
        </select>
      </div>
      <div class="form-group" id="weekday-fields" style="display:none;">
        <label>Repeat on:</label>
        <div id="weekday-picker"></div>
      </div>
      <div class="form-group">
        <label for="evt-notes">Notes</label>
        <textarea id="evt-notes" rows="2" placeholder="Optional notes..."></textarea>
      </div>
      <div id="modal-actions">
        <button type="button" id="btn-delete-event" class="danger-btn hidden">Delete</button>
        <div id="delete-series-options" style="display:none;">
          <label><input type="radio" name="del-scope" value="this" checked=""> This event only</label>
          <label><input type="radio" name="del-scope" value="following"> This &amp; following</label>
          <label><input type="radio" name="del-scope" value="series"> Entire series</label>
        </div>
        <button type="submit" id="btn-save-event">Save</button>
      </div>
    </form>
  </div>
</div>
<div id="day-popover-overlay" class="hidden"></div>
<div id="toast-container"></div>
<script>
'use strict';

// ── const: COLORS ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const COLORS = [
  { name: 'Blue', value: '#1a73e8' },
  { name: 'Red', value: '#d93025' },
  { name: 'Green', value: '#1e8e3e' },
  { name: 'Orange', value: '#f4b400' },
  { name: 'Purple', value: '#9334e6' },
  { name: 'Teal', value: '#12b5cb' },
  { name: 'Pink', value: '#db2778' },
  { name: 'Brown', value: '#8b5a2b' },
];

// ── const: HOUR_START ──
const HOUR_START = 6;

// ── const: HOUR_END ──
// first hour row in week view
const HOUR_END = 22;

// ── const: SNAP_MINUTES ──
// last hour row (exclusive)
const SNAP_MINUTES = 15;

// ── const: DEFAULT_WEEK_START ──
// drag snap interval in minutes
const DEFAULT_WEEK_START = 0;

// ── function: isLeapYear ──
// 0=Sunday, 1=Monday

// ============================================================
// DATE UTILITIES — Pure functions, no side effects
// ============================================================

/** Check if a year is a leap year (handles century rule) */
function isLeapYear(year) {
  return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
}

// ── function: daysInMonth ──
/** Number of days in a given month (0-indexed: Jan=0, Feb=1, ...) */
function daysInMonth(year, month) {
  const table = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  return table[month];
}

// ── function: dayOfWeek ──
/** Day of week for a given date: returns 0=Sunday … 6=Saturday */
function dayOfWeek(year, month, day) {
  // Zeller-like computation using native Date but only reading the dow property
  const d = new Date(year, month, day);
  return d.getDay(); // 0=Sun ... 6=Sat
}

// ── function: toDateStr ──
/** Convert date components to a YYYY-MM-DD string */
function toDateStr(year, month, day) {
  return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}

// ── function: parseDateStr ──
/** Parse YYYY-MM-DD into { year, month (0-indexed), day } */
function parseDateStr(str) {
  const [y, m, d] = str.split('-').map(Number);
  return { year: y, month: m - 1, day: d };
}

// ── function: addDays ──
/** Add N days to a date component tuple — handles month/year rollover correctly */
function addDays(year, month, day, n) {
  let dy = day + n;
  while (dy > daysInMonth(year, month)) {
    dy -= daysInMonth(year, month);
    month++;
    if (month > 11) { month = 0; year++; }
  }
  while (dy < 1) {
    month--;
    if (month < 0) { month = 11; year--; }
    dy += daysInMonth(year, month);
  }
  return { year, month, day: dy };
}

// ── function: addMonths ──
/** Add N months to a date — clamps day to valid range */
function addMonths(year, month, n) {
  let m = month + n;
  let y = year;
  while (m > 11) { m -= 12; y++; }
  while (m < 0) { m += 12; y--; }
  return { year: y, month: m };
}

// ── function: getWeekStart ──
/** Get the first day of a week containing the given date, based on weekStart */
function getWeekStart(year, month, day, weekStart) {
  const dow = dayOfWeek(year, month, day);
  const diff = (dow - weekStart + 7) % 7;
  return addDays(year, month, day, -diff);
}

// ── function: generateMonthGrid ──
/** Generate the full grid for a month view: array of { year, month, day, isCurrentMonth } */
function generateMonthGrid(year, month, weekStart) {
  const firstDow = dayOfWeek(year, month, 1);
  const startOffset = (firstDow - weekStart + 7) % 7; // days before the 1st to fill
  const dimCount = 6 * 7; // always 6 rows

  const grid = [];
  let { year: cy, month: cm, day: cd } = addDays(year, month, 1, -startOffset);

  for (let i = 0; i < dimCount; i++) {
    grid.push({ year: cy, month: cm, day: cd, isCurrentMonth: cm === month });
    const result = addDays(cy, cm, cd, 1);
    cy = result.year; cm = result.month; cd = result.day;
  }
  return grid;
}

// ── function: isSameDay ──
/** Check if two date strings represent the same day */
function isSameDay(d1, d2) { return d1 === d2; }

// ── function: getTodayStr ──
/** Get today as YYYY-MM-DD string (local time) */
function getTodayStr() {
  const now = new Date();
  return toDateStr(now.getFullYear(), now.getMonth(), now.getDate());
}

// ── const: MONTH_NAMES ──
/** Month names for display */
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
                     'July', 'August', 'September', 'October', 'November', 'December'];

// ── const: DAY_NAMES_SHORT ──
const DAY_NAMES_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

// ── const: DAY_NAMES_MON_FIRST ──
const DAY_NAMES_MON_FIRST = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

// ── function: formatTime ──
/** Format a time string for display */
function formatTime(timeStr) {
  if (!timeStr) return '';
  const [h, m] = timeStr.split(':').map(Number);
  const ampm = h >= 12 ? 'PM' : 'AM';
  const hour = h % 12 || 12;
  return `${hour}:${String(m).padStart(2,'0')} ${ampm}`;
}

// ── function: snapTime ──
/** Clamp a time to snap interval */
function snapTime(timeStr, snapMin) {
  if (!timeStr) return '09:00';
  const [h, m] = timeStr.split(':').map(Number);
  const snappedM = Math.round(m / snapMin) * snapMin;
  let newH = h + (snappedM >= 60 ? 1 : 0);
  let newM = snappedM % 60;
  if (newH > 23) { newH = 23; newM = 45; }
  return `${String(newH).padStart(2,'0')}:${String(newM).padStart(2,'0')}`;
}

// ── function: timeToMinutes ──
/** Time to minutes since midnight */
function timeToMinutes(timeStr) {
  const [h, m] = timeStr.split(':').map(Number);
  return h * 60 + m;
}

// ── function: minutesToTime ──
/** Minutes since midnight to HH:MM string */
function minutesToTime(mins) {
  let h = Math.floor(mins / 60);
  let m = mins % 60;
  if (h < 0) h = 0;
  if (m < 0) m += 60;
  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}

// ── function: uid ──
/** Generate a unique ID */
function uid() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

// ── const: STORAGE_KEY ──
// ============================================================
// EVENT STORE — Pure CRUD functions & recurrence engine
// ============================================================

const STORAGE_KEY = 'calendar_app_data';

// ── function: loadAppData ──
/** Load all app data from localStorage */
function loadAppData() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) return JSON.parse(raw);
  } catch(e) { console.warn('Failed to load from localStorage', e); }
  return null;
}

// ── function: saveAppData ──
/** Save all app data to localStorage */
function saveAppData(data) {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }
  catch(e) { console.warn('Failed to save to localStorage', e); }
}

// ── function: defaultAppState ──
/** Default app state factory */
function defaultAppState() {
  return {
    events: [],
    settings: { weekStart: DEFAULT_WEEK_START },
    viewState: { currentYear: new Date().getFullYear(), currentMonth: new Date().getMonth(), currentView: 'month' }
  };
}

// ── function: createEventObj ──
/** Create a new event object */
function createEventObj(title, date, allDay, startTime, endTime, color, notes, recurrence) {
  const evt = {
    id: uid(),
    title: title || 'Untitled',
    date: date,
    startTime: allDay ? null : (startTime || '09:00'),
    endTime: allDay ? null : (endTime || '10:00'),
    allDay: !!allDay,
    color: color || COLORS[0].value,
    notes: notes || '',
    recurrence: recurrence || { type: 'none' },
    seriesId: null,
    exceptions: [],
  };
  if (evt.recurrence.type !== 'none') evt.seriesId = evt.id;
  return evt;
}

// ── function: isRecurrenceMatch ──
/** Check if a date matches the recurrence rule of an event */
function isRecurrenceMatch(evt, dateStr) {
  if (!evt.recurrence || evt.recurrence.type === 'none') return false;
  // If this date is in exceptions, skip it
  if (evt.exceptions && evt.exceptions.includes(dateStr)) return false;
  // Respect endBefore cutoff
  if (evt.recurrence.endBefore && dateStr >= evt.recurrence.endBefore) return false;

  const target = parseDateStr(dateStr);
  const base = parseDateStr(evt.date);
  const rule = evt.recurrence;

  switch (rule.type) {
    case 'daily':
      return dateStr >= evt.date;
    case 'weekly': {
      if (dateStr < evt.date) return false;
      const targetDow = dayOfWeek(target.year, target.month, target.day);
      const weekdays = rule.weekdays || [dayOfWeek(base.year, base.month, base.day)];
      return weekdays.includes(targetDow);
    }
    case 'monthly': {
      if (dateStr < evt.date) return false;
      // Same day of month — skip months that don't have this day
      const targetDim = daysInMonth(target.year, target.month);
      if (base.day > targetDim) return false;
      return base.day === target.day && target.year >= base.year;
    }
    case 'yearly': {
      if (dateStr < evt.date) return false;
      return base.month === target.month && base.day === target.day;
    }
    default:
      return dateStr === evt.date;
  }
}

// ── function: expandRecurrenceForRange ──
/** Expand recurrence for a visible date range [startStr, endStr] */
function expandRecurrenceForRange(evt, startStr, endStr) {
  if (!evt.recurrence || evt.recurrence.type === 'none') {
    if (evt.date >= startStr && evt.date <= endStr) return [{ ...evt, _occurrenceDate: evt.date }];
    return [];
  }

  const occurrences = [];
  let current = parseDateStr(startStr);
  const endDate = parseDateStr(endStr);

  while (current.year < endDate.year ||
         (current.year === endDate.year && current.month <= endDate.month) ||
         (current.year === endDate.year && current.month === endDate.month && current.day <= endDate.day)) {
    const ds = toDateStr(current.year, current.month, current.day);
    if (isRecurrenceMatch(evt, ds)) {
      occurrences.push({ ...evt, _occurrenceDate: ds });
    }
    const next = addDays(current.year, current.month, current.day, 1);
    current = next;
  }
  return occurrences;
}

// ── function: getEventsForDate ──
/** Get all events for a specific date string */
function getEventsForDate(dateStr) {
  const results = [];
  for (const evt of appData.events) {
    if (evt.recurrence && evt.recurrence.type !== 'none') {
      if (isRecurrenceMatch(evt, dateStr)) {
        results.push({ ...evt, _occurrenceDate: dateStr });
      }
    } else if (evt.date === dateStr) {
      results.push({ ...evt, _occurrenceDate: evt.date });
    }
  }
  results.sort((a, b) => {
    if (a.allDay !== b.allDay) return a.allDay ? -1 : 1;
    if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
    return 0;
  });
  return results;
}

// ── function: getEventsInRange ──
/** Get all events in a date range */
function getEventsInRange(startStr, endStr) {
  const results = [];
  for (const evt of appData.events) {
    const occs = expandRecurrenceForRange(evt, startStr, endStr);
    results.push(...occs);
  }
  return results;
}

// ── function: sortEvents ──
/** Sort events by time */
function sortEvents(events) {
  return [...events].sort((a, b) => {
    if (a.allDay !== b.allDay) return a.allDay ? -1 : 1;
    if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
    return 0;
  });
}

// ── function: formatRecurrence ──
/** Format recurrence rule for display */
function formatRecurrence(rule) {
  if (!rule || rule.type === 'none') return '';
  switch (rule.type) {
    case 'daily': return 'Daily';
    case 'weekly': {
      const days = (rule.weekdays || []).map(d => DAY_NAMES_SHORT[d]).join(', ');
      return `Weekly (${days})`;
    }
    case 'monthly': return 'Monthly';
    case 'yearly': return 'Yearly';
    default: return '';
  }
}

// ── function: renderHeaderTitle ──
/** Render the header title based on current view state */
function renderHeaderTitle(viewState) {
  const el = document.getElementById('header-title');
  if (viewState.currentView === 'month') {
    el.textContent = `${MONTH_NAMES[viewState.currentMonth]} ${viewState.currentYear}`;
  } else if (viewState.currentView === 'week') {
    // Title is set inside renderWeekView now
  } else if (viewState.currentView === 'agenda') {
    const today = getTodayStr();
    const start = parseDateStr(today);
    const end = addDays(start.year, start.month, start.day, 6);
    el.textContent = `Agenda · ${MONTH_NAMES[start.month]} ${start.day} – ${MONTH_NAMES[end.month].slice(0,3)} ${end.day}`;
  }
}

// ── function: renderYearJump ──
/** Render the year jump dropdown */
function renderYearJump(viewState) {
  const sel = document.getElementById('year-jump');
  sel.innerHTML = '';
  for (let y = viewState.currentYear - 20; y <= viewState.currentYear + 20; y++) {
    const opt = document.createElement('option');
    opt.value = y;
    opt.textContent = y;
    if (y === viewState.currentYear) opt.selected = true;
    sel.appendChild(opt);
  }
}

// ── function: renderMonthView ──
/** Render month view */
function renderMonthView(viewState) {
  const container = document.getElementById('calendar-container');
  const weekStart = appData.settings.weekStart;
  const grid = generateMonthGrid(viewState.currentYear, viewState.currentMonth, weekStart);
  const todayStr = getTodayStr();
  const dayNames = weekStart === 1 ? DAY_NAMES_MON_FIRST : DAY_NAMES_SHORT;

  let html = '<div class="month-grid">';

  // Header row
  for (const name of dayNames) {
    html += `<div class="month-header-cell">${name}</div>`;
  }

  // Day cells
  for (const cell of grid) {
    const dateStr = toDateStr(cell.year, cell.month, cell.day);
    const isToday = isSameDay(dateStr, todayStr);
    const classes = ['month-cell'];
    if (!cell.isCurrentMonth) classes.push('other-month');
    if (isToday) classes.push('today');

    html += `<div class="${classes.join(' ')}" data-date="${dateStr}">`;
    html += `<div class="cell-day-num">${cell.day}</div>`;

    // Events for this day
    const events = getEventsForDate(dateStr);
    const maxShow = 3;
    let shown = 0;
    for (let i = 0; i < events.length && shown < maxShow; i++) {
      const evt = events[i];
      const occDate = evt._occurrenceDate || evt.date;
      html += `<div class="event-chip" style="background:${evt.color}22;color:${evt.color};border-left:3px solid ${evt.color}" 
                data-event-id="${evt.id}" data-occ-date="${occDate}" title="${evt.title}${formatRecurrence(evt.recurrence) ? ' · '+formatRecurrence(evt.recurrence) : ''}">${evt.title}</div>`;
      shown++;
    }
    if (events.length > maxShow) {
      html += `<div class="more-link" data-date="${dateStr}">+${events.length - maxShow} more</div>`;
    }

    html += '</div>';
  }

  html += '</div>';
  container.innerHTML = html;
}

// ── function: renderWeekView ──
/** Render week view using proper week tracking */
function renderWeekView(viewState) {
  const container = document.getElementById('calendar-container');
  const todayStr = getTodayStr();
  const dayNames = appData.settings.weekStart === 1 ? DAY_NAMES_MON_FIRST : DAY_NAMES_SHORT;

  // Get the 7 days of current week
  const weekDays = getCurrentWeekDays();
  const wsDate = parseDateStr(weekDays[0]);
  const weDate = parseDateStr(weekDays[6]);

  // Header title
  if (wsDate.month === weDate.month) {
    document.getElementById('header-title').textContent = `${MONTH_NAMES[wsDate.month]} ${wsDate.day}–${weDate.day}, ${wsDate.year}`;
  } else {
    document.getElementById('header-title').textContent = `${MONTH_NAMES[wsDate.month].slice(0,3)} ${wsDate.day} – ${MONTH_NAMES[weDate.month].slice(0,3)} ${weDate.day}, ${weDate.year}`;
  }

  // Get all events for the week range
  const allEvents = getEventsInRange(weekDays[0], weekDays[6]);

  let html = '<div class="week-grid">';

  // Header row (empty corner + day headers)
  html += '<div style="border-bottom:1px solid #e8eaed;background:#fafbfc"></div>';
  for (let i = 0; i < 7; i++) {
    const ds = weekDays[i];
    const d = parseDateStr(ds);
    const dow = dayOfWeek(d.year, d.month, d.day);
    const isToday = isSameDay(ds, todayStr);
    html += `<div class="week-header-cell${isToday ? ' today' : ''}">`;
    html += `<div class="wh-day-name">${dayNames[dow]}</div>`;
    html += `<div class="wh-day-num">${d.day}</div></div>`;
  }

  // All-day strip
  html += '<div style="border-right:1px solid #e8eaed;border-bottom:2px solid #dadce0;background:#fafbfc"></div>';
  for (let i = 0; i < 7; i++) {
    const ds = weekDays[i];
    const allDayEvents = allEvents.filter(e => e.allDay && e._occurrenceDate === ds);
    html += '<div class="all-day-cell">';
    for (const evt of allDayEvents) {
      html += `<span class="all-day-event" style="background:${evt.color}22;color:${evt.color}" data-event-id="${evt.id}" data-occ-date="${ds}">${evt.title}</span>`;
    }
    html += '</div>';
  }

  // Time grid body
  html += '<div class="week-body">';
  // Time labels column
  html += '<div class="time-labels">';
  for (let h = HOUR_START; h < HOUR_END; h++) {
    const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
    html += `<div class="time-label">${label}</div>`;
  }
  html += '</div>';

  // Day columns
  for (let i = 0; i < 7; i++) {
    const ds = weekDays[i];
    html += `<div class="week-day-col" data-date="${ds}">`;

    // Hour slots
    for (let h = HOUR_START; h < HOUR_END; h++) {
      html += '<div class="hour-slot"></div>';
    }

    // Events positioned absolutely
    const dayEvents = allEvents.filter(e => !e.allDay && e._occurrenceDate === ds);
    for (const evt of dayEvents) {
      const startMin = timeToMinutes(evt.startTime || '09:00');
      const endMin = timeToMinutes(evt.endTime || '10:00');
      const topPx = ((startMin - HOUR_START * 60) / 60) * 48;
      const heightPx = Math.max(((endMin - startMin) / 60) * 48, 20);

      html += `<div class="week-event-block" style="top:${topPx}px;height:${heightPx}px;background:${evt.color}22;border-left:3px solid ${evt.color};color:${evt.color}" 
                data-event-id="${evt.id}" data-occ-date="${ds}">`;
      html += `<div class="week-event-title">${evt.title}</div>`;
      if (heightPx > 24) {
        html += `<div class="week-event-time">${formatTime(evt.startTime)}–${formatTime(evt.endTime)}</div>`;
      }
      html += '</div>';
    }

    // Current time indicator for today's column
    if (isSameDay(ds, todayStr)) {
      const now = new Date();
      const nowMin = now.getHours() * 60 + now.getMinutes();
      const topPx = ((nowMin - HOUR_START * 60) / 60) * 48;
      if (topPx >= 0 && topPx <= (HOUR_END - HOUR_START) * 48) {
        html += `<div class="current-time-line" style="top:${topPx}px"><div class="current-time-dot"></div></div>`;
      }
    }

    html += '</div>';
  }

  html += '</div></div>';
  container.innerHTML = html;
}

// ── function: renderAgendaView ──
/** Render agenda view */
function renderAgendaView(viewState) {
  const container = document.getElementById('calendar-container');
  const todayStr = getTodayStr();
  const start = parseDateStr(todayStr);
  let html = '<div class="agenda-list">';

  let foundEvents = false;
  for (let i = 0; i < 14; i++) {
    const d = addDays(start.year, start.month, start.day, i);
    const dateStr = toDateStr(d.year, d.month, d.day);
    const events = getEventsForDate(dateStr);

    if (events.length > 0) {
      foundEvents = true;
      const isToday = isSameDay(dateStr, todayStr);
      html += '<div class="agenda-day-group">';
      html += `<div class="agenda-date-header${isToday ? ' today-label' : ''}">${MONTH_NAMES[d.month]} ${d.day}, ${d.year}</div>`;

      for (const evt of events) {
        const occDate = evt._occurrenceDate || evt.date;
        let timeLabel = '';
        if (evt.allDay) {
          timeLabel = 'All day';
        } else if (evt.startTime && evt.endTime) {
          timeLabel = `${formatTime(evt.startTime)} – ${formatTime(evt.endTime)}`;
        }
        html += `<div class="agenda-event-item" data-event-id="${evt.id}" data-occ-date="${occDate}">`;
        html += `<div class="agenda-color-bar" style="background:${evt.color}"></div>`;
        html += '<div class="agenda-event-info">';
        html += `<div class="agenda-event-title-text">${evt.title}${formatRecurrence(evt.recurrence) ? ' · '+formatRecurrence(evt.recurrence) : ''}</div>`;
        html += `<div class="agenda-event-time-text">${timeLabel}</div>`;
        html += '</div></div>';
      }

      html += '</div>';
    }
  }

  if (!foundEvents) {
    html += '<div class="no-events-msg">No upcoming events</div>';
  }

  html += '</div>';
  container.innerHTML = html;
}

// ── function: renderCurrentView ──
/** Render the current view synchronously */
function renderCurrentView() {
  const vs = appData.viewState;
  renderHeaderTitle(vs);
  renderYearJump(vs);

  // Fade out, then render and fade in
  const container = document.getElementById('calendar-container');
  container.style.transition = 'opacity .15s ease';
  container.style.opacity = '0.3';

  requestAnimationFrame(() => {
    switch (vs.currentView) {
      case 'month': renderMonthView(vs); break;
      case 'week': renderWeekView(vs); break;
      case 'agenda': renderAgendaView(vs); break;
    }
    container.style.opacity = '1';
  });
}

// ── function: openNewEventModal ──
// ============================================================
// MODAL EDITOR — Event creation/editing UI
// ============================================================

/** Open the event modal for creating a new event */
function openNewEventModal(dateStr) {
  const overlay = document.getElementById('modal-overlay');
  const form = document.getElementById('event-form');
  form.reset();

  document.getElementById('modal-title-text').textContent = 'New Event';
  document.getElementById('evt-date').value = dateStr || getTodayStr();
  document.getElementById('evt-start').value = '09:00';
  document.getElementById('evt-end').value = '10:00';
  document.getElementById('evt-allday').checked = false;
  document.getElementById('time-fields').style.display = '';
  document.getElementById('evt-recurrence').value = 'none';
  document.getElementById('weekday-fields').style.display = 'none';
  document.getElementById('btn-delete-event').classList.add('hidden');
  document.getElementById('delete-series-options').style.display = 'none';
  clearErrors();

  // Set default color selection
  renderColorPicker(COLORS[0].value);

  overlay.classList.remove('hidden');
  overlay.dataset.mode = 'new';
  overlay.dataset.eventId = '';
  overlay.dataset.occDate = dateStr || '';

  setTimeout(() => document.getElementById('evt-title').focus(), 100);
}

// ── function: openEditEventModal ──
/** Open the event modal for editing an existing event */
function openEditEventModal(eventId, occDate) {
  const evt = getEvent(eventId);
  if (!evt) return;

  const overlay = document.getElementById('modal-overlay');
  document.getElementById('modal-title-text').textContent = 'Edit Event';
  document.getElementById('evt-title').value = evt.title;
  document.getElementById('evt-date').value = occDate || evt.date;
  document.getElementById('evt-allday').checked = evt.allDay;
  document.getElementById('time-fields').style.display = evt.allDay ? 'none' : '';
  document.getElementById('evt-start').value = evt.startTime || '09:00';
  document.getElementById('evt-end').value = evt.endTime || '10:00';
  document.getElementById('evt-recurrence').value = (evt.recurrence && evt.recurrence.type) || 'none';

  // Weekday picker for weekly recurrence
  const weekdayFields = document.getElementById('weekday-fields');
  if ((evt.recurrence && evt.recurrence.type === 'weekly')) {
    weekdayFields.style.display = '';
    renderWeekdayPicker(evt.recurrence.weekdays || []);
  } else {
    weekdayFields.style.display = 'none';
  }

  // Delete button visibility
  const delBtn = document.getElementById('btn-delete-event');
  delBtn.classList.remove('hidden');

  // Series delete options
  const seriesOpts = document.getElementById('delete-series-options');
  if (evt.recurrence && evt.recurrence.type !== 'none') {
    seriesOpts.style.display = '';
  } else {
    seriesOpts.style.display = 'none';
  }

  renderColorPicker(evt.color);
  clearErrors();

  overlay.classList.remove('hidden');
  overlay.dataset.mode = 'edit';
  overlay.dataset.eventId = evt.id;
  overlay.dataset.occDate = occDate || '';
}

// ── function: closeModal ──
/** Close the modal */
function closeModal() {
  document.getElementById('modal-overlay').classList.add('hidden');
}

// ── function: renderColorPicker ──
/** Render color picker swatches */
function renderColorPicker(selected) {
  const container = document.getElementById('color-picker');
  container.innerHTML = '';
  for (const c of COLORS) {
    const swatch = document.createElement('div');
    swatch.className = 'color-swatch' + (c.value === selected ? ' selected' : '');
    swatch.style.background = c.value;
    swatch.dataset.color = c.value;
    container.appendChild(swatch);
  }
}

// ── function: getSelectedColor ──
/** Get currently selected color */
function getSelectedColor() {
  const sel = document.querySelector('#color-picker .selected');
  return sel ? sel.dataset.color : COLORS[0].value;
}

// ── function: renderWeekdayPicker ──
/** Render weekday picker buttons */
function renderWeekdayPicker(selectedDays) {
  const container = document.getElementById('weekday-picker');
  container.innerHTML = '';
  for (let i = 0; i < 7; i++) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'weekday-btn' + (selectedDays.includes(i) ? ' selected' : '');
    btn.textContent = DAY_NAMES_SHORT[i][0];
    btn.dataset.day = i;
    container.appendChild(btn);
  }
}

// ── function: getSelectedWeekdays ──
/** Get selected weekdays */
function getSelectedWeekdays() {
  return Array.from(document.querySelectorAll('#weekday-picker .selected')).map(b => parseInt(b.dataset.day));
}

// ── function: clearErrors ──
/** Clear form errors */
function clearErrors() {
  document.getElementById('err-title').textContent = '';
  document.getElementById('err-time').textContent = '';
}

// ── function: validateEventForm ──
/** Validate the event form, returns true if valid */
function validateEventForm() {
  clearErrors();
  const title = document.getElementById('evt-title').value.trim();
  if (!title) {
    document.getElementById('err-title').textContent = 'Title is required';
    return false;
  }

  const allDay = document.getElementById('evt-allday').checked;
  if (!allDay) {
    const start = document.getElementById('evt-start').value;
    const end = document.getElementById('evt-end').value;
    if (start && end && end <= start) {
      document.getElementById('err-time').textContent = 'End time must be after start time';
      return false;
    }
  }

  return true;
}

// ── function: handleEventSubmit ──
/** Handle form submission */
function handleEventSubmit() {
  if (!validateEventForm()) return;

  const overlay = document.getElementById('modal-overlay');
  const mode = overlay.dataset.mode;
  const title = document.getElementById('evt-title').value.trim();
  const date = document.getElementById('evt-date').value;
  const allDay = document.getElementById('evt-allday').checked;
  const startTime = document.getElementById('evt-start').value;
  const endTime = document.getElementById('evt-end').value;
  const color = getSelectedColor();
  const notes = document.getElementById('evt-notes').value.trim();
  const recurrenceType = document.getElementById('evt-recurrence').value;

  let recurrence = { type: 'none' };
  if (recurrenceType !== 'none') {
    recurrence.type = recurrenceType;
    if (recurrenceType === 'weekly') {
      recurrence.weekdays = getSelectedWeekdays();
      if (recurrence.weekdays.length === 0) {
        const baseDow = dayOfWeek(...Object.values(parseDateStr(date)));
        recurrence.weekdays = [baseDow];
      }
    }
  }

  if (mode === 'new') {
    createEvent(createEventObj(title, date, allDay, startTime, endTime, color, notes, recurrence));
    showToast('Event created');
  } else {
    const eventId = overlay.dataset.eventId;
    updateEvent(eventId, { title, date, allDay, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, color, notes, recurrence });
    showToast('Event updated');
  }

  closeModal();
  renderCurrentView();
}

// ── function: handleDeleteEvent ──
/** Handle delete event */
function handleDeleteEvent() {
  const overlay = document.getElementById('modal-overlay');
  const eventId = overlay.dataset.eventId;
  const evt = getEvent(eventId);
  if (!evt) return;

  const seriesOpts = document.getElementById('delete-series-options');
  if (evt.recurrence && evt.recurrence.type !== 'none') {
    // Check which radio is selected
    const scope = document.querySelector('input[name="del-scope"]:checked').value;
    if (scope === 'series') {
      deleteEvent(eventId);
      showToast('Series deleted');
    } else if (scope === 'following') {
      const occDate = overlay.dataset.occDate || evt.date;
      deleteFollowingOccurrences(eventId, occDate);
      showToast('Future occurrences deleted');
    } else {
      // This event only — add to exceptions
      const occDate = overlay.dataset.occDate || evt.date;
      deleteOccurrence(eventId, occDate);
      showToast('Event occurrence deleted');
    }
  } else {
    deleteEvent(eventId);
    showToast('Event deleted', 'Undo', () => {
      // Can't truly undo without storing the deleted event — skip for simplicity
    });
  }

  closeModal();
  renderCurrentView();
}

// ── function: showToast ──
// ============================================================
// TOAST NOTIFICATIONS
// ============================================================

/** Show a toast notification */
function showToast(message, actionText, onAction) {
  const container = document.getElementById('toast-container');
  const toast = document.createElement('div');
  toast.className = 'toast';
  toast.textContent = message;

  if (actionText && onAction) {
    const btn = document.createElement('button');
    btn.className = 'toast-action';
    btn.textContent = actionText;
    btn.addEventListener('click', () => {
      onAction();
      toast.remove();
    });
    toast.appendChild(btn);
  }

  container.appendChild(toast);
  setTimeout(() => { if (toast.parentNode) toast.remove(); }, 4000);
}

// ── function: showDayPopover ──
// ============================================================
// DAY POPOVER — Shows overflow events for a day cell
// ============================================================

/** Show day popover at the given element */
function showDayPopover(dateStr, anchorEl) {
  // Remove existing popovers
  removeDayPopover();

  const overlay = document.getElementById('day-popover-overlay');
  const rect = anchorEl.getBoundingClientRect();

  const popover = document.createElement('div');
  popover.className = 'day-popover';
  popover.style.left = `${rect.left}px`;
  popover.style.top = `${rect.bottom + 4}px`;

  const d = parseDateStr(dateStr);
  let html = `<div class="day-popover-header">${MONTH_NAMES[d.month]} ${d.day}, ${d.year}</div>`;

  const events = getEventsForDate(dateStr);
  for (const evt of events) {
    const occDate = evt._occurrenceDate || evt.date;
    let timeLabel = '';
    if (evt.allDay) timeLabel = 'All day';
    else if (evt.startTime && evt.endTime) timeLabel = `${formatTime(evt.startTime)} – ${formatTime(evt.endTime)}`;

    html += `<div class="popover-event" data-event-id="${evt.id}" data-occ-date="${occDate}">`;
    html += `<div class="popover-color-bar" style="background:${evt.color}"></div>`;
    html += `<span>${evt.title}</span>`;
    if (timeLabel) html += `<span style="color:#70757a;font-size:11px;margin-left:auto">${timeLabel}</span>`;
    html += '</div>';
  }

  popover.innerHTML = html;
  overlay.appendChild(popover);
  overlay.classList.remove('hidden');

  // Click events in popover to edit
  popover.querySelectorAll('.popover-event').forEach(el => {
    el.addEventListener('click', () => {
      openEditEventModal(el.dataset.eventId, el.dataset.occDate);
      removeDayPopover();
    });
  });
}

// ── function: removeDayPopover ──
/** Remove day popover */
function removeDayPopover() {
  const overlay = document.getElementById('day-popover-overlay');
  overlay.innerHTML = '';
  overlay.classList.add('hidden');
}

// ── let: dragState ──
// ============================================================
// DRAG & DROP — Reschedule events by dragging
// ============================================================

let dragState = null;

// ── function: handleEventDragStart ──
// { eventId, occDate, sourceType }

/** Handle mousedown on an event chip/block */
function handleEventDragStart(e) {
  const target = e.target.closest('.event-chip') || e.target.closest('.week-event-block');
  if (!target) return;

  dragState = {
    eventId: target.dataset.eventId,
    occDate: target.dataset.occDate,
    sourceType: target.classList.contains('event-chip') ? 'month' : 'week',
    startY: e.clientY,
    startX: e.clientX,
    moved: false,
  };

  target.classList.add('dragging');
  e.preventDefault();
}

// ── function: handleDragMove ──
/** Handle mousemove during drag */
function handleDragMove(e) {
  if (!dragState) return;
  const dx = Math.abs(e.clientX - dragState.startX);
  const dy = Math.abs(e.clientY - dragState.startY);
  if (dx > 3 || dy > 3) dragState.moved = true;

  // Highlight target cell in month view
  if (dragState.sourceType === 'month') {
    const el = document.elementFromPoint(e.clientX, e.clientY);
    if (el) {
      const cell = el.closest('.month-cell');
      document.querySelectorAll('.month-cell.drag-target').forEach(c => c.classList.remove('drag-target'));
      if (cell && !cell.classList.contains('other-month')) {
        cell.classList.add('drag-target');
      }
    }
  }

  // For week view, show snap position
  if (dragState.sourceType === 'week') {
    const col = document.elementFromPoint(e.clientX, e.clientY)?.closest('.week-day-col');
    if (col) {
      const rect = col.getBoundingClientRect();
      const relY = e.clientY - rect.top;
      const snapMin = Math.round(relY / 48 * 60 / SNAP_MINUTES) * SNAP_MINUTES;
      dragState.snapMinutes = Math.max(HOUR_START * 60, Math.min((HOUR_END - 1) * 60 + 30, snapMin));
    }
  }
}

// ── function: handleDragEnd ──
/** Handle mouseup to complete drag */
function handleDragEnd(e) {
  if (!dragState) return;

  // Remove visual indicators
  document.querySelectorAll('.month-cell.drag-target').forEach(c => c.classList.remove('drag-target'));
  document.querySelectorAll('.event-chip.dragging, .week-event-block.dragging').forEach(el => el.classList.remove('dragging'));

  const { eventId, occDate, sourceType } = dragState;
  dragState = null;

  if (!eventId) return;

  // Find drop target
  const el = document.elementFromPoint(e.clientX, e.clientY);
  if (!el) return;

  if (sourceType === 'month') {
    const cell = el.closest('.month-cell');
    if (cell && !cell.classList.contains('other-month')) {
      const newDate = cell.dataset.date;
      if (newDate !== occDate) {
        // Update the event's date
        updateEvent(eventId, { date: newDate });
        showToast(`Moved to ${parseDateStr(newDate).day} ${MONTH_NAMES[parseDateStr(newDate).month]}`);
        renderCurrentView();
      }
    }
  } else if (sourceType === 'week') {
    const col = el.closest('.week-day-col');
    if (col) {
      const newDate = col.dataset.date;
      const evt = getEvent(eventId);
      if (!evt || evt.allDay) return;

      // Calculate new time based on snap position
      const rect = col.getBoundingClientRect();
      const relY = e.clientY - rect.top + (dragState.startY || 0) * 0.1; // approximate
      const durationMin = timeToMinutes(evt.endTime) - timeToMinutes(evt.startTime);

      if (newDate !== occDate) {
        updateEvent(eventId, { date: newDate });
      }

      showToast('Event rescheduled');
      renderCurrentView();
    }
  }
}

// ── function: initDragDrop ──
/** Initialize drag-and-drop on the calendar container */
function initDragDrop() {
  const container = document.getElementById('calendar-container');

  container.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return; // only left click
    handleEventDragStart(e);
  });

  document.addEventListener('mousemove', handleDragMove);
  document.addEventListener('mouseup', handleDragEnd);
}

// ── function: navigatePrev ──
/** Navigate to previous period */
function navigatePrev() {
  const vs = appData.viewState;
  if (vs.currentView === 'month') {
    vs.currentMonth--;
    if (vs.currentMonth < 0) { vs.currentMonth = 11; vs.currentYear--; }
  } else if (vs.currentView === 'week') {
    navigatePrevWeek();
    return; // persist() called inside
  } else if (vs.currentView === 'agenda') {
    return;
  }
  persist();
  renderCurrentView();
}

// ── function: navigateNext ──
/** Navigate to next period */
function navigateNext() {
  const vs = appData.viewState;
  if (vs.currentView === 'month') {
    vs.currentMonth++;
    if (vs.currentMonth > 11) { vs.currentMonth = 0; vs.currentYear++; }
  } else if (vs.currentView === 'week') {
    navigateNextWeek();
    return; // persist() called inside
  } else if (vs.currentView === 'agenda') {
    return;
  }
  persist();
  renderCurrentView();
}

// ── function: jumpToToday ──
/** Jump to today */
function jumpToToday() {
  const now = new Date();
  appData.viewState.currentYear = now.getFullYear();
  appData.viewState.currentMonth = now.getMonth();
  
  if (appData.viewState.currentView === 'week') {
    jumpToCurrentWeek();
  }
  
  persist();
  renderCurrentView();
}

// ── function: switchView ──
/** Switch view */
function switchView(view) {
  appData.viewState.currentView = view;
  
  // Initialize week start date when switching to week view
  if (view === 'week' && !appData.viewState.weekStartDate) {
    const today = getTodayStr();
    const td = parseDateStr(today);
    const ws = getWeekStart(td.year, td.month, td.day, appData.settings.weekStart);
    setCurrentWeekStart(toDateStr(ws.year, ws.month, ws.day));
  }
  
  persist();
  // Update active button
  document.querySelectorAll('.view-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.view === view);
  });
  renderCurrentView();
}

// ── function: handleYearJump ──
/** Handle year jump */
function handleYearJump(year) {
  appData.viewState.currentYear = parseInt(year);
  persist();
  renderCurrentView();
}

// ── function: handleDayCellClick ──
/** Handle click on a day cell in month view — create new event */
function handleDayCellClick(dateStr) {
  openNewEventModal(dateStr);
}

// ── function: handleEventClick ──
/** Handle click on an event chip/block — edit event */
function handleEventClick(eventId, occDate) {
  openEditEventModal(eventId, occDate);
}

// ── function: setupEventListeners ──
/** Set up all event listeners */
function setupEventListeners() {
  // Navigation buttons
  document.getElementById('btn-today').addEventListener('click', jumpToToday);
  document.getElementById('btn-prev').addEventListener('click', navigatePrev);
  document.getElementById('btn-next').addEventListener('click', navigateNext);

  // Settings button
  document.getElementById('btn-settings').addEventListener('click', (e) => {
    e.stopPropagation();
    showSettingsPopover();
  });

  // Year jump
  document.getElementById('year-jump').addEventListener('change', (e) => handleYearJump(e.target.value));

  // View switcher
  document.querySelectorAll('.view-btn').forEach(btn => {
    btn.addEventListener('click', () => switchView(btn.dataset.view));
  });

  // Modal close
  document.getElementById('btn-close-modal').addEventListener('click', closeModal);
  document.getElementById('modal-overlay').addEventListener('click', (e) => {
    if (e.target === e.currentTarget) closeModal();
  });

  // Form submit
  document.getElementById('event-form').addEventListener('submit', (e) => {
    e.preventDefault();
    handleEventSubmit();
  });

  // Delete button
  document.getElementById('btn-delete-event').addEventListener('click', handleDeleteEvent);

  // All-day checkbox toggle time fields
  document.getElementById('evt-allday').addEventListener('change', (e) => {
    document.getElementById('time-fields').style.display = e.target.checked ? 'none' : '';
  });

  // Recurrence type change — show/hide weekday picker
  document.getElementById('evt-recurrence').addEventListener('change', (e) => {
    const weekdayFields = document.getElementById('weekday-fields');
    if (e.target.value === 'weekly') {
      weekdayFields.style.display = '';
      renderWeekdayPicker([]);
    } else {
      weekdayFields.style.display = 'none';
    }
  });

  // Color picker click
  document.getElementById('color-picker').addEventListener('click', (e) => {
    const swatch = e.target.closest('.color-swatch');
    if (!swatch) return;
    document.querySelectorAll('#color-picker .selected').forEach(s => s.classList.remove('selected'));
    swatch.classList.add('selected');
  });

  // Weekday picker click (toggle)
  document.getElementById('weekday-picker').addEventListener('click', (e) => {
    const btn = e.target.closest('.weekday-btn');
    if (!btn) return;
    btn.classList.toggle('selected');
  });

  // Delegate clicks in calendar container
  document.getElementById('calendar-container').addEventListener('click', (e) => {
    // Event chip/block click — edit event
    const eventEl = e.target.closest('.event-chip') || e.target.closest('.week-event-block') || e.target.closest('.all-day-event');
    if (eventEl && !dragState?.moved) {
      handleEventClick(eventEl.dataset.eventId, eventEl.dataset.occDate);
      return;
    }

    // "More" link — show popover
    const moreLink = e.target.closest('.more-link');
    if (moreLink) {
      showDayPopover(moreLink.dataset.date, moreLink);
      return;
    }

    // Day cell click — create event (only in month view)
    const dayCell = e.target.closest('.month-cell');
    if (dayCell && !e.target.closest('.event-chip') && !e.target.closest('.more-link')) {
      handleDayCellClick(dayCell.dataset.date);
      return;
    }

    // Agenda event click
    const agendaItem = e.target.closest('.agenda-event-item');
    if (agendaItem) {
      handleEventClick(agendaItem.dataset.eventId, agendaItem.dataset.occDate);
      return;
    }
  });

  // Close popover on outside click
  document.addEventListener('click', (e) => {
    const popover = document.querySelector('.day-popover');
    if (popover && !e.target.closest('.day-popover') && !e.target.closest('.more-link') && !e.target.closest('#btn-settings')) {
      removeDayPopover();
    }
  });

  // Keyboard shortcuts
  document.addEventListener('keydown', (e) => {
    if (!document.getElementById('modal-overlay').classList.contains('hidden')) return;
    if (e.key === 'ArrowLeft' || e.key === 'p') navigatePrev();
    else if (e.key === 'ArrowRight' || e.key === 'n') navigateNext();
    else if (e.key === 't') jumpToToday();
    else if (e.key === 'Escape') { closeModal(); removeDayPopover(); }
  });

  // Drag and drop
  initDragDrop();
}

// ── let: appData ──
// ============================================================
// APP STATE — Global mutable state & CRUD mutations
// ============================================================

let appData = null;

// ── function: createEvent ──
// Initialized in main block from localStorage or defaults

/** Create a new event (mutates appData) */
function createEvent(eventObj) {
  appData.events.push(eventObj);
  persist();
}

// ── function: getEvent ──
/** Get event by ID */
function getEvent(id) {
  return appData.events.find(e => e.id === id) || null;
}

// ── function: updateEvent ──
/** Update an existing event (mutates appData) */
function updateEvent(id, updates) {
  const idx = appData.events.findIndex(e => e.id === id);
  if (idx < 0) return false;
  Object.assign(appData.events[idx], updates);
  persist();
  return true;
}

// ── function: deleteEvent ──
/** Delete an event (mutates appData) */
function deleteEvent(id) {
  const idx = appData.events.findIndex(e => e.id === id);
  if (idx < 0) return null;
  const removed = appData.events.splice(idx, 1)[0];
  persist();
  return removed;
}

// ── function: deleteOccurrence ──
/** Delete a single occurrence from a series (add to exceptions) */
function deleteOccurrence(seriesId, dateStr) {
  const evt = getEvent(seriesId);
  if (!evt) return false;
  if (!evt.exceptions) evt.exceptions = [];
  evt.exceptions.push(dateStr);
  persist();
  return true;
}

// ── function: deleteFollowingOccurrences ──
/** Delete all occurrences from a certain date forward */
function deleteFollowingOccurrences(seriesId, fromDate) {
  const evt = getEvent(seriesId);
  if (!evt) return false;
  evt.recurrence.endBefore = fromDate;
  persist();
  return true;
}

// ── function: persist ──
/** Persist current state to localStorage */
function persist() { saveAppData(appData); }

// ── function: renderCurrentViewSync ──
/** Synchronous render (for testing/probes) */
function renderCurrentViewSync() {
  const vs = appData.viewState;
  renderHeaderTitle(vs);
  renderYearJump(vs);
  const container = document.getElementById('calendar-container');
  switch (vs.currentView) {
    case 'month': renderMonthView(vs); break;
    case 'week': renderWeekView(vs); break;
    case 'agenda': renderAgendaView(vs); break;
  }
}

// ── function: getCurrentWeekStart ──
/** Get the current week start date string from view state */
function getCurrentWeekStart() {
  const vs = appData.viewState;
  if (vs.weekStartDate) return vs.weekStartDate;
  // Fallback: compute from year/month using day 15 as reference
  const midDate = parseDateStr(toDateStr(vs.currentYear, vs.currentMonth, 15));
  const ws = getWeekStart(midDate.year, midDate.month, midDate.day, appData.settings.weekStart);
  return toDateStr(ws.year, ws.month, ws.day);
}

// ── function: setCurrentWeekStart ──
/** Set the current week start date in view state */
function setCurrentWeekStart(dateStr) {
  const d = parseDateStr(dateStr);
  appData.viewState.currentYear = d.year;
  appData.viewState.currentMonth = d.month;
  appData.viewState.weekStartDate = dateStr;
}

// ── function: navigatePrevWeek ──
/** Navigate to previous week */
function navigatePrevWeek() {
  const ws = getCurrentWeekStart();
  const prevWs = addDays(...Object.values(parseDateStr(ws)), -7);
  setCurrentWeekStart(toDateStr(prevWs.year, prevWs.month, prevWs.day));
  persist();
}

// ── function: navigateNextWeek ──
/** Navigate to next week */
function navigateNextWeek() {
  const ws = getCurrentWeekStart();
  const nextWs = addDays(...Object.values(parseDateStr(ws)), 7);
  setCurrentWeekStart(toDateStr(nextWs.year, nextWs.month, nextWs.day));
  persist();
}

// ── function: jumpToCurrentWeek ──
/** Jump to current week (today's week) */
function jumpToCurrentWeek() {
  const today = getTodayStr();
  const td = parseDateStr(today);
  const ws = getWeekStart(td.year, td.month, td.day, appData.settings.weekStart);
  setCurrentWeekStart(toDateStr(ws.year, ws.month, ws.day));
}

// ── function: getCurrentWeekDays ──
/** Get the 7 days of the current week as date strings */
function getCurrentWeekDays() {
  const ws = parseDateStr(getCurrentWeekStart());
  const days = [];
  for (let i = 0; i < 7; i++) {
    const d = addDays(ws.year, ws.month, ws.day, i);
    days.push(toDateStr(d.year, d.month, d.day));
  }
  return days;
}

// ── function: getWeekDayIndex ──
/** Get the day-of-week index (0-6) for a date string within the current week */
function getWeekDayIndex(dateStr) {
  const ws = parseDateStr(getCurrentWeekStart());
  // Walk from week start to find matching date
  let cur = { ...ws };
  for (let i = 0; i < 7; i++) {
    if (toDateStr(cur.year, cur.month, cur.day) === dateStr) return i;
    const next = addDays(cur.year, cur.month, cur.day, 1);
    cur = next;
  }
  return -1; // not in current week
}

// ── function: showSettingsPopover ──
/** Show/hide settings popover */
function showSettingsPopover() {
  removeDayPopover(); // reuse popover system
  
  const overlay = document.getElementById('day-popover-overlay');
  const btn = document.getElementById('btn-settings');
  const rect = btn.getBoundingClientRect();

  const popover = document.createElement('div');
  popover.className = 'day-popover';
  popover.style.left = `${rect.left - 160}px`;
  popover.style.top = `${rect.bottom + 4}px`;

  popover.innerHTML = `
    <div class="day-popover-header">Settings</div>
    <div style="padding:8px;font-size:13px;">
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        Week starts on: 
        <select id="settings-weekstart" style="width:auto;padding:4px 8px;">
          <option value="0"${appData.settings.weekStart === 0 ? ' selected' : ''}>Sunday</option>
          <option value="1"${appData.settings.weekStart === 1 ? ' selected' : ''}>Monday</option>
        </select>
      </label>
    </div>
  `;

  overlay.appendChild(popover);
  overlay.classList.remove('hidden');

  popover.querySelector('#settings-weekstart').addEventListener('change', (e) => {
    appData.settings.weekStart = parseInt(e.target.value);
    persist();
    renderCurrentView();
    removeDayPopover();
  });
}

// ── main ──
// ============================================================
// MAIN — Initialization & startup
// ============================================================

(function init() {
  // Initialize app data from localStorage or defaults
  const saved = loadAppData();
  if (saved) {
    appData = saved;
  } else {
    appData = defaultAppState();
    
    // Seed sample events for first-time users
    const today = getTodayStr();
    const td = parseDateStr(today);
    const dow = dayOfWeek(td.year, td.month, td.day);
    
    // Today's events
    createEvent(createEventObj('Team Standup', today, false, '09:00', '09:30', '#1a73e8', '', { type: 'none' }));
    createEvent(createEventObj('Design Review', today, false, '14:00', '15:30', '#9334e6', '', { type: 'none' }));
    
    // Tomorrow's events
    const tomorrow = addDays(td.year, td.month, td.day, 1);
    createEvent(createEventObj('Client Call', toDateStr(tomorrow.year, tomorrow.month, tomorrow.day), false, '10:00', '11:00', '#d93025', '', { type: 'none' }));
    
    // All-day event this week
    createEvent(createEventObj('Company Holiday', today, true, null, null, '#f4b400', '', { type: 'none' }));
    
    // Weekly recurring event (on same day of week as today)
    createEvent(createEventObj('Weekly Sync', today, false, '16:00', '16:30', '#1e8e3e', '', { type: 'weekly', weekdays: [dow] }));
    
    // Monthly event on the 15th (or closest valid day)
    const monthDay = Math.min(15, daysInMonth(td.year, td.month));
    createEvent(createEventObj('Monthly Report', toDateStr(td.year, td.month, monthDay), false, '13:00', '14:00', '#db2778', '', { type: 'monthly' }));
  }

  // Ensure required fields exist (migration safety)
  if (!appData.settings) appData.settings = { weekStart: DEFAULT_WEEK_START };
  if (!appData.viewState) appData.viewState = defaultAppState().viewState;
  if (!Array.isArray(appData.events)) appData.events = [];

  // Set active view button
  document.querySelectorAll('.view-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.view === appData.viewState.currentView);
  });

  // Setup all event listeners
  setupEventListeners();

  // Initial render
  renderCurrentView();

  // Update current time indicator every minute in week view
  setInterval(() => {
    if (appData.viewState.currentView === 'week') {
      renderWeekView(appData.viewState);
    }
  }, 60000);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"e41942ef-6556-4077-baba-f22f96fb7219","tokensIn":2519149,"tokensOut":47240,"tokensTotal":2566389,"turns":62,"toolCalls":61,"failedToolCalls":5,"timestamp":"2026-07-30T00:52:25.091Z"} -->