diff --git a/api.php b/api.php index 62be7de..912ce32 100644 --- a/api.php +++ b/api.php @@ -20,6 +20,8 @@ try { 'settings' => get_settings($db), 'entries' => get_entries($db), 'corrections' => get_corrections($db), + 'feriefridage_overrides' => get_feriefridage_overrides($db), + 'flex_overrides' => get_flex_overrides($db), ]); } fail('Unknown GET action', 404); @@ -39,10 +41,20 @@ try { break; case 'saveCorrection': - save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), (float)($payload['value'] ?? 0)); + save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null); respond(['ok' => true, 'corrections' => get_corrections($db)]); break; + case 'saveFeriefridageOverride': + save_feriefridage_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null); + respond(['ok' => true, 'feriefridage_overrides' => get_feriefridage_overrides($db)]); + break; + + case 'saveFlexOverride': + save_flex_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null); + respond(['ok' => true, 'flex_overrides' => get_flex_overrides($db)]); + break; + case 'createEntry': $id = save_entry($db, $payload['entry'] ?? []); respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]); @@ -117,6 +129,20 @@ function migrate(PDO $db): void PRIMARY KEY (year, month) )"); + $db->exec("CREATE TABLE IF NOT EXISTS monthly_feriefridage_overrides ( + year INTEGER NOT NULL, + month INTEGER NOT NULL, + feriefridage_days REAL NOT NULL DEFAULT 0, + PRIMARY KEY (year, month) + )"); + + $db->exec("CREATE TABLE IF NOT EXISTS monthly_flex_overrides ( + year INTEGER NOT NULL, + month INTEGER NOT NULL, + flex_hours REAL NOT NULL DEFAULT 0, + PRIMARY KEY (year, month) + )"); + $defaults = [ 'planning_start_year' => '2026', 'planning_start_month' => '1', @@ -191,13 +217,13 @@ function get_corrections(PDO $db): array return $corrections; } -function save_correction(PDO $db, int $year, int $month, float $value): void +function save_correction(PDO $db, int $year, int $month, mixed $value): void { if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) { fail('Invalid correction year/month', 400); } - if (abs($value) < 0.000001) { + if ($value === null || $value === '') { $stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month'); $stmt->execute([':year' => $year, ':month' => $month]); return; @@ -206,7 +232,63 @@ function save_correction(PDO $db, int $year, int $month, float $value): void $stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days) VALUES (:year, :month, :value) ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days'); - $stmt->execute([':year' => $year, ':month' => $month, ':value' => $value]); + $stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]); +} + +function get_feriefridage_overrides(PDO $db): array +{ + $rows = $db->query('SELECT year, month, feriefridage_days FROM monthly_feriefridage_overrides ORDER BY year, month')->fetchAll(); + $overrides = []; + foreach ($rows as $row) { + $overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['feriefridage_days']; + } + return $overrides; +} + +function save_feriefridage_override(PDO $db, int $year, int $month, mixed $value): void +{ + if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) { + fail('Invalid feriefridage override year/month', 400); + } + + if ($value === null || $value === '') { + $stmt = $db->prepare('DELETE FROM monthly_feriefridage_overrides WHERE year = :year AND month = :month'); + $stmt->execute([':year' => $year, ':month' => $month]); + return; + } + + $stmt = $db->prepare('INSERT INTO monthly_feriefridage_overrides (year, month, feriefridage_days) + VALUES (:year, :month, :value) + ON CONFLICT(year, month) DO UPDATE SET feriefridage_days = excluded.feriefridage_days'); + $stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]); +} + +function get_flex_overrides(PDO $db): array +{ + $rows = $db->query('SELECT year, month, flex_hours FROM monthly_flex_overrides ORDER BY year, month')->fetchAll(); + $overrides = []; + foreach ($rows as $row) { + $overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['flex_hours']; + } + return $overrides; +} + +function save_flex_override(PDO $db, int $year, int $month, mixed $value): void +{ + if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) { + fail('Invalid FLEX override year/month', 400); + } + + if ($value === null || $value === '') { + $stmt = $db->prepare('DELETE FROM monthly_flex_overrides WHERE year = :year AND month = :month'); + $stmt->execute([':year' => $year, ':month' => $month]); + return; + } + + $stmt = $db->prepare('INSERT INTO monthly_flex_overrides (year, month, flex_hours) + VALUES (:year, :month, :value) + ON CONFLICT(year, month) DO UPDATE SET flex_hours = excluded.flex_hours'); + $stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]); } function get_entries(PDO $db): array diff --git a/app.js b/app.js index db40751..0af5e32 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,8 @@ const state = { settings: {}, entries: [], corrections: {}, + feriefridageOverrides: {}, + flexOverrides: {}, plan: null, }; @@ -49,7 +51,9 @@ async function loadState() { state.settings = normalizeSettings(data.settings); state.entries = data.entries.map(normalizeEntry); state.corrections = data.corrections || {}; - state.plan = calculatePlan(state.settings, state.entries, state.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(); } @@ -84,34 +88,39 @@ async function saveSettings(event) { } const data = await post({ action: 'saveSettings', settings }); state.settings = normalizeSettings(data.settings); - state.plan = calculatePlan(state.settings, state.entries, state.corrections); + state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); closeSettingsDialog(); toast('Settings saved'); } -async function saveQuickFlex(event) { - event.preventDefault(); - const value = Number($('quickFlexHours').value); - const data = await post({ action: 'saveSettings', settings: { current_flex_hours: value } }); - state.settings = normalizeSettings(data.settings); - state.plan = calculatePlan(state.settings, state.entries, state.corrections); - renderAll(); - toast('FLEX hours updated'); -} - -async function saveCorrection(event) { +async function saveBalanceOverride(event) { const input = event.target; const year = Number(input.dataset.year); const month = Number(input.dataset.month); - const value = Number(input.value || 0); - const data = await post({ action: 'saveCorrection', year, month, value }); - state.corrections = data.corrections || {}; - state.plan = calculatePlan(state.settings, state.entries, state.corrections); + 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('Correction saved'); + toast('Balance saved'); } + async function saveEntry(event) { event.preventDefault(); const id = Number($('entryId').value || 0); @@ -128,7 +137,7 @@ async function saveEntry(event) { 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.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); resetEntryForm(); closeEntryDialog(); renderAll(); @@ -140,7 +149,7 @@ async function approveEntry(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.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Entry approved'); } @@ -150,7 +159,7 @@ async function deleteEntry(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.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides); renderAll(); toast('Entry deleted'); } @@ -230,13 +239,10 @@ function renderDashboard() {
${escapeHtml(hint)}
`).join('')} -
+
FLEX hours
-
- - -
-
Current, used from today
+
${hours(current.flex)}
+
Current balance
Total days
@@ -244,7 +250,6 @@ function renderDashboard() {
Vacation + FF + FLEX
`; - $('quickFlexForm').addEventListener('submit', saveQuickFlex); } function renderHeaderWarnings() { @@ -307,8 +312,8 @@ function renderPlanner() { `; - document.querySelectorAll('.correction-input').forEach((input) => { - input.addEventListener('change', saveCorrection); + document.querySelectorAll('.balance-override-input').forEach((input) => { + input.addEventListener('change', (event) => saveBalanceOverride(event).catch((error) => toast(error.message || String(error)))); }); } @@ -328,16 +333,15 @@ function renderMonthRow(month) { ${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `
Expired FF: ${days(month.expiredFeriefridage)}` : ''} ${entries} - ${days(month.vacationBalance)} - ${days(month.feriefridageBalance)} - ${hours(month.flexBalance)} - + + + ${days(month.availableDays)} `; } -function calculatePlan(settings, entries, corrections = {}) { +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); @@ -385,7 +389,12 @@ function calculatePlan(settings, entries, corrections = {}) { year, month, earnedVacation: Number(settings.vacation_days_per_month || 0), - balanceCorrection: correctionFor(corrections, year, month), + 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, @@ -399,12 +408,13 @@ function calculatePlan(settings, entries, corrections = {}) { entries: [], }; - vacationBalance += current.earnedVacation + current.balanceCorrection; feriefridageBalance += current.grantedFeriefridage; if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { current.expiredFeriefridage = feriefridageBalance; - warnings.push({ level: 'bad', text: `${days(feriefridageBalance)} feriefridage expired on 31 August ${year}. They must be used before that date.` }); + 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; } @@ -466,14 +476,21 @@ function calculatePlan(settings, entries, corrections = {}) { monthItems.forEach(applyWorkday); } - if (month === Number(settings.feriefridage_expiry_month || 8) && feriefridageBalance > 0.001) { + vacationBalance += current.earnedVacation; + + 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) { @@ -499,13 +516,13 @@ function calculatePlan(settings, entries, corrections = {}) { return { months, warnings: dedupeWarnings(warnings), - current: calculateCurrentSnapshot(settings, entries, corrections), + current: calculateCurrentSnapshot(settings, entries, corrections, feriefridageOverrides, flexOverrides), entrySummaries, final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays }, }; } -function calculateCurrentSnapshot(settings, entries, corrections = {}) { +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); @@ -541,7 +558,6 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}) { const year = cursor.getFullYear(); const month = cursor.getMonth() + 1; - vacationBalance += Number(settings.vacation_days_per_month || 0) + correctionFor(corrections, year, month); if (month === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0); if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { @@ -558,9 +574,23 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}) { 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); } - flexBalance = currentFlexHours; + 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.` }); @@ -687,6 +717,10 @@ 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, @@ -704,6 +738,13 @@ 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); diff --git a/index.html b/index.html index c509b5f..b3ab702 100644 --- a/index.html +++ b/index.html @@ -43,7 +43,6 @@ Vacation days Feriefridage FLEX - Correction Available days @@ -124,7 +123,7 @@ -

Start balances are the balances before the first shown month. Monthly corrections are edited directly in the monthly planner table.

+

Start balances are the balances before the first shown month. Monthly vacation, feriefridage, and FLEX balances are edited directly in the planner table.

diff --git a/style.css b/style.css index 3ad772e..f7df4de 100644 --- a/style.css +++ b/style.css @@ -267,8 +267,7 @@ table { width: 100%; border-collapse: collapse; font-size: .92rem; } .month-table th:nth-child(3), .month-table td:nth-child(3), .month-table th:nth-child(4), .month-table td:nth-child(4), .month-table th:nth-child(5), .month-table td:nth-child(5), -.month-table th:nth-child(6), .month-table td:nth-child(6), -.month-table th:nth-child(7), .month-table td:nth-child(7) { width: 7.2rem; } +.month-table th:nth-child(6), .month-table td:nth-child(6) { width: 7.2rem; } th, td { padding: .75rem .65rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; } tbody tr:hover { background: rgba(255,255,255,.04); } @@ -293,9 +292,8 @@ tbody tr:hover { background: rgba(255,255,255,.04); } .pill.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); } .pill.unpaid { background: rgba(201,135,74,.22); color: var(--brand-dark); } .important-col { background: rgba(163,138,99,.13); font-size: 1rem; } -.correction-cell { min-width: 5.2rem; } -.correction-input { - width: 4.8rem; +.balance-override-input { + width: 5.2rem; padding: .28rem .35rem; text-align: right; font-size: .85rem;