const state = { settings: {}, entries: [], corrections: {}, feriefridageOverrides: {}, flexOverrides: {}, plan: null, }; const settingKeys = [ 'previous_months', 'future_months', 'vacation_days_per_month', 'feriefridage_per_year', 'feriefridage_expiry_month', 'normal_day_hours', 'friday_hours' ]; const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const sourceLabels = { auto: 'Smart', vacation: 'Vacation', feriefridag: 'Feriefridage', flex: 'FLEX', unpaid: 'Unpaid', }; const $ = (id) => document.getElementById(id); init().catch((error) => toast(error.message || String(error))); async function init() { bindEvents(); initHeaderEntriesToggle(); await loadState(); } function bindEvents() { $('addEntryBtn').addEventListener('click', () => openEntryDialog()); $('settingsBtn').addEventListener('click', openSettingsDialog); $('entriesTable').querySelector('thead').addEventListener('click', toggleHeaderEntries); $('closeEntryDialogBtn').addEventListener('click', closeEntryDialog); $('closeSettingsDialogBtn').addEventListener('click', closeSettingsDialog); $('entryDialog').addEventListener('click', closeDialogOnBackdrop); $('settingsDialog').addEventListener('click', closeDialogOnBackdrop); $('entryForm').addEventListener('submit', saveEntry); $('settingsForm').addEventListener('submit', saveSettings); $('deleteHiddenEntriesBtn').addEventListener('click', deleteEntriesOutsideDisplayedMonths); ['startDate', 'endDate', 'nonWorkingDays'].forEach((id) => $(id).addEventListener('input', updateWorkdayPreview)); $('cancelEditBtn').addEventListener('click', resetEntryForm); } async function loadState() { const res = await fetch('api.php?action=state'); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Could not load state'); state.settings = normalizeSettings(data.settings); state.entries = data.entries.map(normalizeEntry); state.corrections = data.corrections || {}; state.feriefridageOverrides = data.feriefridage_overrides || {}; state.flexOverrides = data.flex_overrides || {}; state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); } function normalizeSettings(settings) { const normalized = { ...settings }; normalized.previous_months = Number(normalized.previous_months ?? 5); normalized.future_months = Number(normalized.future_months ?? 30); return normalized; } function initHeaderEntriesToggle() { const collapsed = localStorage.getItem('go.headerEntriesCollapsed') === '1'; setHeaderEntriesCollapsed(collapsed); } function toggleHeaderEntries() { const entries = document.querySelector('.header-entries'); const collapsed = !entries?.classList.contains('is-collapsed'); setHeaderEntriesCollapsed(collapsed); localStorage.setItem('go.headerEntriesCollapsed', collapsed ? '1' : '0'); } function setHeaderEntriesCollapsed(collapsed) { const entries = document.querySelector('.header-entries'); if (!entries) return; entries.classList.toggle('is-collapsed', collapsed); $('entriesTable').querySelector('thead').setAttribute('aria-expanded', collapsed ? 'false' : 'true'); } function renderAll() { fillSettingsForm(); renderDashboard(); renderHeaderWarnings(); renderWarnings(); renderEntries(); renderPlanner(); scheduleHaPublish(); } function scheduleHaPublish() { clearTimeout(scheduleHaPublish.timer); scheduleHaPublish.timer = setTimeout(() => { publishHaState().catch((error) => console.warn('HA publish failed', error)); }, 600); } async function publishHaState() { if (!state.plan?.current) return; const current = state.plan.current; await post({ action: 'publishHa', state: { feriefridage: Number(current.feriefridage || 0), vacation_days: Number(current.vacation || 0), flex_hours: Number(current.flex || 0), total_days: Number(current.totalDays || 0), upcoming_vacations: upcomingVacationsForHa(), updated_at: new Date().toISOString(), }, }); } function upcomingVacationsForHa() { const today = toIsoDate(new Date()); return state.entries .filter((entry) => entry.end_date >= today) .sort((a, b) => a.start_date.localeCompare(b.start_date) || a.id - b.id) .map((entry) => { const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 }; return { title: entry.title, start: entry.start_date, end: entry.end_date, start_display: formatDate(entry.start_date), end_display: formatDate(entry.end_date), status: entry.status, source: entry.source, workdays: Number(summary.workdays || 0), vacation_days: Number(summary.usedVacation || 0), feriefridage: Number(summary.usedFeriefridage || 0), flex_hours: Number(summary.usedFlex || 0), flex_days: Number(summary.usedFlexDays || 0), }; }); } function fillSettingsForm() { for (const key of settingKeys) { const node = $(key); if (node) node.value = state.settings[key] ?? ''; } } async function saveSettings(event) { event.preventDefault(); const settings = {}; for (const key of settingKeys) { settings[key] = Number($(key).value); } const data = await post({ action: 'saveSettings', settings }); state.settings = normalizeSettings(data.settings); state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); closeSettingsDialog(); toast('Settings saved'); } async function saveBalanceOverride(event) { const input = event.target; const year = Number(input.dataset.year); const month = Number(input.dataset.month); const kind = input.dataset.kind; const rawValue = String(input.value ?? '').trim(); const value = rawValue === '' ? null : parseDecimal(rawValue); if (value !== null && Number.isNaN(value)) { toast('Balance must be a number'); return; } const actions = { vacation: 'saveCorrection', feriefridage: 'saveFeriefridageOverride', flex: 'saveFlexOverride', }; const data = await post({ action: actions[kind], year, month, value }); if (kind === 'vacation') state.corrections = data.corrections || {}; if (kind === 'feriefridage') state.feriefridageOverrides = data.feriefridage_overrides || {}; if (kind === 'flex') state.flexOverrides = data.flex_overrides || {}; state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Balance saved'); } async function saveEntry(event) { event.preventDefault(); const id = Number($('entryId').value || 0); const entry = { title: $('title').value.trim(), start_date: $('startDate').value, end_date: $('endDate').value, source: $('source').value, status: $('status').value, allow_unpaid: $('allowUnpaid').checked ? 1 : 0, non_working_days: Number($('nonWorkingDays').value || 0), note: $('note').value.trim(), }; const payload = id ? { action: 'updateEntry', id, entry } : { action: 'createEntry', entry }; const data = await post(payload); state.entries = data.entries.map(normalizeEntry); state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); resetEntryForm(); closeEntryDialog(); renderAll(); toast(id ? 'Entry updated' : 'Entry added'); } async function approveEntry(id) { const entry = state.entries.find((item) => item.id === id); if (!entry || entry.status !== 'planned') return; const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } }); state.entries = data.entries.map(normalizeEntry); state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Entry approved'); } async function deleteEntry(id) { const entry = state.entries.find((item) => item.id === id); if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return; const data = await post({ action: 'deleteEntry', id }); state.entries = data.entries.map(normalizeEntry); state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Entry deleted'); } async function deleteEntriesOutsideDisplayedMonths() { const hiddenEntries = state.entries.filter((entry) => !entryOverlapsDisplayedRange(entry)); if (!hiddenEntries.length) { toast('No hidden entries to delete'); return; } if (!confirm(`Delete ${hiddenEntries.length} entries outside the currently displayed months?`)) return; let latestEntries = state.entries; for (const entry of hiddenEntries) { const data = await post({ action: 'deleteEntry', id: entry.id }); latestEntries = data.entries.map(normalizeEntry); } state.entries = latestEntries; state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Hidden entries deleted'); } function entriesInDisplayedMonths() { return state.entries.filter(entryOverlapsDisplayedRange); } function entryOverlapsDisplayedRange(entry) { if (!state.plan?.range) return true; return entry.start_date <= state.plan.range.end && entry.end_date >= state.plan.range.start; } function isPastEntry(entry) { return entry.end_date < toIsoDate(new Date()); } function isPastMonth(month) { const today = new Date(); return month.year < today.getFullYear() || (month.year === today.getFullYear() && month.month < today.getMonth() + 1); } async function post(payload) { const res = await fetch('api.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } function openEntryDialog(entryId = null) { if (!entryId) resetEntryForm(); $('entryDialog').showModal(); } function closeEntryDialog() { $('entryDialog').close(); } function openSettingsDialog() { fillSettingsForm(); $('settingsDialog').showModal(); } function closeSettingsDialog() { $('settingsDialog').close(); } function closeDialogOnBackdrop(event) { if (event.target === event.currentTarget) event.currentTarget.close(); } function editEntry(id) { const entry = state.entries.find((item) => item.id === id); if (!entry) return; $('entryId').value = entry.id; $('title').value = entry.title; $('startDate').value = entry.start_date; $('endDate').value = entry.end_date; $('source').value = entry.source; $('status').value = entry.status; $('allowUnpaid').checked = Boolean(Number(entry.allow_unpaid)); $('nonWorkingDays').value = Number(entry.non_working_days || 0); $('note').value = entry.note || ''; updateWorkdayPreview(); $('entryFormTitle').textContent = 'Edit date-range entry'; $('saveEntryBtn').textContent = 'Update entry'; $('cancelEditBtn').classList.remove('hidden'); openEntryDialog(id); } function updateWorkdayPreview() { const start = $('startDate').value; const end = $('endDate').value; const extra = Number($('nonWorkingDays').value || 0); const preview = $('workdayPreview'); if (!start || !end) { preview.textContent = 'Set dates to see calculated working days.'; return; } if (start > end) { preview.textContent = 'Start date must be before end date.'; return; } const gross = workdaysBetween(start, end).length; const effective = Math.max(0, gross - extra); preview.textContent = `${days(effective)} working days will be used (${days(gross)} weekdays minus ${days(extra)} additional non-working days).`; } function resetEntryForm() { $('entryForm').reset(); $('entryId').value = ''; $('source').value = 'auto'; $('status').value = 'planned'; $('nonWorkingDays').value = 0; updateWorkdayPreview(); $('entryFormTitle').textContent = 'Add date-range entry'; $('saveEntryBtn').textContent = 'Save entry'; $('cancelEditBtn').classList.add('hidden'); } function renderDashboard() { const current = state.plan.current; const cards = [ ['Feriefridage', days(current.feriefridage), 'Use before 31 August', current.feriefridage > 0.001 ? 'warn' : ''], ['Vacation days', days(current.vacation), 'Available today', current.vacation < -0.001 ? 'bad' : 'good'], ]; $('dashboard').innerHTML = ` ${cards.map(([label, value, hint, tone]) => `
${escapeHtml(label)}
${value}
${escapeHtml(hint)}
`).join('')}
FLEX hours
${hours(current.flex)}
Current balance
Total days
${days(current.totalDays)}
Vacation + FF + FLEX
`; } function renderHeaderWarnings() { const warnings = state.plan.current.warnings; $('headerWarnings').innerHTML = warnings.length ? warnings.map((warning) => `${escapeHtml(warning.text)}`).join('') : ''; } function renderWarnings() { const warningsNode = $('warnings'); if (!warningsNode) return; const warnings = state.plan.warnings; if (!warnings.length) { warningsNode.innerHTML = '
No warnings. Your current plan looks safe.
'; return; } warningsNode.innerHTML = warnings.map((warning) => `
${escapeHtml(warning.text)}
`).join(''); } function renderEntries() { const visibleEntries = entriesInDisplayedMonths(); $('entriesHeaderTitle').textContent = `VACATIONS (${visibleEntries.length})`; const tbody = $('entriesTable').querySelector('tbody'); if (!visibleEntries.length) { tbody.innerHTML = 'No entries in displayed months.'; return; } tbody.innerHTML = visibleEntries.map((entry) => { const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, grossWorkdays: 0, nonWorkingDays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 }; const source = sourceLabels[entry.source] || entry.source; return ` ${escapeHtml(entry.title)} ${escapeHtml(source)}
${formatDate(entry.start_date)} → ${formatDate(entry.end_date)}${Number(entry.allow_unpaid) ? ' unpaid' : ''} ${days(summary.workdays)} ${days(summary.usedVacation)} ${days(summary.usedFeriefridage)} ${hours(summary.usedFlex)}
${days(summary.usedFlexDays)}d ${escapeHtml(entry.status)}
${entry.status === 'planned' ? `` : ''}
`; }).join(''); } function renderPlanner() { $('planner').innerHTML = `
${state.plan.months.map(renderMonthRow).join('')}
`; document.querySelectorAll('.balance-override-input').forEach((input) => { input.addEventListener('change', (event) => saveBalanceOverride(event).catch((error) => toast(error.message || String(error)))); }); syncPlannerScroll(); } function syncPlannerScroll() { const headerWrap = document.querySelector('.planner-column-header.table-wrap'); const bodyWrap = document.querySelector('.planner-panel .table-wrap'); if (!headerWrap || !bodyWrap) return; let syncing = false; const sync = (from, to) => { if (syncing) return; syncing = true; to.scrollLeft = from.scrollLeft; requestAnimationFrame(() => { syncing = false; }); }; headerWrap.addEventListener('scroll', () => sync(headerWrap, bodyWrap), { passive: true }); bodyWrap.addEventListener('scroll', () => sync(bodyWrap, headerWrap), { passive: true }); headerWrap.scrollLeft = bodyWrap.scrollLeft; } function renderMonthRow(month) { const balanceClass = month.availableDays < -0.001 ? 'bad-text' : 'good-text'; const entries = month.entries.length ? month.entries.map((item) => ` ${escapeHtml(item.title)} ${days(item.workdays)} wd · ${days(item.usedVacation)} vd · ${days(item.usedFeriefridage)} ff · ${hours(item.usedFlex)} fl ${formatDate(item.start_date)} → ${formatDate(item.end_date)} `).join('') : ''; return ` ${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `
Expired FF: ${days(month.expiredFeriefridage)}` : ''} ${entries} ${days(month.usedUnpaid)} ${days(month.availableDays)} `; } function calculatePlan(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) { const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); const previousMonths = Number(settings.previous_months ?? 5); const futureMonths = Number(settings.future_months ?? 30); const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1); const startYear = startCursor.getFullYear(); const startMonth = startCursor.getMonth() + 1; const monthCount = previousMonths + futureMonths + 1; const startDate = dateFromParts(startYear, startMonth, 1); const endDate = dateFromParts(startYear, startMonth + monthCount, 0); const months = []; const warnings = []; const entrySummaries = {}; const entriesById = new Map(entries.map((entry) => [entry.id, entry])); let vacationBalance = Number(settings.opening_vacation_days || 0); let feriefridageBalance = Number(settings.opening_feriefridage_days || 0); let flexBalance = Number(settings.opening_flex_hours || 0); const allWorkdays = []; for (const entry of entries) { const grossDates = workdaysBetween(entry.start_date, entry.end_date); const units = workdayUnitsForEntry(entry); entrySummaries[entry.id] = { workdays: sumFractions(units), grossWorkdays: grossDates.length, nonWorkingDays: Number(entry.non_working_days || 0), usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 }; for (const unit of units) { if (unit.date < startDate || unit.date > endDate) continue; allWorkdays.push({ date: unit.date, fraction: unit.fraction, entryId: entry.id }); } } allWorkdays.sort((a, b) => a.date - b.date || a.entryId - b.entryId); const workdaysByMonth = new Map(); for (const item of allWorkdays) { const key = monthKey(item.date.getFullYear(), item.date.getMonth() + 1); if (!workdaysByMonth.has(key)) workdaysByMonth.set(key, []); workdaysByMonth.get(key).push(item); } const avgDayHours = averageDayHours(settings); const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance); for (let i = 0; i < monthCount; i++) { const cursor = dateFromParts(startYear, startMonth + i, 1); const year = cursor.getFullYear(); const month = cursor.getMonth() + 1; const current = { year, month, earnedVacation: Number(settings.vacation_days_per_month || 0), hasVacationOverride: hasCorrection(corrections, year, month), vacationOverride: correctionFor(corrections, year, month), hasFeriefridageOverride: hasCorrection(feriefridageOverrides, year, month), feriefridageOverride: correctionFor(feriefridageOverrides, year, month), hasFlexOverride: hasCorrection(flexOverrides, year, month), flexOverride: correctionFor(flexOverrides, year, month), grantedFeriefridage: month === 1 ? Number(settings.feriefridage_per_year || 0) : 0, expiredFeriefridage: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedUnpaid: 0, vacationBalance: 0, feriefridageBalance: 0, flexBalance: 0, availableDays: 0, entries: [], }; feriefridageBalance += current.grantedFeriefridage; if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { current.expiredFeriefridage = feriefridageBalance; if (year === today.getFullYear()) { warnings.push({ level: 'bad', text: `${days(feriefridageBalance)} feriefridage expired on 31 August ${year}. They must be used before that date.` }); } feriefridageBalance = 0; } const key = monthKey(year, month); const monthItems = workdaysByMonth.get(key) || []; const isThisMonth = year === today.getFullYear() && month === today.getMonth() + 1; const applyWorkday = (item) => { const entry = entriesById.get(item.entryId); const allocation = allocateDay(entry, item.date, { vacationBalance, feriefridageBalance, flexBalance, }, settings, item.fraction); vacationBalance = allocation.balances.vacationBalance; feriefridageBalance = allocation.balances.feriefridageBalance; flexBalance = allocation.balances.flexBalance; current.usedVacation += allocation.usedVacation; current.usedFeriefridage += allocation.usedFeriefridage; current.usedFlex += allocation.usedFlex; current.usedUnpaid += allocation.usedUnpaid; const summary = entrySummaries[entry.id]; summary.usedVacation += allocation.usedVacation; summary.usedFeriefridage += allocation.usedFeriefridage; summary.usedFlex += allocation.usedFlex; summary.usedFlexDays += allocation.usedFlexDays; summary.usedUnpaid += allocation.usedUnpaid; let monthEntry = current.entries.find((row) => row.id === entry.id); if (!monthEntry) { monthEntry = { id: entry.id, title: entry.title, source: sourceLabels[entry.source] || entry.source, start_date: entry.start_date, end_date: entry.end_date, workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0, allowUnpaid: Boolean(Number(entry.allow_unpaid)), }; current.entries.push(monthEntry); } monthEntry.workdays += item.fraction; monthEntry.usedVacation += allocation.usedVacation; monthEntry.usedFeriefridage += allocation.usedFeriefridage; monthEntry.usedFlex += allocation.usedFlex; monthEntry.usedFlexDays += allocation.usedFlexDays; monthEntry.usedUnpaid += allocation.usedUnpaid; }; if (isThisMonth) { monthItems.filter((item) => item.date <= today).forEach(applyWorkday); flexBalance = currentFlexHours; monthItems.filter((item) => item.date > today).forEach(applyWorkday); } else { monthItems.forEach(applyWorkday); } vacationBalance += current.earnedVacation; if (vacationBalance < -0.001) { vacationBalance = convertNegativeVacationToUnpaid(current, entrySummaries, vacationBalance); } if (month === Number(settings.feriefridage_expiry_month || 8) && year === today.getFullYear() && feriefridageBalance > 0.001) { warnings.push({ level: 'warn', text: `${days(feriefridageBalance)} feriefridage remain at the end of August ${year}; they must be used before 31 August.` }); } if (current.hasVacationOverride) vacationBalance = current.vacationOverride; if (current.hasFeriefridageOverride) feriefridageBalance = current.feriefridageOverride; if (current.hasFlexOverride) flexBalance = current.flexOverride; current.vacationBalance = vacationBalance; current.feriefridageBalance = feriefridageBalance; current.flexBalance = flexBalance; current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); months.push(current); if (vacationBalance < -0.001) { warnings.push({ level: 'bad', text: `Vacation balance is negative in ${monthNames[month - 1]} ${year}: ${days(vacationBalance)} days.` }); } if (feriefridageBalance < -0.001) { warnings.push({ level: 'bad', text: `Feriefridage balance is negative in ${monthNames[month - 1]} ${year}: ${days(feriefridageBalance)} days.` }); } if (flexBalance < -0.001) { warnings.push({ level: 'bad', text: `FLEX balance is negative in ${monthNames[month - 1]} ${year}: ${hours(flexBalance)}.` }); } } for (const entry of entries) { const summary = entrySummaries[entry.id]; if (!summary) continue; if (summary.usedUnpaid > 0.001) { warnings.push({ level: 'warn', text: `${entry.title}: ${days(summary.usedUnpaid)} unpaid days are used because unpaid fallback is enabled for that date range.` }); } } const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); return { months, range: { start: toIsoDate(startDate), end: toIsoDate(endDate) }, warnings: dedupeWarnings(warnings), current: calculateCurrentSnapshot(settings, entries, corrections, feriefridageOverrides, flexOverrides), entrySummaries, final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays }, }; } function convertNegativeVacationToUnpaid(month, entrySummaries, vacationBalance) { let remaining = -vacationBalance; for (const monthEntry of [...month.entries].reverse()) { if (!monthEntry.allowUnpaid || monthEntry.usedVacation <= 0 || remaining <= 0.001) continue; const convert = Math.min(monthEntry.usedVacation, remaining); monthEntry.usedVacation -= convert; monthEntry.usedUnpaid += convert; month.usedVacation -= convert; month.usedUnpaid += convert; const summary = entrySummaries[monthEntry.id]; if (summary) { summary.usedVacation -= convert; summary.usedUnpaid += convert; } vacationBalance += convert; remaining -= convert; } return vacationBalance; } function calculateCurrentSnapshot(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) { const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); const previousMonths = Number(settings.previous_months ?? 5); const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1); const startYear = startCursor.getFullYear(); const startMonth = startCursor.getMonth() + 1; const startDate = dateFromParts(startYear, startMonth, 1); const avgDayHours = averageDayHours(settings); const warnings = []; let vacationBalance = Number(settings.opening_vacation_days || 0); let feriefridageBalance = Number(settings.opening_feriefridage_days || 0); let flexBalance = Number(settings.opening_flex_hours || 0); const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance); if (today < startDate) { flexBalance = currentFlexHours; const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings }; } const workdays = []; for (const entry of entries) { for (const unit of workdayUnitsForEntry(entry)) { if (unit.date >= startDate && unit.date <= today) workdays.push({ date: unit.date, fraction: unit.fraction, entry }); } } workdays.sort((a, b) => a.date - b.date || a.entry.id - b.entry.id); let workdayIndex = 0; const monthCount = ((today.getFullYear() - startYear) * 12) + (today.getMonth() + 1 - startMonth) + 1; for (let i = 0; i < monthCount; i++) { const cursor = dateFromParts(startYear, startMonth + i, 1); const year = cursor.getFullYear(); const month = cursor.getMonth() + 1; if (month === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0); if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { feriefridageBalance = 0; } const monthStart = dateFromParts(year, month, 1); const monthEnd = dateFromParts(year, month + 1, 0); const limit = today < monthEnd ? today : monthEnd; while (workdayIndex < workdays.length && workdays[workdayIndex].date >= monthStart && workdays[workdayIndex].date <= limit) { const item = workdays[workdayIndex++]; const allocation = allocateDay(item.entry, item.date, { vacationBalance, feriefridageBalance, flexBalance }, settings, item.fraction); vacationBalance = allocation.balances.vacationBalance; feriefridageBalance = allocation.balances.feriefridageBalance; flexBalance = allocation.balances.flexBalance; } if (today >= monthEnd) { vacationBalance += Number(settings.vacation_days_per_month || 0); } if (year === today.getFullYear() && month === today.getMonth() + 1) { flexBalance = currentFlexHours; } if (hasCorrection(corrections, year, month)) vacationBalance = correctionFor(corrections, year, month); if (hasCorrection(feriefridageOverrides, year, month)) feriefridageBalance = correctionFor(feriefridageOverrides, year, month); if (hasCorrection(flexOverrides, year, month)) flexBalance = correctionFor(flexOverrides, year, month); } if (!hasCorrection(flexOverrides, today.getFullYear(), today.getMonth() + 1)) { flexBalance = currentFlexHours; } if (vacationBalance < -0.001) warnings.push({ level: 'bad', text: `Vacation balance is negative today: ${days(vacationBalance)} days.` }); if (feriefridageBalance < -0.001) warnings.push({ level: 'bad', text: `Feriefridage balance is negative today: ${days(feriefridageBalance)} days.` }); if (flexBalance < -0.001) warnings.push({ level: 'bad', text: `FLEX balance is negative today: ${hours(flexBalance)}.` }); if (today.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8) && feriefridageBalance > 0.001) { const remainingAfterApprovedPlans = feriefridageRemainingAfterApprovedPlans(settings, entries, today, { vacationBalance, feriefridageBalance, flexBalance, }); if (remainingAfterApprovedPlans > 0.001) { warnings.push({ level: 'warn', text: `${days(remainingAfterApprovedPlans)} feriefridage need approved plan before 31 August.` }); } } const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings: dedupeWarnings(warnings) }; } function feriefridageRemainingAfterApprovedPlans(settings, entries, today, balances) { const expiryMonth = Number(settings.feriefridage_expiry_month || 8); const deadline = dateFromParts(today.getFullYear(), expiryMonth + 1, 0); let { vacationBalance, feriefridageBalance, flexBalance } = balances; const futureApprovedWorkdays = []; for (const entry of entries) { if (entry.status !== 'approved') continue; for (const unit of workdayUnitsForEntry(entry)) { if (unit.date > today && unit.date <= deadline) { futureApprovedWorkdays.push({ date: unit.date, fraction: unit.fraction, entry }); } } } futureApprovedWorkdays.sort((a, b) => a.date - b.date || a.entry.id - b.entry.id); for (const item of futureApprovedWorkdays) { const allocation = allocateDay(item.entry, item.date, { vacationBalance, feriefridageBalance, flexBalance }, settings, item.fraction); vacationBalance = allocation.balances.vacationBalance; feriefridageBalance = allocation.balances.feriefridageBalance; flexBalance = allocation.balances.flexBalance; } return feriefridageBalance; } function allocateDay(entry, date, balances, settings, fraction = 1) { const source = entry.source; const allowUnpaid = Boolean(Number(entry.allow_unpaid)); const baseDayHours = hoursForDate(date, settings); const dayHours = baseDayHours * fraction; let { vacationBalance, feriefridageBalance, flexBalance } = balances; const result = { usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 }; const useVacation = () => { if (allowUnpaid && vacationBalance < fraction) { const use = Math.max(0, vacationBalance); result.usedVacation += use; result.usedUnpaid += fraction - use; vacationBalance = 0; } else { result.usedVacation += fraction; vacationBalance -= fraction; } }; const useFeriefridag = () => { if (allowUnpaid && feriefridageBalance < fraction) { const use = Math.max(0, feriefridageBalance); result.usedFeriefridage += use; result.usedUnpaid += fraction - use; feriefridageBalance = 0; } else { result.usedFeriefridage += fraction; feriefridageBalance -= fraction; } }; const useFlex = () => { if (allowUnpaid && flexBalance < dayHours) { const useHours = Math.max(0, flexBalance); result.usedFlex += useHours; result.usedFlexDays += useHours / baseDayHours; result.usedUnpaid += fraction - (useHours / baseDayHours); flexBalance = 0; } else { result.usedFlex += dayHours; result.usedFlexDays += fraction; flexBalance -= dayHours; } }; if (source === 'unpaid') { result.usedUnpaid = fraction; } else if (source === 'vacation') { useVacation(); } else if (source === 'feriefridag') { useFeriefridag(); } else if (source === 'flex') { useFlex(); } else { let remaining = fraction; if (remaining > 0 && feriefridageBalance > 0 && isBeforeFeriefridageExpiry(date, settings)) { const use = Math.min(feriefridageBalance, remaining); result.usedFeriefridage += use; feriefridageBalance -= use; remaining -= use; } if (remaining > 0 && vacationBalance > 0) { const use = Math.min(vacationBalance, remaining); result.usedVacation += use; vacationBalance -= use; remaining -= use; } if (remaining > 0 && flexBalance > 0) { const use = Math.min(flexBalance / baseDayHours, remaining); result.usedFlex += use * baseDayHours; result.usedFlexDays += use; flexBalance -= use * baseDayHours; remaining -= use; } if (remaining > 0) { result.usedVacation += remaining; vacationBalance -= remaining; } } return { ...result, balances: { vacationBalance, feriefridageBalance, flexBalance } }; } function isBeforeFeriefridageExpiry(date, settings) { return date.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8); } function workdaysBetween(start, end) { const days = []; let current = parseDate(start); const stop = parseDate(end); while (current <= stop) { const dow = current.getDay(); if (dow >= 1 && dow <= 5) days.push(new Date(current)); current.setDate(current.getDate() + 1); } return days; } function workdayUnitsForEntry(entry) { const units = workdaysBetween(entry.start_date, entry.end_date).map((date) => ({ date, fraction: 1 })); let remaining = Math.max(0, Number(entry.non_working_days || 0)); for (let i = units.length - 1; i >= 0 && remaining > 0; i--) { const reduction = Math.min(units[i].fraction, remaining); units[i].fraction -= reduction; remaining -= reduction; } return units.filter((unit) => unit.fraction > 0.0001); } function sumFractions(units) { return units.reduce((sum, unit) => sum + unit.fraction, 0); } function hoursForDate(date, settings) { return date.getDay() === 5 ? Number(settings.friday_hours || 7) : Number(settings.normal_day_hours || 7.5); } function averageDayHours(settings) { return ((Number(settings.normal_day_hours || 7.5) * 4) + Number(settings.friday_hours || 7)) / 5; } function dateFromParts(year, month, day) { return new Date(year, month - 1, day, 12, 0, 0); } function toIsoDate(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function parseDate(value) { const [year, month, day] = value.split('-').map(Number); return dateFromParts(year, month, day); } function formatDate(value) { const [year, month, day] = String(value).split('-'); if (!year || !month || !day) return escapeHtml(value); return `${day}/${month}/${year}`; } function monthKey(year, month) { return `${year}-${String(month).padStart(2, '0')}`; } function correctionFor(corrections, year, month) { return Number(corrections[monthKey(year, month)] || 0); } function hasCorrection(corrections, year, month) { return Object.prototype.hasOwnProperty.call(corrections, monthKey(year, month)); } function normalizeEntry(entry) { return { ...entry, id: Number(entry.id), allow_unpaid: Number(entry.allow_unpaid), non_working_days: Number(entry.non_working_days || 0), status: entry.status === 'confirmed' ? 'approved' : entry.status, }; } function days(value) { return formatNumber(value, 2); } function hours(value) { return `${formatNumber(value, 2)}h`; } function parseDecimal(value) { const normalized = String(value ?? '').trim().replace(',', '.'); if (normalized === '' || normalized === '+' || normalized === '-') return 0; const number = Number(normalized); return Number.isFinite(number) ? number : NaN; } function formatNumber(value, decimals = 2) { const n = Number(value || 0); const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals); return new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(rounded); } function dedupeWarnings(warnings) { const seen = new Set(); return warnings.filter((warning) => { if (seen.has(warning.text)) return false; seen.add(warning.text); return true; }); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function toast(message) { const node = $('toast'); node.textContent = message; node.classList.remove('hidden'); clearTimeout(toast.timer); toast.timer = setTimeout(() => node.classList.add('hidden'), 2200); } window.editEntry = editEntry; window.approveEntry = approveEntry; window.deleteEntry = deleteEntry;