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

127
app.js
View file

@ -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() {
<div class="hint">${escapeHtml(hint)}</div>
</article>
`).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>
<form id="quickFlexForm" class="quick-flex-form">
<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">
<button type="submit" class="small">Save</button>
</form>
<div class="hint">Current, used from today</div>
<div class="value">${hours(current.flex)}</div>
<div class="hint">Current balance</div>
</article>
<article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}">
<div class="label">Total days</div>
@ -244,7 +250,6 @@ function renderDashboard() {
<div class="hint">Vacation + FF + FLEX</div>
</article>
`;
$('quickFlexForm').addEventListener('submit', saveQuickFlex);
}
function renderHeaderWarnings() {
@ -307,8 +312,8 @@ function renderPlanner() {
</table>
</div>
`;
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) {
<tr>
<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="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}">${days(month.vacationBalance)}</td>
<td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}">${days(month.feriefridageBalance)}</td>
<td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}">${hours(month.flexBalance)}</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 ${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' : ''}"><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' : ''}"><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 important-col ${balanceClass}">${days(month.availableDays)}</td>
</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 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);