diff --git a/api.php b/api.php index 912ce32..13836fe 100644 --- a/api.php +++ b/api.php @@ -117,11 +117,17 @@ function migrate(PDO $db): void source TEXT NOT NULL DEFAULT 'auto', status TEXT NOT NULL DEFAULT 'planned', allow_unpaid INTEGER NOT NULL DEFAULT 0, + non_working_days REAL NOT NULL DEFAULT 0, note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )"); + $columns = array_column($db->query('PRAGMA table_info(entries)')->fetchAll(), 'name'); + if (!in_array('non_working_days', $columns, true)) { + $db->exec('ALTER TABLE entries ADD COLUMN non_working_days REAL NOT NULL DEFAULT 0'); + } + $db->exec("CREATE TABLE IF NOT EXISTS monthly_corrections ( year INTEGER NOT NULL, month INTEGER NOT NULL, @@ -293,7 +299,7 @@ function save_flex_override(PDO $db, int $year, int $month, mixed $value): void function get_entries(PDO $db): array { - return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, note + return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, non_working_days, note FROM entries ORDER BY start_date, end_date, id')->fetchAll(); } @@ -305,7 +311,8 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int $source = (string)($entry['source'] ?? 'auto'); $status = (string)($entry['status'] ?? 'planned'); $allowUnpaid = !empty($entry['allow_unpaid']) ? 1 : 0; - $note = trim((string)($entry['note'] ?? '')); + $nonWorkingDays = (float)($entry['non_working_days'] ?? 0); + $note = trim((string)($entry['note'] ?? '')); if ($title === '') { fail('Title is required', 400); @@ -319,6 +326,9 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int if (!in_array($source, ['auto', 'vacation', 'feriefridag', 'flex', 'unpaid'], true)) { fail('Invalid source', 400); } + if ($nonWorkingDays < 0 || fmod($nonWorkingDays * 2, 1.0) !== 0.0) { + fail('Additional non-working days must be in steps of 0.5', 400); + } if ($status === 'confirmed') { $status = 'approved'; } @@ -333,13 +343,14 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int ':source' => $source, ':status' => $status, ':allow_unpaid' => $allowUnpaid, + ':non_working_days' => $nonWorkingDays, ':note' => $note, ]; if ($id === null) { $stmt = $db->prepare('INSERT INTO entries - (title, start_date, end_date, source, status, allow_unpaid, note) - VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :note)'); + (title, start_date, end_date, source, status, allow_unpaid, non_working_days, note) + VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :non_working_days, :note)'); $stmt->execute($params); return (int)$db->lastInsertId(); } @@ -352,6 +363,7 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int source = :source, status = :status, allow_unpaid = :allow_unpaid, + non_working_days = :non_working_days, note = :note, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); diff --git a/app.js b/app.js index d837761..1852910 100644 --- a/app.js +++ b/app.js @@ -35,13 +35,14 @@ async function init() { function bindEvents() { $('addEntryBtn').addEventListener('click', () => openEntryDialog()); $('settingsBtn').addEventListener('click', openSettingsDialog); - $('toggleEntriesBtn').addEventListener('click', toggleHeaderEntries); + $('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); + ['startDate', 'endDate', 'nonWorkingDays'].forEach((id) => $(id).addEventListener('input', updateWorkdayPreview)); $('cancelEditBtn').addEventListener('click', resetEntryForm); } @@ -79,11 +80,9 @@ function toggleHeaderEntries() { function setHeaderEntriesCollapsed(collapsed) { const entries = document.querySelector('.header-entries'); - const button = $('toggleEntriesBtn'); - if (!entries || !button) return; + if (!entries) return; entries.classList.toggle('is-collapsed', collapsed); - button.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); - button.textContent = collapsed ? 'Show entries' : 'Hide entries'; + $('entriesTable').querySelector('thead').setAttribute('aria-expanded', collapsed ? 'false' : 'true'); } function renderAll() { @@ -153,6 +152,7 @@ async function saveEntry(event) { source: $('source').value, status: $('status').value, allow_unpaid: $('allowUnpaid').checked ? 1 : 0, + non_working_days: Number($('nonWorkingDays').value || 0), note: $('note').value.trim(), }; @@ -229,18 +229,40 @@ function editEntry(id) { $('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'); @@ -295,6 +317,7 @@ function renderWarnings() { } function renderEntries() { + $('entriesHeaderTitle').textContent = `VACATIONS (${state.entries.length})`; const tbody = $('entriesTable').querySelector('tbody'); if (!state.entries.length) { tbody.innerHTML = 'No entries yet.'; @@ -302,7 +325,7 @@ function renderEntries() { } tbody.innerHTML = state.entries.map((entry) => { - const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 }; + 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 ` @@ -403,11 +426,12 @@ function calculatePlan(settings, entries, corrections = {}, feriefridageOverride const allWorkdays = []; for (const entry of entries) { - const dates = workdaysBetween(entry.start_date, entry.end_date); - entrySummaries[entry.id] = { workdays: dates.length, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 }; - for (const date of dates) { - if (date < startDate || date > endDate) continue; - allWorkdays.push({ date, entryId: entry.id }); + 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); @@ -468,7 +492,7 @@ function calculatePlan(settings, entries, corrections = {}, feriefridageOverride vacationBalance, feriefridageBalance, flexBalance, - }, settings); + }, settings, item.fraction); vacationBalance = allocation.balances.vacationBalance; feriefridageBalance = allocation.balances.feriefridageBalance; @@ -502,7 +526,7 @@ function calculatePlan(settings, entries, corrections = {}, feriefridageOverride }; current.entries.push(monthEntry); } - monthEntry.workdays += 1; + monthEntry.workdays += item.fraction; monthEntry.usedVacation += allocation.usedVacation; monthEntry.usedFeriefridage += allocation.usedFeriefridage; monthEntry.usedFlex += allocation.usedFlex; @@ -586,8 +610,8 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}, feriefrid const workdays = []; for (const entry of entries) { - for (const date of workdaysBetween(entry.start_date, entry.end_date)) { - if (date >= startDate && date <= today) workdays.push({ date, entry }); + 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); @@ -610,7 +634,7 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}, feriefrid 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); + 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; @@ -659,16 +683,16 @@ function feriefridageRemainingAfterApprovedPlans(settings, entries, today, balan const futureApprovedWorkdays = []; for (const entry of entries) { if (entry.status !== 'approved') continue; - for (const date of workdaysBetween(entry.start_date, entry.end_date)) { - if (date > today && date <= deadline) { - futureApprovedWorkdays.push({ date, entry }); + 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); + 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; @@ -677,34 +701,34 @@ function feriefridageRemainingAfterApprovedPlans(settings, entries, today, balan return feriefridageBalance; } -function allocateDay(entry, date, balances, settings) { +function allocateDay(entry, date, balances, settings, fraction = 1) { const source = entry.source; const allowUnpaid = Boolean(Number(entry.allow_unpaid)); - const dayHours = hoursForDate(date, settings); + const dayHours = hoursForDate(date, settings) * fraction; let { vacationBalance, feriefridageBalance, flexBalance } = balances; const result = { usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 }; const useVacation = () => { - if (allowUnpaid && vacationBalance < 1) { + if (allowUnpaid && vacationBalance < fraction) { const use = Math.max(0, vacationBalance); result.usedVacation += use; - result.usedUnpaid += 1 - use; + result.usedUnpaid += fraction - use; vacationBalance = 0; } else { - result.usedVacation += 1; - vacationBalance -= 1; + result.usedVacation += fraction; + vacationBalance -= fraction; } }; const useFeriefridag = () => { - if (allowUnpaid && feriefridageBalance < 1) { + if (allowUnpaid && feriefridageBalance < fraction) { const use = Math.max(0, feriefridageBalance); result.usedFeriefridage += use; - result.usedUnpaid += 1 - use; + result.usedUnpaid += fraction - use; feriefridageBalance = 0; } else { - result.usedFeriefridage += 1; - feriefridageBalance -= 1; + result.usedFeriefridage += fraction; + feriefridageBalance -= fraction; } }; @@ -712,18 +736,18 @@ function allocateDay(entry, date, balances, settings) { if (allowUnpaid && flexBalance < dayHours) { const useHours = Math.max(0, flexBalance); result.usedFlex += useHours; - result.usedFlexDays += useHours / dayHours; - result.usedUnpaid += 1 - (useHours / dayHours); + result.usedFlexDays += useHours / hoursForDate(date, settings); + result.usedUnpaid += fraction - (useHours / hoursForDate(date, settings)); flexBalance = 0; } else { result.usedFlex += dayHours; - result.usedFlexDays += 1; + result.usedFlexDays += fraction; flexBalance -= dayHours; } }; if (source === 'unpaid') { - result.usedUnpaid = 1; + result.usedUnpaid = fraction; } else if (source === 'vacation') { useVacation(); } else if (source === 'feriefridag') { @@ -731,16 +755,16 @@ function allocateDay(entry, date, balances, settings) { } else if (source === 'flex') { useFlex(); } else { - if (feriefridageBalance >= 1 && isBeforeFeriefridageExpiry(date, settings)) { + if (feriefridageBalance >= fraction && isBeforeFeriefridageExpiry(date, settings)) { useFeriefridag(); - } else if (vacationBalance >= 1) { + } else if (vacationBalance >= fraction) { useVacation(); } else if (flexBalance >= dayHours) { useFlex(); } else if (allowUnpaid) { const use = Math.max(0, vacationBalance); result.usedVacation += use; - result.usedUnpaid += 1 - use; + result.usedUnpaid += fraction - use; vacationBalance = 0; } else { useVacation(); @@ -766,6 +790,21 @@ function workdaysBetween(start, end) { 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); } @@ -800,6 +839,7 @@ function normalizeEntry(entry) { ...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, }; } diff --git a/index.html b/index.html index 6bd8327..0946fa5 100644 --- a/index.html +++ b/index.html @@ -14,7 +14,7 @@ - + @@ -30,7 +30,6 @@
- @@ -93,6 +92,11 @@ + +
Set dates to see calculated working days.
EntryVACATIONS Days Vac. days FF