Calendar
Qwen 3.6 27b MTP · composer

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 & 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 & 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"} -->