1
TOOLS#d49959e9
PTO Planner
@Owner·deposited 1w ago·updated 1w ago·210 views
TOOLS#d49959e9
PTO Planner
OW
@Owner
210Views
0Comments
0Forks
0Saves
SHARE · REMIX
PTO Planner — a HTML tools widget by @Owner.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.load
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "PTO Planner" by @Owner]
Source: https://itjustvibes.com/Owner/pto-planner-tq2d
Type: HTML
--- SOURCE CODE ---
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PTO Planner</title>
<style>
:root{color-scheme:light;--ink:#17211b;--muted:#647168;--paper:#f5f3eb;--card:#fffdf7;--line:#d9ddd5;--green:#1c6b4a;--green2:#dff3e7;--gold:#d49d3c;--red:#ae3d35;--shadow:0 12px 32px rgba(23,33,27,.08)}*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#eef3e9 0,#f7f2e7 48%,#edf1ea 100%);color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;min-height:100vh}button,input,select{font:inherit}button{cursor:pointer}.shell{max-width:1280px;margin:auto;padding:22px}.topbar{display:flex;align-items:center;gap:16px;justify-content:space-between;margin-bottom:18px}.brand{display:flex;align-items:center;gap:12px}.mark{width:42px;height:42px;border-radius:14px;background:var(--green);color:white;display:grid;place-items:center;font-size:22px;box-shadow:var(--shadow)}h1,h2,h3,p{margin:0}.brand p,.muted{color:var(--muted)}.actions,.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status{padding:7px 10px;border-radius:999px;background:#e8eee9;color:var(--muted);font-size:12px}.status.error{background:#fbe6e3;color:var(--red)}button,.button{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:8px 12px;font-weight:650}button:hover{border-color:#9eaaa1}.primary{background:var(--green);border-color:var(--green);color:#fff}.danger{color:var(--red)}.ghost{background:transparent}.balances{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:14px}.balance-card,.panel{background:rgba(255,253,247,.94);border:1px solid rgba(217,221,213,.9);border-radius:18px;box-shadow:var(--shadow)}.balance-card{padding:15px;position:relative;overflow:hidden}.balance-card:before{content:"";position:absolute;inset:0 auto 0 0;width:4px;background:var(--green)}.balance-card.flag:before{background:var(--red)}.balance-value{font-size:28px;font-weight:780;letter-spacing:-.04em;margin-top:4px}.balance-meta{display:flex;justify-content:space-between;color:var(--muted);font-size:12px;margin-top:4px}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:14px}.panel{padding:16px}.calendar-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.month-title{font:700 24px/1.1 Georgia,serif}.calendar{display:grid;grid-template-columns:repeat(7,1fr);border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--line);gap:1px}.weekday{background:#eff2ec;padding:8px;text-align:center;color:var(--muted);font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.day{min-height:104px;background:var(--card);padding:7px;border:0;border-radius:0;text-align:left;position:relative;font-weight:500}.day:hover{background:#f4f8f2}.day.out{color:#9fa7a0;background:#f5f4ef}.day.today .num{background:var(--green);color:white}.num{display:grid;place-items:center;width:25px;height:25px;border-radius:8px;font-weight:750}.nonwork{color:var(--muted)}.tag{display:block;margin-top:4px;padding:2px 5px;border-radius:5px;background:var(--green2);color:#20593f;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tag.holiday{background:#f7e9cb;color:#755213}.tag.off{background:#eceeea;color:#657068}.tag.entry{background:#dfe8f6;color:#334f78}.side h2{font:700 20px Georgia,serif;margin-bottom:4px}.empty{padding:25px 6px;text-align:center;color:var(--muted)}form{margin-top:12px}label{display:grid;gap:4px;color:var(--muted);font-size:12px;font-weight:650;margin-bottom:9px}input,select{width:100%;border:1px solid var(--line);border-radius:9px;background:#fff;padding:8px 9px;color:var(--ink)}input:focus,select:focus{outline:2px solid #9bcbb3;outline-offset:1px}.two{display:grid;grid-template-columns:1fr 1fr;gap:9px}.check{display:flex;align-items:center;gap:7px}.check input{width:auto}.preview{padding:10px;border-radius:10px;background:#eff5ef;margin:9px 0}.preview.flag{background:#fbe8e5;color:var(--red)}.entry-row,.ledger-row{display:grid;grid-template-columns:1fr auto;gap:8px;padding:9px 0;border-top:1px solid var(--line)}.entry-row button{padding:4px 7px;font-size:11px}.settings{margin-top:14px}.settings>summary{list-style:none;display:flex;align-items:center;justify-content:space-between;font-weight:750;cursor:pointer}.settings>summary::-webkit-details-marker{display:none}.settings[open]>summary{margin-bottom:15px}.settings-grid{display:grid;grid-template-columns:1fr 1.5fr;gap:14px}.subpanel{border:1px solid var(--line);border-radius:14px;padding:13px;background:#faf9f4}.subpanel h3{margin-bottom:8px}.bucket-config{border-top:1px solid var(--line);padding:12px 0}.bucket-config:first-child{border-top:0}.rule-note{font-size:11px;color:var(--muted);margin:-4px 0 8px}.hidden{display:none!important}.notice{border:1px solid #ebc7c2;background:#fff0ed;color:#743a34;border-radius:14px;padding:14px;margin-bottom:14px}.loading{position:fixed;inset:0;background:rgba(245,243,235,.92);display:grid;place-items:center;z-index:10;font-weight:750}.recovery{max-width:700px;margin:80px auto;padding:28px}.ledger{max-height:260px;overflow:auto;font-size:12px}.ledger-row{grid-template-columns:90px 1fr auto}.pill{padding:2px 6px;border-radius:999px;background:#eceee9;font-size:10px}.section-title{display:flex;justify-content:space-between;align-items:center;margin:12px 0 6px}.small{font-size:11px}.footer{padding:18px 0;color:var(--muted);text-align:center;font-size:11px}@media(max-width:850px){.workspace,.settings-grid{grid-template-columns:1fr}.balances{grid-template-columns:1fr}.day{min-height:82px}.shell{padding:12px}}@media(max-width:540px){.calendar{font-size:11px}.day{min-height:67px;padding:4px}.tag{font-size:8px}.topbar{align-items:flex-start}.actions{justify-content:flex-end}.weekday{padding:5px 1px}}
.ledger-row.flag{color:var(--red);background:#fff0ed}
.calendar-head>.row{flex-wrap:nowrap}.day.in-range{background:#e5f2e9}.day.range-start,.day.range-end{box-shadow:inset 0 0 0 2px var(--green)}#rangeToggle[aria-pressed="true"]{background:var(--green);border-color:var(--green);color:#fff}.range-list{max-height:260px;overflow:auto;border:1px solid var(--line);border-radius:10px;margin:9px 0}.range-date{display:flex;justify-content:space-between;gap:8px;padding:7px 9px;border-top:1px solid var(--line);font-size:12px}.range-date:first-child{border-top:0}.range-date.skipped{color:var(--muted);background:#f3f3ef}
.tabs{display:flex;gap:6px;margin-bottom:14px;border-bottom:1px solid var(--line)}.tab{border:1px solid var(--line);border-bottom:0;background:var(--card);color:var(--muted);border-radius:10px 10px 0 0;padding:9px 18px;font-weight:700}.tab[aria-selected="true"]{background:var(--green);border-color:var(--green);color:#fff}
.help{margin-bottom:14px}.help h3{margin:10px 0 3px;font-size:14px}.help p{color:var(--muted);margin-bottom:6px}
.timeline-chart{width:100%;overflow-x:auto}.timeline-chart svg{display:block;width:100%;min-width:520px;height:auto;touch-action:none;cursor:crosshair;border-radius:10px}.timeline-chart svg:focus{outline:2px solid #9bcbb3;outline-offset:2px}#timelineReadout{font-weight:650;color:var(--ink)}
.legend-item{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin-right:14px}.legend-swatch{width:14px;height:4px;border-radius:2px;display:inline-block}
.dayoff-row{display:grid;grid-template-columns:110px 1fr auto auto auto;gap:10px;align-items:center;padding:9px 0;border-top:1px solid var(--line)}.dayoff-row:first-child{border-top:0}.dayoff-row.flag{color:var(--red)}.dayoff-row .kind{font-size:11px}#quickAddForm{display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin:8px 0 14px}#quickAddForm label{margin-bottom:0}#quickAddForm .check{align-self:center}#quickAddForm select,#quickAddForm input[type=number],#quickAddForm input[type=date]{width:auto}
@media(max-width:540px){.dayoff-row{grid-template-columns:90px 1fr auto}.dayoff-row .kind,.dayoff-row .hrs{display:none}}
.export-overlay{position:fixed;inset:0;background:rgba(23,33,27,.62);display:grid;place-items:center;z-index:50;padding:16px}.export-sheet{background:var(--card);border-radius:16px;padding:16px;max-width:min(96vw,940px);max-height:92vh;overflow:auto;box-shadow:var(--shadow)}.export-img{display:block;max-width:100%;height:auto;border-radius:10px;border:1px solid var(--line)}.export-sheet .row{margin-top:12px;justify-content:flex-end}
</style>
</head>
<body>
<div id="loading" class="loading">Loading your PTO Planner…</div>
<main id="app" class="shell hidden">
<div id="notice"></div>
<header class="topbar"><div class="brand"><div class="mark" aria-hidden="true">↗</div><div><h1>PTO Planner</h1><p>Plan ahead. Payroll stays authoritative.</p></div></div><div class="actions"><span id="saveStatus" class="status">Saved</span><button id="retrySave" class="hidden">Retry save</button><button id="helpToggle" type="button" aria-expanded="false">Help</button><button id="exportPng">Export PNG</button><button id="exportCsv">Export CSV</button><button id="exportIcs" class="primary">Export .ics</button></div></header>
<section id="balances" class="balances" aria-label="Bucket balances"></section>
<section id="helpPanel" class="panel help hidden" aria-label="How to use PTO Planner">
<h2>How PTO Planner works</h2>
<h3>Buckets & balances</h3><p>Each <strong>Bucket</strong> (PTO, Sick, Floating Holiday…) tracks its own hours. The cards up top show your <strong>current balance</strong> and a <strong>Projected</strong> figure — your balance carried forward through the date shown on the card (its Projection Horizon): current balance + upcoming Accruals/Grants and any future Ledger activity, minus already-logged future Time-Off Entries, with the Carryover Cap applied at each Jan 1. The date is why the number is trustworthy: it always says <em>through when</em>.</p>
<h3>The three tabs</h3><p><strong>Calendar</strong> logs time off day-by-day (or a multi-day range). <strong>Timeline</strong> charts each Bucket's balance over time — hover, tap, or use the arrow keys to read any Bucket's balance on any date. <strong>Days Off</strong> lists every Time-Off Entry and Observed Holiday in one place; delete an entry or quick-add one without leaving the list.</p>
<h3>Exporting</h3><p>Export a <strong>.ics</strong> calendar or a <strong>CSV</strong> ledger any time. Balances are planning estimates — payroll stays authoritative.</p>
</section>
<div class="tabs" role="tablist" aria-label="Views">
<button id="tab-calendar" role="tab" aria-selected="true" aria-controls="view-calendar" class="tab" type="button">Calendar</button>
<button id="tab-timeline" role="tab" aria-selected="false" aria-controls="view-timeline" class="tab" type="button" tabindex="-1">Timeline</button>
<button id="tab-daysoff" role="tab" aria-selected="false" aria-controls="view-daysoff" class="tab" type="button" tabindex="-1">Days Off</button>
</div>
<div id="view-calendar" role="tabpanel" aria-labelledby="tab-calendar">
<section class="workspace">
<div class="panel"><div class="calendar-head"><button id="rangeToggle" type="button" aria-pressed="false">Select range</button><div class="row"><button id="prevMonth" aria-label="Previous month">←</button><h2 id="monthTitle" class="month-title"></h2><button id="nextMonth" aria-label="Next month">→</button></div></div><div id="calendar" class="calendar"></div></div>
<aside class="panel side"><h2 id="selectedTitle">Choose a date</h2><p id="selectedMeta" class="muted">Select a calendar day to log time off.</p><div id="rangeArea" class="hidden"><label>Bucket<select id="rangeBucket"></select></label><div id="rangeSummary" class="preview"></div><div id="rangeDates" class="range-list"></div><div class="row"><button id="confirmRange" class="primary" type="button">Log working days</button><button id="clearRange" type="button">Clear range</button></div></div><div id="entryArea" class="hidden"><form id="entryForm"><input id="entryId" type="hidden"><label>Bucket<select id="entryBucket" required></select></label><label id="wholeDayLabel" class="check"><input id="entryWholeDay" type="checkbox"> Whole day using Work Schedule hours</label><label>Hours<input id="entryHours" type="number" min="0" step="0.5" required></label><div id="entryPreview" class="preview"></div><div class="row"><button class="primary" type="submit">Save Time-Off Entry</button><button id="cancelEntry" type="button">Clear</button></div></form><div class="section-title"><h3>Entries on this date</h3></div><div id="dateEntries"></div></div><div id="sideEmpty" class="empty">Your calendar combines your Work Schedule, Observed Holidays, and Time-Off Entries.</div></aside>
</section>
</div>
<div id="view-timeline" role="tabpanel" aria-labelledby="tab-timeline" hidden>
<div class="panel">
<div class="section-title"><h2>Balance Timeline</h2><div id="timelineReadout" class="small" aria-live="polite"></div></div>
<div id="timelineLegend" class="row" style="margin-bottom:8px"></div>
<div id="timelineChart" class="timeline-chart"></div>
<div id="timelineEmpty" class="empty hidden">Add a Bucket to see your balance timeline.</div>
<p class="muted small" style="margin-top:8px">Hover, tap, or focus the chart and use ←/→/Home/End to read balances on any date. The dashed portion of each line is projected.</p>
</div>
</div>
<div id="view-daysoff" role="tabpanel" aria-labelledby="tab-daysoff" hidden>
<div class="panel">
<div class="section-title"><h2>All Days Off</h2></div>
<form id="quickAddForm">
<label>Bucket<select id="quickBucket" required></select></label>
<label>Date<input id="quickDate" type="date" required></label>
<label class="check"><input id="quickWholeDay" type="checkbox" checked> Whole day</label>
<label>Hours<input id="quickHours" type="number" min="0" step="0.5"></label>
<button class="primary" type="submit">Add Time-Off Entry</button>
</form>
<div id="daysOffList"></div>
</div>
</div>
<details class="panel settings"><summary>Configuration & Ledger <span class="muted">Work Schedule · Buckets · Observed Holidays</span></summary><div class="settings-grid">
<div><section class="subpanel"><h3>Work Schedule</h3><form id="scheduleForm"><label>Schedule<select id="scheduleType"><option value="standard">Standard</option><option value="group-a">Group A</option><option value="group-b">Group B</option></select></label><div id="standardFields"><label>Daily hours<input id="dailyHours" type="number" min="0" step="0.5"></label></div><div id="alternateFields" class="hidden"><label>Known off-Friday anchor<input id="offFridayAnchorDate" type="date"></label><div class="two"><label>Mon–Thu hours<input id="weekdayHours" type="number" min="0" step="0.5"></label><label>Working Friday hours<input id="workingFridayHours" type="number" min="0" step="0.5"></label></div></div><button class="primary" type="submit">Save Work Schedule</button></form></section>
<section class="subpanel" style="margin-top:14px"><h3>Observed Holidays</h3><div id="holidayToggles"></div><form id="holidayForm"><div class="two"><label>Custom holiday name<input id="holidayName" maxlength="80" required></label><label>Nominal date<input id="holidayDate" type="date" required></label></div><button type="submit">Add custom Observed Holiday</button></form><div id="customHolidays"></div></section>
<section class="subpanel" style="margin-top:14px"><h3>Balance policy</h3><label class="check"><input id="allowNegative" type="checkbox"> Allow balances to go below zero</label><label>Allowed hours below zero (over-booked past this is flagged)<input id="negativeLimit" type="number" min="0" step="0.5" value="0"></label></section></div>
<div><section class="subpanel"><div class="section-title"><h3>Active Buckets</h3><button id="showAddBucket" type="button">Add Bucket</button></div><form id="addBucketForm" class="hidden"><div class="two"><label>Name<input id="newBucketName" maxlength="60" required></label><label>Mechanic<select id="newBucketType"><option value="accrual">Accrual</option><option value="grant">Grant</option></select></label><label>Opening Balance hours<input id="newOpeningHours" type="number" min="0" step="0.01" value="0" required></label><label>Balance as of date<input id="newBalanceDate" type="date" required></label></div><button class="primary" type="submit">Create Bucket</button></form><div id="bucketConfigs"></div></section>
<section class="subpanel" style="margin-top:14px"><h3>Archived Buckets</h3><div id="archivedBuckets" class="muted small"></div></section></div>
</div></details><div class="footer">Balances are planning estimates. Confirm official balances with payroll.</div>
</main>
<script>
'use strict';
const SCHEMA_VERSION = 1;
const CADENCES = ['weekly', 'biweekly', 'semi-monthly', 'monthly'];
const SEMI_MONTHLY_PATTERNS = ['1st-and-15th', '15th-and-last-day'];
function pad(value) { return String(value).padStart(2, '0'); }
function formatDateParts(year, month, day) { return `${year}-${pad(month)}-${pad(day)}`; }
function isLeapYear(year) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); }
function daysInMonth(year, month) { return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] || 0; }
function parseDateParts(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
if (!match) throw new Error(`Invalid domain date: ${value}`);
const parts = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
if (parts.month < 1 || parts.month > 12 || parts.day < 1 || parts.day > daysInMonth(parts.year, parts.month)) {
throw new Error(`Invalid domain date: ${value}`);
}
return parts;
}
function isValidDateString(value) { try { parseDateParts(value); return true; } catch (_) { return false; } }
function dateOrdinal(value) { const p = parseDateParts(value); return Math.floor(Date.UTC(p.year, p.month - 1, p.day) / 86400000); }
function compareDates(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
function addDays(value, amount) { const p = parseDateParts(value); const d = new Date(Date.UTC(p.year, p.month - 1, p.day + amount)); return formatDateParts(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()); }
function diffDays(a, b) { return dateOrdinal(a) - dateOrdinal(b); }
function weekday(value) { const p = parseDateParts(value); return new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay(); }
function todayString(now) { const d = now || new Date(); return formatDateParts(d.getFullYear(), d.getMonth() + 1, d.getDate()); }
function maxDate(...values) { return values.filter(Boolean).reduce((a, b) => compareDates(a, b) >= 0 ? a : b); }
function minDate(...values) { return values.filter(Boolean).reduce((a, b) => compareDates(a, b) <= 0 ? a : b); }
function eachDate(start, end, callback) { for (let d = start; compareDates(d, end) <= 0; d = addDays(d, 1)) callback(d); }
function assertFinite(value, label, options = {}) {
const number = Number(value);
if (!Number.isFinite(number) || (options.positive && number <= 0) || (options.nonNegative && number < 0) || (options.nonZero && number === 0)) {
throw new Error(`${label} must be ${options.positive ? 'finite and positive' : options.nonNegative ? 'finite and non-negative' : options.nonZero ? 'finite and non-zero' : 'finite'}.`);
}
return number;
}
function validateRuleVersion(rule) {
if (!rule || !isValidDateString(rule.effectiveFrom)) throw new Error('Rule Version effectiveFrom must be a valid calendar date.');
assertFinite(rule.sequence, 'Rule Version sequence', { nonNegative: true });
if (!['accrual', 'grant'].includes(rule.bucketType)) throw new Error('Bucket type must be Accrual or Grant.');
if (rule.carryoverCap !== null && rule.carryoverCap !== '' && rule.carryoverCap !== undefined) assertFinite(rule.carryoverCap, 'Carryover Cap', { nonNegative: true });
if (rule.bucketType === 'accrual') {
assertFinite(rule.hoursPerPayPeriod, 'Accrual hours', { nonNegative: true });
if (!CADENCES.includes(rule.payPeriodCadence)) throw new Error('Invalid Pay Period Cadence.');
if (rule.payPeriodCadence === 'semi-monthly') {
if (!SEMI_MONTHLY_PATTERNS.includes(rule.semiMonthlyPattern)) throw new Error('Invalid semi-monthly pattern.');
} else if (!isValidDateString(rule.accrualAnchorDate)) throw new Error('Accrual anchor must be a valid calendar date.');
} else {
assertFinite(rule.grantHours, 'Grant hours', { nonNegative: true });
if (!isValidDateString(rule.grantDate)) throw new Error('Grant date must be a valid calendar date.');
if (!['one-time', 'annual'].includes(rule.grantRecurrence)) throw new Error('Grant recurrence must be one-time or annual.');
}
return true;
}
function validateWorkSchedule(schedule) {
if (!schedule || !['standard', 'group-a', 'group-b'].includes(schedule.type)) throw new Error('Work Schedule must be Standard, Group A, or Group B.');
if (schedule.type === 'standard') {
const hours = assertFinite(schedule.dailyHours, 'Daily hours', { positive: true });
if (!Number.isInteger(hours * 2)) throw new Error('Daily hours must be a multiple of 0.5.');
}
else {
const weekdayHours = assertFinite(schedule.weekdayHours, 'Weekday hours', { positive: true });
const workingFridayHours = assertFinite(schedule.workingFridayHours, 'Working Friday hours', { positive: true });
if (!Number.isInteger(weekdayHours * 2) || !Number.isInteger(workingFridayHours * 2)) throw new Error('Work Schedule hours must be multiples of 0.5.');
if (!isValidDateString(schedule.offFridayAnchorDate) || weekday(schedule.offFridayAnchorDate) !== 5) throw new Error('Alternating Friday anchor must be an actual Friday.');
}
return true;
}
function validateTimeOffEntry(entry) {
if (!entry || !entry.id || !isValidDateString(entry.date)) throw new Error('Time-Off Entry requires a stable ID and valid date.');
const hours = assertFinite(entry.hours, 'Time-Off Entry hours', { positive: true });
if (!Number.isInteger(hours * 2)) throw new Error('Time-Off Entry hours must be a multiple of 0.5.');
return true;
}
function validateAdjustment(adjustment) {
if (!adjustment || !adjustment.id || !isValidDateString(adjustment.date)) throw new Error('Adjustment requires a stable ID and valid date.');
assertFinite(adjustment.hours, 'Adjustment hours', { nonZero: true });
if (!String(adjustment.note || '').trim()) throw new Error('Adjustment requires a note.');
return true;
}
function resolveRuleVersion(ruleVersions, date) {
return (ruleVersions || []).filter(rule => compareDates(rule.effectiveFrom, date) <= 0)
.sort((a, b) => compareDates(b.effectiveFrom, a.effectiveFrom) || Number(b.sequence) - Number(a.sequence))[0] || null;
}
function isAccrualDate(rule, date) {
if (!rule || rule.bucketType !== 'accrual' || compareDates(date, rule.effectiveFrom) < 0) return false;
const cadence = rule.payPeriodCadence;
if (cadence === 'weekly' || cadence === 'biweekly') {
const interval = cadence === 'weekly' ? 7 : 14;
return ((diffDays(date, rule.accrualAnchorDate) % interval) + interval) % interval === 0;
}
const p = parseDateParts(date);
if (cadence === 'monthly') {
const anchor = parseDateParts(rule.accrualAnchorDate);
return p.day === Math.min(anchor.day, daysInMonth(p.year, p.month));
}
const last = daysInMonth(p.year, p.month);
return rule.semiMonthlyPattern === '1st-and-15th' ? p.day === 1 || p.day === 15 : p.day === 15 || p.day === last;
}
function annualGrantDate(grantDate, year) { const p = parseDateParts(grantDate); return formatDateParts(year, p.month, Math.min(p.day, daysInMonth(year, p.month))); }
function isGrantDate(rule, date) {
if (!rule || rule.bucketType !== 'grant' || compareDates(date, rule.effectiveFrom) < 0) return false;
if (rule.grantRecurrence === 'one-time') return date === rule.grantDate;
return date === annualGrantDate(rule.grantDate, parseDateParts(date).year);
}
function generateAccrualDates(rule, start, end) { validateRuleVersion(rule); const dates = []; eachDate(start, end, d => { if (isAccrualDate(rule, d)) dates.push(d); }); return dates; }
function generateGrantDates(rule, start, end) { validateRuleVersion(rule); const dates = []; eachDate(start, end, d => { if (isGrantDate(rule, d)) dates.push(d); }); return dates; }
function generatedPostingId(bucketId, ruleId, eventType, date) { return `generated:${bucketId}:${ruleId}:${eventType}:${date}`; }
function generateCatchUp(bucket, throughDate, stopAfterDate) {
if (!bucket || !isValidDateString(bucket.lastGeneratedThrough) || !isValidDateString(throughDate)) throw new Error('Catch-up requires valid watermark and through date.');
const target = stopAfterDate ? minDate(throughDate, stopAfterDate) : throughDate;
if (compareDates(target, bucket.lastGeneratedThrough) <= 0) return { postings: (bucket.postings || []).slice(), lastGeneratedThrough: bucket.lastGeneratedThrough, added: [] };
const postings = (bucket.postings || []).map(item => ({ ...item }));
const ids = new Set(postings.map(item => item.id));
const generatedDates = new Set(postings.filter(item => item.postingType === 'accrual' || item.postingType === 'grant').map(item => item.date));
const added = [];
eachDate(addDays(bucket.lastGeneratedThrough, 1), target, date => {
const rule = resolveRuleVersion(bucket.ruleVersions, date);
if (!rule || compareDates(date, bucket.balanceAsOfDate) <= 0) return;
const postingType = isAccrualDate(rule, date) ? 'accrual' : isGrantDate(rule, date) ? 'grant' : null;
if (!postingType) return;
const id = generatedPostingId(bucket.id, rule.id, postingType, date);
if (ids.has(id) || generatedDates.has(date)) return;
const posting = { id, date, postingType, hours: postingType === 'accrual' ? Number(rule.hoursPerPayPeriod) : Number(rule.grantHours), ruleVersionId: rule.id };
ids.add(id); generatedDates.add(date); postings.push(posting); added.push(posting);
});
return { postings, lastGeneratedThrough: target, added };
}
function canEditOpeningBalance(bucket) {
if (!bucket || !Array.isArray(bucket.postings) || !Array.isArray(bucket.timeOffEntries) || !Array.isArray(bucket.ruleVersions)) return false;
if (bucket.postings.length !== 1 || bucket.timeOffEntries.length !== 0 || bucket.ruleVersions.length !== 1) return false;
const opening = bucket.postings[0], rule = bucket.ruleVersions[0];
return Boolean(opening && typeof opening === 'object' && opening.postingType === 'opening-balance' && opening.date === bucket.balanceAsOfDate
&& rule && typeof rule === 'object' && rule.effectiveFrom === bucket.balanceAsOfDate);
}
function editOpeningBalance(bucket, { date, hours }, nowDate) {
if (!canEditOpeningBalance(bucket)) throw new Error('Opening Balance can only be edited before Ledger activity begins.');
if (typeof hours === 'string' && !hours.trim()) throw new Error('Opening Balance hours must be finite and non-negative.');
if (!isValidDateString(date)) throw new Error('Opening Balance date must be a valid calendar date.');
const normalizedHours = assertFinite(hours, 'Opening Balance hours', { nonNegative: true });
const working = JSON.parse(JSON.stringify(bucket));
working.postings[0].date = date;
working.postings[0].hours = normalizedHours;
working.balanceAsOfDate = date;
working.ruleVersions[0].effectiveFrom = date;
working.lastGeneratedThrough = date;
const catchUp = generateCatchUp(working, nowDate);
working.postings = catchUp.postings;
working.lastGeneratedThrough = catchUp.lastGeneratedThrough;
return working;
}
function isOffFriday(date, anchorDate) { return weekday(date) === 5 && ((diffDays(date, anchorDate) % 14) + 14) % 14 === 0; }
function generateAlternatingFridays(anchorDate, start, end) {
if (!isValidDateString(anchorDate) || weekday(anchorDate) !== 5) throw new Error('Alternating Friday anchor must be an actual Friday.');
const dates = []; eachDate(start, end, date => { if (isOffFriday(date, anchorDate)) dates.push(date); }); return dates;
}
function workScheduleHours(schedule, date, observedHolidayDates = new Set()) {
validateWorkSchedule(schedule);
if (observedHolidayDates.has(date) || weekday(date) === 0 || weekday(date) === 6) return 0;
if (schedule.type === 'standard') return Number(schedule.dailyHours);
if (isOffFriday(date, schedule.offFridayAnchorDate)) return 0;
return weekday(date) === 5 ? Number(schedule.workingFridayHours) : Number(schedule.weekdayHours);
}
function rangeWorkingDates(start, end, workSchedule, observedHolidayDates = new Set()) {
if (!isValidDateString(start) || !isValidDateString(end)) throw new Error('Range requires valid start and end dates.');
validateWorkSchedule(workSchedule);
const first = minDate(start, end), last = maxDate(start, end), dates = [];
eachDate(first, last, date => { if (workScheduleHours(workSchedule, date, observedHolidayDates) > 0) dates.push(date); });
return dates;
}
function foldLedger(bucket, throughDate, options = {}) {
const points = [];
let balance = 0;
const postings = (options.postings || bucket.postings || []).filter(p => compareDates(p.date, throughDate) <= 0);
const entries = (options.timeOffEntries || bucket.timeOffEntries || []).filter(e => compareDates(e.date, throughDate) <= 0);
const dates = new Set([...postings.map(p => p.date), ...entries.map(e => e.date)]);
const earliest = minDate(bucket.balanceAsOfDate, ...dates);
if (earliest) {
const firstYear = parseDateParts(earliest).year;
const lastYear = parseDateParts(throughDate).year;
for (let year = firstYear; year <= lastYear; year += 1) dates.add(`${year}-01-01`);
}
[...dates].filter(date => compareDates(date, throughDate) <= 0).sort(compareDates).forEach(date => {
if (date.endsWith('-01-01')) {
const rule = resolveRuleVersion(bucket.ruleVersions, date);
const cap = rule && rule.carryoverCap !== null && rule.carryoverCap !== '' && rule.carryoverCap !== undefined ? Number(rule.carryoverCap) : null;
if (cap !== null) {
const before = balance; balance = Math.min(balance, cap);
if (balance !== before || options.includeNoopCaps) points.push({ date, kind: 'carryover-cap', id: `derived-cap:${bucket.id}:${date}`, hours: balance - before, balance, note: '' });
}
}
postings.filter(p => p.date === date).sort((a, b) => String(a.id).localeCompare(String(b.id))).forEach(posting => {
balance += Number(posting.hours); points.push({ ...posting, kind: posting.postingType, balance });
});
entries.filter(e => e.date === date).sort((a, b) => String(a.id).localeCompare(String(b.id))).forEach(entry => {
balance -= Number(entry.hours); points.push({ ...entry, kind: 'time-off-entry', hours: -Number(entry.hours), balance });
});
});
return { balance, points, flagged: points.filter(point => point.balance <= 0) };
}
function projectionHorizon(today, visibleRangeEnd, timeOffEntries) {
const nextBoundary = `${parseDateParts(today).year + 1}-01-01`;
const latestEntry = (timeOffEntries || []).map(e => e.date).filter(date => compareDates(date, today) > 0).sort(compareDates).pop();
return maxDate(visibleRangeEnd, nextBoundary, latestEntry || today);
}
function projectBucketThrough(bucket, today, horizon) {
const target = maxDate(today, horizon);
const existing = (bucket.postings || []).slice();
const ids = new Set(existing.map(p => p.id));
const projectedPostings = [];
if (compareDates(target, today) > 0) eachDate(addDays(today, 1), target, date => {
const rule = resolveRuleVersion(bucket.ruleVersions, date);
if (!rule) return;
const postingType = isAccrualDate(rule, date) ? 'accrual' : isGrantDate(rule, date) ? 'grant' : null;
if (!postingType) return;
const id = generatedPostingId(bucket.id, rule.id, postingType, date);
if (ids.has(id)) return;
projectedPostings.push({ id, date, postingType: `projected-${postingType}`, hours: postingType === 'accrual' ? Number(rule.hoursPerPayPeriod) : Number(rule.grantHours), ruleVersionId: rule.id, projected: true });
});
const folded = foldLedger(bucket, target, { postings: existing.concat(projectedPostings) });
return { ...folded, horizon: target, projectedPostings };
}
function projectBucket(bucket, today, visibleRangeEnd) {
return projectBucketThrough(bucket, today, projectionHorizon(today, visibleRangeEnd, bucket.timeOffEntries));
}
// End of the calendar year `date` falls in. The balance cards / entry preview
// project through this (not the next Jan 1) so a use-it-or-lose-it Grant shows
// the hours still usable this year instead of the post-Carryover-Cap 0 that
// lands exactly on the Jan 1 boundary. The Timeline keeps projecting past Jan 1
// so the expiry drop stays visible there.
function endOfYear(date) { return `${parseDateParts(date).year}-12-31`; }
function nthWeekdayOfMonth(year, month, targetWeekday, nth) {
const first = formatDateParts(year, month, 1); const offset = (targetWeekday - weekday(first) + 7) % 7;
return formatDateParts(year, month, 1 + offset + (nth - 1) * 7);
}
function lastWeekdayOfMonth(year, month, targetWeekday) { const last = formatDateParts(year, month, daysInMonth(year, month)); return addDays(last, -((weekday(last) - targetWeekday + 7) % 7)); }
function easterSunday(year) {
const a = year % 19, b = Math.floor(year / 100), c = year % 100, d = Math.floor(b / 4), e = b % 4;
const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31), day = ((h + l - 7 * m + 114) % 31) + 1;
return formatDateParts(year, month, day);
}
function shiftObservedHoliday(nominalDate) { const day = weekday(nominalDate); return day === 6 ? addDays(nominalDate, -1) : day === 0 ? addDays(nominalDate, 1) : nominalDate; }
const BUILT_IN_HOLIDAYS = [
{ id: 'new-years-day', name: "New Year's Day", date: year => `${year}-01-01` },
{ id: 'memorial-day', name: 'Memorial Day', date: year => lastWeekdayOfMonth(year, 5, 1) },
{ id: 'independence-day', name: 'Independence Day', date: year => `${year}-07-04` },
{ id: 'labor-day', name: 'Labor Day', date: year => nthWeekdayOfMonth(year, 9, 1, 1) },
{ id: 'thanksgiving', name: 'Thanksgiving', date: year => nthWeekdayOfMonth(year, 11, 4, 4) },
{ id: 'christmas', name: 'Christmas', date: year => `${year}-12-25` }
];
const CURATED_HOLIDAYS = [
{ id: 'good-friday', name: 'Good Friday', date: year => addDays(easterSunday(year), -2) },
{ id: 'christmas-eve', name: 'Christmas Eve', date: year => `${year}-12-24` },
{ id: 'day-after-thanksgiving', name: 'Day after Thanksgiving', date: year => addDays(nthWeekdayOfMonth(year, 11, 4, 4), 1) },
{ id: 'juneteenth', name: 'Juneteenth', date: year => `${year}-06-19` }
];
function generateObservedHolidays(startYear, endYear, enabledHolidayIds = [], customHolidays = []) {
const events = [];
for (let year = startYear; year <= endYear; year += 1) {
BUILT_IN_HOLIDAYS.concat(CURATED_HOLIDAYS.filter(h => enabledHolidayIds.includes(h.id))).forEach(holiday => {
const nominalDate = holiday.date(year); events.push({ id: `${holiday.id}:${year}`, holidayId: holiday.id, name: holiday.name, nominalDate, date: shiftObservedHoliday(nominalDate) });
});
}
customHolidays.forEach(holiday => { if (holiday && holiday.id && holiday.name && isValidDateString(holiday.date)) events.push({ id: holiday.id, holidayId: holiday.id, name: String(holiday.name), nominalDate: holiday.date, date: shiftObservedHoliday(holiday.date), custom: true }); });
const byDate = new Map();
events.sort((a, b) => compareDates(a.date, b.date) || a.name.localeCompare(b.name)).forEach(event => {
if (!byDate.has(event.date)) byDate.set(event.date, { date: event.date, names: [], events: [] });
const collision = byDate.get(event.date); collision.names.push(event.name); collision.events.push(event);
});
return [...byDate.values()].map(item => ({ ...item, name: item.names.join(' / '), id: item.events.map(e => e.id).join('|') }));
}
function escapeIcsText(value) { return String(value == null ? '' : value).replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); }
function utf8Length(value) { if (typeof Buffer !== 'undefined') return Buffer.byteLength(value, 'utf8'); return new TextEncoder().encode(value).length; }
function foldIcsLine(line) {
const lines = []; let current = '';
for (const char of String(line)) { if (current && utf8Length(current + char) > 75) { lines.push(current); current = ` ${char}`; } else current += char; }
lines.push(current); return lines.join('\r\n');
}
function formatIcs({ holidays = [], buckets = [], dtstamp = '20000101T000000Z' }) {
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//It Just Vibes//PTO Planner//EN', 'CALSCALE:GREGORIAN'];
holidays.forEach(holiday => lines.push('BEGIN:VEVENT', `UID:${escapeIcsText(holiday.id)}@pto-planner`, `DTSTAMP:${dtstamp}`, `DTSTART;VALUE=DATE:${holiday.date.replace(/-/g, '')}`, `DTEND;VALUE=DATE:${addDays(holiday.date, 1).replace(/-/g, '')}`, `SUMMARY:${escapeIcsText(holiday.name)}`, 'TRANSP:TRANSPARENT', 'END:VEVENT'));
buckets.forEach(bucket => (bucket.timeOffEntries || []).forEach(entry => {
const hours = Number(entry.hours), wholeDay = entry.wholeDay === true;
lines.push('BEGIN:VEVENT', `UID:${escapeIcsText(entry.id)}@pto-planner`, `DTSTAMP:${dtstamp}`, `DTSTART;VALUE=DATE:${entry.date.replace(/-/g, '')}`, `DTEND;VALUE=DATE:${addDays(entry.date, 1).replace(/-/g, '')}`, `SUMMARY:${escapeIcsText(wholeDay ? bucket.name : `${bucket.name} (${hours}h)`)}`, `TRANSP:${wholeDay ? 'OPAQUE' : 'TRANSPARENT'}`, 'END:VEVENT');
}));
lines.push('END:VCALENDAR'); return lines.map(foldIcsLine).join('\r\n') + '\r\n';
}
function neutralizeCsvCell(value) { const text = String(value == null ? '' : value); return /^[=+\-@]/.test(text) ? `'${text}` : text; }
function csvCell(value) { const safe = neutralizeCsvCell(value); return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; }
function formatCsv(buckets, throughDate) {
const rows = [['Bucket', 'Date', 'Ledger item', 'Hours', 'Running balance', 'Note', 'Derived']];
(buckets || []).forEach(bucket => {
foldLedger(bucket, throughDate).points.forEach(point => rows.push([bucket.name, point.date, point.kind === 'carryover-cap' ? 'Carryover Cap adjustment' : point.kind, point.hours, point.balance, point.note || '', point.kind === 'carryover-cap' ? 'yes' : 'no']));
});
return rows.map(row => row.map(csvCell).join(',')).join('\r\n') + '\r\n';
}
function migrateStateDocument(raw) {
if (!raw || typeof raw !== 'object') throw new Error('Stored state is corrupted.');
if (raw.schemaVersion === SCHEMA_VERSION) return JSON.parse(JSON.stringify(raw));
if (raw.schemaVersion === 0 || raw.schemaVersion === undefined) {
const settings = raw.settings || {};
settings.workSchedule = settings.workSchedule || { type: 'standard', dailyHours: 8 };
settings.enabledHolidayIds = Array.isArray(settings.enabledHolidayIds) ? settings.enabledHolidayIds : [];
return { schemaVersion: SCHEMA_VERSION, settings, buckets: raw.buckets || [], customHolidays: raw.customHolidays || [] };
}
throw new Error(`Unsupported schemaVersion: ${raw.schemaVersion}`);
}
function validateStateDocument(raw) {
const state = migrateStateDocument(raw);
if (!state.settings || !Array.isArray(state.buckets) || !Array.isArray(state.customHolidays)) throw new Error('Stored state is corrupted.');
validateWorkSchedule(state.settings.workSchedule);
if (!Array.isArray(state.settings.enabledHolidayIds)) throw new Error('Stored holiday settings are corrupted.');
state.buckets.forEach(bucket => {
if (!bucket.id || !bucket.name || !isValidDateString(bucket.balanceAsOfDate) || !isValidDateString(bucket.lastGeneratedThrough) || !Array.isArray(bucket.ruleVersions) || !Array.isArray(bucket.postings) || !Array.isArray(bucket.timeOffEntries)) throw new Error('Stored Bucket data is corrupted.');
bucket.ruleVersions.forEach(validateRuleVersion); bucket.timeOffEntries.forEach(validateTimeOffEntry);
bucket.postings.forEach(posting => {
if (!posting.id || !isValidDateString(posting.date)) throw new Error('Stored Posting data is corrupted.');
assertFinite(posting.hours, 'Posting hours');
if (posting.postingType === 'adjustment') validateAdjustment(posting);
});
});
return state;
}
function createSaveQueue(saveFunction, onStatus) {
// Status tracks the count of enqueued-but-not-settled snapshots, not a boolean:
// rapid enqueues (e.g. rapid delete) must keep reporting "saving" until the
// NEWEST snapshot settles, instead of flashing "Saved" when the first resolves.
let tail = Promise.resolve(); let pending = 0; let lastError = null;
const isDirty = () => pending > 0 || lastError != null; // in-flight OR last save failed (unsaved changes remain)
const emit = () => { if (onStatus) onStatus({ dirty: isDirty(), saving: pending > 0, error: pending > 0 ? null : lastError }); };
function enqueue(snapshot) {
pending += 1; emit();
const attempt = tail.catch(() => undefined).then(() => saveFunction('state', JSON.parse(JSON.stringify(snapshot))));
tail = attempt.then(
() => { pending -= 1; lastError = null; emit(); },
error => { pending -= 1; lastError = error; emit(); throw error; }
);
return tail;
}
return { enqueue, retry: enqueue, isDirty, whenIdle: () => tail };
}
// ---------------------------------------------------------------------------
// Balance Timeline / All Days Off feature — pure operations & view helpers.
// (Kept here so lib/logic.js stays the single source; the inline copy in
// widget.html is byte-mirrored and guarded by the parity test.)
// ---------------------------------------------------------------------------
// End-of-day balance: the running balance after ALL activity on the latest
// point-date <= `date` (so Jan 1 returns the balance after Cap -> Postings ->
// Time-Off Entries), or 0 before the first point. `points` must be date-sorted.
function balanceAtDate(points, date) {
let balance = 0;
for (const point of points || []) { if (compareDates(point.date, date) <= 0) balance = Number(point.balance); else break; }
return balance;
}
// The supported ceiling for a NEW or date-changed Time-Off Entry: the same
// calendar date ten years ahead, Feb 29 -> Feb 28 clamped. Inclusive.
function supportedMaxDate(today) {
const p = parseDateParts(today); const year = p.year + 10;
return formatDateParts(year, p.month, Math.min(p.day, daysInMonth(year, p.month)));
}
// Structural validation PLUS the supported-date ceiling. Applied only on
// creation or an actual date change — never at load (see validateTimeOffEntry,
// which validateStateDocument uses so legacy out-of-range entries still load).
function validateNewOrMovedTimeOffEntry(entry, today, maxDate) {
validateTimeOffEntry(entry);
const max = maxDate || supportedMaxDate(today);
if (compareDates(entry.date, max) > 0) throw new Error(`Time-Off Entry date is beyond the supported range (through ${max}).`);
return true;
}
function observedHolidayDateSet(state, year) {
return new Set(generateObservedHolidays(year, year, (state.settings && state.settings.enabledHolidayIds) || [], state.customHolidays || []).map(h => h.date));
}
function scheduledHoursForDate(state, date) {
return workScheduleHours(state.settings.workSchedule, date, observedHolidayDateSet(state, parseDateParts(date).year));
}
// One operation for create / edit / move of a Time-Off Entry, shared by the
// Calendar single-entry form, the multi-day range handler, and Days Off
// quick-add. Returns new state, never mutates the input.
// opts: { targetBucketId, entryId?, sourceBucketId?, date, wholeDay, hours,
// recalculateWholeDay?, today? }
function upsertTimeOffEntry(state, opts, idFactory) {
const { targetBucketId, entryId, sourceBucketId, date, wholeDay } = opts;
if (!isValidDateString(date)) throw new Error('Time-Off Entry requires a valid date.');
const buckets = state.buckets.map(b => ({ ...b, timeOffEntries: (b.timeOffEntries || []).slice() }));
const target = buckets.find(b => b.id === targetBucketId);
if (!target) throw new Error('Target Bucket not found.');
const isEdit = entryId != null || sourceBucketId != null;
let existing = null, previousDate = null;
if (isEdit) {
if (entryId == null || sourceBucketId == null) throw new Error('Editing a Time-Off Entry requires both sourceBucketId and entryId.');
const source = buckets.find(b => b.id === sourceBucketId);
const index = source ? source.timeOffEntries.findIndex(e => e.id === entryId) : -1;
if (index < 0) throw new Error('Source Time-Off Entry not found.');
existing = source.timeOffEntries[index]; previousDate = existing.date;
source.timeOffEntries.splice(index, 1); // remove (sourceBucketId, entryId) FIRST
}
const id = isEdit ? entryId : (idFactory ? idFactory() : null);
if (id == null) throw new Error('A new Time-Off Entry requires an idFactory.');
if (target.timeOffEntries.some(e => e.id === id)) throw new Error('A Time-Off Entry with this ID already exists in the target Bucket.');
const dateChanged = existing ? previousDate !== date : true;
let hours, resolvedWholeDay;
if (wholeDay) {
const recalc = !existing || opts.recalculateWholeDay || dateChanged || existing.wholeDay !== true;
if (recalc) {
hours = scheduledHoursForDate(state, date);
if (!(hours > 0)) throw new Error('A whole-day Time-Off Entry requires a working day with scheduled hours.');
} else hours = Number(existing.hours);
resolvedWholeDay = true;
} else {
hours = opts.hours != null ? assertFinite(opts.hours, 'Time-Off Entry hours', { positive: true })
: existing ? Number(existing.hours) : assertFinite(opts.hours, 'Time-Off Entry hours', { positive: true });
resolvedWholeDay = false;
}
const entry = { id, date, hours, wholeDay: resolvedWholeDay };
const today = opts.today || todayString();
if (!existing || dateChanged) validateNewOrMovedTimeOffEntry(entry, today);
else validateTimeOffEntry(entry);
target.timeOffEntries.push(entry);
return { ...state, buckets };
}
// All-or-nothing batch used by the range handler: folds each item through
// upsertTimeOffEntry over a working copy. Any failure throws and the caller's
// input state is left deeply unchanged (nothing is committed).
function upsertTimeOffEntriesAtomically(state, items, idFactory) {
let working = state;
(items || []).forEach(item => { working = upsertTimeOffEntry(working, item, idFactory); });
return working;
}
function deleteTimeOffEntry(state, bucketId, entryId) {
let changed = false;
const buckets = state.buckets.map(b => {
if (b.id !== bucketId || !(b.timeOffEntries || []).some(e => e.id === entryId)) return b;
changed = true;
return { ...b, timeOffEntries: b.timeOffEntries.filter(e => e.id !== entryId) };
});
return changed ? { ...state, buckets } : state;
}
// Global display Horizon (includes archived Buckets), always defined even with
// no non-archived Buckets — drives the Days Off holiday-generation window.
function displayHorizon(buckets, today) {
const entries = (buckets || []).reduce((all, b) => all.concat(b.timeOffEntries || []), []);
return projectionHorizon(today, today, entries);
}
function filterHolidaysToWindow(holidays, start, end) {
return (holidays || []).filter(h => compareDates(h.date, start) >= 0 && compareDates(h.date, end) <= 0);
}
// One chronological list of every planned day off: all Buckets' Time-Off
// Entries (archived included, to match Calendar/exports) + Observed Holidays.
function aggregateDaysOff(buckets, observedHolidays) {
const rows = [];
(buckets || []).forEach(b => (b.timeOffEntries || []).forEach(e => rows.push({
kind: 'time-off', date: e.date, bucketId: b.id, bucketName: b.name, archived: b.archived === true,
entryId: e.id, hours: Number(e.hours), wholeDay: e.wholeDay === true
})));
(observedHolidays || []).forEach(h => rows.push({ kind: 'holiday', date: h.date, name: h.name }));
const label = row => row.kind === 'holiday' ? row.name : row.bucketName;
return rows.sort((a, b) => compareDates(a.date, b.date) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || String(label(a)).localeCompare(String(label(b))));
}
// Pure geometry (fractions in [0,1]); the SVG renderer scales by width/height,
// and the inverse is what the pointer/keyboard hover controller uses.
function dateToFraction(date, domainStart, horizon) { const span = diffDays(horizon, domainStart) || 1; return diffDays(date, domainStart) / span; }
function fractionToDate(fraction, domainStart, horizon) {
const span = diffDays(horizon, domainStart);
const offset = Math.max(0, Math.min(span, Math.round(fraction * span)));
return addDays(domainStart, offset);
}
function hoursToFraction(hours, valueRange) { const span = (valueRange[1] - valueRange[0]) || 1; return (hours - valueRange[0]) / span; }
// The staircase model for the Timeline: one line per non-archived Bucket over a
// SHARED x-domain and y-range. Steps carry the previous point's balance so
// multiple same-date events (Cap -> Adjustment -> Accrual -> Entry) render
// correctly. `empty` is true only when there are no non-archived Buckets.
function timelineModel(buckets, today) {
const active = (buckets || []).filter(b => b.archived !== true);
if (!active.length) return { empty: true, series: [], domainStart: today, horizon: projectionHorizon(today, today, []), valueRange: [0, 1], settledThrough: today };
const projected = active.map(b => {
const horizonB = projectionHorizon(today, today, b.timeOffEntries || []);
const proj = projectBucket(b, today, horizonB);
return { bucketId: b.id, name: b.name, horizon: proj.horizon, points: proj.points.slice().sort((a, c) => compareDates(a.date, c.date)) };
});
const horizon = projected.map(s => s.horizon).reduce((a, b) => maxDate(a, b));
const starts = projected.map(s => s.points.length ? s.points[0].date : today);
const domainStart = minDate(today, ...starts);
let lo = 0, hi = 0;
projected.forEach(s => s.points.forEach(p => { lo = Math.min(lo, Number(p.balance)); hi = Math.max(hi, Number(p.balance)); }));
lo = Math.min(0, lo); hi = Math.max(0, hi); if (lo === hi) { lo -= 1; hi += 1; }
const series = projected.map(s => {
const steps = [];
const before = s.points.length ? balanceAtDate(s.points, addDays(s.points[0].date, -1)) : 0;
let prev = before;
steps.push({ date: domainStart, balance: before });
s.points.forEach(p => {
if (compareDates(p.date, domainStart) < 0 || compareDates(p.date, horizon) > 0) { prev = Number(p.balance); return; }
steps.push({ date: p.date, balance: prev }); // horizontal at prior balance
steps.push({ date: p.date, balance: Number(p.balance) }); // vertical step to new balance
prev = Number(p.balance);
});
// Explicit vertex at `today` so the settled/projected split joins cleanly.
if (compareDates(today, domainStart) >= 0 && compareDates(today, horizon) <= 0) {
const todayBalance = balanceAtDate(s.points, today);
const insertAt = steps.findIndex(pt => compareDates(pt.date, today) > 0);
const vertex = { date: today, balance: todayBalance };
if (insertAt < 0) steps.push(vertex); else steps.splice(insertAt, 0, vertex);
}
steps.push({ date: horizon, balance: prev });
return { bucketId: s.bucketId, name: s.name, horizon: s.horizon, points: s.points, steps };
});
return { empty: false, series, domainStart, horizon, valueRange: [lo, hi], settledThrough: today };
}
// The lowest balance a Bucket may reach before it is flagged over-booked.
// 0 unless the user allows going below zero, then -limitHours. A balance is
// over-booked when it is strictly LESS THAN this floor (so exactly reaching the
// floor — including exactly 0 by default — is fine, not flagged).
function negativeFloor(negativeBalance) {
const n = negativeBalance || {};
return n.allowed ? (-Math.abs(Number(n.limitHours) || 0) || 0) : 0; // `|| 0` avoids -0
}
// Classifies every day of a calendar year for the year-at-a-glance PNG:
// each date -> { kind: 'time-off'|'holiday'|'off'|'work', bucketId?, bucketName?,
// holidayName? } (a Time-Off Entry wins over a holiday, which wins over a
// non-working day), plus per-Bucket totals and the legend flags. The canvas
// renderer assigns colours; this stays pure and testable.
function yearOverviewModel(year, buckets, observedHolidays, workSchedule) {
const holidayByDate = new Map((observedHolidays || []).map(h => [h.date, h.name]));
const holidayDates = new Set(holidayByDate.keys());
const entryByDate = new Map();
const totals = new Map();
(buckets || []).forEach(b => (b.timeOffEntries || []).forEach(e => {
if (parseDateParts(e.date).year !== year) return;
if (!entryByDate.has(e.date)) entryByDate.set(e.date, { bucketId: b.id, bucketName: b.name });
const t = totals.get(b.id) || { bucketId: b.id, name: b.name, dates: new Set(), hours: 0 };
t.dates.add(e.date); t.hours += Number(e.hours); totals.set(b.id, t);
}));
const days = {};
eachDate(`${year}-01-01`, `${year}-12-31`, date => {
if (entryByDate.has(date)) days[date] = { kind: 'time-off', ...entryByDate.get(date) };
else if (holidayDates.has(date)) days[date] = { kind: 'holiday', holidayName: holidayByDate.get(date) };
else if (workScheduleHours(workSchedule, date, holidayDates) <= 0) days[date] = { kind: 'off' };
else days[date] = { kind: 'work' };
});
const summary = [...totals.values()].map(t => ({ bucketId: t.bucketId, name: t.name, days: t.dates.size, hours: t.hours }));
return { year, days, totals: summary, legend: { buckets: summary, hasHoliday: holidayDates.size > 0 } };
}
(function () {
const $ = id => document.getElementById(id);
const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };
const uid = prefix => `${prefix}-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
const nowDate = todayString();
let state, rawRecoveryData = null, selectedDate = null, rangeMode = false, rangeStart = null, rangeEnd = null, currentMonth = nowDate.slice(0, 7), observed = [], saveQueue, lastDirtySnapshot = null, activeTab = 'calendar';
const SVGNS = 'http://www.w3.org/2000/svg';
const CHART = { w: 820, h: 300, padL: 48, padR: 18, padT: 16, padB: 30 };
// Gold (#d49d3c) is reserved for Observed Holidays in the year PNG, so it is
// intentionally left out of the per-Bucket palette to avoid a colour clash.
const BUCKET_COLORS = ['#1c6b4a', '#334f78', '#ae3d35', '#6b21a8', '#0e7490', '#b45309', '#0f766e', '#7c3aed'];
const TABS = [['tab-calendar', 'view-calendar', 'calendar'], ['tab-timeline', 'view-timeline', 'timeline'], ['tab-daysoff', 'view-daysoff', 'daysoff']];
function defaultRule(id, bucketType, date, overrides) {
return Object.assign({ id: uid('rule'), effectiveFrom: date, sequence: 0, bucketType, carryoverCap: bucketType === 'grant' ? 0 : null,
hoursPerPayPeriod: 4, payPeriodCadence: 'biweekly', accrualAnchorDate: date, semiMonthlyPattern: '1st-and-15th',
grantHours: 8, grantDate: date, grantRecurrence: 'annual' }, overrides || {});
}
function defaultBucket(name, bucketType, date, overrides) {
const id = uid('bucket'); const openingId = uid('opening-balance');
return { id, name, archived: false, balanceAsOfDate: date, lastGeneratedThrough: date, ruleVersions: [defaultRule(id, bucketType, date, overrides)], postings: [{ id: openingId, date, postingType: 'opening-balance', hours: 0, note: 'Opening Balance' }], timeOffEntries: [] };
}
function initialState() {
return { schemaVersion: SCHEMA_VERSION, settings: { workSchedule: { type: 'standard', dailyHours: 8, weekdayHours: 9, workingFridayHours: 8, offFridayAnchorDate: nextFriday(nowDate) }, enabledHolidayIds: [] },
buckets: [defaultBucket('PTO', 'accrual', nowDate), defaultBucket('Sick', 'accrual', nowDate), defaultBucket('Floating Holiday', 'grant', nowDate)], customHolidays: [] };
}
function nextFriday(date) { let d = date; while (weekday(d) !== 5) d = addDays(d, 1); return d; }
function latestRule(bucket) { return bucket.ruleVersions.slice().sort((a,b) => compareDates(b.effectiveFrom,a.effectiveFrom) || b.sequence-a.sequence)[0]; }
function activeBuckets() { return state.buckets.filter(bucket => !bucket.archived); }
function monthRange(month) { const p = parseDateParts(`${month}-01`); const first = `${month}-01`; const gridStart = addDays(first, -weekday(first)); const last = formatDateParts(p.year,p.month,daysInMonth(p.year,p.month)); return { first,last,gridStart,gridEnd:addDays(last,6-weekday(last)),year:p.year,month:p.month }; }
function holidaySet() { return new Set(observed.map(h => h.date)); }
function scheduleHours(date) { return workScheduleHours(state.settings.workSchedule,date,holidaySet()); }
function monthLabel(month) { const p=parseDateParts(`${month}-01`); return new Intl.DateTimeFormat(undefined,{month:'long',year:'numeric'}).format(new Date(p.year,p.month-1,1,12)); }
function shortDate(date) { const p=parseDateParts(date); return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',year:'numeric'}).format(new Date(p.year,p.month-1,p.day,12)); }
function hours(value) { return `${Number(value).toLocaleString(undefined,{maximumFractionDigits:2})}h`; }
function setNotice(message) { const host=$('notice'); host.replaceChildren(); if(message) host.append(el('div','notice',message)); }
function persist() {
lastDirtySnapshot = JSON.parse(JSON.stringify(state));
saveQueue.enqueue(lastDirtySnapshot).catch(() => undefined);
}
function saveStatus(status) {
const node=$('saveStatus'); node.className=`status${status.error?' error':''}`;
node.textContent=status.error?'Unsaved changes':status.saving?'Saving\u2026':status.dirty?'Changes not saved':'Saved';
$('retrySave').classList.toggle('hidden',!status.error);
}
function rerenderAndPersist() { render(); persist(); }
function refreshObserved() {
const range=monthRange(currentMonth); observed=generateObservedHolidays(range.year-1,range.year+1,state.settings.enabledHolidayIds,state.customHolidays);
}
function render() { refreshObserved(); renderBalances(); renderCalendar(); renderSide(); renderSchedule(); renderHolidays(); renderBuckets(); if (activeTab === 'timeline') renderTimeline(); if (activeTab === 'daysoff') renderDaysOff(); }
function renderBalances() {
const host=$('balances'); host.replaceChildren();
state.buckets.filter(b=>!b.archived).forEach(bucket=>{
// Projected figure is the balance usable through the END OF THIS YEAR
// (month-independent, labelled with that date). Projecting to end-of-year
// rather than the next Jan 1 avoids a use-it-or-lose-it Grant reading as 0
// the instant its Carryover Cap wipes it at the year boundary.
const current=foldLedger(bucket,nowDate), projected=projectBucketThrough(bucket,nowDate,endOfYear(nowDate)), flr=balanceFloor(), over=projected.points.some(p=>Number(p.balance)<flr); const card=el('article',`balance-card${over?' flag':''}`);
card.append(el('h3','',bucket.name),el('div','balance-value',hours(current.balance)));
const meta=el('div','balance-meta'); meta.append(el('span','',`Projected ${hours(projected.balance)} through ${shortDate(projected.horizon)}`),el('span','',over?'Over-booked':'On track')); card.append(meta); host.append(card);
});
}
function renderCalendar() {
$('monthTitle').textContent=monthLabel(currentMonth); $('rangeToggle').setAttribute('aria-pressed',String(rangeMode)); const host=$('calendar'); host.replaceChildren(); ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(day=>host.append(el('div','weekday',day)));
const range=monthRange(currentMonth), holidayMap=new Map(observed.map(h=>[h.date,h])); const entryMap=new Map();
state.buckets.forEach(bucket=>(bucket.timeOffEntries||[]).forEach(entry=>{ if(!entryMap.has(entry.date))entryMap.set(entry.date,[]); entryMap.get(entry.date).push({entry,bucket}); }));
eachDate(range.gridStart,range.gridEnd,date=>{
const rangeLast=rangeEnd||rangeStart, inSelectedRange=rangeMode&&rangeStart&&compareDates(date,rangeStart)>=0&&compareDates(date,rangeLast)<=0;
const inMonth=date.slice(0,7)===currentMonth, working=scheduleHours(date)>0, cell=el('button',`day${inMonth?'':' out'}${date===nowDate?' today':''}${working?'':' nonwork'}${inSelectedRange?' in-range':''}${rangeMode&&date===rangeStart?' range-start':''}${rangeMode&&rangeEnd&&date===rangeEnd?' range-end':''}`); cell.type='button'; cell.dataset.date=date; cell.setAttribute('aria-label',shortDate(date));
cell.append(el('span','num',String(parseDateParts(date).day)));
if(holidayMap.has(date)) cell.append(el('span','tag holiday',holidayMap.get(date).name)); else if(!working) cell.append(el('span','tag off',weekday(date)===0||weekday(date)===6?'Weekend':'Off Friday'));
(entryMap.get(date)||[]).forEach(item=>cell.append(el('span','tag entry',`${item.bucket.name} \u00B7 ${hours(item.entry.hours)}`)));
cell.addEventListener('click',()=>{if(rangeMode){if(!rangeStart||rangeEnd){rangeStart=date;rangeEnd=null;}else{const firstClick=rangeStart;rangeStart=minDate(firstClick,date);rangeEnd=maxDate(firstClick,date);}selectedDate=null;$('entryId').value='';renderCalendar();renderSide();return;}selectedDate=date;$('entryId').value='';renderSide();}); host.append(cell);
});
}
function rangeHolidayEvents() {
if(!rangeStart)return[];const firstYear=parseDateParts(rangeStart).year,lastYear=parseDateParts(rangeEnd||rangeStart).year;
return generateObservedHolidays(firstYear-1,lastYear+1,state.settings.enabledHolidayIds,state.customHolidays);
}
function rangePreviewData() {
if(!rangeStart||!rangeEnd)return{all:[],working:[],totalHours:0,holidayMap:new Map(),holidayDates:new Set()};
const holidayEvents=rangeHolidayEvents(),holidayMap=new Map(holidayEvents.map(item=>[item.date,item])),holidayDates=new Set(holidayMap.keys()),all=[];
eachDate(rangeStart,rangeEnd,date=>all.push(date));
const working=rangeWorkingDates(rangeStart,rangeEnd,state.settings.workSchedule,holidayDates),totalHours=working.reduce((total,date)=>total+workScheduleHours(state.settings.workSchedule,date,holidayDates),0);
return{all,working,totalHours,holidayMap,holidayDates};
}
function populateRangeBuckets(selected) { const select=$('rangeBucket'); select.replaceChildren(); activeBuckets().forEach(bucket=>{const option=el('option','',bucket.name);option.value=bucket.id;select.append(option);}); if(selected&&activeBuckets().some(bucket=>bucket.id===selected))select.value=selected; }
function renderRangeConfirmation() {
const priorBucket=$('rangeBucket').value;populateRangeBuckets(priorBucket);const list=$('rangeDates');list.replaceChildren();const summary=$('rangeSummary'),confirm=$('confirmRange');
if(!rangeStart){$('selectedTitle').textContent='Select a date range';$('selectedMeta').textContent='Click the first day of the range.';summary.className='preview';summary.textContent='Whole-day Time-Off Entries will be created only for working days.';confirm.disabled=true;return;}
if(!rangeEnd){$('selectedTitle').textContent=`Range starts ${shortDate(rangeStart)}`;$('selectedMeta').textContent='Start selected. Click the last day, before or after it.';summary.className='preview';summary.textContent='Waiting for the second date.';list.append(el('div','range-date',`${shortDate(rangeStart)} \u00B7 range start`));confirm.disabled=true;return;}
$('selectedTitle').textContent=`${shortDate(rangeStart)} to ${shortDate(rangeEnd)}`;$('selectedMeta').textContent='Review the working and skipped dates before confirming.';const preview=rangePreviewData(),workingSet=new Set(preview.working),skipped=preview.all.length-preview.working.length;
summary.className=`preview${preview.working.length?'':' flag'}`;summary.textContent=preview.working.length?`${preview.working.length} working day${preview.working.length===1?'':'s'} \u00B7 ${skipped} skipped \u00B7 ${hours(preview.totalHours)} total`:'No working days fall in this range. Choose a range containing a Work Schedule day.';
preview.all.forEach(date=>{const working=workingSet.has(date),row=el('div',`range-date${working?'':' skipped'}`);row.append(el('span','',shortDate(date)));if(working)row.append(el('strong','',hours(workScheduleHours(state.settings.workSchedule,date,preview.holidayDates))));else{const holiday=preview.holidayMap.get(date),reason=holiday?holiday.name:weekday(date)===0||weekday(date)===6?'Weekend':'Work Schedule non-working day';row.append(el('span','',`Skipped \u2014 ${reason}`));}list.append(row);});
confirm.disabled=!preview.working.length||!$('rangeBucket').value;if(!$('rangeBucket').value&&preview.working.length){summary.className='preview flag';summary.textContent='No active Bucket is available for this range.';}
}
function populateEntryBuckets(selected) { const select=$('entryBucket'); select.replaceChildren(); activeBuckets().forEach(bucket=>{const option=el('option','',bucket.name);option.value=bucket.id;select.append(option);}); if(selected)select.value=selected; }
function updateEntryPreview() {
if(!selectedDate||!$('entryBucket').value)return; const bucket=state.buckets.find(b=>b.id===$('entryBucket').value); let value=Number($('entryHours').value||0);
const editingId=$('entryId').value, copy=JSON.parse(JSON.stringify(bucket)); if(editingId)copy.timeOffEntries=copy.timeOffEntries.filter(e=>e.id!==editingId); copy.timeOffEntries.push({id:'preview',date:selectedDate,hours:value,wholeDay:$('entryWholeDay').checked});
const projected=projectBucketThrough(copy,nowDate,endOfYear(nowDate)), over=projected.balance<balanceFloor(), box=$('entryPreview'); box.className=`preview${over?' flag':''}`; box.textContent=`Projected ${bucket.name} balance through ${shortDate(projected.horizon)}: ${hours(projected.balance)}${over?' \u00B7 over-booked':''}`;
}
function renderSide() {
const has=Boolean(selectedDate); $('rangeArea').classList.toggle('hidden',!rangeMode); $('entryArea').classList.toggle('hidden',rangeMode||!has); $('sideEmpty').classList.toggle('hidden',rangeMode||has); if(rangeMode){renderRangeConfirmation();return;} if(!has){$('selectedTitle').textContent='Choose a date';$('selectedMeta').textContent='Select a calendar day to log time off.';return;}
$('selectedTitle').textContent=shortDate(selectedDate); const dayHours=scheduleHours(selectedDate), holiday=observed.find(h=>h.date===selectedDate);
$('selectedMeta').textContent=holiday?`Observed Holiday \u00B7 ${holiday.name}`:dayHours?`${hours(dayHours)} Work Schedule day`:'Non-working date \u00B7 enter hours explicitly';
populateEntryBuckets($('entryBucket').value); const whole=$('entryWholeDay'); whole.disabled=!dayHours; $('wholeDayLabel').classList.toggle('muted',!dayHours);
if(!$('entryId').value){whole.checked=Boolean(dayHours);$('entryHours').value=dayHours||'';} updateEntryPreview();
const list=$('dateEntries');list.replaceChildren();let count=0;state.buckets.forEach(bucket=>bucket.timeOffEntries.filter(e=>e.date===selectedDate).forEach(entry=>{count++;const row=el('div','entry-row');const info=el('div');info.append(el('strong','',bucket.name),el('div','muted small',`${hours(entry.hours)} \u00B7 ${entry.wholeDay?'whole day':'partial/hourly'}`));const actions=el('div','row');const edit=el('button','', 'Edit');edit.type='button';edit.addEventListener('click',()=>editEntry(bucket,entry));const del=el('button','danger','Delete');del.type='button';armConfirm(del,()=>{state=deleteTimeOffEntry(state,bucket.id,entry.id);rerenderAndPersist();});actions.append(edit,del);row.append(info,actions);list.append(row);}));if(!count)list.append(el('div','muted small','No Time-Off Entries yet.'));
}
function editEntry(bucket,entry){$('entryId').value=entry.id;populateEntryBuckets(bucket.id);$('entryWholeDay').checked=Boolean(entry.wholeDay);$('entryHours').value=entry.hours;updateEntryPreview();}
function renderSchedule(){const s=state.settings.workSchedule;$('scheduleType').value=s.type;$('dailyHours').value=s.dailyHours;$('offFridayAnchorDate').value=s.offFridayAnchorDate;$('weekdayHours').value=s.weekdayHours;$('workingFridayHours').value=s.workingFridayHours;const alt=s.type!=='standard';$('standardFields').classList.toggle('hidden',alt);$('alternateFields').classList.toggle('hidden',!alt);const neg=state.settings.negativeBalance||{allowed:false,limitHours:0};$('allowNegative').checked=!!neg.allowed;$('negativeLimit').value=neg.limitHours||0;$('negativeLimit').disabled=!neg.allowed;}
function balanceFloor(){return negativeFloor(state.settings.negativeBalance);}
function renderHolidays(){const toggles=$('holidayToggles');toggles.replaceChildren();CURATED_HOLIDAYS.forEach(holiday=>{const label=el('label','check');const input=document.createElement('input');input.type='checkbox';input.checked=state.settings.enabledHolidayIds.includes(holiday.id);input.addEventListener('change',()=>{state.settings.enabledHolidayIds=input.checked?[...state.settings.enabledHolidayIds,holiday.id]:state.settings.enabledHolidayIds.filter(id=>id!==holiday.id);rerenderAndPersist();});label.append(input,document.createTextNode(holiday.name));toggles.append(label);});const custom=$('customHolidays');custom.replaceChildren();state.customHolidays.forEach(holiday=>{const row=el('div','entry-row');row.append(el('span','',`${holiday.name} \u00B7 ${holiday.date}`));const del=el('button','danger','Remove');del.type='button';del.addEventListener('click',()=>{state.customHolidays=state.customHolidays.filter(h=>h.id!==holiday.id);rerenderAndPersist();});row.append(del);custom.append(row);});}
function field(labelText,value,type,name,options){const label=el('label','',labelText),input=document.createElement(type==='select'?'select':'input');input.name=name;if(type==='select')options.forEach(item=>{const option=el('option','',item.label);option.value=item.value;input.append(option);});else{input.type=type||'text';if(type==='number'){input.step=name==='hours'?'.25':'.01';input.min='0';}}input.value=value==null?'':value;label.append(input);return label;}
function renderBuckets(){const host=$('bucketConfigs');host.replaceChildren();activeBuckets().forEach(bucket=>host.append(bucketEditor(bucket)));const archived=$('archivedBuckets');archived.replaceChildren();const items=state.buckets.filter(b=>b.archived);if(!items.length)archived.append(document.createTextNode('No archived Buckets.'));items.forEach(bucket=>{const block=el('div','bucket-config');block.append(el('strong','',bucket.name));const folded=foldLedger(bucket,nowDate);block.append(el('div','muted',`${hours(folded.balance)} current \u00B7 Ledger retained`),ledgerView(bucket));archived.append(block);});}
function bucketEditor(bucket){const rule=latestRule(bucket),wrap=el('div','bucket-config'),heading=el('div','section-title');heading.append(el('h3','',bucket.name));const archive=el('button','danger','Archive');archive.type='button';archive.addEventListener('click',()=>{bucket.archived=true;rerenderAndPersist();});heading.append(archive);wrap.append(heading,el('div','rule-note',`${rule.bucketType==='accrual'?'Accrual':'Grant'} Rule Version effective ${rule.effectiveFrom} \u00B7 sequence ${rule.sequence}`));
const openingPostings=Array.isArray(bucket.postings)?bucket.postings.filter(posting=>posting&&posting.postingType==='opening-balance'):[];
if(canEditOpeningBalance(bucket)){const opening=openingPostings[0],openingForm=document.createElement('form'),openingGrid=el('div','two'),hoursField=field('Starting balance',opening.hours,'number','hours'),dateField=field('As of',opening.date,'date','date'),hoursInput=hoursField.querySelector('input'),dateInput=dateField.querySelector('input');hoursInput.min='0';hoursInput.step='0.01';hoursInput.required=true;dateInput.required=true;openingGrid.append(hoursField,dateField);const openingSubmit=el('button','','Update Starting Balance');openingSubmit.type='submit';openingForm.append(openingGrid,openingSubmit);openingForm.addEventListener('submit',event=>{event.preventDefault();try{const data=new FormData(openingForm),updated=editOpeningBalance(bucket,{date:data.get('date'),hours:data.get('hours')},nowDate),generatedCount=updated.postings.length-bucket.postings.length,postingType=rule.bucketType==='grant'?'grant':'accrual';state.buckets=state.buckets.map(item=>item.id===bucket.id?updated:item);setNotice(`Starting balance updated to ${updated.postings[0].hours}h as of ${updated.balanceAsOfDate}. ${generatedCount?`${generatedCount} ${postingType} posting${generatedCount===1?'':'s'} generated to catch up.`:'No postings generated yet.'}`);rerenderAndPersist();}catch(error){setNotice(error.message);}});wrap.append(openingForm);}
else if(openingPostings.length===1)wrap.append(el('div','rule-note',`Starting balance: ${openingPostings[0].hours}h as of ${openingPostings[0].date} \u2014 use Add an Adjustment to correct`));
else wrap.append(el('div','rule-note','Opening Balance unavailable'));
const form=document.createElement('form');form.dataset.bucket=bucket.id;const two=el('div','two'),mechanicField=field('Mechanic',rule.bucketType,'select','bucketType',[{value:'accrual',label:'Accrual'},{value:'grant',label:'Grant'}]);two.append(field('Bucket name',bucket.name,'text','name'),mechanicField);form.append(two);
const accrualGrid=el('div','two'),grantGrid=el('div','two');accrualGrid.append(field('Hours per pay period',rule.hoursPerPayPeriod==null?4:rule.hoursPerPayPeriod,'number','hoursPerPayPeriod'),field('Pay Period Cadence',rule.payPeriodCadence||'biweekly','select','payPeriodCadence',CADENCES.map(v=>({value:v,label:v}))),field('Accrual anchor date',rule.accrualAnchorDate||nowDate,'date','accrualAnchorDate'),field('Semi-monthly pattern',rule.semiMonthlyPattern||'1st-and-15th','select','semiMonthlyPattern',SEMI_MONTHLY_PATTERNS.map(v=>({value:v,label:v}))));grantGrid.append(field('Grant hours',rule.grantHours==null?8:rule.grantHours,'number','grantHours'),field('Grant date',rule.grantDate||nowDate,'date','grantDate'),field('Recurrence',rule.grantRecurrence||'annual','select','grantRecurrence',[{value:'one-time',label:'one-time'},{value:'annual',label:'annually on date'}]));form.append(accrualGrid,grantGrid);const showMechanicFields=()=>{const isAccrual=mechanicField.querySelector('select').value==='accrual';accrualGrid.classList.toggle('hidden',!isAccrual);grantGrid.classList.toggle('hidden',isAccrual);};mechanicField.querySelector('select').addEventListener('change',showMechanicFields);showMechanicFields();
form.append(field('Carryover Cap hours (blank = unlimited)',rule.carryoverCap,'number','carryoverCap'));const submit=el('button','primary','Append Rule Version');submit.type='submit';form.append(submit);form.addEventListener('submit',event=>{event.preventDefault();try{const data=new FormData(form),type=data.get('bucketType'),next=Object.assign({},rule,{id:uid('rule'),effectiveFrom:nowDate,sequence:Math.max(...bucket.ruleVersions.filter(v=>v.effectiveFrom===nowDate).map(v=>Number(v.sequence)),-1)+1,bucketType:type,carryoverCap:data.get('carryoverCap')===''?null:Number(data.get('carryoverCap'))});if(type==='accrual'){next.hoursPerPayPeriod=Number(data.get('hoursPerPayPeriod'));next.payPeriodCadence=data.get('payPeriodCadence');next.accrualAnchorDate=data.get('accrualAnchorDate');next.semiMonthlyPattern=data.get('semiMonthlyPattern');}else{next.grantHours=Number(data.get('grantHours'));next.grantDate=data.get('grantDate');next.grantRecurrence=data.get('grantRecurrence');}validateRuleVersion(next);bucket.name=String(data.get('name')).trim();if(!bucket.name)throw new Error('Bucket name is required.');bucket.ruleVersions.push(next);setNotice('');rerenderAndPersist();}catch(error){setNotice(error.message);}});wrap.append(form,adjustmentForm(bucket),ledgerView(bucket));return wrap;}
function adjustmentForm(bucket){const details=document.createElement('details');details.style.marginTop='10px';const summary=el('summary','small','Add an Adjustment');details.append(summary);const form=document.createElement('form'),grid=el('div','two'),signedHours=field('Signed hours',1,'number','hours');signedHours.querySelector('input').removeAttribute('min');grid.append(field('Date',nowDate,'date','date'),signedHours);form.append(grid,field('Required note','','text','note'));const button=el('button','','Add immutable Adjustment');button.type='submit';form.append(button);form.addEventListener('submit',event=>{event.preventDefault();try{const data=new FormData(form),posting={id:uid('adjustment'),date:data.get('date'),postingType:'adjustment',hours:Number(data.get('hours')),note:String(data.get('note')).trim()};validateAdjustment(posting);bucket.postings.push(posting);rerenderAndPersist();}catch(error){setNotice(error.message);}});details.append(form);return details;}
function ledgerView(bucket){const details=document.createElement('details');details.style.marginTop='10px';details.append(el('summary','small','View Ledger'));const list=el('div','ledger');const folded=foldLedger(bucket,maxDate(nowDate,...bucket.timeOffEntries.map(e=>e.date)));folded.points.slice().reverse().forEach(point=>{const row=el('div',`ledger-row${Number(point.balance)<balanceFloor()?' flag':''}`);row.append(el('span','',point.date),el('span','',point.kind),el('strong','',hours(point.balance)));list.append(row);});details.append(list);return details;}
function isTouch(){return (navigator.maxTouchPoints||0)>0||(window.matchMedia&&matchMedia('(pointer:coarse)').matches);}
// Export for embedded/mobile: on touch devices prefer the native share sheet
// (an <a download> inside the platform iframe silently does nothing on phones);
// fall back to a normal download everywhere else. Desktop behaviour unchanged.
async function shareOrDownload(filename,blob,mime){
try{const file=new File([blob],filename,{type:mime});if(isTouch()&&navigator.canShare&&navigator.canShare({files:[file]})){await navigator.share({files:[file],title:'PTO Planner'});return;}}catch(err){if(err&&err.name==='AbortError')return;}
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=filename;link.rel='noopener';document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),15000);
}
function download(filename,content,type){shareOrDownload(filename,new Blob([content],{type}),type);}
// Reliable image export on mobile: show the PNG in-app so it can be long-pressed
// to save (needs no download/share permission), with Share and Download buttons.
function showImageOverlay(filename,blob,dataUrl){
const url=URL.createObjectURL(blob),overlay=el('div','export-overlay'),sheet=el('div','export-sheet');
const img=document.createElement('img');img.src=dataUrl||url;img.alt='Year overview';img.className='export-img';
const hint=el('p','muted small','Long-press (or right-click) the image to save it, or use the buttons below.');
const row=el('div','row'),share=el('button','primary','Share / Save');share.type='button';share.addEventListener('click',()=>shareOrDownload(filename,blob,'image/png'));
const dl=document.createElement('a');dl.className='button';dl.textContent='Download';dl.href=url;dl.download=filename;dl.rel='noopener';
const close=el('button','','Close'),cleanup=()=>{overlay.remove();setTimeout(()=>URL.revokeObjectURL(url),0);};close.type='button';close.addEventListener('click',cleanup);
overlay.addEventListener('click',e=>{if(e.target===overlay)cleanup();});
row.append(share,dl,close);sheet.append(img,hint,row);overlay.append(sheet);document.body.append(overlay);
}
function roundRectPath(ctx,x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
// Full-year at-a-glance PNG: 12 mini-month grids, days coloured per Bucket /
// Observed Holiday / non-working, with a text legend and per-Bucket totals.
// Native <canvas>, no library. Year = the year currently shown on the Calendar.
function exportYearPng(){
const year=parseDateParts(`${currentMonth}-01`).year;
const holidays=generateObservedHolidays(year,year,state.settings.enabledHolidayIds,state.customHolidays);
const model=yearOverviewModel(year,state.buckets,holidays,state.settings.workSchedule);
const colorMap=new Map();activeBuckets().forEach((b,i)=>colorMap.set(b.id,BUCKET_COLORS[i%BUCKET_COLORS.length]));
const colorFor=id=>colorMap.get(id)||'#8a8f88';
const HOLIDAY='#d49d3c',OFF='#e7e3d7',WORK='#fffdf7',INK='#17211b',MUTED='#647168',LINE='#d0d5cc',PAPER='#f5f3eb';
const cols=4,rows=3,cell=30,gap=3,dowH=18,mLabelH=22;
const monthW=cell*7+gap*6,monthH=mLabelH+dowH+cell*6+gap*5;
const padX=40,padY=34,colGap=34,rowGap=34,titleH=42,legendH=66;
const chartSectionH=340;
const W=padX*2+cols*monthW+(cols-1)*colGap,H=padY*2+titleH+legendH+rows*monthH+(rows-1)*rowGap+chartSectionH;
const scale=2,canvas=document.createElement('canvas');canvas.width=Math.round(W*scale);canvas.height=Math.round(H*scale);
const ctx=canvas.getContext('2d');
if(!ctx){setNotice('PNG export is not supported in this browser.');return;}
ctx.scale(scale,scale);ctx.fillStyle=PAPER;ctx.fillRect(0,0,W,H);ctx.textAlign='left';ctx.textBaseline='alphabetic';
ctx.fillStyle=INK;ctx.font='700 26px Georgia, serif';ctx.fillText(`PTO Planner — ${year}`,padX,padY+24);
ctx.textAlign='right';ctx.textBaseline='alphabetic';ctx.font='600 12px Inter, system-ui, sans-serif';ctx.fillStyle=MUTED;ctx.fillText(`Generated ${shortDate(nowDate)}`,W-padX,padY+18);ctx.textAlign='left';
// legend: current remaining balance (+ days off) per Bucket, then Holiday / Non-working
const usedBy=new Map(model.totals.map(t=>[t.bucketId,t]));
ctx.font='600 13px Inter, system-ui, sans-serif';
const items=activeBuckets().map(b=>{const u=usedBy.get(b.id);return {color:colorFor(b.id),label:`${b.name} — ${hours(foldLedger(b,nowDate).balance)} left${u?` · ${u.days}d off`:''}`};});
if(model.legend.hasHoliday)items.push({color:HOLIDAY,label:'Observed Holiday'});
items.push({color:OFF,label:'Non-working'});
let lx=padX,ly=padY+titleH+16;
items.forEach(it=>{const iw=20+ctx.measureText(it.label).width+22;if(lx>padX&&lx+iw>W-padX){lx=padX;ly+=22;}ctx.fillStyle=it.color;roundRectPath(ctx,lx,ly-11,14,14,3);ctx.fill();ctx.strokeStyle=LINE;ctx.lineWidth=1;ctx.stroke();ctx.fillStyle=INK;ctx.fillText(it.label,lx+20,ly);lx+=iw;});
const monthNames=['January','February','March','April','May','June','July','August','September','October','November','December'];
const gridTop=padY+titleH+legendH;
for(let m=0;m<12;m++){
const mx=padX+(m%cols)*(monthW+colGap),my=gridTop+Math.floor(m/cols)*(monthH+rowGap);
ctx.textAlign='left';ctx.fillStyle=INK;ctx.font='700 15px Georgia, serif';ctx.fillText(monthNames[m],mx+2,my+15);
ctx.textAlign='center';ctx.font='700 10px Inter, system-ui, sans-serif';ctx.fillStyle=MUTED;
['S','M','T','W','T','F','S'].forEach((d,i)=>ctx.fillText(d,mx+i*(cell+gap)+cell/2,my+mLabelH+12));
const month=`${year}-${pad(m+1)}`,lead=weekday(`${month}-01`),dim=daysInMonth(year,m+1);
for(let day=1;day<=dim;day++){
const idx=lead+day-1,x=mx+(idx%7)*(cell+gap),y=my+mLabelH+dowH+Math.floor(idx/7)*(cell+gap);
const info=model.days[`${month}-${pad(day)}`]||{kind:'work'};
ctx.fillStyle=info.kind==='time-off'?colorFor(info.bucketId):info.kind==='holiday'?HOLIDAY:info.kind==='off'?OFF:WORK;
roundRectPath(ctx,x,y,cell,cell,5);ctx.fill();ctx.strokeStyle=LINE;ctx.lineWidth=1;ctx.stroke();
ctx.fillStyle=info.kind==='time-off'?'#ffffff':MUTED;ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textBaseline='middle';ctx.fillText(String(day),x+cell/2,y+cell/2+1);ctx.textBaseline='alphabetic';
}
}
// --- Balance Timeline line chart under the calendar grid ---
const tm=timelineModel(state.buckets,nowDate);
const chartTop=gridTop+rows*monthH+(rows-1)*rowGap+42;
ctx.textAlign='left';ctx.textBaseline='alphabetic';ctx.fillStyle=INK;ctx.font='700 20px Georgia, serif';ctx.fillText('Balance Timeline',padX,chartTop);
if(!tm.empty){
const ix=padX+52,iy=chartTop+22,iw=W-padX*2-52,ih=200;
const xF=d=>ix+dateToFraction(d,tm.domainStart,tm.horizon)*iw,yF=h=>iy+(1-hoursToFraction(h,tm.valueRange))*ih;
ctx.strokeStyle=LINE;ctx.lineWidth=1;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(ix,iy);ctx.lineTo(ix,iy+ih);ctx.lineTo(ix+iw,iy+ih);ctx.stroke();
const zy=yF(0);if(zy>=iy&&zy<=iy+ih){ctx.strokeStyle='#c2c8be';ctx.setLineDash([2,3]);ctx.beginPath();ctx.moveTo(ix,zy);ctx.lineTo(ix+iw,zy);ctx.stroke();ctx.setLineDash([]);}
ctx.fillStyle=MUTED;ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textAlign='right';ctx.textBaseline='middle';
[tm.valueRange[1],0,tm.valueRange[0]].forEach(v=>ctx.fillText(hours(v),ix-6,yF(v)));
ctx.textBaseline='alphabetic';
[[tm.domainStart,'left'],[nowDate,'center'],[tm.horizon,'right']].forEach(([d,al])=>{if(compareDates(d,tm.domainStart)<0||compareDates(d,tm.horizon)>0)return;ctx.textAlign=al;ctx.fillText(shortDate(d),xF(d),iy+ih+16);});
if(compareDates(nowDate,tm.domainStart)>=0&&compareDates(nowDate,tm.horizon)<=0){ctx.strokeStyle='#9eaaa1';ctx.setLineDash([3,3]);ctx.beginPath();ctx.moveTo(xF(nowDate),iy);ctx.lineTo(xF(nowDate),iy+ih);ctx.stroke();ctx.setLineDash([]);}
tm.series.forEach(s=>{const color=colorFor(s.bucketId);const line=(steps,dash)=>{if(steps.length<2)return;ctx.strokeStyle=color;ctx.lineWidth=2;ctx.setLineDash(dash);ctx.beginPath();steps.forEach((pt,j)=>{const x=xF(pt.date),y=yF(pt.balance);j?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke();};line(s.steps.filter(pt=>compareDates(pt.date,tm.settledThrough)<=0),[]);line(s.steps.filter(pt=>compareDates(pt.date,tm.settledThrough)>=0),[5,4]);});
ctx.setLineDash([]);ctx.textAlign='left';ctx.fillStyle=MUTED;ctx.font='400 11px Inter, system-ui, sans-serif';ctx.fillText('Solid = settled · dashed = projected. Colours match the calendar legend above.',ix,iy+ih+34);
}
if(canvas.toBlob)canvas.toBlob(blob=>{if(!blob){setNotice('PNG export is not supported in this browser.');return;}if(isTouch())showImageOverlay(`pto-planner-${year}.png`,blob,canvas.toDataURL('image/png'));else shareOrDownload(`pto-planner-${year}.png`,blob,'image/png');},'image/png');
else setNotice('PNG export is not supported in this browser.');
}
function rawTimestamp(){const d=new Date();return `${d.getUTCFullYear()}${pad(d.getUTCMonth()+1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;}
function catchUpAll(){let changed=false;state.buckets.forEach(bucket=>{const result=generateCatchUp(bucket,nowDate);if(result.added.length||result.lastGeneratedThrough!==bucket.lastGeneratedThrough){bucket.postings=result.postings;bucket.lastGeneratedThrough=result.lastGeneratedThrough;changed=true;}});if(changed){render();persist();}}
// Two-step inline delete guard (no blocking native dialog): first click arms
// the button, a second within 2.5s confirms.
function armConfirm(button,onConfirm){const label=button.textContent;let armed=false,timer=null;button.addEventListener('click',()=>{if(armed){clearTimeout(timer);armed=false;button.textContent=label;button.classList.remove('primary');onConfirm();return;}armed=true;button.textContent='Confirm?';button.classList.add('primary');timer=setTimeout(()=>{armed=false;button.textContent=label;button.classList.remove('primary');},2500);});}
function setTab(name,focus){activeTab=name;TABS.forEach(([tabId,viewId,key])=>{const sel=key===name;$(tabId).setAttribute('aria-selected',String(sel));$(tabId).tabIndex=sel?0:-1;$(viewId).hidden=!sel;});if(focus)$(TABS.find(t=>t[2]===name)[0]).focus();if(name==='timeline')renderTimeline();if(name==='daysoff')renderDaysOff();}
function renderTimeline(){
const model=timelineModel(state.buckets,nowDate);
const chart=$('timelineChart'),legend=$('timelineLegend'),readout=$('timelineReadout'),empty=$('timelineEmpty');
chart.replaceChildren();legend.replaceChildren();readout.textContent='';
empty.classList.toggle('hidden',!model.empty);chart.classList.toggle('hidden',model.empty);
if(model.empty)return;
const innerW=CHART.w-CHART.padL-CHART.padR,innerH=CHART.h-CHART.padT-CHART.padB;
const xFor=date=>CHART.padL+dateToFraction(date,model.domainStart,model.horizon)*innerW;
const yFor=h=>CHART.padT+(1-hoursToFraction(h,model.valueRange))*innerH;
const mk=(name,attrs,text)=>{const n=document.createElementNS(SVGNS,name);for(const k in attrs)n.setAttribute(k,attrs[k]);if(text!=null)n.textContent=text;return n;};
const svg=mk('svg',{viewBox:`0 0 ${CHART.w} ${CHART.h}`,role:'img','aria-label':'Bucket balance over time',tabindex:'0'});
svg.append(mk('line',{x1:CHART.padL,y1:CHART.padT,x2:CHART.padL,y2:CHART.padT+innerH,stroke:'#d9ddd5'}));
svg.append(mk('line',{x1:CHART.padL,y1:CHART.padT+innerH,x2:CHART.padL+innerW,y2:CHART.padT+innerH,stroke:'#d9ddd5'}));
const zeroY=yFor(0);if(zeroY>=CHART.padT&&zeroY<=CHART.padT+innerH)svg.append(mk('line',{x1:CHART.padL,y1:zeroY,x2:CHART.padL+innerW,y2:zeroY,stroke:'#c2c8be','stroke-dasharray':'2 3'}));
[model.valueRange[1],0,model.valueRange[0]].forEach(v=>svg.append(mk('text',{x:CHART.padL-6,y:yFor(v)+3,'text-anchor':'end',fill:'#647168','font-size':'11'},hours(v))));
[model.domainStart,nowDate,model.horizon].forEach((d,i)=>{if(compareDates(d,model.domainStart)<0||compareDates(d,model.horizon)>0)return;svg.append(mk('text',{x:xFor(d),y:CHART.h-10,'text-anchor':i===0?'start':i===2?'end':'middle',fill:'#647168','font-size':'11'},shortDate(d)));});
if(compareDates(nowDate,model.domainStart)>=0&&compareDates(nowDate,model.horizon)<=0)svg.append(mk('line',{x1:xFor(nowDate),y1:CHART.padT,x2:xFor(nowDate),y2:CHART.padT+innerH,stroke:'#9eaaa1','stroke-dasharray':'3 3'}));
const path=steps=>steps.map((pt,j)=>`${j?'L':'M'}${xFor(pt.date).toFixed(1)} ${yFor(pt.balance).toFixed(1)}`).join(' ');
model.series.forEach((s,i)=>{
const color=BUCKET_COLORS[i%BUCKET_COLORS.length];
const settled=s.steps.filter(pt=>compareDates(pt.date,model.settledThrough)<=0),projected=s.steps.filter(pt=>compareDates(pt.date,model.settledThrough)>=0);
if(settled.length>1)svg.append(mk('path',{d:path(settled),fill:'none',stroke:color,'stroke-width':'2'}));
if(projected.length>1)svg.append(mk('path',{d:path(projected),fill:'none',stroke:color,'stroke-width':'2','stroke-dasharray':'5 4','stroke-opacity':'0.85'}));
const item=el('span','legend-item'),sw=el('span','legend-swatch');sw.style.background=color;item.append(sw,document.createTextNode(s.name));legend.append(item);
});
const crosshair=mk('line',{y1:CHART.padT,y2:CHART.padT+innerH,stroke:'#17211b','stroke-width':'1','stroke-opacity':'0.4',visibility:'hidden'});svg.append(crosshair);
let hoverDate=null;
const select=date=>{hoverDate=date;crosshair.setAttribute('x1',xFor(date));crosshair.setAttribute('x2',xFor(date));crosshair.setAttribute('visibility','visible');readout.textContent=`${shortDate(date)} — ${model.series.map(s=>`${s.name} ${hours(balanceAtDate(s.points,date))}`).join(' · ')}`;};
const fromClientX=clientX=>{const rect=svg.getBoundingClientRect();if(!rect.width)return null;const vbX=(clientX-rect.left)/rect.width*CHART.w;return fractionToDate(Math.max(0,Math.min(1,(vbX-CHART.padL)/innerW)),model.domainStart,model.horizon);};
svg.addEventListener('pointermove',e=>{const d=fromClientX(e.clientX);if(d)select(d);});
svg.addEventListener('pointerdown',e=>{try{if(svg.setPointerCapture)svg.setPointerCapture(e.pointerId);}catch(_){}const d=fromClientX(e.clientX);if(d)select(d);});
svg.addEventListener('keydown',e=>{let d=hoverDate||nowDate;if(e.key==='ArrowRight')d=minDate(model.horizon,addDays(d,1));else if(e.key==='ArrowLeft')d=maxDate(model.domainStart,addDays(d,-1));else if(e.key==='Home')d=model.domainStart;else if(e.key==='End')d=model.horizon;else return;e.preventDefault();select(d);});
chart.append(svg);
}
function populateQuickBuckets(){const select=$('quickBucket'),prior=select.value;select.replaceChildren();activeBuckets().forEach(b=>{const o=el('option','',b.name);o.value=b.id;select.append(o);});if(prior&&activeBuckets().some(b=>b.id===prior))select.value=prior;if(!$('quickDate').value)$('quickDate').value=nowDate;}
function renderDaysOff(){
populateQuickBuckets();
const entryDates=state.buckets.flatMap(b=>b.timeOffEntries.map(e=>e.date));
const start=minDate(nowDate,...entryDates,nowDate),horizon=displayHorizon(state.buckets,nowDate);
// ponytail: clamp holiday-generation span so pathological legacy far-future data can't spin the year loop (new entries are already bounded by supportedMaxDate).
const hzYear=Math.min(parseDateParts(horizon).year,parseDateParts(start).year+11);
const holidays=filterHolidaysToWindow(generateObservedHolidays(parseDateParts(start).year,hzYear,state.settings.enabledHolidayIds,state.customHolidays),start,horizon);
const rows=aggregateDaysOff(state.buckets,holidays);
// Over-booked flag uses the PROJECTION (future Accruals/Grants included), not
// the raw Ledger — otherwise a future day reads as over-booked just because
// the accruals that will cover it haven't posted yet.
const flr=balanceFloor();
const flagged=new Set();
state.buckets.forEach(b=>{if(!b.timeOffEntries.length)return;projectBucketThrough(b,nowDate,maxDate(nowDate,...b.timeOffEntries.map(e=>e.date))).points.forEach(p=>{if(p.kind==='time-off-entry'&&Number(p.balance)<flr&&p.id)flagged.add(`${b.id}:${p.id}`);});});
const list=$('daysOffList');list.replaceChildren();
if(!rows.length){list.append(el('div','empty','No days off yet. Quick-add one above, or log time off on the Calendar.'));return;}
rows.forEach(row=>{
if(row.kind==='holiday'){const r=el('div','dayoff-row');r.append(el('span','',shortDate(row.date)),el('span','',row.name),el('span','kind pill','Observed Holiday'),el('span','muted small hrs','—'),el('span',''));list.append(r);return;}
const r=el('div',`dayoff-row${flagged.has(`${row.bucketId}:${row.entryId}`)?' flag':''}`);
r.append(el('span','',shortDate(row.date)),el('span','',`${row.bucketName}${row.archived?' (archived)':''}`),el('span','kind muted small',row.wholeDay?'whole day':'partial'),el('span','hrs',hours(row.hours)));
const del=el('button','danger','Delete');del.type='button';armConfirm(del,()=>{state=deleteTimeOffEntry(state,row.bucketId,row.entryId);rerenderAndPersist();});r.append(del);list.append(r);
});
}
function bind(){
TABS.forEach(([tabId],i)=>{$(tabId).addEventListener('click',()=>setTab(TABS[i][2]));$(tabId).addEventListener('keydown',e=>{let j=null;if(e.key==='ArrowRight')j=(i+1)%TABS.length;else if(e.key==='ArrowLeft')j=(i-1+TABS.length)%TABS.length;else if(e.key==='Home')j=0;else if(e.key==='End')j=TABS.length-1;if(j!=null){e.preventDefault();setTab(TABS[j][2],true);}});});
$('helpToggle').addEventListener('click',()=>{const hidden=$('helpPanel').classList.toggle('hidden');$('helpToggle').setAttribute('aria-expanded',String(!hidden));});
$('quickAddForm').addEventListener('submit',e=>{e.preventDefault();try{const targetBucketId=$('quickBucket').value,wholeDay=$('quickWholeDay').checked,date=$('quickDate').value;if(!isValidDateString(date))throw new Error('Enter a valid date for the Time-Off Entry.');state=upsertTimeOffEntry(state,{targetBucketId,date,wholeDay,hours:Number($('quickHours').value),recalculateWholeDay:wholeDay,today:nowDate},()=>uid('time-off-entry'));$('quickWholeDay').checked=true;$('quickHours').value='';setNotice('');rerenderAndPersist();}catch(err){setNotice(err.message);}});
$('rangeToggle').addEventListener('click',()=>{rangeMode=!rangeMode;rangeStart=null;rangeEnd=null;if(rangeMode){selectedDate=null;$('entryId').value='';}renderCalendar();renderSide();});
$('clearRange').addEventListener('click',()=>{rangeStart=null;rangeEnd=null;renderCalendar();renderSide();});
$('rangeBucket').addEventListener('change',renderRangeConfirmation);
$('confirmRange').addEventListener('click',()=>{try{const preview=rangePreviewData(),bucket=state.buckets.find(item=>!item.archived&&item.id===$('rangeBucket').value);if(!bucket||!preview.working.length)return;const items=preview.working.map(date=>({targetBucketId:bucket.id,date,wholeDay:true,recalculateWholeDay:true,today:nowDate}));state=upsertTimeOffEntriesAtomically(state,items,()=>uid('time-off-entry'));selectedDate=preview.working[0];rangeMode=false;rangeStart=null;rangeEnd=null;$('entryId').value='';setNotice('');rerenderAndPersist();}catch(error){setNotice(error.message);}});
$('prevMonth').addEventListener('click',()=>{const p=parseDateParts(`${currentMonth}-01`);currentMonth=addDays(formatDateParts(p.year,p.month,1),-1).slice(0,7);render();});$('nextMonth').addEventListener('click',()=>{const p=parseDateParts(`${currentMonth}-01`);currentMonth=addDays(formatDateParts(p.year,p.month,daysInMonth(p.year,p.month)),1).slice(0,7);render();});
$('scheduleType').addEventListener('change',()=>{const alt=$('scheduleType').value!=='standard';$('standardFields').classList.toggle('hidden',alt);$('alternateFields').classList.toggle('hidden',!alt);});
const saveNegative=()=>{state.settings.negativeBalance={allowed:$('allowNegative').checked,limitHours:Math.max(0,Number($('negativeLimit').value)||0)};rerenderAndPersist();};
$('allowNegative').addEventListener('change',saveNegative);$('negativeLimit').addEventListener('change',saveNegative);
$('scheduleForm').addEventListener('submit',event=>{event.preventDefault();try{const schedule={type:$('scheduleType').value,dailyHours:Number($('dailyHours').value),weekdayHours:Number($('weekdayHours').value),workingFridayHours:Number($('workingFridayHours').value),offFridayAnchorDate:$('offFridayAnchorDate').value};validateWorkSchedule(schedule);state.settings.workSchedule=schedule;setNotice('');rerenderAndPersist();}catch(error){setNotice(error.message);}});
$('entryForm').addEventListener('submit',event=>{event.preventDefault();try{const targetBucketId=$('entryBucket').value,wholeDay=$('entryWholeDay').checked;let entryId=$('entryId').value||undefined,sourceBucketId;if(entryId){const src=state.buckets.find(b=>b.timeOffEntries.some(e=>e.id===entryId));if(src)sourceBucketId=src.id;else entryId=undefined;}state=upsertTimeOffEntry(state,{targetBucketId,entryId,sourceBucketId,date:selectedDate,wholeDay,hours:Number($('entryHours').value),recalculateWholeDay:wholeDay,today:nowDate},()=>uid('time-off-entry'));$('entryId').value='';setNotice('');rerenderAndPersist();}catch(error){setNotice(error.message);}});$('entryBucket').addEventListener('change',updateEntryPreview);$('entryHours').addEventListener('input',()=>{$('entryWholeDay').checked=Number($('entryHours').value)===scheduleHours(selectedDate)&&scheduleHours(selectedDate)>0;updateEntryPreview();});$('entryWholeDay').addEventListener('change',()=>{if($('entryWholeDay').checked)$('entryHours').value=scheduleHours(selectedDate);updateEntryPreview();});$('cancelEntry').addEventListener('click',()=>{$('entryId').value='';renderSide();});
$('holidayForm').addEventListener('submit',event=>{event.preventDefault();if(!isValidDateString($('holidayDate').value)||!$('holidayName').value.trim())return;state.customHolidays.push({id:uid('custom-holiday'),name:$('holidayName').value.trim(),date:$('holidayDate').value});event.target.reset();rerenderAndPersist();});
$('showAddBucket').addEventListener('click',()=>{$('addBucketForm').classList.toggle('hidden');$('newBalanceDate').value=nowDate;});$('addBucketForm').addEventListener('submit',event=>{event.preventDefault();try{const name=$('newBucketName').value.trim(),type=$('newBucketType').value,date=$('newBalanceDate').value,opening=assertFinite($('newOpeningHours').value,'Opening Balance hours',{nonNegative:true});if(!name||!isValidDateString(date))throw new Error('Bucket name, Opening Balance, and balance date are required.');const b=defaultBucket(name,type,date);b.postings[0].hours=opening;state.buckets.push(b);event.target.reset();event.target.classList.add('hidden');rerenderAndPersist();}catch(error){setNotice(error.message);}});
$('exportCsv').addEventListener('click',()=>download('pto-ledger.csv',formatCsv(state.buckets,maxDate(nowDate,...state.buckets.flatMap(b=>b.timeOffEntries.map(e=>e.date)))),'text/csv;charset=utf-8'));$('exportIcs').addEventListener('click',()=>download('pto-planner.ics',formatIcs({holidays:observed,buckets:state.buckets,dtstamp:rawTimestamp()}),'text/calendar;charset=utf-8'));$('exportPng').addEventListener('click',exportYearPng);$('retrySave').addEventListener('click',()=>{if(lastDirtySnapshot)saveQueue.retry(lastDirtySnapshot).catch(()=>undefined);});
}
function recovery(error,raw){rawRecoveryData=raw;$('loading').classList.add('hidden');const box=el('section','panel recovery');box.append(el('h1','',"We couldn't load your PTO Planner"),el('p','muted',error.message),el('p','',"Your stored data was not changed. Export the raw state for recovery."));const button=el('button','primary','Export raw state');button.style.marginTop='14px';button.addEventListener('click',()=>download('pto-planner-recovery.json',JSON.stringify(rawRecoveryData,null,2),'application/json'));box.append(button);document.body.append(box);}
async function start(){
bind();saveQueue=createSaveQueue((key,value)=>window.vibes.save(key,value),saveStatus);
let raw=null;try{raw=await window.vibes.load('state');state=raw==null?initialState():validateStateDocument(raw);$('loading').classList.add('hidden');$('app').classList.remove('hidden');render();if(raw==null)persist();catchUpAll();}
catch(error){recovery(error,raw);}
}
if(!window.vibes||typeof window.vibes.onReady!=='function'){recovery(new Error('The Vibes SDK was not injected. Open this widget on itjustvibes.com.'),null);}else window.vibes.onReady(start);
})();
</script>
</body>
</html>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/pto-planner-tq2d */