Calendar
Qwen 3.6 27b MTP · full

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 75,998 bytes · SHA-256 ceb022fbeef1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Event Planner</title>
<style>
/* ============================================================
CONSTANTS & CSS CUSTOM PROPERTIES
============================================================ */
:root {
--color-primary: #1a73e8;
--color-primary-light: #e8f0fe;
--color-today-bg: #e8f0fe;
--color-today-border: #1a73e8;
--color-text: #202124;
--color-text-secondary: #5f6368;
--color-text-dimmed: #bbb;
--color-bg: #fff;
--color-bg-hover: #f1f3f4;
--color-border: #dadce0;
--color-grid-line: #e8eaed;
--color-header-bg: #fff;
--shadow-sm: 0 1px 2px rgba(0,0,0,.1);
--shadow-md: 0 2px 8px rgba(0,0,0,.15);
--shadow-lg: 0 4px 16px rgba(0,0,0,.2);
--radius-sm: 4px;
--radius-md: 8px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
/* Event colors */
--ev-blue: #1a73e8;
--ev-red: #d93025;
--ev-green: #1e8e3e;
--ev-yellow: #f9ab00;
--ev-purple: #9334e6;
--ev-teal: #12b5cb;
--ev-orange: #ea8600;
--ev-pink: #d91e78;
/* Layout */
--hour-height: 50px;
--sidebar-width: 70px;
--all-day-height: 32px;
}
/* ============================================================
RESET & BASE
============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: var(--font-sans); color: var(--color-text); background: var(--color-bg); overflow: hidden; }
button { font-family: inherit; cursor: pointer; border: none; background: none; font-size: inherit; color: inherit; }
input, select, textarea { font-family: inherit; font-size: 14px; }
/* ============================================================
LAYOUT SHELL
============================================================ */
#app { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Header */
.header { display: flex; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--color-border); background: var(--color-header-bg); gap: 8px; flex-shrink: 0; z-index: 10; }
.header-left { display: flex; align-items: center; gap: 8px; }
.header-center { flex: 1; text-align: center; }
.header-right { display: flex; align-items: center; gap: 8px; }
.btn-icon { width: 36px; height: 36px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 18px; transition: background .15s; }
.btn-icon:hover { background: var(--color-bg-hover); }
.btn-text { padding: 6px 12px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; transition: background .15s; }
.btn-text:hover { background: var(--color-bg-hover); }
.today-btn { background: var(--color-primary); color: #fff !important; border-radius: var(--radius-sm); padding: 6px 14px; font-weight: 500; font-size: 13px; }
.today-btn:hover { opacity: .9; }
.nav-title { font-size: 22px; font-weight: 400; cursor: pointer; min-width: 200px; text-align: center; user-select: none; }
.nav-title:hover { color: var(--color-primary); }
.view-switcher { display: flex; border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow: hidden; }
.view-btn { padding: 6px 14px; font-size: 13px; font-weight: 500; color: var(--color-text-secondary); transition: all .15s; }
.view-btn.active { background: var(--color-primary-light); color: var(--color-primary); }
.view-btn:hover:not(.active) { background: var(--color-bg-hover); }
/* Main content area */
.main-content { flex: 1; display: flex; overflow: hidden; position: relative; }
.view-container { flex: 1; overflow: auto; position: relative; }
/* ============================================================
MONTH VIEW
============================================================ */
.month-view { display: flex; flex-direction: column; height: 100%; animation: fadeIn .2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.month-header-row { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
.month-header-cell { padding: 8px 4px; text-align: center; font-size: 11px; font-weight: 500; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: .5px; }
.month-grid { display: grid; grid-template-columns: repeat(7, 1fr); flex: 1; overflow-y: auto; }
.month-cell { border-right: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); min-height: 80px; padding: 2px 4px; cursor: pointer; position: relative; transition: background .1s; overflow: hidden; }
.month-cell:hover { background: #f8f9fa; }
.month-cell.other-month { background: #fafafa; }
.month-cell.today { background: var(--color-today-bg); }
.cell-date { font-size: 12px; font-weight: 500; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; margin-bottom: 2px; }
.month-cell.other-month .cell-date { color: var(--color-text-dimmed); }
.month-cell.today .cell-date { background: var(--color-primary); color: #fff; font-weight: 700; }
.event-chip { font-size: 11px; padding: 1px 4px; border-radius: 2px; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; line-height: 18px; }
.event-chip:hover { opacity: .85; }
.overflow-link { font-size: 11px; color: var(--color-text-secondary); padding: 1px 4px; cursor: pointer; border-radius: 2px; }
.overflow-link:hover { background: rgba(0,0,0,.06); }
/* ============================================================
WEEK VIEW
============================================================ */
.week-view { display: flex; flex-direction: column; height: 100%; animation: fadeIn .2s ease; }
.week-header-row { display: grid; border-bottom: 1px solid var(--color-border); flex-shrink: 0; position: sticky; top: 0; background: #fff; z-index: 5; }
.week-header-cell { padding: 8px 4px; text-align: center; font-size: 12px; border-left: 1px solid var(--color-border); }
.week-header-cell .day-name { color: var(--color-text-secondary); font-weight: 500; text-transform: uppercase; letter-spacing: .3px; }
.week-header-cell .day-num { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; margin: 4px auto 0; font-size: 18px; font-weight: 400; }
.week-header-cell.today .day-num { background: var(--color-primary); color: #fff; font-weight: 700; }
.week-body-wrapper { flex: 1; overflow-y: auto; position: relative; }
.week-body-inner { display: grid; min-height: calc(var(--hour-height) * 17); }
.all-day-strip { display: grid; border-bottom: 2px solid var(--color-border); background: #fafafa; }
.all-day-cell { border-left: 1px solid var(--color-border); padding: 2px 4px; min-height: var(--all-day-height); position: relative; }
.time-grid { display: grid; position: relative; }
.hour-row { display: contents; }
.hour-label { font-size: 10px; color: var(--color-text-secondary); text-align: right; padding-right: 8px; padding-top: -4px; height: var(--hour-height); border-left: 1px solid transparent; position: relative; }
.hour-label span { position: absolute; top: -6px; right: 8px; }
.hour-cell { border-left: 1px solid var(--color-border); border-bottom: 1px solid var(--color-grid-line); height: var(--hour-height); position: relative; }
.time-grid-container { position: relative; }
.event-overlay { pointer-events: none; }
.event-overlay > * { pointer-events: auto; }
.week-event-block { border-radius: var(--radius-sm); padding: 2px 6px; font-size: 11px; overflow: hidden; cursor: pointer; z-index: 2; transition: box-shadow .1s; line-height: 1.3; word-break: break-word; }
.week-event-block:hover { box-shadow: var(--shadow-md); z-index: 3; }
.current-time-line { position: absolute; height: 2px; background: #d93025; z-index: 4; pointer-events: none; }
.current-time-line::before { content: ''; position: absolute; left: -5px; top: -4px; width: 10px; height: 10px; border-radius: 50%; background: #d93025; }
.all-day-strip { display: grid; border-bottom: 2px solid var(--color-border); background: #fafafa; }
/* ============================================================
AGENDA VIEW
============================================================ */
.agenda-view { padding: 24px; max-width: 700px; margin: 0 auto; animation: fadeIn .2s ease; overflow-y: auto; height: 100%; }
.agenda-date-group { margin-bottom: 24px; }
.agenda-date-header { font-size: 13px; font-weight: 500; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: .5px; padding-bottom: 8px; border-bottom: 1px solid var(--color-border); margin-bottom: 8px; }
.agenda-event-item { display: flex; gap: 12px; padding: 8px 0; cursor: pointer; transition: background .1s; border-radius: var(--radius-sm); padding-left: 4px; padding-right: 4px; }
.agenda-event-item:hover { background: var(--color-bg-hover); }
.agenda-color-bar { width: 4px; border-radius: 2px; flex-shrink: 0; align-self: stretch; }
.agenda-event-info { flex: 1; min-width: 0; }
.agenda-event-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agenda-event-time { font-size: 12px; color: var(--color-text-secondary); margin-top: 2px; }
/* ============================================================
MODAL
============================================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 100; display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease; }
.modal { background: #fff; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); width: 90%; max-width: 460px; max-height: 90vh; overflow-y: auto; animation: modalSlideIn .2s ease; }
@keyframes modalSlideIn { from { transform: translateY(20px) scale(.97); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--color-border); }
.modal-title { font-size: 18px; font-weight: 500; }
.modal-close { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; color: var(--color-text-secondary); transition: background .15s; }
.modal-close:hover { background: var(--color-bg-hover); }
.modal-body { padding: 20px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 12px; font-weight: 500; color: var(--color-text-secondary); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .3px; }
.form-input, .form-select, .form-textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: 14px; outline: none; transition: border-color .15s; }
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: var(--color-primary); box-shadow: 0 0 0 2px rgba(26,115,232,.15); }
.form-textarea { resize: vertical; min-height: 60px; }
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.color-picker { display: flex; gap: 8px; flex-wrap: wrap; }
.color-swatch { width: 28px; height: 28px; border-radius: 50%; cursor: pointer; border: 3px solid transparent; transition: all .15s; }
.color-swatch:hover { transform: scale(1.1); }
.color-swatch.selected { border-color: var(--color-text); box-shadow: 0 0 0 2px #fff inset; }
.checkbox-group { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-primary); }
.recurrence-options { margin-top: 8px; padding-left: 24px; }
.recurrence-option { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; cursor: pointer; }
.recurrence-option input[type="radio"] { accent-color: var(--color-primary); }
.weekday-pills { display: flex; gap: 4px; margin-top: 6px; padding-left: 24px; }
.weekday-pill { width: 30px; height: 24px; border-radius: 12px; font-size: 11px; font-weight: 500; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 1px solid var(--color-border); transition: all .15s; }
.weekday-pill.selected { background: var(--color-primary-light); color: var(--color-primary); border-color: var(--color-primary); }
.modal-footer { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--color-border); }
.btn-delete { color: #d93025; font-size: 13px; font-weight: 500; padding: 6px 12px; border-radius: var(--radius-sm); transition: background .15s; }
.btn-delete:hover { background: #fce8e6; }
.btn-save { background: var(--color-primary); color: #fff; font-size: 13px; font-weight: 500; padding: 8px 20px; border-radius: var(--radius-sm); transition: opacity .15s; }
.btn-save:hover { opacity: .9; }
.error-text { color: #d93025; font-size: 12px; margin-top: 4px; display: none; }
.error-text.visible { display: block; }
/* ============================================================
DAY POPOVER
============================================================ */
.day-popover { position: fixed; background: #fff; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); width: 300px; z-index: 50; animation: fadeIn .12s ease; }
.popover-header { padding: 12px 16px; font-size: 14px; font-weight: 500; border-bottom: 1px solid var(--color-border); display: flex; justify-content: space-between; align-items: center; }
.popover-body { max-height: 300px; overflow-y: auto; padding: 8px; }
.popover-event { display: flex; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; margin-bottom: 2px; transition: background .1s; }
.popover-event:hover { background: var(--color-bg-hover); }
.popover-color-bar { width: 4px; border-radius: 2px; flex-shrink: 0; }
.popover-event-info { flex: 1; min-width: 0; }
.popover-event-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.popover-event-time { font-size: 11px; color: var(--color-text-secondary); }
/* ============================================================
TOAST
============================================================ */
.toast-container { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast { background: #323232; color: #fff; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 14px; box-shadow: var(--shadow-md); animation: toastIn .2s ease; display: flex; align-items: center; gap: 12px; }
@keyframes toastIn { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.toast-action { color: var(--color-primary-light); font-weight: 500; cursor: pointer; text-transform: uppercase; font-size: 13px; padding: 4px 8px; border-radius: var(--radius-sm); transition: background .1s; }
.toast-action:hover { background: rgba(255,255,255,.1); }
/* ============================================================
DRAG STYLES
============================================================ */
.dragging-chip { opacity: .4 !important; }
.drag-preview { position: fixed; pointer-events: none; z-index: 300; padding: 4px 8px; border-radius: var(--radius-sm); font-size: 12px; color: #fff; box-shadow: var(--shadow-md); transform: translate(-50%, -50%); }
.drop-highlight { background: rgba(26,115,232,.1) !important; outline: 2px solid var(--color-primary); outline-offset: -2px; }
/* ============================================================
YEAR JUMP OVERLAY
============================================================ */
.year-jump-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 100; display: flex; align-items: center; justify-content: center; animation: fadeIn .12s ease; }
.year-jump-modal { background: #fff; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: 24px; width: 320px; text-align: center; }
.year-jump-input { font-size: 36px; text-align: center; width: 120px; border: none; border-bottom: 2px solid var(--color-border); outline: none; padding: 4px; margin: 12px auto; display: block; }
.year-jump-input:focus { border-color: var(--color-primary); }
/* ============================================================
SETTINGS PANEL
============================================================ */
.settings-btn { font-size: 18px; color: var(--color-text-secondary); padding: 4px; }
.settings-panel { position: absolute; top: 56px; right: 16px; background: #fff; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); width: 240px; z-index: 30; padding: 16px; animation: fadeIn .12s ease; }
.settings-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; font-size: 14px; border-bottom: 1px solid var(--color-border); }
.settings-item:last-child { border-bottom: none; }
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 768px) {
.header { padding: 6px 8px; gap: 4px; flex-wrap: wrap; }
.nav-title { font-size: 16px; min-width: auto; }
.month-cell { min-height: 60px; padding: 1px 2px; }
.cell-date { font-size: 10px; width: 20px; height: 20px; }
.event-chip { font-size: 9px; line-height: 14px; padding: 0 2px; }
.week-header-cell .day-num { width: 26px; height: 26px; font-size: 14px; }
}
/* ============================================================
RECURRING SERIES EDIT OPTIONS
============================================================ */
.series-edit-options { margin-bottom: 16px; padding: 12px; background: var(--color-bg-hover); border-radius: var(--radius-sm); }
.series-edit-title { font-size: 13px; font-weight: 500; margin-bottom: 8px; color: var(--color-text-secondary); }
</style>
</head>
<body>
<div id="app">
<!-- HEADER -->
<div class="header">
<div class="header-left">
<button class="today-btn" id="btnToday">Today</button>
<button class="btn-icon" id="btnPrev" title="Previous">◀</button>
<button class="btn-icon" id="btnNext" title="Next">▶</button>
</div>
<div class="header-center">
<span class="nav-title" id="navTitle"></span>
</div>
<div class="header-right">
<div class="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>
<button class="btn-icon settings-btn" id="btnSettings" title="Settings">⚙</button>
</div>
</div>
<!-- MAIN CONTENT -->
<div class="main-content">
<div class="view-container" id="viewContainer"></div>
</div>
</div>
<!-- MODAL CONTAINER -->
<div id="modalContainer"></div>
<!-- TOAST CONTAINER -->
<div class="toast-container" id="toastContainer"></div>
<script>
/* ============================================================
SECTION: TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
COLORS: [
{ name: 'Blue', value: '#1a73e8' },
{ name: 'Red', value: '#d93025' },
{ name: 'Green', value: '#1e8e3e' },
{ name: 'Yellow', value: '#f9ab00' },
{ name: 'Purple', value: '#9334e6' },
{ name: 'Teal', value: '#12b5cb' },
{ name: 'Orange', value: '#ea8600' },
{ name: 'Pink', value: '#d91e78' },
],
HOUR_START: 6,
HOUR_END: 22,
HOUR_HEIGHT: 50,
SNAP_MINUTES: 15,
DEFAULT_WEEK_START: 0, // 0 = Sunday, 1 = Monday
MAX_CHIPS_DISPLAY: 3,
};
const MONTH_NAMES = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
const DAY_NAMES_SHORT = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const DAY_NAMES_FULL = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
/* ============================================================
SECTION: DATE UTILITIES (Pure Functions)
============================================================ */
const DateUtil = {
/** Check if a year is a leap year (handles century rule) */
isLeap(year) {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
},
/** Number of days in a given month/year */
daysInMonth(year, month) { // month: 0-11
if (month === 1) return this.isLeap(year) ? 29 : 28;
const table = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return table[month];
},
/** Day of week for a given date: 0=Sun, 1=Mon, ..., 6=Sat */
dayOfWeek(year, month, day) { // month: 0-11
const d = new Date(year, month, day);
return d.getDay();
},
/** Add N days to a date (component-based, no ms hacks) */
addDays(year, month, day, n) {
let y = year, m = month, d = day + n;
while (d < 1) {
m--;
if (m < 0) { m = 11; y--; }
d += this.daysInMonth(y, m);
}
const dim = this.daysInMonth(y, m);
while (d > dim) {
d -= dim;
m++;
if (m > 11) { m = 0; y++; }
}
return { year: y, month: m, day: d };
},
/** Add N months to a date */
addMonths(year, month, n) {
let m = month + n;
let y = year + Math.floor(m / 12);
m = ((m % 12) + 12) % 12;
return { year: y, month: m };
},
/** Get the start of week for a given date */
getWeekStart(year, month, day, weekStartDay) { // weekStartDay: 0=Sun, 1=Mon
const dow = this.dayOfWeek(year, month, day);
const diff = (dow - weekStartDay + 7) % 7;
return this.addDays(year, month, day, -diff);
},
/** Generate the full month grid: array of {year,month,day,isCurrentMonth} */
generateMonthGrid(year, month, weekStartDay) {
const firstDow = this.dayOfWeek(year, month, 1);
const startOffset = (firstDow - weekStartDay + 7) % 7;
const prevResult = this.addMonths(year, month, -1);
const prevDays = this.daysInMonth(prevResult.year, prevResult.month);
const cells = [];
// Previous month trailing days
for (let i = startOffset - 1; i >= 0; i--) {
cells.push({ year: prevResult.year, month: prevResult.month, day: prevDays - i, isCurrentMonth: false });
}
// Current month days
const dim = this.daysInMonth(year, month);
for (let d = 1; d <= dim; d++) {
cells.push({ year, month, day: d, isCurrentMonth: true });
}
// Next month leading days — fill to 6 rows (42 cells) or 5 rows (35)
const remaining = 42 - cells.length;
for (let d = 1; d <= remaining; d++) {
const nextResult = this.addMonths(year, month, 1);
cells.push({ year: nextResult.year, month: nextResult.month, day: d, isCurrentMonth: false });
}
return cells;
},
/** Format date as YYYY-MM-DD */
formatDateKey(y, m, d) {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
},
/** Parse YYYY-MM-DD to {year, month, day} (month 0-11) */
parseDateKey(key) {
const [y, m, d] = key.split('-').map(Number);
return { year: y, month: m - 1, day: d };
},
/** Check if two dates are the same day */
isSameDay(y1, m1, d1, y2, m2, d2) {
return y1 === y2 && m1 === m2 && d1 === d2;
},
/** Get today's date components */
getToday() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth(), day: now.getDate() };
},
/** Format time as HH:MM */
formatTime(hours, minutes) {
const h = hours % 12 || 12;
const ampm = hours < 12 ? 'AM' : 'PM';
return `${h}:${String(minutes).padStart(2, '0')} ${ampm}`;
},
/** Parse "HH:MM" to {hours, minutes} */
parseTime(timeStr) {
const [h, m] = timeStr.split(':').map(Number);
return { hours: h, minutes: m };
},
/** Minutes from midnight */
timeToMinutes(hours, minutes) {
return hours * 60 + minutes;
},
/** Snap minutes to interval */
snapMinutes(minutes, interval) {
return Math.round(minutes / interval) * interval;
},
};
/* ============================================================
SECTION: RECURRENCE ENGINE
============================================================ */
const RecurrenceEngine = {
/** Expand a recurrence rule for events within [startKey, endKey] date range */
expand(event, startKey, endKey) {
if (!event.recurrence || !event.recurrence.type || event.recurrence.type === 'none') {
return [{ ...event, _occurrenceDate: DateUtil.formatDateKey(event.year, event.month, event.day), _isMaster: true }];
}
const startDate = DateUtil.parseDateKey(startKey);
const endDate = DateUtil.parseDateKey(endKey);
const occurrences = [];
const rule = event.recurrence;
// Determine the effective start date for expansion
let expandStartYear = event.year;
let expandStartMonth = event.month;
let expandStartDay = event.day;
if (event._seriesStartDate) {
const sd = DateUtil.parseDateKey(event._seriesStartDate);
expandStartYear = sd.year;
expandStartMonth = sd.month;
expandStartDay = sd.day;
}
// Safety: limit expansion to avoid infinite loops
let iterations = 0;
const MAX_ITERATIONS = 500;
switch (rule.type) {
case 'daily': {
let curY = expandStartYear, curM = expandStartMonth, curD = expandStartDay;
while (iterations < MAX_ITERATIONS) {
iterations++;
const key = DateUtil.formatDateKey(curY, curM, curD);
if (key > endKey) break;
if (key >= startKey && (!event._seriesStartDate || key >= event._seriesStartDate)) {
occurrences.push(this.makeOccurrence(event, curY, curM, curD));
}
const next = DateUtil.addDays(curY, curM, curD, rule.interval || 1);
curY = next.year; curM = next.month; curD = next.day;
}
break;
}
case 'weekly': {
let curY = expandStartYear, curM = expandStartMonth, curD = expandStartDay;
while (iterations < MAX_ITERATIONS) {
iterations++;
const dow = DateUtil.dayOfWeek(curY, curM, curD);
if ((rule.weekdays || [dow]).includes(dow)) {
const key = DateUtil.formatDateKey(curY, curM, curD);
if (key > endKey) break;
if (key >= startKey && (!event._seriesStartDate || key >= event._seriesStartDate)) {
occurrences.push(this.makeOccurrence(event, curY, curM, curD));
}
}
const next = DateUtil.addDays(curY, curM, curD, rule.interval ? (rule.interval - 1) * 7 + 7 : 7);
curY = next.year; curM = next.month; curD = next.day;
}
break;
}
case 'monthly': {
let curM = expandStartMonth;
let curY = expandStartYear;
const targetDay = rule.dayOfMonth || event.day;
while (iterations < MAX_ITERATIONS) {
iterations++;
const dim = DateUtil.daysInMonth(curY, curM);
if (targetDay <= dim) {
const key = DateUtil.formatDateKey(curY, curM, targetDay);
if (key > endKey) break;
if (key >= startKey && (!event._seriesStartDate || key >= event._seriesStartDate)) {
occurrences.push(this.makeOccurrence(event, curY, curM, targetDay));
}
}
// Move to next month by interval
const inc = rule.interval || 1;
let totalMonths = curM + inc;
curY += Math.floor(totalMonths / 12);
curM = ((totalMonths % 12) + 12) % 12;
}
break;
}
case 'yearly': {
let curY = expandStartYear;
const targetMonth = event.month;
const targetDay = event.day;
while (iterations < MAX_ITERATIONS) {
iterations++;
const dim = DateUtil.daysInMonth(curY, targetMonth);
if (targetDay <= dim) {
const key = DateUtil.formatDateKey(curY, targetMonth, targetDay);
if (key > endKey) break;
if (key >= startKey && (!event._seriesStartDate || key >= event._seriesStartDate)) {
occurrences.push(this.makeOccurrence(event, curY, targetMonth, targetDay));
}
}
const inc = rule.interval || 1;
curY += inc;
}
break;
}
}
return occurrences;
},
makeOccurrence(event, year, month, day) {
return {
...event,
year, month, day,
_occurrenceDate: DateUtil.formatDateKey(year, month, day),
_isMaster: false,
_seriesId: event.id,
};
},
/** Get all events for a specific date (expanding recurrences) */
getEventsForDate(events, year, month, day, exceptions = {}) {
const key = DateUtil.formatDateKey(year, month, day);
const result = [];
// Visible range: expand from 30 days before to 60 days after for safety
const startResult = DateUtil.addDays(year, month, day, -30);
const endResult = DateUtil.addDays(year, month, day, 60);
const startKey = DateUtil.formatDateKey(startResult.year, startResult.month, startResult.day);
const endKey = DateUtil.formatDateKey(endResult.year, endResult.month, endResult.day);
for (const event of events) {
if (!event.recurrence || !event.recurrence.type || event.recurrence.type === 'none') {
if (DateUtil.isSameDay(event.year, event.month, event.day, year, month, day)) {
// Check exceptions
const excKey = `${event.id}::${key}`;
if (exceptions[excKey] && exceptions[excKey].excluded) continue;
result.push({ ...event, _occurrenceDate: key });
}
} else {
const occurrences = this.expand(event, startKey, endKey);
for (const occ of occurrences) {
if (!DateUtil.isSameDay(occ.year, occ.month, occ.day, year, month, day)) continue;
const excKey = `${event.id}::${key}`;
if (exceptions[excKey] && exceptions[excKey].excluded) continue;
// Apply exception overrides
if (exceptions[excKey]) {
result.push({ ...occ, ...exceptions[excKey].overrides });
} else {
result.push(occ);
}
}
}
}
return result.sort((a, b) => {
const aMin = a.allDay ? -1 : DateUtil.timeToMinutes(a.startHours || 0, a.startMinutes || 0);
const bMin = b.allDay ? -1 : DateUtil.timeToMinutes(b.startHours || 0, b.startMinutes || 0);
return aMin - bMin;
});
},
/** Get all events in a date range */
getEventsInRange(events, startKey, endKey, exceptions = {}) {
const result = [];
for (const event of events) {
if (!event.recurrence || !event.recurrence.type || event.recurrence.type === 'none') {
const ekey = DateUtil.formatDateKey(event.year, event.month, event.day);
if (ekey >= startKey && ekey <= endKey) {
result.push({ ...event, _occurrenceDate: ekey });
}
} else {
const occurrences = this.expand(event, startKey, endKey);
for (const occ of occurrences) {
const excKey = `${event.id}::${occ._occurrenceDate}`;
if (exceptions[excKey] && exceptions[excKey].excluded) continue;
let finalOcc = occ;
if (exceptions[excKey]) {
finalOcc = { ...occ, ...exceptions[excKey].overrides };
}
result.push(finalOcc);
}
}
}
return result.sort((a, b) => a._occurrenceDate.localeCompare(b._occurrenceDate));
},
};
/* ============================================================
SECTION: EVENT STORE (CRUD + Persistence)
============================================================ */
const Store = {
STORAGE_KEY_EVENTS: 'cal_events',
STORAGE_KEY_EXCEPTIONS: 'cal_exceptions',
STORAGE_KEY_SETTINGS: 'cal_settings',
events: [],
exceptions: {}, // key: "eventId::YYYY-MM-DD" -> { excluded?: bool, overrides?: {} }
settings: { weekStart: CONFIG.DEFAULT_WEEK_START },
init() {
try {
const ev = localStorage.getItem(this.STORAGE_KEY_EVENTS);
if (ev) this.events = JSON.parse(ev);
const ex = localStorage.getItem(this.STORAGE_KEY_EXCEPTIONS);
if (ex) this.exceptions = JSON.parse(ex);
const st = localStorage.getItem(this.STORAGE_KEY_SETTINGS);
if (st) this.settings = { ...this.settings, ...JSON.parse(st) };
} catch (e) { console.error('Store init error:', e); }
},
save() {
try {
localStorage.setItem(this.STORAGE_KEY_EVENTS, JSON.stringify(this.events));
localStorage.setItem(this.STORAGE_KEY_EXCEPTIONS, JSON.stringify(this.exceptions));
localStorage.setItem(this.STORAGE_KEY_SETTINGS, JSON.stringify(this.settings));
} catch (e) { console.error('Store save error:', e); }
},
_genId() { return 'evt_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7); },
createEvent(data) {
const event = {
id: this._genId(),
title: data.title || 'Untitled',
year: data.year,
month: data.month,
day: data.day,
allDay: !!data.allDay,
startHours: data.startHours ?? 9,
startMinutes: data.startMinutes ?? 0,
endHours: data.endHours ?? 10,
endMinutes: data.endMinutes ?? 0,
color: data.color || CONFIG.COLORS[0].value,
notes: data.notes || '',
recurrence: data.recurrence || { type: 'none' },
};
this.events.push(event);
this.save();
return event;
},
updateEvent(id, updates) {
const idx = this.events.findIndex(e => e.id === id);
if (idx === -1) return null;
Object.assign(this.events[idx], updates);
this.save();
return this.events[idx];
},
deleteEvent(id) {
this.events = this.events.filter(e => e.id !== id);
// Clean up exceptions for deleted event
const keysToRemove = Object.keys(this.exceptions).filter(k => k.startsWith(id + '::'));
for (const k of keysToRemove) delete this.exceptions[k];
this.save();
},
getEvent(id) { return this.events.find(e => e.id === id); },
addException(eventId, dateKey, data) {
const key = `${eventId}::${dateKey}`;
this.exceptions[key] = data;
this.save();
},
removeException(eventId, dateKey) {
delete this.exceptions[`${eventId}::${dateKey}`];
this.save();
},
getEventsForDate(year, month, day) {
return RecurrenceEngine.getEventsForDate(this.events, year, month, day, this.exceptions);
},
getEventsInRange(startKey, endKey) {
return RecurrenceEngine.getEventsInRange(this.events, startKey, endKey, this.exceptions);
},
};
/* ============================================================
SECTION: APP STATE & CONTROLLER
============================================================ */
const App = {
currentYear: null,
currentMonth: null, // 0-11
currentView: 'month', // month | week | agenda
animating: false,
init() {
Store.init();
const today = DateUtil.getToday();
this.currentYear = today.year;
this.currentMonth = today.month;
this.bindEvents();
this.render();
},
bindEvents() {
document.getElementById('btnToday').addEventListener('click', () => {
const t = DateUtil.getToday();
this.navigate(t.year, t.month);
});
document.getElementById('btnPrev').addEventListener('click', () => this.prev());
document.getElementById('btnNext').addEventListener('click', () => this.next());
document.getElementById('navTitle').addEventListener('click', () => this.showYearJump());
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
if (view !== this.currentView) {
this.currentView = view;
document.querySelectorAll('.view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
this.render();
}
});
});
document.getElementById('btnSettings').addEventListener('click', (e) => {
e.stopPropagation();
const panel = document.querySelector('.settings-panel');
if (panel) { panel.remove(); return; }
this.showSettingsPanel(e.target);
});
// Close popovers/panels on outside click
document.addEventListener('click', () => {
document.querySelectorAll('.day-popover, .settings-panel').forEach(el => el.remove());
});
},
navigate(year, month) {
if (this.currentYear === year && this.currentMonth === month) return;
const oldView = this.currentView;
this.currentYear = year;
this.currentMonth = month;
this.render();
},
prev() {
let m = this.currentMonth - 1;
let y = this.currentYear;
if (m < 0) { m = 11; y--; }
this.navigate(y, m);
},
next() {
let m = this.currentMonth + 1;
let y = this.currentYear;
if (m > 11) { m = 0; y++; }
this.navigate(y, m);
},
showSettingsPanel(btnEl) {
const panel = document.createElement('div');
panel.className = 'settings-panel';
const ws = Store.settings.weekStart;
panel.innerHTML = `
<div class="settings-item">
<span>Week starts on</span>
<select id="settingWeekStart" style="padding:4px 8px;border-radius:4px;border:1px solid var(--color-border);font-size:13px;">
<option value="0" ${ws===0?'selected':''}>Sunday</option>
<option value="1" ${ws===1?'selected':''}>Monday</option>
</select>
</div>
`;
document.body.appendChild(panel);
panel.querySelector('#settingWeekStart').addEventListener('change', (e) => {
Store.settings.weekStart = parseInt(e.target.value);
Store.save();
this.render();
});
},
showYearJump() {
const overlay = document.createElement('div');
overlay.className = 'year-jump-overlay';
overlay.innerHTML = `
<div class="year-jump-modal">
<div style="font-size:14px;color:var(--color-text-secondary);margin-bottom:8px;">Jump to year</div>
<input type="number" class="year-jump-input" id="yearInput" value="${this.currentYear}" min="1900" max="2200">
<div style="display:flex;gap:8px;justify-content:center;margin-top:16px;">
<button class="btn-text" id="yearCancel">Cancel</button>
<button class="btn-save" id="yearGo">Go</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#yearInput');
setTimeout(() => { input.focus(); input.select(); }, 100);
overlay.querySelector('#yearCancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#yearGo').addEventListener('click', () => {
const y = parseInt(input.value);
if (!isNaN(y) && y >= 1900 && y <= 2200) {
this.navigate(y, this.currentMonth);
}
overlay.remove();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') overlay.querySelector('#yearGo').click();
if (e.key === 'Escape') overlay.remove();
});
},
render() {
const container = document.getElementById('viewContainer');
this.updateTitle();
switch (this.currentView) {
case 'month': container.innerHTML = ''; this.renderMonth(container); break;
case 'week': container.innerHTML = ''; this.renderWeek(container); break;
case 'agenda': container.innerHTML = ''; this.renderAgenda(container); break;
}
},
updateTitle() {
const titleEl = document.getElementById('navTitle');
if (this.currentView === 'month') {
titleEl.textContent = `${MONTH_NAMES[this.currentMonth]} ${this.currentYear}`;
} else if (this.currentView === 'week') {
const ws = Store.settings.weekStart;
const start = DateUtil.getWeekStart(this.currentYear, this.currentMonth, 15, ws);
const end = DateUtil.addDays(start.year, start.month, start.day, 6);
if (start.month === end.month) {
titleEl.textContent = `${MONTH_NAMES[start.month]} ${start.day}–${end.day}, ${start.year}`;
} else if (start.year === end.year) {
titleEl.textContent = `${MONTH_NAMES[start.month].slice(0,3)} ${start.day} – ${MONTH_NAMES[end.month].slice(0,3)} ${end.day}, ${start.year}`;
} else {
titleEl.textContent = `${MONTH_NAMES[start.month]} ${start.day}, ${start.year} – ${MONTH_NAMES[end.month]} ${end.day}, ${end.year}`;
}
} else if (this.currentView === 'agenda') {
const today = DateUtil.getToday();
if (DateUtil.isSameDay(this.currentYear, this.currentMonth, 15, today.year, today.month, today.day)) {
titleEl.textContent = `Starting Today`;
} else {
titleEl.textContent = `${MONTH_NAMES[this.currentMonth]} ${this.currentYear}`;
}
}
},
/* ============================================================
RENDER: MONTH VIEW
============================================================ */
renderMonth(container) {
const ws = Store.settings.weekStart;
const grid = DateUtil.generateMonthGrid(this.currentYear, this.currentMonth, ws);
const today = DateUtil.getToday();
let html = '<div class="month-view">';
// Header row
html += '<div class="month-header-row">';
for (let i = 0; i < 7; i++) {
const dayIdx = (ws + i) % 7;
html += `<div class="month-header-cell">${DAY_NAMES_SHORT[dayIdx]}</div>`;
}
html += '</div>';
// Grid
html += '<div class="month-grid">';
for (const cell of grid) {
const isToday = DateUtil.isSameDay(cell.year, cell.month, cell.day, today.year, today.month, today.day);
const classes = ['month-cell'];
if (!cell.isCurrentMonth) classes.push('other-month');
if (isToday) classes.push('today');
html += `<div class="${classes.join(' ')}" data-date="${DateUtil.formatDateKey(cell.year, cell.month, cell.day)}">`;
html += `<div class="cell-date">${cell.day}</div>`;
// Events for this day
const events = Store.getEventsForDate(cell.year, cell.month, cell.day);
const maxChips = CONFIG.MAX_CHIPS_DISPLAY;
let shown = 0;
for (const ev of events) {
if (shown >= maxChips) break;
html += `<div class="event-chip" style="background:${ev.color}22;color:${ev.color};border-left:3px solid ${ev.color}" data-event-id="${ev.id}" data-date="${DateUtil.formatDateKey(cell.year, cell.month, cell.day)}">${ev.title}</div>`;
shown++;
}
if (events.length > maxChips) {
html += `<div class="overflow-link" data-date="${DateUtil.formatDateKey(cell.year, cell.month, cell.day)}">+${events.length - maxChips} more</div>`;
}
html += '</div>';
}
html += '</div></div>';
container.innerHTML = html;
// Bind month view events
this.bindMonthEvents(container);
},
bindMonthEvents(container) {
const grid = container.querySelector('.month-grid');
// Click on cell to create event
grid.addEventListener('click', (e) => {
const chip = e.target.closest('.event-chip');
if (chip) return; // handled separately
const overflow = e.target.closest('.overflow-link');
if (overflow) return; // handled separately
const cell = e.target.closest('.month-cell');
if (!cell) return;
const dateKey = cell.dataset.date;
const d = DateUtil.parseDateKey(dateKey);
this.openEventModal(null, d.year, d.month, d.day);
});
// Click on event chip to edit
grid.addEventListener('click', (e) => {
const chip = e.target.closest('.event-chip');
if (!chip) return;
const eventId = chip.dataset.eventId;
const dateKey = chip.dataset.date || chip.closest('[data-date]')?.dataset.date;
this.openEventModal(eventId, null, null, null, dateKey);
});
// Overflow link -> day popover
grid.addEventListener('click', (e) => {
const overflow = e.target.closest('.overflow-link');
if (!overflow) return;
const dateKey = overflow.dataset.date;
this.showDayPopover(overflow, dateKey);
});
// Drag to reschedule in month view
let dragState = null;
grid.addEventListener('mousedown', (e) => {
const chip = e.target.closest('.event-chip');
if (!chip) return;
e.preventDefault();
const eventId = chip.dataset.eventId;
const sourceDateKey = chip.dataset.date || chip.closest('[data-date]')?.dataset.date;
dragState = { eventId, sourceDateKey, chip };
chip.classList.add('dragging-chip');
// Create preview
const preview = document.createElement('div');
preview.className = 'drag-preview';
preview.style.background = chip.style.borderLeftColor || '#1a73e8';
preview.textContent = chip.textContent;
preview.id = '_dragPreview';
document.body.appendChild(preview);
const onMove = (ev) => {
const p = document.getElementById('_dragPreview');
if (p) { p.style.left = ev.clientX + 'px'; p.style.top = ev.clientY + 'px'; }
// Highlight drop target
grid.querySelectorAll('.month-cell').forEach(c => c.classList.remove('drop-highlight'));
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (el) {
const cell = el.closest?.('.month-cell');
if (cell && cell !== chip.closest('.month-cell')) cell.classList.add('drop-highlight');
}
};
const onUp = (ev) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
grid.querySelectorAll('.month-cell').forEach(c => c.classList.remove('drop-highlight'));
chip.classList.remove('dragging-chip');
const p = document.getElementById('_dragPreview');
if (p) p.remove();
// Find drop target
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (el) {
const cell = el.closest?.('.month-cell');
if (cell && cell !== chip.closest('.month-cell')) {
const newDateKey = cell.dataset.date;
this.rescheduleEvent(eventId, sourceDateKey, newDateKey);
}
}
dragState = null;
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
},
rescheduleEvent(eventId, oldDateKey, newDateKey) {
const event = Store.getEvent(eventId);
if (!event) return;
const d = DateUtil.parseDateKey(newDateKey);
// If recurring, add exception for old date and create new one-time event
if (event.recurrence && event.recurrence.type !== 'none') {
// Exclude the old occurrence
Store.addException(eventId, oldDateKey, { excluded: true });
// Create a new standalone event on the new date
const newEvent = Store.createEvent({
title: event.title,
year: d.year, month: d.month, day: d.day,
allDay: event.allDay,
startHours: event.startHours, startMinutes: event.startMinutes,
endHours: event.endHours, endMinutes: event.endMinutes,
color: event.color,
notes: event.notes,
recurrence: { type: 'none' },
});
showToast(`Event moved to ${MONTH_NAMES[d.month]} ${d.day}`);
} else {
// Simple move
Store.updateEvent(eventId, { year: d.year, month: d.month, day: d.day });
showToast(`Event moved to ${MONTH_NAMES[d.month]} ${d.day}`);
}
this.render();
},
showDayPopover(anchorEl, dateKey) {
// Remove existing popovers
document.querySelectorAll('.day-popover').forEach(el => el.remove());
const d = DateUtil.parseDateKey(dateKey);
const events = Store.getEventsForDate(d.year, d.month, d.day);
const rect = anchorEl.getBoundingClientRect();
const popover = document.createElement('div');
popover.className = 'day-popover';
popover.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
popover.style.top = (rect.bottom + 4) + 'px';
let html = `<div class="popover-header">
<span>${MONTH_NAMES[d.month]} ${d.day}, ${d.year}</span>
<button style="font-size:18px;color:var(--color-text-secondary)" id="popoverClose">×</button>
</div><div class="popover-body">`;
if (events.length === 0) {
html += '<div style="padding:12px;text-align:center;color:var(--color-text-secondary);font-size:13px;">No events</div>';
} else {
for (const ev of events) {
const timeStr = ev.allDay ? 'All day' : `${DateUtil.formatTime(ev.startHours, ev.startMinutes)} – ${DateUtil.formatTime(ev.endHours, ev.endMinutes)}`;
html += `<div class="popover-event" data-event-id="${ev.id}" data-date="${dateKey}">
<div class="popover-color-bar" style="background:${ev.color}"></div>
<div class="popover-event-info">
<div class="popover-event-title">${ev.title}</div>
<div class="popover-event-time">${timeStr}</div>
</div>
</div>`;
}
}
html += '</div>';
popover.innerHTML = html;
document.body.appendChild(popover);
popover.querySelector('#popoverClose').addEventListener('click', (e) => { e.stopPropagation(); popover.remove(); });
// Click event in popover to edit
popover.addEventListener('click', (e) => {
const item = e.target.closest('.popover-event');
if (!item) return;
e.stopPropagation();
popover.remove();
this.openEventModal(item.dataset.eventId, null, null, null, item.dataset.date);
});
// Click outside to close is handled by global listener
},
/* ============================================================
RENDER: WEEK VIEW
============================================================ */
renderWeek(container) {
const ws = Store.settings.weekStart;
const today = DateUtil.getToday();
const weekStart = DateUtil.getWeekStart(this.currentYear, this.currentMonth, 15, ws);
let html = '<div class="week-view">';
// Header row
html += '<div class="week-header-row" style="grid-template-columns:48px repeat(7,1fr)">';
html += '<div></div>'; // time gutter
for (let i = 0; i < 7; i++) {
const d = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
const isToday = DateUtil.isSameDay(d.year, d.month, d.day, today.year, today.month, today.day);
html += `<div class="week-header-cell${isToday ? ' today' : ''}" data-date="${DateUtil.formatDateKey(d.year, d.month, d.day)}">`;
html += `<div class="day-name">${DAY_NAMES_SHORT[DateUtil.dayOfWeek(d.year, d.month, d.day)]}</div>`;
html += `<div class="day-num">${d.day}</div></div>`;
}
html += '</div>';
// Body
const totalHours = CONFIG.HOUR_END - CONFIG.HOUR_START + 1;
const gridHeight = totalHours * CONFIG.HOUR_HEIGHT;
html += '<div class="week-body-wrapper" id="weekBody">';
html += `<div class="time-grid-container" style="position:relative;height:${gridHeight}px;">`;
// All-day strip (above time grid)
html += '<div class="all-day-strip" style="grid-template-columns:48px repeat(7,1fr);">';
html += '<div></div>';
for (let i = 0; i < 7; i++) {
const d = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
const dateKey = DateUtil.formatDateKey(d.year, d.month, d.day);
const events = Store.getEventsForDate(d.year, d.month, d.day).filter(e => e.allDay);
html += `<div class="all-day-cell" data-date="${dateKey}">`;
for (const ev of events) {
html += `<div class="event-chip" style="background:${ev.color}22;color:${ev.color};border-left:3px solid ${ev.color}" data-event-id="${ev.id}" data-date="${dateKey}">${ev.title}</div>`;
}
html += '</div>';
}
html += '</div>';
// Time grid (hour cells as click targets)
html += '<div class="time-grid" style="display:grid;grid-template-columns:48px repeat(7,1fr);height:' + gridHeight + 'px;">';
for (let h = CONFIG.HOUR_START; h <= CONFIG.HOUR_END; h++) {
html += `<div class="hour-label"><span>${DateUtil.formatTime(h, 0)}</span></div>`;
for (let i = 0; i < 7; i++) {
const d = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
html += `<div class="hour-cell" data-date="${DateUtil.formatDateKey(d.year, d.month, d.day)}" data-hour="${h}"></div>`;
}
}
html += '</div>';
// Event overlay (absolute positioned on top of time grid)
html += '<div class="event-overlay" style="position:absolute;top:0;left:48px;right:0;bottom:0;">';
for (let i = 0; i < 7; i++) {
const d = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
const dateKey = DateUtil.formatDateKey(d.year, d.month, d.day);
const events = Store.getEventsForDate(d.year, d.month, d.day).filter(e => !e.allDay);
for (const ev of events) {
const startMin = DateUtil.timeToMinutes(ev.startHours || 0, ev.startMinutes || 0);
const endMin = DateUtil.timeToMinutes(ev.endHours || 0, ev.endMinutes || 0);
const topOffset = ((startMin - CONFIG.HOUR_START * 60) / 60) * CONFIG.HOUR_HEIGHT;
const height = Math.max(((endMin - startMin) / 60) * CONFIG.HOUR_HEIGHT, 20);
if (topOffset + height > 0 && topOffset < gridHeight) {
html += `<div class="week-event-block" data-event-id="${ev.id}" data-date="${dateKey}" style="
position:absolute;
left:calc((100% / 7) * ${i} + 2px);
width:calc(100% / 7 - 4px);
top:${topOffset}px;
height:${height}px;
background:${ev.color}22;
border-left:3px solid ${ev.color};
color:${ev.color};
">${ev.title}</div>`;
}
}
}
// Current time indicator
const now = new Date();
if (DateUtil.isSameDay(weekStart.year, weekStart.month, weekStart.day, today.year, today.month, today.day) ||
this._isInWeekRange(today.year, today.month, today.day, weekStart)) {
const todayOffset = this._getTodayWeekOffset(weekStart);
if (todayOffset >= 0 && todayOffset < 7) {
const nowMin = DateUtil.timeToMinutes(now.getHours(), now.getMinutes());
const topPos = ((nowMin - CONFIG.HOUR_START * 60) / 60) * CONFIG.HOUR_HEIGHT;
html += `<div class="current-time-line" style="left:calc((100% / 7) * ${todayOffset});width:calc(100% / 7);top:${Math.max(topPos, 0)}px;"></div>`;
}
}
html += '</div></div></div>';
container.innerHTML = html;
// Bind week view events
this.bindWeekEvents(container, weekStart);
},
_isInWeekRange(y, m, d, weekStart) {
for (let i = 0; i < 7; i++) {
const dd = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
if (DateUtil.isSameDay(dd.year, dd.month, dd.day, y, m, d)) return true;
}
return false;
},
_getTodayWeekOffset(weekStart) {
const today = DateUtil.getToday();
for (let i = 0; i < 7; i++) {
const dd = DateUtil.addDays(weekStart.year, weekStart.month, weekStart.day, i);
if (DateUtil.isSameDay(dd.year, dd.month, dd.day, today.year, today.month, today.day)) return i;
}
return -1;
},
bindWeekEvents(container, weekStart) {
const body = container.querySelector('.week-body-wrapper');
// Click on hour cell to create event
body.addEventListener('click', (e) => {
const block = e.target.closest('.week-event-block');
if (block) return;
const chip = e.target.closest('.event-chip');
if (chip) return;
const cell = e.target.closest('.hour-cell');
if (!cell) return;
const d = DateUtil.parseDateKey(cell.dataset.date);
const hour = parseInt(cell.dataset.hour);
this.openEventModal(null, d.year, d.month, d.day, null, false, hour, 0, hour + 1, 0);
});
// Click on event block to edit
body.addEventListener('click', (e) => {
const block = e.target.closest('.week-event-block');
if (!block) return;
const eventId = block.dataset.eventId;
this.openEventModal(eventId, null, null, null, block.dataset.date);
});
// Click on all-day chip to edit
body.addEventListener('click', (e) => {
const chip = e.target.closest('.all-day-cell .event-chip');
if (!chip) return;
this.openEventModal(chip.dataset.eventId, null, null, null, chip.dataset.date);
});
// Drag to reschedule in week view (vertical drag for time change)
let dragState = null;
body.addEventListener('mousedown', (e) => {
const block = e.target.closest('.week-event-block');
if (!block) return;
e.preventDefault();
const eventId = block.dataset.eventId;
const dateKey = block.dataset.date;
const startY = e.clientY;
const origTop = parseFloat(block.style.top);
block.classList.add('dragging-chip');
const onMove = (ev) => {
const dy = ev.clientY - startY;
let newTop = origTop + dy;
// Snap to 15-minute intervals
const snappedMin = DateUtil.snapMinutes((newTop / CONFIG.HOUR_HEIGHT) * 60, CONFIG.SNAP_MINUTES);
newTop = (snappedMin / 60) * CONFIG.HOUR_HEIGHT;
block.style.top = Math.max(0, newTop) + 'px';
};
const onUp = (ev) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
block.classList.remove('dragging-chip');
const dy = ev.clientY - startY;
let newTop = origTop + dy;
const snappedMin = DateUtil.snapMinutes((newTop / CONFIG.HOUR_HEIGHT) * 60, CONFIG.SNAP_MINUTES);
newTop = (snappedMin / 60) * CONFIG.HOUR_HEIGHT;
if (Math.abs(newTop - origTop) > 5) {
const event = Store.getEvent(eventId);
if (!event) return;
const startMinutesFromMidnight = Math.round((newTop / CONFIG.HOUR_HEIGHT) * 60);
const durationMin = DateUtil.timeToMinutes(event.endHours, event.endMinutes) - DateUtil.timeToMinutes(event.startHours, event.startMinutes);
let newStartH = Math.floor(startMinutesFromMidnight / 60);
let newStartM = startMinutesFromMidnight % 60;
let newEndMin = startMinutesFromMidnight + durationMin;
let newEndH = Math.min(Math.floor(newEndMin / 60), 23);
let newEndM = newEndMin % 60;
if (event.recurrence && event.recurrence.type !== 'none') {
// Add exception for this occurrence with new time
Store.addException(eventId, dateKey, { overrides: { startHours: newStartH, startMinutes: newStartM, endHours: newEndH, endMinutes: newEndM } });
} else {
Store.updateEvent(eventId, { startHours: newStartH, startMinutes: newStartM, endHours: newEndH, endMinutes: newEndM });
}
this.render();
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
},
/* ============================================================
RENDER: AGENDA VIEW
============================================================ */
renderAgenda(container) {
const today = DateUtil.getToday();
// Show events from today for the next 7 days
let html = '<div class="agenda-view">';
let foundEvents = false;
for (let i = 0; i < 14; i++) {
const d = DateUtil.addDays(this.currentYear, this.currentMonth, 15, i - 7);
const events = Store.getEventsForDate(d.year, d.month, d.day);
if (events.length === 0) continue;
foundEvents = true;
const isToday = DateUtil.isSameDay(d.year, d.month, d.day, today.year, today.month, today.day);
let dateLabel = `${MONTH_NAMES[d.month]} ${d.day}, ${d.year}`;
if (isToday) dateLabel = 'Today';
else {
const tomorrow = DateUtil.addDays(today.year, today.month, today.day, 1);
if (DateUtil.isSameDay(d.year, d.month, d.day, tomorrow.year, tomorrow.month, tomorrow.day)) dateLabel = 'Tomorrow';
}
html += `<div class="agenda-date-group">`;
html += `<div class="agenda-date-header">${dateLabel}</div>`;
for (const ev of events) {
const timeStr = ev.allDay ? 'All day' : `${DateUtil.formatTime(ev.startHours, ev.startMinutes)} – ${DateUtil.formatTime(ev.endHours, ev.endMinutes)}`;
html += `<div class="agenda-event-item" data-event-id="${ev.id}" data-date="${DateUtil.formatDateKey(d.year, d.month, d.day)}">`;
html += `<div class="agenda-color-bar" style="background:${ev.color}"></div>`;
html += `<div class="agenda-event-info"><div class="agenda-event-title">${ev.title}</div><div class="agenda-event-time">${timeStr}</div></div>`;
html += '</div>';
}
html += '</div>';
}
if (!foundEvents) {
html += '<div style="text-align:center;padding:48px;color:var(--color-text-secondary);font-size:15px;">No upcoming events</div>';
}
html += '</div>';
container.innerHTML = html;
// Bind agenda click to edit
container.addEventListener('click', (e) => {
const item = e.target.closest('.agenda-event-item');
if (!item) return;
this.openEventModal(item.dataset.eventId, null, null, null, item.dataset.date);
});
},
/* ============================================================
EVENT MODAL
============================================================ */
openEventModal(eventId, year, month, day, occurrenceDateKey, allDay, startH, startM, endH, endM) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
let event = null;
if (eventId) {
event = Store.getEvent(eventId);
}
// Determine the occurrence date for display/editing
let occYear, occMonth, occDay;
if (occurrenceDateKey) {
const d = DateUtil.parseDateKey(occurrenceDateKey);
occYear = d.year; occMonth = d.month; occDay = d.day;
} else if (year !== null && month !== null && day !== null) {
occYear = year; occMonth = month; occDay = day;
} else if (event) {
occYear = event.year; occMonth = event.month; occDay = event.day;
}
const isRecurringSeries = event && event.recurrence && event.recurrence.type !== 'none';
const isExceptionEdit = occurrenceDateKey && isRecurringSeries && !Store.getEvent(eventId)?.recurrence?.type || false;
// Check if this is an exception instance of a recurring series
let isInstanceOnly = false;
if (isRecurringSeries && occurrenceDateKey) {
const excKey = `${eventId}::${occurrenceDateKey}`;
isInstanceOnly = !!Store.exceptions[excKey] || !DateUtil.isSameDay(event.year, event.month, event.day, occYear, occMonth, occDay);
}
// Default values for new event
const title = event ? event.title : '';
const evtAllDay = event ? event.allDay : (allDay ?? false);
const sH = event ? event.startHours : (startH ?? 9);
const sM = event ? event.startMinutes : (startM ?? 0);
const eH = event ? event.endHours : (endH ?? 10);
const eM = event ? event.endMinutes : (endM ?? 0);
const color = event ? event.color : CONFIG.COLORS[0].value;
const notes = event ? event.notes : '';
// Recurrence defaults
let recType = 'none';
let recWeekdays = [];
if (event && event.recurrence) {
recType = event.recurrence.type || 'none';
recWeekdays = event.recurrence.weekdays || [DateUtil.dayOfWeek(event.year, event.month, event.day)];
}
const dateStr = DateUtil.formatDateKey(occYear, occMonth, occDay);
let seriesOptionsHtml = '';
if (isInstanceOnly) {
seriesOptionsHtml = `
<div class="series-edit-options">
<div class="series-edit-title">This is a recurring event</div>
<label class="recurrence-option"><input type="radio" name="seriesEditScope" value="instance" checked> This event only</label>
<label class="recurrence-option"><input type="radio" name="seriesEditScope" value="following"> This and following events</label>
<label class="recurrence-option"><input type="radio" name="seriesEditScope" value="all"> All events in series</label>
</div>`;
}
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<span class="modal-title">${event ? 'Edit Event' : 'New Event'}</span>
<button class="modal-close" id="modalClose">×</button>
</div>
<div class="modal-body">
${seriesOptionsHtml}
<div class="form-group">
<label class="form-label">Title</label>
<input type="text" class="form-input" id="evtTitle" value="${this._esc(title)}" placeholder="Add title">
</div>
<div class="form-group">
<label class="form-label">Date</label>
<input type="date" class="form-input" id="evtDate" value="${dateStr}">
</div>
<div class="form-row">
<div class="form-group">
<label class="checkbox-group">
<input type="checkbox" id="evtAllDay" ${evtAllDay ? 'checked' : ''}>
<span>All day</span>
</label>
</div>
</div>
<div class="form-row" id="timeRow">
<div class="form-group">
<label class="form-label">Start time</label>
<input type="time" class="form-input" id="evtStartTime" value="${String(sH).padStart(2,'0')}:${String(sM).padStart(2,'0')}">
</div>
<div class="form-group">
<label class="form-label">End time</label>
<input type="time" class="form-input" id="evtEndTime" value="${String(eH).padStart(2,'0')}:${String(eM).padStart(2,'0')}">
</div>
</div>
<div id="timeError" class="error-text">End time must be after start time</div>
<div class="form-group">
<label class="form-label">Color</label>
<div class="color-picker" id="colorPicker">
${CONFIG.COLORS.map(c => `<div class="color-swatch${c.value === color ? ' selected' : ''}" style="background:${c.value}" data-color="${c.value}"></div>`).join('')}
</div>
</div>
<div class="form-group">
<label class="form-label">Recurrence</label>
<select class="form-select" id="evtRecType">
<option value="none" ${recType==='none'?'selected':''}>Does not repeat</option>
<option value="daily" ${recType==='daily'?'selected':''}>Daily</option>
<option value="weekly" ${recType==='weekly'?'selected':''}>Weekly</option>
<option value="monthly" ${recType==='monthly'?'selected':''}>Monthly</option>
<option value="yearly" ${recType==='yearly'?'selected':''}>Yearly</option>
</select>
</div>
<div id="weeklyOptions" style="${recType==='weekly'?'':'display:none'}">
<label class="form-label" style="margin-bottom:4px;">Repeat on</label>
<div class="weekday-pills" id="weekdayPills">
${DAY_NAMES_SHORT.map((name, i) => `<div class="weekday-pill${recWeekdays.includes(i)?' selected':''}" data-day="${i}">${name.charAt(0)}</div>`).join('')}
</div>
</div>
<div class="form-group">
<label class="form-label">Notes</label>
<textarea class="form-textarea" id="evtNotes" placeholder="Add notes...">${this._esc(notes)}</textarea>
</div>
</div>
<div class="modal-footer">
${event ? `<button class="btn-delete" id="evtDelete">Delete</button>` : '<span></span>'}
<button class="btn-save" id="evtSave">Save</button>
</div>
</div>
`;
document.getElementById('modalContainer').appendChild(overlay);
// Focus title
setTimeout(() => overlay.querySelector('#evtTitle')?.focus(), 100);
// All-day toggle
const allDayCb = overlay.querySelector('#evtAllDay');
const timeRow = overlay.querySelector('#timeRow');
allDayCb.addEventListener('change', () => {
timeRow.style.display = allDayCb.checked ? 'none' : '';
});
// Color picker
let selectedColor = color;
overlay.querySelectorAll('.color-swatch').forEach(sw => {
sw.addEventListener('click', () => {
overlay.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
sw.classList.add('selected');
selectedColor = sw.dataset.color;
});
});
// Recurrence type change
const recSelect = overlay.querySelector('#evtRecType');
const weeklyOpts = overlay.querySelector('#weeklyOptions');
recSelect.addEventListener('change', () => {
weeklyOpts.style.display = recSelect.value === 'weekly' ? '' : 'none';
});
// Weekday pills toggle
overlay.querySelectorAll('.weekday-pill').forEach(pill => {
pill.addEventListener('click', () => pill.classList.toggle('selected'));
});
// Close modal
const closeModal = () => overlay.remove();
overlay.querySelector('#modalClose').addEventListener('click', closeModal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });
// Save
overlay.querySelector('#evtSave').addEventListener('click', () => {
const titleVal = overlay.querySelector('#evtTitle').value.trim() || 'Untitled';
const dateVal = overlay.querySelector('#evtDate').value;
if (!dateVal) return;
const d = DateUtil.parseDateKey(dateVal);
const allDayVal = overlay.querySelector('#evtAllDay').checked;
let startHr = sH, startMi = sM, endHr = eH, endMi = eM;
if (!allDayVal) {
const st = DateUtil.parseTime(overlay.querySelector('#evtStartTime').value || '09:00');
const et = DateUtil.parseTime(overlay.querySelector('#evtEndTime').value || '10:00');
startHr = st.hours; startMi = st.minutes; endHr = et.hours; endMi = et.minutes;
// Validation
if (DateUtil.timeToMinutes(endHr, endMi) <= DateUtil.timeToMinutes(startHr, startMi)) {
overlay.querySelector('#timeError').classList.add('visible');
return;
}
}
overlay.querySelector('#timeError')?.classList.remove('visible');
const notesVal = overlay.querySelector('#evtNotes').value.trim();
const recTypeVal = overlay.querySelector('#evtRecType').value;
const selectedWeekdays = [];
overlay.querySelectorAll('.weekday-pill.selected').forEach(p => selectedWeekdays.push(parseInt(p.dataset.day)));
const recurrence = { type: recTypeVal };
if (recTypeVal === 'weekly') recurrence.weekdays = selectedWeekdays.length ? selectedWeekdays : [DateUtil.dayOfWeek(d.year, d.month, d.day)];
if (recTypeVal === 'monthly') recurrence.dayOfMonth = d.day;
const seriesScope = overlay.querySelector('input[name="seriesEditScope"]:checked');
const scope = seriesScope ? seriesScope.value : null;
if (event) {
// Editing existing event
if (isInstanceOnly && scope === 'instance') {
// Edit this occurrence only — add exception override
Store.addException(eventId, dateVal, { overrides: { title: titleVal, allDay: allDayVal, startHours: startHr, startMinutes: startMi, endHours: endHr, endMinutes: endMi, color: selectedColor, notes: notesVal } });
} else if (isInstanceOnly && scope === 'following') {
// Edit this and following — set series start date to current occurrence
Store.updateEvent(eventId, { title: titleVal, allDay: allDayVal, startHours: startHr, startMinutes: startMi, endHours: endHr, endMinutes: endMi, color: selectedColor, notes: notesVal, recurrence, _seriesStartDate: dateVal });
} else if (isInstanceOnly && scope === 'all') {
// Edit entire series — update master and clear exceptions
Store.updateEvent(eventId, { title: titleVal, allDay: allDayVal, startHours: startHr, startMinutes: startMi, endHours: endHr, endMinutes: endMi, color: selectedColor, notes: notesVal, recurrence });
// Clear all exceptions for this series
const keysToRemove = Object.keys(Store.exceptions).filter(k => k.startsWith(eventId + '::'));
for (const k of keysToRemove) delete Store.exceptions[k];
Store.save();
} else {
// Normal edit (non-recurring or editing master directly)
Store.updateEvent(eventId, { title: titleVal, year: d.year, month: d.month, day: d.day, allDay: allDayVal, startHours: startHr, startMinutes: startMi, endHours: endHr, endMinutes: endMi, color: selectedColor, notes: notesVal, recurrence });
}
} else {
// Creating new event
Store.createEvent({ title: titleVal, year: d.year, month: d.month, day: d.day, allDay: allDayVal, startHours: startHr, startMinutes: startMi, endHours: endHr, endMinutes: endMi, color: selectedColor, notes: notesVal, recurrence });
}
closeModal();
this.render();
});
// Delete
if (event) {
overlay.querySelector('#evtDelete').addEventListener('click', () => {
const seriesScope = overlay.querySelector('input[name="seriesEditScope"]:checked');
const scope = seriesScope ? seriesScope.value : null;
if (isInstanceOnly && scope === 'instance') {
// Delete only this occurrence
Store.addException(eventId, dateVal, { excluded: true });
showToast('Event deleted', () => {});
} else if (isInstanceOnly && scope === 'following') {
// Delete this and following — update series start
const newStart = DateUtil.addDays(occYear, occMonth, occDay, 1);
Store.updateEvent(eventId, { _seriesStartDate: DateUtil.formatDateKey(newStart.year, newStart.month, newStart.day) });
showToast('Future events deleted');
} else if (isInstanceOnly && scope === 'all') {
// Delete entire series
if (!confirm('Delete the entire recurring event?')) return;
Store.deleteEvent(eventId);
showToast('Series deleted');
} else {
if (!confirm('Delete this event?')) return;
Store.deleteEvent(eventId);
showToast('Event deleted');
}
closeModal();
this.render();
});
}
// Keyboard shortcuts
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
},
_esc(str) { return String(str).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
};
/* ============================================================
SECTION: TOAST NOTIFICATIONS
============================================================ */
function showToast(message, undoAction) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast';
let html = `<span>${message}</span>`;
if (undoAction) html += `<button class="toast-action" id="toastUndo">UNDO</button>`;
toast.innerHTML = html;
container.appendChild(toast);
if (undoAction) {
toast.querySelector('#toastUndo').addEventListener('click', () => { undoAction(); toast.remove(); });
}
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 4000);
}
/* ============================================================
SECTION: INITIALIZATION
============================================================ */
document.addEventListener('DOMContentLoaded', () => App.init());
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"cceb6fa9-34f3-479f-b157-e346201e76ec","tokensIn":1741634,"tokensOut":40302,"tokensTotal":1781936,"turns":39,"toolCalls":38,"failedToolCalls":0,"timestamp":"2026-07-29T21:57:06.019Z"} -->