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 @@
- | Entry |
+
Days |
Vac. days |
FF |
@@ -30,7 +30,6 @@