Calendar
Deepseek v4.1 Flash · 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 89,854 bytes · SHA-256 3108000efff1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Event Planner</title>
<style>
/* ============================================================
THEME / TUNABLES (mirrored in JS CONFIG block)
============================================================ */
:root{
--bg:#f6f7f9;
--surface:#ffffff;
--surface-2:#f1f3f4;
--line:#e3e6ea;
--line-2:#eef0f3;
--text:#1f2328;
--text-2:#5f6673;
--text-3:#8a919c;
--accent:#1a73e8;
--accent-soft:#e8f0fe;
--today:#1a73e8;
--danger:#d93025;
--radius:10px;
--radius-sm:6px;
--shadow:0 1px 2px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.06);
--shadow-lg:0 8px 30px rgba(0,0,0,.18);
--chipH:20px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg);
color:var(--text);
font-size:14px;
-webkit-font-smoothing:antialiased;
overflow:hidden;
}
button{font-family:inherit;font-size:inherit;color:inherit;cursor:pointer;border:none;background:none}
input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}
/* ============================ APP SHELL ============================ */
#app{display:flex;flex-direction:column;height:100vh}
/* ============================ TOP BAR ============================ */
.topbar{
display:flex;align-items:center;gap:10px;
padding:8px 14px;background:var(--surface);
border-bottom:1px solid var(--line);
flex:0 0 auto;z-index:40;
}
.brand{display:flex;align-items:center;gap:9px;margin-right:6px;min-width:0}
.logo{
width:26px;height:26px;border-radius:50%;background:var(--accent);
display:grid;place-items:center;color:#fff;font-weight:700;font-size:13px;flex:0 0 auto;
}
.brand h1{font-size:16px;font-weight:600;margin:0;white-space:nowrap}
.tbtn{
display:inline-flex;align-items:center;gap:6px;
padding:7px 12px;border-radius:var(--radius-sm);border:1px solid var(--line);
background:var(--surface);white-space:nowrap;transition:background .12s,border-color .12s;
}
.tbtn:hover{background:var(--surface-2)}
.tbtn.ghost{border-color:transparent}
.tbtn.ghost:hover{background:var(--surface-2)}
.icon-btn{
width:34px;height:34px;border-radius:50%;display:grid;place-items:center;
border:1px solid var(--line);background:var(--surface);transition:background .12s;
}
.icon-btn:hover{background:var(--surface-2)}
.date-title{
font-size:18px;font-weight:600;min-width:190px;text-align:center;
letter-spacing:-.2px;white-space:nowrap;
}
.spacer{flex:1 1 auto}
.seg{display:flex;border:1px solid var(--line);border-radius:var(--radius-sm);overflow:hidden}
.seg button{padding:7px 13px;background:var(--surface);transition:background .12s}
.seg button+button{border-left:1px solid var(--line)}
.seg button:hover{background:var(--surface-2)}
.seg button.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
/* ============================ VIEW AREA ============================ */
.viewwrap{position:relative;flex:1 1 auto;overflow:hidden}
.view{position:absolute;inset:0;display:flex;flex-direction:column;will-change:transform,opacity}
.view.anim-next{animation:slideNext .26s cubic-bezier(.3,.7,.3,1)}
.view.anim-prev{animation:slidePrev .26s cubic-bezier(.3,.7,.3,1)}
.view.anim-fade{animation:fadeIn .2s ease}
@keyframes slideNext{from{transform:translateX(46px);opacity:0}to{transform:none;opacity:1}}
@keyframes slidePrev{from{transform:translateX(-46px);opacity:0}to{transform:none;opacity:1}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
/* ============================ MONTH VIEW ============================ */
.month-head{
display:grid;grid-template-columns:repeat(7,1fr);
border-bottom:1px solid var(--line);background:var(--surface);flex:0 0 auto;
}
.month-head div{
padding:8px 10px;font-size:11px;font-weight:700;color:var(--text-2);
text-transform:uppercase;letter-spacing:.6px;text-align:left;
}
.month-head div.we{color:var(--text-3)}
.month-grid{flex:1 1 auto;display:grid;grid-template-rows:repeat(6,1fr);min-height:0}
.week-row{display:grid;grid-template-columns:repeat(7,1fr);min-height:0}
.day-cell{
border-right:1px solid var(--line-2);border-bottom:1px solid var(--line-2);
padding:5px 5px 4px;min-height:0;overflow:hidden;position:relative;
display:flex;flex-direction:column;gap:2px;background:var(--surface);
transition:background .12s;
}
.week-row .day-cell:last-child{border-right:none}
.day-cell.other{background:#fafbfc}
.day-cell.other .daynum{color:var(--text-3)}
.day-cell:hover{background:#fbfcfe}
.day-cell.drop-target{background:var(--accent-soft);box-shadow:inset 0 0 0 2px var(--accent)}
.day-head{display:flex;align-items:center;justify-content:space-between;gap:4px}
.daynum{
font-size:12px;font-weight:600;color:var(--text-2);
width:22px;height:22px;display:grid;place-items:center;border-radius:50%;
}
.day-cell.today .daynum{background:var(--today);color:#fff;font-weight:700}
.daynum.weekend{color:var(--text-3)}
.chips{display:flex;flex-direction:column;gap:2px;overflow:hidden}
.chip{
display:flex;align-items:center;gap:5px;min-height:var(--chipH);
padding:1px 6px 1px 5px;border-radius:4px;cursor:pointer;
font-size:11.5px;line-height:1.25;overflow:hidden;position:relative;
background:var(--chip-bg,#e8f0fe);color:var(--chip-fg,#174ea6);font-weight:500;
border-left:3px solid var(--chip-bar,#1a73e8);
transition:filter .12s,box-shadow .12s;
user-select:none;
}
.chip:hover{filter:brightness(.95);box-shadow:0 1px 3px rgba(0,0,0,.14)}
.chip .ct{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}
.chip .ctime{flex:0 0 auto;opacity:.85;font-variant-numeric:tabular-nums}
.chip .ck{flex:0 0 auto;font-size:9px;opacity:.8}
.chip.dragging{opacity:.4}
.chip.timed{border-left-width:3px}
.chip.allday{background:var(--chip-bar);color:#fff;border-left-color:rgba(0,0,0,.18);font-weight:600}
.chip.allday:hover{filter:brightness(1.08)}
.wev.allday{background:var(--chip-bar);color:#fff;border-left-color:rgba(0,0,0,.18)}
.chip span.dot{width:6px;height:6px;border-radius:50%;flex:0 0 auto}
.more{
font-size:11px;font-weight:600;color:var(--text-2);padding:1px 5px;border-radius:4px;
text-align:left;background:transparent;
}
.more:hover{background:var(--surface-2);color:var(--text)}
/* ============================ WEEK VIEW ============================ */
.week-wrap{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
.allstrip{
display:grid;grid-template-columns:58px repeat(7,1fr);
border-bottom:1px solid var(--line);background:var(--surface);flex:0 0 auto;
min-height:26px;
}
.allstrip .asl{
font-size:10px;color:var(--text-3);text-align:right;padding:5px 7px 0 0;
text-transform:uppercase;letter-spacing:.4px;
}
.allcell{
border-left:1px solid var(--line-2);padding:3px;display:flex;flex-direction:column;gap:2px;min-height:26px;
}
.wk-head{
display:grid;grid-template-columns:58px repeat(7,1fr);
border-bottom:1px solid var(--line);background:var(--surface);flex:0 0 auto;
}
.wk-head .gcorner{border-right:1px solid var(--line-2)}
.wk-day{
padding:6px 4px;text-align:center;border-left:1px solid var(--line-2);cursor:pointer;
}
.wk-day .dow{font-size:10.5px;font-weight:700;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px}
.wk-day .dnum{font-size:20px;font-weight:500;width:34px;height:34px;line-height:34px;margin:2px auto 0;border-radius:50%}
.wk-day.today .dnum{background:var(--today);color:#fff;font-weight:600}
.wk-day.today .dow{color:var(--today)}
.wk-day:hover .dnum{background:var(--surface-2)}
.wk-day.today:hover .dnum{background:var(--today)}
.scroller{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;position:relative;background:var(--surface)}
.wk-body{display:grid;grid-template-columns:58px repeat(7,1fr);position:relative}
.hourcol{position:relative}
.hourlab{
height:var(--hourH,44px);font-size:10.5px;color:var(--text-3);text-align:right;
padding-right:7px;position:relative;top:-6px;
}
.wkcol{position:relative;border-left:1px solid var(--line-2)}
.wkcol.we{background:#fbfcfd}
.wkcol .hr{height:var(--hourH,44px);border-bottom:1px solid var(--line-2)}
.wkcol .hr.half{border-bottom:1px dotted #f0f2f5}
.wev{
position:absolute;left:2px;right:3px;border-radius:5px;padding:2px 6px 2px 5px;
font-size:11px;line-height:1.2;overflow:hidden;cursor:pointer;
background:var(--chip-bg,#e8f0fe);color:var(--chip-fg,#174ea6);
border-left:3px solid var(--chip-bar,#1a73e8);
box-shadow:0 0 0 1px rgba(255,255,255,.7);
transition:filter .12s,box-shadow .12s;
}
.wev:hover{filter:brightness(.94);box-shadow:0 2px 6px rgba(0,0,0,.18);z-index:6}
.wev b{font-weight:600;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wev i{font-style:normal;opacity:.8;font-size:10px;white-space:nowrap;overflow:hidden;display:block}
.wev.dragging{opacity:.45}
.wev.resizing{cursor:ns-resize}
.nowline{position:absolute;left:0;right:0;height:0;border-top:2px solid #ea4335;z-index:8;pointer-events:none}
.nowline::before{
content:"";position:absolute;left:52px;top:-5px;width:9px;height:9px;border-radius:50%;
background:#ea4335;
}
.nowline .nltime{
position:absolute;left:2px;top:-8px;font-size:9.5px;color:#ea4335;background:var(--surface);
padding:0 2px;border-radius:3px;font-weight:600;
}
.resizeh{position:absolute;left:0;right:0;bottom:0;height:8px;cursor:ns-resize;z-index:3}
/* ============================ AGENDA VIEW ============================ */
.agenda{flex:1 1 auto;overflow-y:auto;padding:14px 18px 60px}
.ag-empty{color:var(--text-3);text-align:center;padding:60px 20px;font-size:14px}
.ag-group{display:grid;grid-template-columns:132px 1fr;gap:14px;margin-bottom:6px}
.ag-date{
text-align:right;padding-top:10px;position:sticky;top:0;background:var(--bg);
}
.ag-date .ad-dow{font-size:11px;font-weight:700;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px}
.ag-date .ad-num{font-size:22px;font-weight:500;color:var(--text-2);line-height:1.1}
.ag-date.today .ad-num,.ag-date.today .ad-dow{color:var(--today)}
.ag-date .ad-mo{font-size:11px;color:var(--text-3)}
.ag-items{border-top:1px solid var(--line);padding:4px 0 10px}
.ag-row{
display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:var(--radius-sm);
cursor:pointer;transition:background .12s;
}
.ag-row:hover{background:var(--surface)}
.ag-row .bar{width:4px;align-self:stretch;min-height:26px;border-radius:2px;background:var(--chip-bar,#1a73e8)}
.ag-row .agt{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ag-row .agtime{color:var(--text-2);font-size:12.5px;font-variant-numeric:tabular-nums;min-width:118px}
.ag-row .agnote{color:var(--text-3);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ag-past{opacity:.55}
/* ============================ MODAL ============================ */
.scrim{
position:fixed;inset:0;background:rgba(28,32,36,.45);z-index:100;
display:flex;align-items:center;justify-content:center;padding:16px;
animation:fadeIn .16s ease;
}
.scrim[hidden]{display:none}
.modal{
background:var(--surface);border-radius:14px;box-shadow:var(--shadow-lg);
width:100%;max-width:560px;max-height:92vh;overflow:auto;
animation:popIn .18s cubic-bezier(.2,.9,.3,1.2);
}
@keyframes popIn{from{transform:scale(.96) translateY(8px);opacity:0}to{transform:none;opacity:1}}
.modal h2{margin:0;font-size:17px;font-weight:600;padding:16px 18px 10px}
.mbody{padding:0 18px 6px;display:flex;flex-direction:column;gap:13px}
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-size:11.5px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px}
.field input[type=text],.field input[type=date],.field input[type=time],.field select,.field textarea{
padding:9px 11px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);
outline:none;transition:border-color .12s,box-shadow .12s;width:100%;
}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.field textarea{resize:vertical;min-height:64px}
.field.err input,.field.err select{border-color:var(--danger);box-shadow:0 0 0 3px #fce8e6}
.errmsg{color:var(--danger);font-size:12px;min-height:0}
.errmsg:empty{display:none}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
.swatches{display:flex;flex-wrap:wrap;gap:8px}
.sw{
width:30px;height:30px;border-radius:50%;cursor:pointer;position:relative;
border:2px solid transparent;transition:transform .12s;
}
.sw:hover{transform:scale(1.1)}
.sw.sel{box-shadow:0 0 0 2px var(--surface),0 0 0 4px var(--text-2)}
.sw.sel::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-size:14px;font-weight:700}
.dow-toggle{display:flex;gap:6px;flex-wrap:wrap}
.dow-toggle button{
width:38px;height:38px;border-radius:50%;border:1px solid var(--line);font-weight:600;font-size:12.5px;
transition:background .12s,color .12s;
}
.dow-toggle button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.chk{display:flex;align-items:center;gap:8px;font-size:13.5px;cursor:pointer;text-transform:none;letter-spacing:0;color:var(--text)}
.chk input{width:17px;height:17px;accent-color:var(--accent);cursor:pointer}
.mfoot{
display:flex;align-items:center;gap:8px;padding:14px 18px 16px;flex-wrap:wrap;
}
.mfoot .del{margin-right:auto;color:var(--danger);font-weight:600}
.mfoot .del:hover{background:#fce8e6}
.primary{
background:var(--accent);color:#fff;padding:9px 18px;border-radius:var(--radius-sm);font-weight:600;
transition:filter .12s;
}
.primary:hover{filter:brightness(1.08)}
.hint{font-size:12px;color:var(--text-3);padding:0 18px 14px}
.menu{display:flex;flex-direction:column;gap:6px;padding:8px 18px 18px}
.menu button{
text-align:left;padding:11px 13px;border-radius:var(--radius-sm);border:1px solid var(--line);
transition:background .12s;
}
.menu button:hover{background:var(--surface-2)}
.menu button span{display:block;font-size:12px;color:var(--text-2);margin-top:2px}
.menu button.dngr{color:var(--danger)}
/* ============================ POPOVER / TOAST ============================ */
.popover{
position:fixed;z-index:90;background:var(--surface);border-radius:12px;box-shadow:var(--shadow-lg);
width:290px;max-height:340px;overflow:auto;padding:6px;border:1px solid var(--line);
animation:popIn .14s ease;
}
.popover .ph{
display:flex;justify-content:space-between;align-items:center;padding:8px 8px 6px;font-weight:600;
}
.popover .ph small{font-weight:400;color:var(--text-2)}
.popover .pi{
display:flex;gap:8px;align-items:flex-start;padding:7px 8px;border-radius:8px;cursor:pointer;
}
.popover .pi:hover{background:var(--surface-2)}
.popover .pi .dot2{width:10px;height:10px;border-radius:50%;margin-top:4px;flex:0 0 auto}
.popover .pi .pt{font-weight:600}
.popover .pi .ps{color:var(--text-2);font-size:12px}
.toastwrap{
position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:200;
display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;
}
.toast{
background:#323639;color:#fff;padding:11px 16px;border-radius:8px;box-shadow:var(--shadow-lg);
display:flex;align-items:center;gap:14px;font-size:13.5px;pointer-events:auto;
animation:toastIn .2s ease;
}
@keyframes toastIn{from{transform:translateY(14px);opacity:0}to{transform:none;opacity:1}}
.toast button{color:#8ab4f8;font-weight:600;white-space:nowrap}
.toast button:hover{text-decoration:underline}
.confirm{padding:6px 18px 18px;color:var(--text-2);line-height:1.5}
/* ============================ SETTINGS ============================ */
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}
.settings-row .sl{font-weight:600}
.settings-row .sd{font-size:12px;color:var(--text-2);margin-top:2px}
.switch{width:44px;height:24px;border-radius:12px;background:#c9ced6;position:relative;transition:background .16s;flex:0 0 auto}
.switch.on{background:var(--accent)}
.switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:transform .16s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
.switch.on::after{transform:translateX(20px)}
/* ============================ RESPONSIVE ============================ */
@media (max-width:900px){
.date-title{min-width:110px;font-size:16px}
.brand h1{display:none}
.seg button{padding:7px 9px;font-size:13px}
.day-cell{padding:3px 3px 2px}
.chip{font-size:10.5px;padding:1px 4px}
.agenda{padding:10px 10px 60px}
.ag-group{grid-template-columns:92px 1fr;gap:8px}
.ag-row .agtime{min-width:94px;font-size:11.5px}
}
@media (max-width:640px){
.topbar{flex-wrap:wrap;gap:6px;padding:6px 8px}
.month-head div{padding:6px 4px;font-size:10px}
.ag-row{flex-wrap:wrap;gap:4px}
.chip .ctime{display:none}
.chip .ck{display:none}
.chip .dot{display:none}
.chip{padding:1px 4px 1px 3px}
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#c8cdd4;border-radius:6px;border:2px solid var(--surface)}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div id="app">
<header class="topbar">
<div class="brand"><div class="logo">C</div><h1>Calendar & Planner</h1></div>
<button class="tbtn" id="btnToday">Today</button>
<button class="icon-btn" id="btnPrev" title="Previous (←)">‹</button>
<button class="icon-btn" id="btnNext" title="Next (→)">›</button>
<div class="date-title" id="dateTitle">—</div>
<div class="spacer"></div>
<div class="seg" id="viewSeg">
<button data-view="month">Month</button>
<button data-view="week">Week</button>
<button data-view="agenda">Agenda</button>
</div>
<button class="tbtn" id="btnNew">+ Create</button>
<button class="icon-btn" id="btnSettings" title="Settings">⚙</button>
</header>
<div class="viewwrap" id="viewwrap"></div>
</div>
<div class="toastwrap" id="toastwrap"></div>
<div class="scrim" id="modalScrim" hidden><div class="modal" id="modalBox"></div></div>
<script>
/* ============================================================================
SECTION 1 — TUNABLE CONSTANTS
============================================================================ */
const CONFIG = {
HOUR_START: 6, // week view first hour shown
HOUR_END: 22, // week view last hour shown
HOUR_HEIGHT: 44, // px per hour in week view
SNAP_MINUTES: 15, // drag snap in week view
WEEK_START_DEFAULT: 1, // 0 = Sunday, 1 = Monday
MAX_CHIPS: 3, // chips shown before "+N more"
AGENDA_DAYS: 120, // how far ahead agenda looks
MONTH_ROW_EST: 6, // month grid rows
COLORS: [
{ id:'tomato', bar:'#d93025', bg:'#fce8e6', fg:'#a50e0e', solid:'#d93025' },
{ id:'peacock', bar:'#1a73e8', bg:'#e8f0fe', fg:'#174ea6', solid:'#1a73e8' },
{ id:'sage', bar:'#188038', bg:'#e6f4ea', fg:'#0d652d', solid:'#188038' },
{ id:'banana', bar:'#f29900', bg:'#fef7e0', fg:'#b06000', solid:'#f29900' },
{ id:'lavender', bar:'#8430ce', bg:'#f3e8fd', fg:'#5b1d91', solid:'#8430ce' },
{ id:'grape', bar:'#c2185b', bg:'#fde7ef', fg:'#8e1142', solid:'#c2185b' },
{ id:'graphite', bar:'#3c4043', bg:'#eceff1', fg:'#202124', solid:'#3c4043' },
{ id:'teal', bar:'#00796b', bg:'#e0f2f1', fg:'#00574d', solid:'#00796b' }
]
};
function colorById(id){
return CONFIG.COLORS.find(c=>c.id===id) || CONFIG.COLORS[1];
}
/* ============================================================================
SECTION 2 — DATE UTIL MODULE (pure functions, no DOM, no state)
All arithmetic on Y/M/D components. Native Date used ONLY for conversions.
============================================================================ */
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const DAY_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const DAY_SHORT = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const DAY_MIN = ['S','M','T','W','T','F','S'];
const DateUtil = (() => {
/** Is `y` a leap year? Gregorian century rule: /4, not /100 unless /400. */
function isLeapYear(y){
return (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
}
/** Days in month (1-based month). */
function daysInMonth(y, m){
const d = [31, isLeapYear(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return d[m - 1];
}
/** Day of week for a Y/M/D. 0=Sun..6=Sat. Uses Zeller-ish via native UTC Date (no TZ drift). */
function dayOfWeek(y, m, d){
// Native Date in UTC avoids local-time/DST surprises entirely.
return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
}
/** Normalize an out-of-range {y,m,d} (m is 1-based, may be <1 or >12). */
function normalize(y, m, d){
// Month rollover first
let yy = y + Math.floor((m - 1) / 12);
let mm = ((m - 1) % 12 + 12) % 12 + 1;
// Day rollover via month-length walk (component math, no ms tricks)
while (d > daysInMonth(yy, mm)) { d -= daysInMonth(yy, mm); mm++; if (mm > 12){ mm = 1; yy++; } }
while (d < 1) { mm--; if (mm < 1){ mm = 12; yy--; } d += daysInMonth(yy, mm); }
return { y: yy, m: mm, d: d };
}
/** Canonical key string YYYY-MM-DD (padded). */
function key(y, m, d){
return y + '-' + String(m).padStart(2,'0') + '-' + String(d).padStart(2,'0');
}
function keyOf(obj){ return key(obj.y, obj.m, obj.d); }
function parseKey(s){
const p = s.split('-').map(Number);
return { y: p[0], m: p[1], d: p[2] };
}
/** Add n days to a Y/M/D (n may be negative). Pure component math. */
function addDays(y, m, d, n){
const base = daysInMonth(y, m);
const total = d + n;
if (total >= 1 && total <= base) return { y: y, m: m, d: total };
return normalize(y, m, total);
}
function addDaysTo(obj, n){ return addDays(obj.y, obj.m, obj.d, n); }
/** Add n months, clamping the day to the target month length (Jan31 +1mo = Feb28/29). */
function addMonths(y, m, d, n, clamp){
let yy = y + Math.floor((m - 1 + n) / 12);
let mm = ((m - 1 + n) % 12 + 12) % 12 + 1;
if (clamp !== false){
const dim = daysInMonth(yy, mm);
if (d > dim) d = dim;
}
return { y: yy, m: mm, d: d };
}
/** Days between two dates (signed, from a to b). */
function diffDays(a, b){
const ta = Date.UTC(a.y, a.m - 1, a.d), tb = Date.UTC(b.y, b.m - 1, b.d);
return Math.round((tb - ta) / 86400000);
}
/** Compare two dates: -1, 0, 1 */
function cmp(a, b){
if (a.y !== b.y) return a.y < b.y ? -1 : 1;
if (a.m !== b.m) return a.m < b.m ? -1 : 1;
if (a.d !== b.d) return a.d < b.d ? -1 : 1;
return 0;
}
function today(){
const n = new Date();
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate() };
}
/** ISO-ish week number honoring the given week start.
* weekStart=1 (Monday): ISO 8601. weekStart=0 (Sunday): week containing Jan 1 = week 1,
* weeks start Sunday. Both computed with component math + a stable "week anchor". */
function weekNumber(y, m, d, weekStart){
if (weekStart === 1){ // ISO 8601
const doy = dayOfYear(y, m, d);
const dow = (dayOfWeek(y, m, d) + 6) % 7; // Mon=0..Sun=6
const week = Math.floor((doy - dow + 10) / 7);
if (week < 1) return weeksInYear(y - 1, weekStart);
if (week > weeksInYear(y, weekStart)) return 1;
return week;
}
// Sunday-start: formula on the year's first-Sunday anchor
const jan1dow = dayOfWeek(y, 1, 1); // 0..6
const doy = dayOfYear(y, m, d);
// Week 1 is the week (Sun..Sat) containing Jan 1.
// Offset of this day back to the start of its week, then count weeks.
const dowSun = dayOfWeek(y, m, d); // 0=Sun
const week = Math.floor((doy - 1 - dowSun + 7) / 7) + 1;
// If Jan 1 itself is Sunday, week 1 starts Jan 1 -> formula above already right.
if (jan1dow === 0) return week;
return week;
}
function dayOfYear(y, m, d){
let n = d;
for (let i = 1; i < m; i++) n += daysInMonth(y, i);
return n;
}
/** Number of ISO-ish weeks in a year. */
function weeksInYear(y, weekStart){
if (weekStart === 1){
// ISO: 53 weeks if Jan 1 is Thursday, or Dec 31 is Thursday
const jan1 = dayOfWeek(y, 1, 1);
const dec31 = dayOfWeek(y, 12, 31);
return (jan1 === 4 || dec31 === 4) ? 53 : 52;
}
// Sunday-start: 53 if Jan 1 is Saturday (leap: Friday or Saturday)
const jan1 = dayOfWeek(y, 1, 1);
if (isLeapYear(y)) return (jan1 === 6 || jan1 === 5) ? 53 : 52;
return jan1 === 6 ? 53 : 52;
}
/** First cell of the month grid (the week-start day on/before the 1st). */
function gridStart(y, m, weekStart){
const dow = dayOfWeek(y, m, 1);
const back = ((dow - weekStart) % 7 + 7) % 7;
return addDays(y, m, 1, -back);
}
/** Build a 6x7 grid of {y,m,d,inMonth}. */
function monthGrid(y, m, weekStart, rows){
rows = rows || CONFIG.MONTH_ROW_EST;
const start = gridStart(y, m, weekStart);
const out = [];
let cur = start;
for (let r = 0; r < rows; r++){
const row = [];
for (let c = 0; c < 7; c++){
row.push({ y: cur.y, m: cur.m, d: cur.d, inMonth: (cur.y === y && cur.m === m) });
cur = addDays(cur.y, cur.m, cur.d, 1);
}
out.push(row);
}
return out;
}
/** The 7 dates of the week containing (y,m,d), honoring weekStart. */
function weekDates(y, m, d, weekStart){
const dow = dayOfWeek(y, m, d);
const back = ((dow - weekStart) % 7 + 7) % 7;
const start = addDays(y, m, d, -back);
const out = [];
let cur = start;
for (let i = 0; i < 7; i++){ out.push(cur); cur = addDays(cur.y, cur.m, cur.d, 1); }
return out;
}
/** Format helpers */
function fmtLong(d){ return DAY_NAMES[dayOfWeek(d.y,d.m,d.d)] + ', ' + MONTH_NAMES[d.m-1] + ' ' + d.d + ', ' + d.y; }
function fmtShort(d){ return DAY_SHORT[dayOfWeek(d.y,d.m,d.d)] + ', ' + MONTH_SHORT[d.m-1] + ' ' + d.d; }
function fmtTime(hh, mm){
const ampm = hh < 12 ? 'AM' : 'PM';
let h = hh % 12; if (h === 0) h = 12;
return h + ':' + String(mm).padStart(2,'0') + ' ' + ampm;
}
function monthsBetween(a, b){ return (b.y - a.y) * 12 + (b.m - a.m); }
return { isLeapYear, daysInMonth, dayOfWeek, normalize, key, keyOf, parseKey,
addDays, addDaysTo, addMonths, diffDays, cmp, today, weekNumber, weeksInYear,
dayOfYear, gridStart, monthGrid, weekDates, fmtLong, fmtShort, fmtTime, monthsBetween };
})();
/* ============================================================================
SECTION 3 — RECURRENCE ENGINE (pure)
Expands a series ONLY for the requested window. Never materializes infinity.
============================================================================ */
/** Candidate occurrence dates of one event inside [fromKey, toKey] inclusive. */
function recurrenceOccurrences(ev, from, to){
const out = [];
const start = DateUtil.parseKey(ev.date);
if (from && DateUtil.cmp(start, to) > 0) { /* series starts after window */ }
const hardEnd = ev.recurrence && ev.recurrence.until ? DateUtil.parseKey(ev.recurrence.until) : null;
const rec = ev.recurrence;
// Effective last date: series until/end OR window end
let last = to ? to : start;
if (hardEnd && DateUtil.cmp(hardEnd, last) < 0) last = hardEnd;
if (DateUtil.cmp(start, last) > 0) return out;
if (!rec){
if (DateUtil.cmp(start, from) >= 0 && DateUtil.cmp(start, to) <= 0) out.push(start);
return out;
}
if (rec.freq === 'daily'){
const step = Math.max(1, rec.interval || 1);
// Jump straight into window using day diff math
let n = Math.max(0, Math.floor(DateUtil.diffDays(start, from) / step) - 1);
let cur = DateUtil.addDaysTo(start, n * step);
let guard = 0;
while (DateUtil.cmp(cur, last) <= 0 && guard++ < 4000){
if (DateUtil.cmp(cur, from) >= 0) out.push({ ...cur });
cur = DateUtil.addDaysTo(cur, step);
}
return out;
}
if (rec.freq === 'weekly'){
const step = Math.max(1, rec.interval || 1);
const wds = (rec.weekdays && rec.weekdays.length) ? rec.weekdays.slice().sort() : [DateUtil.dayOfWeek(start.y,start.m,start.d)];
// Week anchor = start of the week (weekStart=WEEK_START_DEFAULT) containing series start
const anchorY = start.y, anchorM = start.m, anchorD = start.d;
const dow0 = DateUtil.dayOfWeek(anchorY, anchorM, anchorD);
const back = ((dow0 - CONFIG.WEEK_START_DEFAULT) % 7 + 7) % 7;
const anchor = DateUtil.addDays(anchorY, anchorM, anchorD, -back); // week-start of week 0
// Start iterating a little before the window
const w0 = Math.max(0, Math.floor(DateUtil.diffDays(anchor, from) / (7 * step)) - 1);
for (let w = w0; w < 4000; w++){
const weekStart = DateUtil.addDaysTo(anchor, w * step * 7);
if (DateUtil.cmp(weekStart, last) > 0) break;
let any = false;
for (const wd of wds){
const off = ((wd - CONFIG.WEEK_START_DEFAULT) % 7 + 7) % 7;
const cand = DateUtil.addDaysTo(weekStart, off);
if (DateUtil.cmp(cand, start) < 0) continue; // before series began
if (DateUtil.cmp(cand, last) > 0) continue;
if (DateUtil.cmp(cand, from) < 0) continue;
out.push(cand); any = true;
}
if (DateUtil.cmp(weekStart, to) > 0 && any) break;
if (DateUtil.cmp(weekStart, to) > 0) break;
}
return out;
}
if (rec.freq === 'monthly'){
const step = Math.max(1, rec.interval || 1);
const dom = rec.dayOfMonth || start.d; // the ORIGINAL day-of-month (e.g. 31)
const startIdx = start.y * 12 + (start.m - 1);
const fromIdx = from.y * 12 + (from.m - 1);
let k = Math.max(0, Math.floor((fromIdx - startIdx) / step) - 1);
for (let guard = 0; guard < 500; guard++, k++){
const idx = startIdx + k * step;
const yy = Math.floor(idx / 12), mm = (idx % 12) + 1;
// SKIP months that do not have `dom` (Feb for the 31st) — the event simply
// does not exist in those months; it reconvenes in the next month that does.
if (dom > DateUtil.daysInMonth(yy, mm)) continue;
const cand = { y: yy, m: mm, d: dom };
if (DateUtil.cmp(cand, start) < 0) continue;
if (DateUtil.cmp(cand, last) > 0) break;
if (DateUtil.cmp(cand, from) >= 0) out.push(cand);
}
return out;
}
if (rec.freq === 'yearly'){
const step = Math.max(1, rec.interval || 1);
const mm = rec.month || start.m, dom = rec.dayOfMonth || start.d;
for (let yy = start.y, guard = 0; guard < 400; yy += step, guard++){
// Feb 29 yearly only exists in leap years.
if (mm === 2 && dom === 29 && !DateUtil.isLeapYear(yy)) continue;
if (dom > DateUtil.daysInMonth(yy, mm)) continue;
const cand = { y: yy, m: mm, d: dom };
if (DateUtil.cmp(cand, start) < 0) continue;
if (DateUtil.cmp(cand, last) > 0) break;
if (DateUtil.cmp(cand, from) >= 0) out.push(cand);
}
return out;
}
return out;
}
/* ============================================================================
SECTION 4 — STORE (state + persistence)
============================================================================ */
const STORE_KEY = 'calendar.planner.v1';
const Store = (() => {
let state = {
events: [],
settings: { weekStart: CONFIG.WEEK_START_DEFAULT, view: 'month', showWeekNums: false },
cursor: null, // {y,m,d} anchor date
version: 1
};
function uid(){ return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2,7); }
function defaults(){
state.cursor = DateUtil.today();
}
function load(){
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw){
const p = JSON.parse(raw);
if (p && Array.isArray(p.events)){
state.events = p.events.map(normalizeEvent);
state.settings = Object.assign({ weekStart: CONFIG.WEEK_START_DEFAULT, view:'month', showWeekNums:false }, p.settings || {});
state.cursor = p.cursor || DateUtil.today();
return true;
}
}
} catch(e){ console.warn('load failed', e); }
defaults();
seedDemo();
return false;
}
/** First run: a small, pleasant starting calendar (relative to today). */
function seedDemo(){
const t = DateUtil.today();
const K = (n) => DateUtil.keyOf(DateUtil.addDays(t.y, t.m, t.d, n));
const mk = (o) => normalizeEvent(Object.assign({ id: uid(), exceptions:{} }, o));
// Anchor the "31st of the month" demo on a month that actually has 31 days.
let my = t.y, mm = t.m;
for (let i = 0; i < 12 && DateUtil.daysInMonth(my, mm) < 31; i++){
const nx = DateUtil.addMonths(my, mm, 1, 1, true); my = nx.y; mm = nx.m;
}
state.events = [
mk({ title:'Team standup', color:'peacock', date: K(0), start:570, end:585, notes:'Daily sync — 15 minutes.',
recurrence:{ freq:'weekly', interval:1, weekdays:[1,2,3,4,5], until:null } }),
mk({ title:'Design review', color:'lavender', date: K(1), allDay:true, start:0, end:0, notes:'Bring the latest mockups.' }),
mk({ title:'1:1 with Sam', color:'sage', date: K(2), start:840, end:900 }),
mk({ title:'Sprint planning', color:'banana', date: K(4), start:600, end:690, notes:'Plan the next sprint.' }),
mk({ title:'Monthly report', color:'tomato', date: DateUtil.key(my, mm, 31), start:1020, end:1080,
notes:'Always filed on the 31st — months without a 31st are skipped.',
recurrence:{ freq:'monthly', interval:1, dayOfMonth:31, until:null } }),
mk({ title:'Quarterly offsite', color:'teal', date: K(9), allDay:true, start:0, end:0 })
];
state.cursor = t;
save(true);
}
function normalizeEvent(e){
return {
id: e.id || uid(),
title: e.title || '(No title)',
color: e.color || 'peacock',
notes: e.notes || '',
allDay: !!e.allDay,
date: e.date,
start: typeof e.start === 'number' ? e.start : 540,
end: typeof e.end === 'number' ? e.end : 600,
recurrence: e.recurrence || null,
exceptions: e.exceptions || {}
};
}
let saveTimer = null;
function save(immediate){
if (saveTimer) clearTimeout(saveTimer);
const doIt = () => {
try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); }
catch(err){ console.warn('save failed', err); }
};
if (immediate) doIt(); else saveTimer = setTimeout(doIt, 120);
}
function getEvents(){ return state.events; }
function getEvent(id){ return state.events.find(e => e.id === id); }
function getSettings(){ return state.settings; }
function setSetting(k, v){ state.settings[k] = v; save(); }
function getCursor(){ return state.cursor; }
function setCursor(y, m, d){ state.cursor = { y, m, d }; save(); }
function addEvent(data){
const ev = normalizeEvent(Object.assign({ id: uid() }, data));
state.events.push(ev); save(true);
return ev;
}
function updateEvent(id, patch){
const ev = getEvent(id); if (!ev) return null;
Object.assign(ev, patch); save(true);
return ev;
}
function removeEvent(id){
const i = state.events.findIndex(e => e.id === id);
if (i >= 0){ const [ev] = state.events.splice(i, 1); save(true); return ev; }
return null;
}
/** Re-insert a removed event (for undo). */
function restoreEvent(ev){
if (getEvent(ev.id)) return;
state.events.push(ev); save(true);
}
/** Overrides / cancels on a single occurrence. */
function setException(id, occKey, ex){
const ev = getEvent(id); if (!ev) return;
ev.exceptions = ev.exceptions || {};
if (ex === null) delete ev.exceptions[occKey];
else ev.exceptions[occKey] = ex;
save(true);
}
return { load, save, getEvents, getEvent, getSettings, setSetting, getCursor, setCursor,
addEvent, updateEvent, removeEvent, restoreEvent, setException, uid,
_raw: () => state };
})();
/* ============================================================================
SECTION 5 — OCCURRENCE FEED
Expands all series over a window and merges exceptions.
============================================================================ */
function occurrencesInRange(fromD, toD){
const fromKey = DateUtil.keyOf(fromD), toKey = DateUtil.keyOf(toD);
const res = [];
for (const ev of Store.getEvents()){
const dates = recurrenceOccurrences(ev, fromD, toD);
const seen = new Set();
for (const d of dates){
const k = DateUtil.keyOf(d);
if (seen.has(k)) continue; seen.add(k);
const ex = ev.exceptions && ev.exceptions[k];
if (ex && ex.type === 'cancel') continue;
const occ = makeOccurrence(ev, d, k, ex && ex.type === 'override' ? ex.patch : null);
res.push(occ);
}
// Overrides that were MOVED into this window from outside it
if (ev.exceptions){
for (const [ok, ex] of Object.entries(ev.exceptions)){
if (!ex || ex.type !== 'override' || !ex.patch || !ex.patch.date) continue;
if (seen.has(ok)) continue; // already produced above
const pk = ex.patch.date;
if (pk < fromKey || pk > toKey) continue;
// Only if the original occurrence is NOT itself in-range (avoid dupes)
const pd = DateUtil.parseKey(pk);
res.push(makeOccurrence(ev, pd, ok, ex.patch, true));
}
}
}
res.sort(occCompare);
return res;
}
function makeOccurrence(ev, occDate, occKey, patch, movedIn){
const p = patch || {};
const dateObj = p.date ? DateUtil.parseKey(p.date) : occDate;
const allDay = p.allDay !== undefined ? p.allDay : ev.allDay;
return {
evId: ev.id,
occKey,
date: dateObj,
fromDate: occDate,
allDay,
start: p.start !== undefined ? p.start : ev.start,
end: p.end !== undefined ? p.end : ev.end,
title: p.title !== undefined ? p.title : ev.title,
color: p.color !== undefined ? p.color : ev.color,
notes: p.notes !== undefined ? p.notes : ev.notes,
isException: !!patch,
movedIn: !!movedIn,
recurring: !!ev.recurrence
};
}
function occCompare(a, b){
const c = DateUtil.cmp(a.date, b.date); if (c) return c;
// all-day first, then by start time, then longer first
if (a.allDay !== b.allDay) return a.allDay ? -1 : 1;
if (a.start !== b.start) return a.start - b.start;
return (b.end - b.start) - (a.end - a.start);
}
/** Occurrences of ONE event across a wide net (for agenda + lookups by instance). */
/* ============================================================================
SECTION 6 — VIEW STATE + NAVIGATION
============================================================================ */
const ViewState = {
view: 'month', // 'month' | 'week' | 'agenda'
animDir: 'fade',
scrollTop: null
};
function settings(){ return Store.getSettings(); }
function weekStart(){ return settings().weekStart | 0; }
function navStep(delta){
const c = Store.getCursor();
if (ViewState.view === 'month'){
// Walk month-by-month on Y/M/D components; keep the day-of-month where the
// target month allows it (clamped to the month length otherwise).
const d = DateUtil.addMonths(c.y, c.m, c.d, delta, true);
Store.setCursor(d.y, d.m, d.d);
} else {
// Week + agenda advance by whole weeks (component math, DST-safe).
const d = DateUtil.addDays(c.y, c.m, c.d, delta * 7);
Store.setCursor(d.y, d.m, d.d);
}
ViewState.animDir = delta > 0 ? 'next' : 'prev';
render();
}
function goToday(){
const t = DateUtil.today();
Store.setCursor(t.y, t.m, t.d);
ViewState.animDir = 'fade';
render();
}
function setView(v){
ViewState.view = v;
Store.setSetting('view', v);
ViewState.animDir = 'fade';
ViewState.scrollTop = null;
render();
}
/* ============================================================================
SECTION 7 — RENDERERS
============================================================================ */
const viewwrap = document.getElementById('viewwrap');
const dateTitle = document.getElementById('dateTitle');
function render(){
const c = Store.getCursor();
// Update segmented control
document.querySelectorAll('#viewSeg button').forEach(b => {
b.classList.toggle('active', b.dataset.view === ViewState.view);
});
// Title
if (ViewState.view === 'month'){
dateTitle.textContent = MONTH_NAMES[c.m - 1] + ' ' + c.y;
} else if (ViewState.view === 'week'){
const wd = DateUtil.weekDates(c.y, c.m, c.d, weekStart());
const a = wd[0], b = wd[6];
dateTitle.textContent = (a.m === b.m)
? MONTH_SHORT[a.m - 1] + ' ' + a.d + ' – ' + b.d + ', ' + b.y
: MONTH_SHORT[a.m - 1] + ' ' + a.d + ' – ' + MONTH_SHORT[b.m - 1] + ' ' + b.d + ', ' + b.y;
} else {
dateTitle.textContent = 'Upcoming';
}
viewwrap.innerHTML = '';
const root = document.createElement('div');
root.className = 'view';
if (ViewState.view === 'month') renderMonth(root);
else if (ViewState.view === 'week') renderWeek(root);
else renderAgenda(root);
viewwrap.appendChild(root);
const cls = ViewState.animDir === 'next' ? 'anim-next' : ViewState.animDir === 'prev' ? 'anim-prev' : 'anim-fade';
root.classList.add(cls);
ViewState.animDir = 'fade';
}
function bucketByKey(occs){
const map = new Map();
for (const o of occs){
const k = DateUtil.keyOf(o.date);
if (!map.has(k)) map.set(k, []);
map.get(k).push(o);
}
return map;
}
/* ---------------------------- MONTH ---------------------------- */
function renderMonth(root){
const c = Store.getCursor();
const rows = CONFIG.MONTH_ROW_EST;
const grid = DateUtil.monthGrid(c.y, c.m, weekStart(), rows);
const first = grid[0][0], last = grid[rows - 1][6];
const occs = occurrencesInRange(first, last);
const bucket = bucketByKey(occs);
const t = DateUtil.today();
const tKey = DateUtil.key(t.y, t.m, t.d);
const sw = weekStart();
const head = document.createElement('div');
head.className = 'month-head';
for (let i = 0; i < 7; i++){
const dow = (sw + i) % 7;
const el = document.createElement('div');
el.textContent = DAY_SHORT[dow];
if (dow === 0 || dow === 6) el.classList.add('we');
head.appendChild(el);
}
root.appendChild(head);
const g = document.createElement('div');
g.className = 'month-grid';
g.id = 'monthGrid';
for (let r = 0; r < rows; r++){
const rowEl = document.createElement('div');
rowEl.className = 'week-row';
for (let ci = 0; ci < 7; ci++){
const cellD = grid[r][ci];
const k = DateUtil.keyOf(cellD);
const cell = document.createElement('div');
cell.className = 'day-cell';
cell.dataset.key = k;
cell.dataset.date = k;
if (!cellD.inMonth) cell.classList.add('other');
if (k === tKey) cell.classList.add('today');
const dow = (sw + ci) % 7;
if (dow === 0 || dow === 6) cell.classList.add('weekend');
const dh = document.createElement('div');
dh.className = 'day-head';
const num = document.createElement('div');
num.className = 'daynum';
num.textContent = cellD.d;
dh.appendChild(num);
if (settings().showWeekNums && ci === 0){
const wk = document.createElement('div');
wk.style.cssText = 'font-size:9.5px;color:var(--text-3);font-weight:600;padding-right:2px';
wk.textContent = 'W' + DateUtil.weekNumber(cellD.y, cellD.m, cellD.d, sw);
dh.appendChild(wk);
}
cell.appendChild(dh);
const chips = document.createElement('div');
chips.className = 'chips';
const list = bucket.get(k) || [];
const show = list.slice(0, CONFIG.MAX_CHIPS);
for (const o of show) chips.appendChild(makeChip(o, cellD));
cell.appendChild(chips);
if (list.length > CONFIG.MAX_CHIPS){
const more = document.createElement('button');
more.className = 'more';
more.textContent = '+' + (list.length - CONFIG.MAX_CHIPS) + ' more';
more.dataset.key = k;
more.addEventListener('click', (e) => { e.stopPropagation(); openDayPopover(k, more); });
cell.appendChild(more);
}
cell.addEventListener('click', (e) => {
if (e.target.closest('.chip')) return;
if (e.target.closest('.more')) return;
openEditor({ date: k });
});
rowEl.appendChild(cell);
}
g.appendChild(rowEl);
}
root.appendChild(g);
}
function chipStyle(el, colorId){
const col = colorById(colorId);
el.style.setProperty('--chip-bar', col.bar);
el.style.setProperty('--chip-bg', col.bg);
el.style.setProperty('--chip-fg', col.fg);
}
function makeChip(o, cellD){
const el = document.createElement('div');
el.className = 'chip' + (o.allDay ? ' allday' : ' timed');
el.dataset.evId = o.evId;
el.dataset.occKey = o.occKey;
chipStyle(el, o.color);
if (!o.allDay){
const dot = document.createElement('span');
dot.className = 'dot';
dot.style.background = colorById(o.color).solid;
el.appendChild(dot);
}
const t = document.createElement('span');
t.className = 'ct';
let prefix = '';
if (!o.allDay) prefix = timeShort(o.start) + ' ';
if (prefix){
const pre = document.createElement('span');
pre.className = 'ctime';
pre.textContent = prefix;
el.appendChild(pre);
}
t.textContent = o.title;
el.appendChild(t);
if (o.recurring){
const kk = document.createElement('span');
kk.className = 'ck'; kk.textContent = '↻';
el.appendChild(kk);
}
el.title = (o.allDay ? 'All day' : DateUtil.fmtTime(Math.floor(o.start/60), o.start%60) + ' – ' + DateUtil.fmtTime(Math.floor(o.end/60), o.end%60)) + ' · ' + o.title;
el.addEventListener('click', (e) => {
e.stopPropagation();
openOccurrence(o);
});
el._occ = o;
return el;
}
function timeShort(mins){
const hh = Math.floor(mins / 60), mm = mins % 60;
return hh + (mm ? ':' + String(mm).padStart(2, '0') : '');
}
/* ---------------------------- WEEK ---------------------------- */
function renderWeek(root){
const c = Store.getCursor();
const sw = weekStart();
const days = DateUtil.weekDates(c.y, c.m, c.d, sw);
const t = DateUtil.today();
const tKey = DateUtil.key(t.y, t.m, t.d);
const H0 = CONFIG.HOUR_START, H1 = CONFIG.HOUR_END, HH = CONFIG.HOUR_HEIGHT;
const totalH = (H1 - H0) * HH;
// occurrences for the 7 days (plus padding for multi-day? keep single-day)
const occs = occurrencesInRange(days[0], days[6]);
const byDay = new Map();
for (const d of days) byDay.set(DateUtil.keyOf(d), []);
for (const o of occs){
const k = DateUtil.keyOf(o.date);
if (byDay.has(k)) byDay.get(k).push(o);
}
const wrap = document.createElement('div');
wrap.className = 'week-wrap';
// ---- all-day strip ----
const strip = document.createElement('div');
strip.className = 'allstrip';
const label = document.createElement('div');
label.className = 'asl'; label.textContent = 'all-day';
strip.appendChild(label);
for (const d of days){
const k = DateUtil.keyOf(d);
const cell = document.createElement('div');
cell.className = 'allcell';
cell.dataset.key = k;
const list = (byDay.get(k) || []).filter(o => o.allDay);
for (const o of list){
const chip = document.createElement('div');
chip.className = 'chip allday';
chip.dataset.evId = o.evId; chip.dataset.occKey = o.occKey;
chipStyle(chip, o.color);
const sp = document.createElement('span'); sp.className = 'ct'; sp.textContent = o.title;
chip.appendChild(sp); chip._occ = o;
chip.addEventListener('click', (e) => { e.stopPropagation(); openOccurrence(o); });
cell.appendChild(chip);
}
cell.addEventListener('click', (e) => { if (!e.target.closest('.chip')) openEditor({ date: k, allDay: true }); });
strip.appendChild(cell);
}
wrap.appendChild(strip);
// ---- day header ----
const head = document.createElement('div');
head.className = 'wk-head';
const corner = document.createElement('div');
corner.className = 'gcorner';
head.appendChild(corner);
days.forEach((d, i) => {
const k = DateUtil.keyOf(d);
const el = document.createElement('div');
el.className = 'wk-day';
el.dataset.key = k;
if (k === tKey) el.classList.add('today');
const dow = new Date(Date.UTC(d.y, d.m - 1, d.d)).getUTCDay();
el.innerHTML = '<div class="dow">' + DAY_SHORT[dow] + '</div><div class="dnum">' + d.d + '</div>';
el.addEventListener('click', () => { Store.setCursor(d.y, d.m, d.d); Store.setSetting('view','month'); ViewState.view='month'; render(); });
head.appendChild(el);
});
wrap.appendChild(head);
// ---- scrolling body ----
const scroller = document.createElement('div');
scroller.className = 'scroller';
scroller.id = 'wkScroller';
const body = document.createElement('div');
body.className = 'wk-body';
body.style.height = totalH + 'px';
const gutter = document.createElement('div');
gutter.className = 'hourcol';
gutter.style.height = totalH + 'px';
for (let h = H0; h < H1; h++){
const lab = document.createElement('div');
lab.className = 'hourlab';
lab.style.height = HH + 'px';
lab.textContent = DateUtil.fmtTime(h, 0);
gutter.appendChild(lab);
}
body.appendChild(gutter);
const cols = [];
days.forEach((d, ci) => {
const k = DateUtil.keyOf(d);
const col = document.createElement('div');
col.className = 'wkcol';
col.dataset.key = k;
col.dataset.colIndex = ci;
col.style.height = totalH + 'px';
const dow = new Date(Date.UTC(d.y, d.m - 1, d.d)).getUTCDay();
if (dow === 0 || dow === 6) col.classList.add('we');
for (let h = H0; h < H1; h++){
const hr = document.createElement('div');
hr.className = 'hr'; hr.style.height = HH + 'px';
col.appendChild(hr);
}
// timed events (exclude all-day)
const timed = (byDay.get(k) || []).filter(o => !o.allDay);
const laid = layoutColumns(timed);
for (const item of laid){
col.appendChild(makeWeekEvent(item, HH, H0));
}
// double-click empty area to create at that time
col.addEventListener('click', (e) => {
if (e.target.closest('.wev')) return;
const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top;
const mins = H0 * 60 + Math.round((y / HH) * 60 / CONFIG.SNAP_MINUTES) * CONFIG.SNAP_MINUTES;
const clamped = Math.max(0, Math.min(23 * 60 + 59, mins));
openEditor({ date: k, start: clamped, end: Math.min(1439, clamped + 60), allDay: false });
});
col.addEventListener('dblclick', (e) => e.preventDefault());
cols.push(col);
body.appendChild(col);
});
scroller.appendChild(body);
// ---- current time line ----
if (days.some(d => DateUtil.keyOf(d) === tKey)){
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
const line = document.createElement('div');
line.className = 'nowline';
line.id = 'nowLine';
line.style.top = ((mins - H0 * 60) / 60 * HH) + 'px';
const lt = document.createElement('span');
lt.className = 'nltime';
lt.textContent = DateUtil.fmtTime(now.getHours(), now.getMinutes());
line.appendChild(lt);
body.appendChild(line);
}
wrap.appendChild(scroller);
root.appendChild(wrap);
// restore / initial scroll
const saved = ViewState.scrollTop;
requestAnimationFrame(() => {
if (saved != null) scroller.scrollTop = saved;
else scroller.scrollTop = Math.max(0, (8 - H0) * HH - 8);
});
scroller.addEventListener('scroll', () => { ViewState.scrollTop = scroller.scrollTop; });
}
/** Overlap column layout: greedy packing into columns within overlap clusters. */
function layoutColumns(events){
const sorted = events.slice().sort((a, b) => a.start - b.start || a.end - b.end);
const items = sorted.map(o => ({ o, col: 0, cols: 1 }));
let cluster = [];
let clusterEnd = -1;
const flush = () => {
if (!cluster.length) return;
const n = Math.max(...cluster.map(i => i.col)) + 1;
cluster.forEach(i => { i.cols = n; });
cluster = [];
};
for (const it of items){
if (cluster.length && it.o.start >= clusterEnd){ flush(); clusterEnd = -1; }
if (!cluster.length) clusterEnd = it.o.end;
clusterEnd = Math.max(clusterEnd, it.o.end);
// find first free column
const used = new Set(cluster.filter(c => c.o.end > it.o.start).map(c => c.col));
let c = 0; while (used.has(c)) c++;
it.col = c;
cluster.push(it);
}
flush();
return items;
}
function makeWeekEvent(item, HH, H0){
const o = item.o;
const el = document.createElement('div');
el.className = 'wev';
el.dataset.evId = o.evId;
el.dataset.occKey = o.occKey;
chipStyle(el, o.color);
const top = (o.start - H0 * 60) / 60 * HH;
const hgt = Math.max(16, (o.end - o.start) / 60 * HH);
const w = 100 / item.cols;
el.style.top = top + 'px';
el.style.height = (o.end === o.start ? 20 : hgt) + 'px';
el.style.left = 'calc(' + (item.col * w) + '% + 2px)';
el.style.right = 'auto';
el.style.width = 'calc(' + w + '% - 5px)';
const b = document.createElement('b'); b.textContent = o.title;
const i = document.createElement('i');
i.textContent = DateUtil.fmtTime(Math.floor(o.start/60), o.start%60) + '–' + DateUtil.fmtTime(Math.floor(o.end/60), o.end%60);
el.appendChild(b);
if (o.end - o.start >= 30) el.appendChild(i);
const rz = document.createElement('div');
rz.className = 'resizeh';
el.appendChild(rz);
el._occ = o;
el.addEventListener('click', (e) => { e.stopPropagation(); openOccurrence(o); });
return el;
}
/* ---------------------------- AGENDA ---------------------------- */
function renderAgenda(root){
const c = Store.getCursor();
const t = DateUtil.today();
const from = DateUtil.cmp(c, t) < 0 ? t : c;
const to = DateUtil.addDaysTo(from, CONFIG.AGENDA_DAYS);
const occs = occurrencesInRange(from, to);
const bucket = bucketByKey(occs);
const tKey = DateUtil.key(t.y, t.m, t.d);
const wrap = document.createElement('div');
wrap.className = 'agenda';
if (!occs.length){
const e = document.createElement('div');
e.className = 'ag-empty';
e.textContent = 'Nothing scheduled in the next ' + CONFIG.AGENDA_DAYS + ' days.';
wrap.appendChild(e);
root.appendChild(wrap);
return;
}
const keys = Array.from(bucket.keys()).sort();
for (const k of keys){
const list = bucket.get(k);
const d = DateUtil.parseKey(k);
const grp = document.createElement('div');
grp.className = 'ag-group';
const dh = document.createElement('div');
dh.className = 'ag-date' + (k === tKey ? ' today' : '');
const dow = new Date(Date.UTC(d.y, d.m - 1, d.d)).getUTCDay();
dh.innerHTML = '<div class="ad-dow">' + (k === tKey ? 'Today' : DAY_SHORT[dow]) + '</div>' +
'<div class="ad-num">' + d.d + '</div>' +
'<div class="ad-mo">' + MONTH_SHORT[d.m - 1] + (d.m === 1 && d.d === 1 ? ' ' + d.y : '') + '</div>';
const items = document.createElement('div');
items.className = 'ag-items';
for (const o of list){
const row = document.createElement('div');
row.className = 'ag-row';
row.dataset.evId = o.evId; row.dataset.occKey = o.occKey;
const bar = document.createElement('div');
bar.className = 'bar'; bar.style.background = colorById(o.color).solid;
const time = document.createElement('div');
time.className = 'agtime';
time.textContent = o.allDay ? 'All day' : DateUtil.fmtTime(Math.floor(o.start/60), o.start%60) + ' – ' + DateUtil.fmtTime(Math.floor(o.end/60), o.end%60);
const title = document.createElement('div');
title.className = 'agt';
title.textContent = o.title + (o.recurring ? ' ↻' : '');
row.appendChild(bar); row.appendChild(time); row.appendChild(title);
if (o.notes){
const n = document.createElement('div'); n.className = 'agnote'; n.textContent = o.notes;
row.appendChild(n);
}
row.addEventListener('click', () => openOccurrence(o));
items.appendChild(row);
}
grp.appendChild(dh); grp.appendChild(items);
wrap.appendChild(grp);
}
root.appendChild(wrap);
}
/* ============================================================================
SECTION 8 — MODAL FRAMEWORK
============================================================================ */
const scrim = document.getElementById('modalScrim');
const modalBox = document.getElementById('modalBox');
let modalOnClose = null;
function showModal(innerHTML, onClose){
modalBox.innerHTML = innerHTML;
scrim.hidden = false;
modalOnClose = onClose || null;
return modalBox;
}
function closeModal(){
scrim.hidden = true;
modalBox.innerHTML = '';
const cb = modalOnClose; modalOnClose = null;
if (cb) cb();
}
scrim.addEventListener('click', (e) => { if (e.target === scrim) closeModal(); });
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !scrim.hidden){ closeModal(); e.preventDefault(); return; }
if (!scrim.hidden) return;
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (/INPUT|TEXTAREA|SELECT/.test(tag)) return;
if (e.key === 'ArrowLeft'){ navStep(-1); e.preventDefault(); }
else if (e.key === 'ArrowRight'){ navStep(1); e.preventDefault(); }
else if (e.key === 't' || e.key === 'T'){ goToday(); }
else if (e.key === 'm' || e.key === 'M'){ setView('month'); }
else if (e.key === 'w' || e.key === 'W'){ setView('week'); }
else if (e.key === 'a' || e.key === 'A'){ setView('agenda'); }
else if (e.key === 'n' || e.key === 'N'){ openEditor({ date: DateUtil.keyOf(Store.getCursor()) }); }
});
/* ============================================================================
SECTION 9 — TOASTS + CONFIRM
============================================================================ */
const toastwrap = document.getElementById('toastwrap');
function toast(msg, actions, ms){
const el = document.createElement('div');
el.className = 'toast';
const s = document.createElement('span'); s.textContent = msg;
el.appendChild(s);
(actions || []).forEach(a => {
const b = document.createElement('button');
b.textContent = a.label;
b.addEventListener('click', () => { a.fn(); el.remove(); });
el.appendChild(b);
});
toastwrap.appendChild(el);
setTimeout(() => { el.style.transition = 'opacity .25s'; el.style.opacity = '0';
setTimeout(() => el.remove(), 260); }, ms || 6000);
return el;
}
function confirmDialog(title, msg, onYes, opts){
const o = opts || {};
showModal(
'<h2>' + esc(title) + '</h2>' +
'<div class="confirm">' + msg + '</div>' +
'<div class="mfoot">' +
'<div class="spacer" style="flex:1"></div>' +
'<button class="tbtn" data-act="cancel">Cancel</button>' +
'<button class="primary" data-act="yes" style="' + (o.danger ? 'background:var(--danger)' : '') + '">' + (o.yesLabel || 'Delete') + '</button>' +
'</div>');
modalBox.querySelector('[data-act=cancel]').onclick = closeModal;
modalBox.querySelector('[data-act=yes]').onclick = () => { closeModal(); onYes(); };
}
function esc(s){
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function pad2(n){ return String(n).padStart(2, '0'); }
function timeInputValue(mins){
const h = Math.floor(mins / 60), m = mins % 60;
return pad2(h) + ':' + pad2(m);
}
function parseTimeInput(v){
const m = /^(\d{1,2}):(\d{2})/.exec(v || '');
if (!m) return null;
const h = +m[1], mi = +m[2];
if (h > 23 || mi > 59) return null;
return h * 60 + mi;
}
/* ============================================================================
SECTION 10 — EVENT EDITOR MODAL
============================================================================ */
const FREQ_LABEL = { none:'Does not repeat', daily:'Daily', weekly:'Weekly', monthly:'Monthly (same day-of-month)', yearly:'Yearly' };
/**
* openEditor(opts):
* { date, start, end, allDay } -> create new
* { eventId } -> edit master series (entire)
* { occ } -> edit one occurrence (scope asked on save)
*/
function openEditor(opts){
const o = opts || {};
const editing = !!o.eventId || !!o.occ;
const ev = o.eventId ? Store.getEvent(o.eventId) : (o.occ ? Store.getEvent(o.occ.evId) : null);
const occ = o.occ || null;
const value = {
title: ev ? (occ && occ.isException ? occ.title : ev.title) : '',
color: ev ? (occ && occ.isException ? occ.color : ev.color) : 'peacock',
notes: ev ? (occ && occ.isException ? occ.notes : ev.notes) : '',
allDay: ev ? (occ && occ.isException ? occ.allDay : ev.allDay) : !!o.allDay,
date: o.date || (occ ? DateUtil.keyOf(occ.date) : (ev ? ev.date : DateUtil.keyOf(Store.getCursor()))),
start: o.start !== undefined ? o.start : (occ && !occ.isException ? occ.start : (ev ? ev.start : 540)),
end: o.end !== undefined ? o.end : (occ && !occ.isException ? occ.end : (ev ? ev.end : 600)),
recurrence: ev ? (ev.recurrence ? ev.recurrence.freq : 'none') : 'none',
interval: ev && ev.recurrence ? (ev.recurrence.interval || 1) : 1,
weekdays: ev && ev.recurrence && ev.recurrence.weekdays ? ev.recurrence.weekdays.slice() : [],
until: ev && ev.recurrence && ev.recurrence.until ? ev.recurrence.until : ''
};
if (value.recurrence === 'weekly' && !value.weekdays.length && value.date){
value.weekdays = [DateUtil.dayOfWeek(DateUtil.parseKey(value.date).y, DateUtil.parseKey(value.date).m, DateUtil.parseKey(value.date).d)];
}
if (value.end <= value.start) value.end = Math.min(1439, value.start + 60);
const title = occ && occ.recurring ? 'Edit occurrence' : (editing ? 'Edit event' : 'New event');
showModal(
'<h2>' + title + '</h2>' +
'<div class="mbody">' +
'<div class="field" id="fTitle"><label>Title</label><input type="text" id="inTitle" placeholder="Add a title" value="' + esc(value.title) + '"><div class="errmsg" id="eTitle"></div></div>' +
'<div class="field"><label>When</label>' +
'<label class="chk"><input type="checkbox" id="inAllDay"' + (value.allDay ? ' checked' : '') + '> All day</label>' +
'</div>' +
'<div class="row2">' +
'<div class="field" id="fDate"><label>Date</label><input type="date" id="inDate" value="' + esc(value.date) + '"><div class="errmsg" id="eDate"></div></div>' +
'<div class="field" id="fUntil"><label>Repeat until (optional)</label><input type="date" id="inUntil" value="' + esc(value.until) + '"></div>' +
'</div>' +
'<div class="row2" id="timeRow">' +
'<div class="field" id="fStart"><label>Start time</label><input type="time" id="inStart" value="' + timeInputValue(value.start) + '" step="900"><div class="errmsg" id="eStart"></div></div>' +
'<div class="field" id="fEnd"><label>End time</label><input type="time" id="inEnd" value="' + timeInputValue(value.end) + '" step="900"><div class="errmsg" id="eTime"></div></div>' +
'</div>' +
'<div class="field" id="fRec"><label>Repeat</label>' +
'<select id="inFreq">' + Object.keys(FREQ_LABEL).map(k => '<option value="' + k + '"' + (k === value.recurrence ? ' selected' : '') + '>' + FREQ_LABEL[k] + '</option>').join('') + '</select>' +
'<div class="row2" id="repRow" style="margin-top:9px;align-items:start">' +
'<div class="field" id="ivWrap"><label>Every</label>' +
'<select id="inInterval">' + [1,2,3,4,6].map(n => '<option value="' + n + '"' + (n === value.interval ? ' selected' : '') + '>every ' + n + '</option>').join('') + '</select>' +
'</div>' +
'<div class="field" id="wdWrap"><label>On days</label>' +
'<div class="dow-toggle" id="dowToggle">' + DAY_SHORT.map((d, i) =>
'<button type="button" data-dow="' + i + '" class="' + (value.weekdays.includes(i) ? 'on' : '') + '">' + d.slice(0,3) + '</button>').join('') +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="field"><label>Color</label><div class="swatches" id="swatches">' +
CONFIG.COLORS.map(c => '<button type="button" class="sw' + (c.id === value.color ? ' sel' : '') + '" data-color="' + c.id + '" style="background:' + c.solid + '" title="' + c.id + '"></button>').join('') +
'</div></div>' +
'<div class="field"><label>Notes</label><textarea id="inNotes" placeholder="Add notes...">' + esc(value.notes) + '</textarea></div>' +
'</div>' +
'<div class="mfoot">' +
(editing ? '<button class="tbtn del" id="btnDelete">Delete</button>' : '') +
'<div style="flex:1"></div>' +
'<button class="tbtn" id="btnCancel">Cancel</button>' +
'<button class="primary" id="btnSave">Save</button>' +
'</div>'
);
const $ = id => modalBox.querySelector(id);
const inAllDay = $('#inAllDay'), timeRow = $('#timeRow'), wdWrap = $('#wdWrap'), inFreq = $('#inFreq'), inInterval = $('#inInterval');
function syncFreqUI(){
const f = inFreq.value;
const repRow = $('#repRow');
repRow.style.display = f === 'none' ? 'none' : 'grid';
repRow.style.gridTemplateColumns = (f === 'daily' || f === 'monthly') ? '1fr' : '1fr 1fr';
$('#wdWrap').style.display = f === 'weekly' ? 'flex' : 'none';
$('#fUntil').style.display = f === 'none' ? 'none' : 'flex';
// Convenience: if the user just switched to Weekly and picked no days,
// default to the weekday of the chosen date.
if (f === 'weekly' && !modalBox.querySelector('#dowToggle .on')){
const dv = $('#inDate').value;
if (/^\d{4}-\d{2}-\d{2}$/.test(dv)){
const dd = DateUtil.parseKey(dv);
const dow = DateUtil.dayOfWeek(dd.y, dd.m, dd.d);
const btn = modalBox.querySelector('#dowToggle [data-dow="' + dow + '"]');
if (btn) btn.classList.add('on');
}
}
}
function syncAllDay(){
timeRow.style.display = inAllDay.checked ? 'none' : 'grid';
syncFreqUI();
}
inAllDay.addEventListener('change', () => { syncAllDay(); });
inFreq.addEventListener('change', syncFreqUI);
$('#dowToggle').addEventListener('click', (e) => {
const b = e.target.closest('[data-dow]'); if (!b) return;
b.classList.toggle('on');
});
$('#swatches').addEventListener('click', (e) => {
const b = e.target.closest('[data-color]'); if (!b) return;
modalBox.querySelectorAll('.sw').forEach(s => s.classList.remove('sel'));
b.classList.add('sel');
});
syncFreqUI(); syncAllDay();
setTimeout(() => $('#inTitle').focus(), 30);
$('#btnCancel').onclick = closeModal;
if (editing) $('#btnDelete').onclick = () => { closeModal(); requestDelete(ev, occ); };
$('#btnSave').onclick = () => {
const data = collect();
if (!data) return;
closeModal();
commit(data);
};
function setErr(fieldId, msgId, msg){
const f = modalBox.querySelector(fieldId), m = modalBox.querySelector(msgId);
if (f) f.classList.toggle('err', !!msg);
if (m) m.textContent = msg || '';
return !!msg;
}
function collect(){
let bad = false;
const t = $('#inTitle').value.trim();
if (!t) bad = setErr('#fTitle', '#eTitle', 'Title is required.') || bad;
else setErr('#fTitle', '#eTitle', '');
const dstr = $('#inDate').value;
const dObj = dstr ? DateUtil.parseKey(dstr) : null;
const dValid = dObj && /^\d{4}-\d{2}-\d{2}$/.test(dstr) && dObj.m >= 1 && dObj.m <= 12 && dObj.d >= 1 && dObj.d <= DateUtil.daysInMonth(dObj.y, dObj.m);
if (!dValid) bad = setErr('#fDate', '#eDate', 'Enter a valid date.') || bad;
else setErr('#fDate', '#eDate', '');
const allDay = inAllDay.checked;
let s = value.start, e = value.end;
if (!allDay){
s = parseTimeInput($('#inStart').value);
e = parseTimeInput($('#inEnd').value);
if (s === null) bad = setErr('#fStart', '#eStart', 'Invalid start time.') || bad; else setErr('#fStart', '#eStart', '');
if (e === null) bad = setErr('#fEnd', '#eTime', 'Invalid end time.') || bad;
else if (s !== null && e <= s) bad = setErr('#fEnd', '#eTime', 'End time must be after start time.') || bad;
else setErr('#fEnd', '#eTime', '');
}
const untilStr = $('#inUntil').value;
if (untilStr && (!dValid || untilStr < dstr)) bad = setErr('#fUntil', '#eDate', '') || setErr('#fDate', '#eDate', 'Repeat-until must be on or after the start date.') || bad;
if (bad) return null;
let weekdays = [];
if (inFreq.value === 'weekly'){
weekdays = Array.from(modalBox.querySelectorAll('#dowToggle [data-dow].on')).map(b => +b.dataset.dow).sort((a,b)=>a-b);
if (!weekdays.length){
toast('Pick at least one weekday for a weekly repeat.');
return null;
}
}
const freq = inFreq.value;
const recurrence = freq === 'none' ? null : {
freq,
interval: +inInterval.value || 1,
weekdays: freq === 'weekly' ? weekdays : null,
until: untilStr || null,
dayOfMonth: freq === 'monthly' || freq === 'yearly' ? dObj.d : null,
month: freq === 'yearly' ? dObj.m : null
};
return {
title: t, color: (modalBox.querySelector('.sw.sel') || {}).dataset ? modalBox.querySelector('.sw.sel').dataset.color : 'peacock',
notes: $('#inNotes').value.trim(), allDay, date: dstr, start: s, end: e, recurrence
};
}
function commit(data){
if (!editing){
const n = Store.addEvent(data);
render();
toast('Event created', [{ label:'Undo', fn: () => { Store.removeEvent(n.id); render(); } }]);
return;
}
if (occ && ev && ev.recurrence){
askScope('edit', ev, occ, data);
return;
}
if (occ && occ.isException){
// plain event moved via exception — collapse back onto the master
Store.updateEvent(ev.id, { title:data.title, color:data.color, notes:data.notes, allDay:data.allDay, date:data.date, start:data.start, end:data.end });
Store.setException(ev.id, occ.occKey, null);
render(); toast('Event updated');
return;
}
Store.updateEvent(ev.id, data);
render();
toast('Event updated', [{ label:'Undo', fn: () => { Store.updateEvent(ev.id, snapshot); render(); } }]);
}
var snapshot = ev ? JSON.parse(JSON.stringify(ev)) : null;
}
/* ============================================================================
SECTION 11 — SCOPE DIALOG (this / following / all) + DELETE FLOWS
============================================================================ */
function askScope(kind, ev, occ, data){
const isEdit = kind === 'edit';
const occKey = occ.occKey;
showModal(
'<h2>' + (isEdit ? 'Edit repeating event' : 'Delete repeating event') + '</h2>' +
'<div class="confirm">“' + esc(ev.title) + '” repeats.<br>How much of the series do you want to ' + (isEdit ? 'change' : 'delete') + '?</div>' +
'<div class="menu">' +
'<button data-scope="one">This event only<span>' + (isEdit ? 'Changes' : 'Deletes') + ' just ' + fmtKeyLong(occKey) + '</span></button>' +
'<button data-scope="following">This and following events<span>' + (isEdit ? 'Changes' : 'Deletes') + ' everything from ' + fmtKeyLong(occKey) + ' onward</span></button>' +
'<button data-scope="all" class="' + (isEdit ? '' : 'dngr') + '">All events<span>' + (isEdit ? 'Changes' : 'Deletes') + ' the entire series</span></button>' +
'</div>' +
'<div class="mfoot"><div style="flex:1"></div><button class="tbtn" id="btnCancelScope">Cancel</button></div>'
);
modalBox.querySelector('#btnCancelScope').onclick = closeModal;
modalBox.querySelectorAll('[data-scope]').forEach(b => {
b.onclick = () => {
const scope = b.dataset.scope;
closeModal();
setTimeout(() => {
if (isEdit) applyEditScope(scope, ev, occ, data);
else applyDeleteScope(scope, ev, occ);
}, 10);
};
});
}
function fmtKeyLong(k){ const d = DateUtil.parseKey(k); return MONTH_SHORT[d.m-1] + ' ' + d.d + ', ' + d.y; }
function applyEditScope(scope, ev, occ, data){
const occKey = occ.occKey;
if (scope === 'one'){
const merged = mergePatch(ev, occKey, {
title:data.title, color:data.color, notes:data.notes, allDay:data.allDay,
date:data.date, start:data.start, end:data.end
});
Store.setException(ev.id, occKey, { type:'override', patch: merged });
render();
toast('One occurrence updated', [{ label:'Undo', fn: () => { Store.setException(ev.id, occKey, null); render(); } }]);
return;
}
if (scope === 'following'){
// 1) truncate the master series the day before this occurrence
const cut = DateUtil.addDaysTo(DateUtil.parseKey(occKey), -1);
const masterSnap = JSON.parse(JSON.stringify(ev));
const origRec = JSON.parse(JSON.stringify(ev.recurrence)); // capture BEFORE mutating
const newStartObj = DateUtil.parseKey(data.date ? data.date : occKey);
Store.updateEvent(ev.id, { recurrence: Object.assign({}, origRec, { until: DateUtil.keyOf(cut) }) });
// 2) start a new series at this occurrence with the new values
const newEv = Store.addEvent({
title:data.title, color:data.color, notes:data.notes, allDay:data.allDay,
date:data.date ? data.date : occKey, start:data.start, end:data.end,
recurrence: Object.assign({}, origRec, {
until: origRec.until || null,
weekdays: origRec.freq === 'weekly'
? (origRec.weekdays && origRec.weekdays.length ? origRec.weekdays.slice() : [DateUtil.dayOfWeek(newStartObj.y, newStartObj.m, newStartObj.d)])
: null,
dayOfMonth: (origRec.freq === 'monthly' || origRec.freq === 'yearly') ? newStartObj.d : null,
month: origRec.freq === 'yearly' ? newStartObj.m : null
})
});
render();
toast('New series started', [{ label:'Undo', fn: () => {
Store.removeEvent(newEv.id);
Store.updateEvent(ev.id, { recurrence: masterSnap.recurrence });
render();
} }]);
return;
}
// all
const snap = JSON.parse(JSON.stringify(ev));
const oldStart = DateUtil.parseKey(ev.date);
const shift = DateUtil.diffDays(DateUtil.parseKey(occKey), DateUtil.parseKey(data.date));
const newStart = DateUtil.addDaysTo(oldStart, shift);
Store.updateEvent(ev.id, {
title:data.title, color:data.color, notes:data.notes, allDay:data.allDay,
date: (shift === 0 ? ev.date : DateUtil.keyOf(newStart)),
start:data.start, end:data.end,
recurrence: data.recurrence || ev.recurrence,
exceptions: shift === 0 ? ev.exceptions : {}
});
render();
toast('Series updated', [{ label:'Undo', fn: () => { Store.updateEvent(ev.id, snap); render(); } }]);
}
function mergePatch(ev, occKey, patch){
const ex = ev.exceptions && ev.exceptions[occKey];
const base = (ex && ex.type === 'override' && ex.patch) ? ex.patch : {};
return Object.assign({}, base, patch);
}
function requestDelete(ev, occ){
if (occ && ev.recurrence){ askScope('delete', ev, occ, null); return; }
const snap = JSON.parse(JSON.stringify(ev));
removeWithUndo([snap], 'Event deleted');
}
function applyDeleteScope(scope, ev, occ){
const occKey = occ.occKey;
if (scope === 'one'){
Store.setException(ev.id, occKey, { type:'cancel' });
render();
toast('Occurrence deleted', [{ label:'Undo', fn: () => { Store.setException(ev.id, occKey, null); render(); } }]);
return;
}
if (scope === 'following'){
const snap = JSON.parse(JSON.stringify(ev));
const cut = DateUtil.addDaysTo(DateUtil.parseKey(occKey), -1);
Store.updateEvent(ev.id, { recurrence: Object.assign({}, ev.recurrence, { until: DateUtil.keyOf(cut) }) });
render();
toast('Remaining series deleted', [{ label:'Undo', fn: () => { Store.updateEvent(ev.id, snap); render(); } }]);
return;
}
const snap = JSON.parse(JSON.stringify(ev));
removeWithUndo([snap], 'Series deleted');
}
function removeWithUndo(snaps, label){
snaps.forEach(s => Store.removeEvent(s.id));
render();
toast(label, [{ label:'Undo', fn: () => { snaps.forEach(s => Store.restoreEvent(s)); render(); } }]);
}
/** Clicking an occurrence chip/row: plain event -> edit; series -> occurrence editor. */
function openOccurrence(occ){
if (occ.recurring) openEditor({ occ });
else {
const ev = Store.getEvent(occ.evId);
if (!ev) return;
if (occ.isException) openEditor({ occ });
else openEditor({ eventId: ev.id });
}
}
/* ============================================================================
SECTION 12 — DAY POPOVER (overflow "+N more")
============================================================================ */
let popoverEl = null;
function closePopover(){
if (popoverEl){ popoverEl.remove(); popoverEl = null; }
document.removeEventListener('mousedown', popoverOutside, true);
}
function popoverOutside(e){
if (popoverEl && !popoverEl.contains(e.target)) closePopover();
}
function openDayPopover(key, anchor){
closePopover();
const d = DateUtil.parseKey(key);
const occs = occurrencesInRange(d, d);
const pop = document.createElement('div');
pop.className = 'popover';
const dow = new Date(Date.UTC(d.y, d.m - 1, d.d)).getUTCDay();
const h = document.createElement('div');
h.className = 'ph';
h.innerHTML = '<span>' + MONTH_NAMES[d.m-1] + ' ' + d.d + ', ' + d.y + '</span><small>' + DAY_NAMES[dow] + '</small>';
pop.appendChild(h);
if (!occs.length){
const e = document.createElement('div');
e.className = 'pi'; e.innerHTML = '<div class="ps">No events</div>';
pop.appendChild(e);
}
for (const o of occs){
const row = document.createElement('div');
row.className = 'pi';
row.innerHTML = '<div class="dot2" style="background:' + colorById(o.color).solid + '"></div>' +
'<div style="min-width:0"><div class="pt">' + esc(o.title) + '</div><div class="ps">' +
(o.allDay ? 'All day' : DateUtil.fmtTime(Math.floor(o.start/60), o.start%60) + ' – ' + DateUtil.fmtTime(Math.floor(o.end/60), o.end%60)) +
(o.recurring ? ' · repeats' : '') + '</div></div>';
row.addEventListener('click', () => { closePopover(); openOccurrence(o); });
pop.appendChild(row);
}
const add = document.createElement('div');
add.className = 'pi';
add.innerHTML = '<div class="dot2" style="background:var(--accent)"></div><div class="pt">Add event</div>';
add.addEventListener('click', () => { closePopover(); openEditor({ date:key }); });
pop.appendChild(add);
document.body.appendChild(pop);
const r = anchor.getBoundingClientRect();
let left = Math.min(window.innerWidth - 300, Math.max(8, r.left - 60));
let top = r.bottom + 6;
if (top + 340 > window.innerHeight) top = Math.max(8, r.top - 346);
pop.style.left = left + 'px';
pop.style.top = top + 'px';
popoverEl = pop;
setTimeout(() => document.addEventListener('mousedown', popoverOutside, true), 0);
}
/* ============================================================================
SECTION 13 — SETTINGS MODAL
============================================================================ */
function openSettings(){
const s = settings();
showModal(
'<h2>Settings</h2>' +
'<div class="mbody">' +
'<div class="settings-row"><div><div class="sl">Week starts on Monday</div><div class="sd">Affects month grid, week view and week numbers.</div></div>' +
'<button class="switch ' + (s.weekStart === 1 ? 'on' : '') + '" id="swWeek"></button></div>' +
'<div class="settings-row"><div><div class="sl">Show week numbers</div><div class="sd">Displays W-numbers in the month grid.</div></div>' +
'<button class="switch ' + (s.showWeekNums ? 'on' : '') + '" id="swWk"></button></div>' +
'<div class="settings-row"><div><div class="sl">Hour range in week view</div><div class="sd">Currently ' + CONFIG.HOUR_START + ':00 – ' + CONFIG.HOUR_END + ':00</div></div>' +
'<div style="color:var(--text-2);font-size:12.5px">editable in CONFIG</div></div>' +
'</div>' +
'<div class="mfoot"><button class="tbtn del" id="btnClearAll">Clear all events</button>' +
'<div style="flex:1"></div><button class="primary" id="btnDone">Done</button></div>' +
'<div class="hint">Data is stored locally in your browser (localStorage).</div>'
);
modalBox.querySelector('#swWeek').onclick = (e) => {
const on = !e.currentTarget.classList.contains('on');
e.currentTarget.classList.toggle('on', on);
Store.setSetting('weekStart', on ? 1 : 0);
render();
};
modalBox.querySelector('#swWk').onclick = (e) => {
const on = !e.currentTarget.classList.contains('on');
e.currentTarget.classList.toggle('on', on);
Store.setSetting('showWeekNums', on);
render();
};
modalBox.querySelector('#btnClearAll').onclick = () => {
closeModal();
setTimeout(() => confirmDialog('Clear all events?', 'This removes every event from this calendar. This cannot be undone once you dismiss the notification.', () => {
const snap = JSON.parse(JSON.stringify(Store.getEvents()));
Store.getEvents().slice().forEach(e => Store.removeEvent(e.id));
render();
toast('All events cleared', [{ label:'Undo', fn: () => { snap.forEach(e => Store.restoreEvent(e)); render(); } }]);
}, { danger:true, yesLabel:'Clear all' }), 10);
};
modalBox.querySelector('#btnDone').onclick = closeModal;
}
/* ============================================================================
SECTION 14 — DRAG & DROP (pointer based)
Month: move a chip to another day.
Week: move vertically to change time (SNAP_MINUTES); drag bottom edge to resize.
============================================================================ */
const dragState = { active:false };
document.addEventListener('pointerdown', onDragDown, true);
function onDragDown(e){
if (e.button !== 0) return;
const handle = e.target.closest('.resizeh');
const wev = e.target.closest('.wev');
const chip = e.target.closest('.chip');
if (handle && wev){ beginWeekDrag(e, wev, 'resize'); return; }
if (wev){ beginWeekDrag(e, wev, 'move'); return; }
if (chip && chip.dataset.evId){ beginMonthDrag(e, chip); return; }
}
function beginMonthDrag(e, chip){
const occ = chip._occ; if (!occ) return;
const startX = e.clientX, startY = e.clientY;
let started = false, ghost = null, targetKey = null;
const move = (ev) => {
if (!started){
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 5) return;
started = true;
document.body.style.userSelect = 'none';
chip.classList.add('dragging');
ghost = chip.cloneNode(true);
ghost.style.position = 'fixed'; ghost.style.zIndex = 300; ghost.style.pointerEvents = 'none';
ghost.style.opacity = '.9'; ghost.style.width = chip.offsetWidth + 'px'; ghost.style.boxShadow = 'var(--shadow-lg)';
ghost.classList.remove('dragging');
document.body.appendChild(ghost);
}
ghost.style.left = (ev.clientX - 60) + 'px';
ghost.style.top = (ev.clientY - 12) + 'px';
const under = document.elementFromPoint(ev.clientX, ev.clientY);
const cell = under && under.closest('.day-cell');
document.querySelectorAll('.day-cell.drop-target').forEach(c => c.classList.remove('drop-target'));
targetKey = null;
if (cell){ cell.classList.add('drop-target'); targetKey = cell.dataset.key; }
};
const up = (ev) => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
document.body.style.userSelect = '';
document.querySelectorAll('.day-cell.drop-target').forEach(c => c.classList.remove('drop-target'));
if (ghost) ghost.remove();
chip.classList.remove('dragging');
if (!started || !targetKey) return;
const fromKey = DateUtil.keyOf(occ.date);
if (targetKey === fromKey) return;
moveOccurrenceToDate(occ, targetKey);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
function moveOccurrenceToDate(occ, newKey){
const ev = Store.getEvent(occ.evId); if (!ev) return;
if (ev.recurrence){
const patch = mergePatch(ev, occ.occKey, { date: newKey });
Store.setException(ev.id, occ.occKey, { type:'override', patch });
render();
toast('Occurrence moved to ' + fmtKeyLong(newKey), [{ label:'Undo', fn: () => { Store.setException(ev.id, occ.occKey, null); render(); } }]);
} else {
const prev = ev.date;
Store.updateEvent(ev.id, { date: newKey });
render();
toast('Moved to ' + fmtKeyLong(newKey), [{ label:'Undo', fn: () => { Store.updateEvent(ev.id, { date: prev }); render(); } }]);
}
}
function beginWeekDrag(e, el, mode){
const occ = el._occ; if (!occ) return;
e.preventDefault();
const ev = Store.getEvent(occ.evId); if (!ev) return;
const H0 = CONFIG.HOUR_START, HH = CONFIG.HOUR_HEIGHT, snap = CONFIG.SNAP_MINUTES;
const col = el.closest('.wkcol');
const colRect = col.getBoundingClientRect();
const startY = e.clientY, startX = e.clientX;
const origH = el.offsetHeight;
let dur = Math.max(snap, occ.end - occ.start);
let curStart = occ.start, curEnd = occ.end, curKey = DateUtil.keyOf(occ.date);
let moved = false;
const move = (mev) => {
const dy = mev.clientY - startY;
if (!moved && Math.abs(dy) < 4 && Math.abs(mev.clientX - startX) < 4) return;
moved = true;
el.classList.add('dragging');
const dMin = Math.round((dy / HH * 60) / snap) * snap;
if (mode === 'resize'){
curEnd = Math.max(curStart + snap, Math.min(1440, occ.end + dMin));
} else {
let s = occ.start + dMin;
s = Math.max(0, Math.min(1440 - dur, s));
curStart = s; curEnd = s + dur;
}
el.style.top = ((curStart - H0 * 60) / 60 * HH) + 'px';
el.style.height = Math.max(16, (curEnd - curStart) / 60 * HH) + 'px';
// horizontal: move across day columns
const under = document.elementFromPoint(mev.clientX, mev.clientY);
const tcol = under && under.closest('.wkcol');
if (tcol && tcol !== col){ curKey = tcol.dataset.key; }
};
const up = (uev) => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
el.classList.remove('dragging');
if (!moved) return;
commit();
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
function commit(){
const changedTime = curStart !== occ.start || curEnd !== occ.end;
const changedDate = curKey !== DateUtil.keyOf(occ.date);
if (!changedTime && !changedDate) return;
const patch = { start: curStart, end: curEnd };
if (changedDate) patch.date = curKey;
if (ev.recurrence){
const merged = mergePatch(ev, occ.occKey, patch);
Store.setException(ev.id, occ.occKey, { type:'override', patch: merged });
render();
toast('Occurrence updated', [{ label:'Undo', fn: () => { Store.setException(ev.id, occ.occKey, null); render(); } }]);
} else {
const prev = { date: ev.date, start: ev.start, end: ev.end };
Store.updateEvent(ev.id, patch);
render();
toast('Event updated', [{ label:'Undo', fn: () => { Store.updateEvent(ev.id, prev); render(); } }]);
}
}
}
/* ============================================================================
SECTION 15 — BOOT
============================================================================ */
document.getElementById('btnToday').onclick = goToday;
document.getElementById('btnPrev').onclick = () => navStep(-1);
document.getElementById('btnNext').onclick = () => navStep(1);
document.getElementById('btnNew').onclick = () => openEditor({ date: DateUtil.keyOf(Store.getCursor()) });
document.getElementById('btnSettings').onclick = openSettings;
document.getElementById('viewSeg').addEventListener('click', (e) => {
const b = e.target.closest('button[data-view]'); if (b) setView(b.dataset.view);
});
Store.load();
ViewState.view = settings().view || 'month';
if (!['month','week','agenda'].includes(ViewState.view)) ViewState.view = 'month';
render();
// Keep the current-time indicator fresh
setInterval(() => {
const line = document.getElementById('nowLine');
if (!line) return;
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
line.style.top = ((mins - CONFIG.HOUR_START * 60) / 60 * CONFIG.HOUR_HEIGHT) + 'px';
line.querySelector('.nltime').textContent = DateUtil.fmtTime(now.getHours(), now.getMinutes());
}, 30000);
// Re-render at midnight so the "today" highlight stays correct
let lastDayKey = DateUtil.keyOf(DateUtil.today());
setInterval(() => {
const k = DateUtil.keyOf(DateUtil.today());
if (k !== lastDayKey){ lastDayKey = k; render(); }
}, 60000);
// Expose internals for testing / debugging
window.CalendarAPI = {
CONFIG, DateUtil, Store, occurrencesInRange, recurrenceOccurrences,
ViewState, render, navStep, goToday, setView, openEditor, openOccurrence,
mergePatch, applyEditScope, applyDeleteScope, moveOccurrenceToDate,
settings, weekStart
};
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"f23ebe99-81bc-4819-917d-a754f5c6a775","tokensIn":5604190,"tokensOut":63341,"tokensTotal":5667531,"cost":0.072969612,"turns":51,"toolCalls":50,"failedToolCalls":0,"timestamp":"2026-09-11T00:40:31.445Z"} -->