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 vacation balance
Start feriefridage balance
Start FLEX hours
- 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.
Save settings
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;