Calculations and inputs corrected

This commit is contained in:
marijo 2026-06-09 08:27:18 +00:00
parent 2ea0d9363e
commit 9b717ee6d6
4 changed files with 174 additions and 54 deletions

90
api.php
View file

@ -20,6 +20,8 @@ try {
'settings' => get_settings($db), 'settings' => get_settings($db),
'entries' => get_entries($db), 'entries' => get_entries($db),
'corrections' => get_corrections($db), 'corrections' => get_corrections($db),
'feriefridage_overrides' => get_feriefridage_overrides($db),
'flex_overrides' => get_flex_overrides($db),
]); ]);
} }
fail('Unknown GET action', 404); fail('Unknown GET action', 404);
@ -39,10 +41,20 @@ try {
break; break;
case 'saveCorrection': 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)]); respond(['ok' => true, 'corrections' => get_corrections($db)]);
break; 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': case 'createEntry':
$id = save_entry($db, $payload['entry'] ?? []); $id = save_entry($db, $payload['entry'] ?? []);
respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]); respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]);
@ -117,6 +129,20 @@ function migrate(PDO $db): void
PRIMARY KEY (year, month) 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 = [ $defaults = [
'planning_start_year' => '2026', 'planning_start_year' => '2026',
'planning_start_month' => '1', 'planning_start_month' => '1',
@ -191,13 +217,13 @@ function get_corrections(PDO $db): array
return $corrections; 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) { if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
fail('Invalid correction year/month', 400); 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 = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month');
$stmt->execute([':year' => $year, ':month' => $month]); $stmt->execute([':year' => $year, ':month' => $month]);
return; 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) $stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days)
VALUES (:year, :month, :value) VALUES (:year, :month, :value)
ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days'); 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 function get_entries(PDO $db): array

127
app.js
View file

@ -2,6 +2,8 @@ const state = {
settings: {}, settings: {},
entries: [], entries: [],
corrections: {}, corrections: {},
feriefridageOverrides: {},
flexOverrides: {},
plan: null, plan: null,
}; };
@ -49,7 +51,9 @@ async function loadState() {
state.settings = normalizeSettings(data.settings); state.settings = normalizeSettings(data.settings);
state.entries = data.entries.map(normalizeEntry); state.entries = data.entries.map(normalizeEntry);
state.corrections = data.corrections || {}; 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(); renderAll();
} }
@ -84,34 +88,39 @@ async function saveSettings(event) {
} }
const data = await post({ action: 'saveSettings', settings }); const data = await post({ action: 'saveSettings', settings });
state.settings = normalizeSettings(data.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(); renderAll();
closeSettingsDialog(); closeSettingsDialog();
toast('Settings saved'); toast('Settings saved');
} }
async function saveQuickFlex(event) { async function saveBalanceOverride(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) {
const input = event.target; const input = event.target;
const year = Number(input.dataset.year); const year = Number(input.dataset.year);
const month = Number(input.dataset.month); const month = Number(input.dataset.month);
const value = Number(input.value || 0); const kind = input.dataset.kind;
const data = await post({ action: 'saveCorrection', year, month, value }); const rawValue = String(input.value ?? '').trim();
state.corrections = data.corrections || {}; const value = rawValue === '' ? null : parseDecimal(rawValue);
state.plan = calculatePlan(state.settings, state.entries, state.corrections); 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(); renderAll();
toast('Correction saved'); toast('Balance saved');
} }
async function saveEntry(event) { async function saveEntry(event) {
event.preventDefault(); event.preventDefault();
const id = Number($('entryId').value || 0); 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 payload = id ? { action: 'updateEntry', id, entry } : { action: 'createEntry', entry };
const data = await post(payload); const data = await post(payload);
state.entries = data.entries.map(normalizeEntry); 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(); resetEntryForm();
closeEntryDialog(); closeEntryDialog();
renderAll(); renderAll();
@ -140,7 +149,7 @@ async function approveEntry(id) {
if (!entry || entry.status !== 'planned') return; if (!entry || entry.status !== 'planned') return;
const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } }); const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } });
state.entries = data.entries.map(normalizeEntry); 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(); renderAll();
toast('Entry approved'); toast('Entry approved');
} }
@ -150,7 +159,7 @@ async function deleteEntry(id) {
if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return; if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return;
const data = await post({ action: 'deleteEntry', id }); const data = await post({ action: 'deleteEntry', id });
state.entries = data.entries.map(normalizeEntry); 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(); renderAll();
toast('Entry deleted'); toast('Entry deleted');
} }
@ -230,13 +239,10 @@ function renderDashboard() {
<div class="hint">${escapeHtml(hint)}</div> <div class="hint">${escapeHtml(hint)}</div>
</article> </article>
`).join('')} `).join('')}
<article class="card ${current.flex < -0.001 ? 'bad' : 'good'} quick-flex-card"> <article class="card ${current.flex < -0.001 ? 'bad' : 'good'}">
<div class="label">FLEX hours</div> <div class="label">FLEX hours</div>
<form id="quickFlexForm" class="quick-flex-form"> <div class="value">${hours(current.flex)}</div>
<input id="quickFlexHours" type="number" step="0.25" value="${Number(state.settings.current_flex_hours ?? state.settings.opening_flex_hours ?? 0)}" aria-label="Current FLEX hours"> <div class="hint">Current balance</div>
<button type="submit" class="small">Save</button>
</form>
<div class="hint">Current, used from today</div>
</article> </article>
<article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}"> <article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}">
<div class="label">Total days</div> <div class="label">Total days</div>
@ -244,7 +250,6 @@ function renderDashboard() {
<div class="hint">Vacation + FF + FLEX</div> <div class="hint">Vacation + FF + FLEX</div>
</article> </article>
`; `;
$('quickFlexForm').addEventListener('submit', saveQuickFlex);
} }
function renderHeaderWarnings() { function renderHeaderWarnings() {
@ -307,8 +312,8 @@ function renderPlanner() {
</table> </table>
</div> </div>
`; `;
document.querySelectorAll('.correction-input').forEach((input) => { document.querySelectorAll('.balance-override-input').forEach((input) => {
input.addEventListener('change', saveCorrection); input.addEventListener('change', (event) => saveBalanceOverride(event).catch((error) => toast(error.message || String(error))));
}); });
} }
@ -328,16 +333,15 @@ function renderMonthRow(month) {
<tr> <tr>
<td>${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `<br><span class="warn-text">Expired FF: ${days(month.expiredFeriefridage)}</span>` : ''}</td> <td>${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `<br><span class="warn-text">Expired FF: ${days(month.expiredFeriefridage)}</span>` : ''}</td>
<td class="entries">${entries}</td> <td class="entries">${entries}</td>
<td class="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}">${days(month.vacationBalance)}</td> <td class="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" type="number" step="0.01" value="${month.hasVacationOverride ? Number(month.vacationOverride) : Number(month.vacationBalance.toFixed(2))}" data-kind="vacation" data-year="${month.year}" data-month="${month.month}" aria-label="Vacation days for ${monthNames[month.month - 1]} ${month.year}"></td>
<td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}">${days(month.feriefridageBalance)}</td> <td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" type="number" step="0.01" value="${month.hasFeriefridageOverride ? Number(month.feriefridageOverride) : Number(month.feriefridageBalance.toFixed(2))}" data-kind="feriefridage" data-year="${month.year}" data-month="${month.month}" aria-label="Feriefridage for ${monthNames[month.month - 1]} ${month.year}"></td>
<td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}">${hours(month.flexBalance)}</td> <td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" type="number" step="0.25" value="${month.hasFlexOverride ? Number(month.flexOverride) : Number(month.flexBalance.toFixed(2))}" data-kind="flex" data-year="${month.year}" data-month="${month.month}" aria-label="FLEX hours for ${monthNames[month.month - 1]} ${month.year}"></td>
<td class="num correction-cell"><input class="correction-input" type="number" step="0.01" value="${Number(month.balanceCorrection || 0)}" data-year="${month.year}" data-month="${month.month}" aria-label="Correction for ${monthNames[month.month - 1]} ${month.year}"></td>
<td class="num important-col ${balanceClass}">${days(month.availableDays)}</td> <td class="num important-col ${balanceClass}">${days(month.availableDays)}</td>
</tr> </tr>
`; `;
} }
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 today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
const previousMonths = Number(settings.previous_months ?? 5); const previousMonths = Number(settings.previous_months ?? 5);
const futureMonths = Number(settings.future_months ?? 30); const futureMonths = Number(settings.future_months ?? 30);
@ -385,7 +389,12 @@ function calculatePlan(settings, entries, corrections = {}) {
year, year,
month, month,
earnedVacation: Number(settings.vacation_days_per_month || 0), 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, grantedFeriefridage: month === 1 ? Number(settings.feriefridage_per_year || 0) : 0,
expiredFeriefridage: 0, expiredFeriefridage: 0,
usedVacation: 0, usedVacation: 0,
@ -399,12 +408,13 @@ function calculatePlan(settings, entries, corrections = {}) {
entries: [], entries: [],
}; };
vacationBalance += current.earnedVacation + current.balanceCorrection;
feriefridageBalance += current.grantedFeriefridage; feriefridageBalance += current.grantedFeriefridage;
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
current.expiredFeriefridage = feriefridageBalance; 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; feriefridageBalance = 0;
} }
@ -466,14 +476,21 @@ function calculatePlan(settings, entries, corrections = {}) {
monthItems.forEach(applyWorkday); 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.` }); 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.vacationBalance = vacationBalance;
current.feriefridageBalance = feriefridageBalance; current.feriefridageBalance = feriefridageBalance;
current.flexBalance = flexBalance; current.flexBalance = flexBalance;
current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
months.push(current); months.push(current);
if (vacationBalance < -0.001) { if (vacationBalance < -0.001) {
@ -499,13 +516,13 @@ function calculatePlan(settings, entries, corrections = {}) {
return { return {
months, months,
warnings: dedupeWarnings(warnings), warnings: dedupeWarnings(warnings),
current: calculateCurrentSnapshot(settings, entries, corrections), current: calculateCurrentSnapshot(settings, entries, corrections, feriefridageOverrides, flexOverrides),
entrySummaries, entrySummaries,
final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays }, 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 today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
const previousMonths = Number(settings.previous_months ?? 5); const previousMonths = Number(settings.previous_months ?? 5);
const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1); const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1);
@ -541,7 +558,6 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}) {
const year = cursor.getFullYear(); const year = cursor.getFullYear();
const month = cursor.getMonth() + 1; 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 === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0);
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) { 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; feriefridageBalance = allocation.balances.feriefridageBalance;
flexBalance = allocation.balances.flexBalance; 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 (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 (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); return Number(corrections[monthKey(year, month)] || 0);
} }
function hasCorrection(corrections, year, month) {
return Object.prototype.hasOwnProperty.call(corrections, monthKey(year, month));
}
function normalizeEntry(entry) { function normalizeEntry(entry) {
return { return {
...entry, ...entry,
@ -704,6 +738,13 @@ function hours(value) {
return `${formatNumber(value, 2)}h`; 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) { function formatNumber(value, decimals = 2) {
const n = Number(value || 0); const n = Number(value || 0);
const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals); const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals);

View file

@ -43,7 +43,6 @@
<th class="num">Vacation days</th> <th class="num">Vacation days</th>
<th class="num">Feriefridage</th> <th class="num">Feriefridage</th>
<th class="num">FLEX</th> <th class="num">FLEX</th>
<th class="num">Correction</th>
<th class="num important-col">Available days</th> <th class="num important-col">Available days</th>
</tr> </tr>
</thead> </thead>
@ -124,7 +123,7 @@
<label>Start vacation balance<input id="opening_vacation_days" type="number" step="0.01"></label> <label>Start vacation balance<input id="opening_vacation_days" type="number" step="0.01"></label>
<label>Start feriefridage balance<input id="opening_feriefridage_days" type="number" step="0.01"></label> <label>Start feriefridage balance<input id="opening_feriefridage_days" type="number" step="0.01"></label>
<label>Start FLEX hours<input id="opening_flex_hours" type="number" step="0.25"></label> <label>Start FLEX hours<input id="opening_flex_hours" type="number" step="0.25"></label>
<p class="full help-text">Start balances are the balances before the first shown month. Monthly corrections are edited directly in the monthly planner table.</p> <p class="full help-text">Start balances are the balances before the first shown month. Monthly vacation, feriefridage, and FLEX balances are edited directly in the planner table.</p>
<div class="actions full"> <div class="actions full">
<button type="submit">Save settings</button> <button type="submit">Save settings</button>
</div> </div>

View file

@ -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(3), .month-table td:nth-child(3),
.month-table th:nth-child(4), .month-table td:nth-child(4), .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(5), .month-table td:nth-child(5),
.month-table th:nth-child(6), .month-table td:nth-child(6), .month-table th:nth-child(6), .month-table td:nth-child(6) { width: 7.2rem; }
.month-table th:nth-child(7), .month-table td:nth-child(7) { width: 7.2rem; }
th, td { padding: .75rem .65rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } 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; } th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; }
tbody tr:hover { background: rgba(255,255,255,.04); } 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.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); }
.pill.unpaid { background: rgba(201,135,74,.22); color: var(--brand-dark); } .pill.unpaid { background: rgba(201,135,74,.22); color: var(--brand-dark); }
.important-col { background: rgba(163,138,99,.13); font-size: 1rem; } .important-col { background: rgba(163,138,99,.13); font-size: 1rem; }
.correction-cell { min-width: 5.2rem; } .balance-override-input {
.correction-input { width: 5.2rem;
width: 4.8rem;
padding: .28rem .35rem; padding: .28rem .35rem;
text-align: right; text-align: right;
font-size: .85rem; font-size: .85rem;