From 0727798af7f97acb59025b588cdd313fb830861a Mon Sep 17 00:00:00 2001 From: marijo Date: Tue, 9 Jun 2026 07:20:08 +0000 Subject: [PATCH] First version --- .gitignore | 3 + README.md | 27 ++ api.php | 301 ++++++++++++++++++++ app.js | 733 +++++++++++++++++++++++++++++++++++++++++++++++++ data/.htaccess | 1 + index.html | 138 ++++++++++ style.css | 378 +++++++++++++++++++++++++ 7 files changed, 1581 insertions(+) create mode 100644 README.md create mode 100644 api.php create mode 100644 app.js create mode 100644 data/.htaccess create mode 100644 index.html create mode 100644 style.css diff --git a/.gitignore b/.gitignore index c317064..0a2dadf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ AGENTS.md +GO.xlsx +data/* +!data/.htaccess diff --git a/README.md b/README.md new file mode 100644 index 0000000..2617847 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# GO Vacation Planner + +Small PHP + SQLite web app for planning Danish vacation days, feriefridage, FLEX hours, and explicit unpaid fallback per date range. + +## Local run + +```bash +php -S 127.0.0.1:8080 +``` + +Open . + +The SQLite database is created automatically in `data/go.sqlite`. + +## Rules currently modeled + +- Vacation accrues monthly, default `2.08` days/month. +- Feriefridage grant yearly, default `5` days/year. +- Feriefridage must be used before `31 August` and expire before September planning. +- FLEX days use `7.5h` Monday-Thursday and `7h` Friday. +- Start balances are configured separately from current FLEX hours. +- Monthly vacation-day corrections can be entered, e.g. `+1.2` or `-2.6` days. +- Each date-range entry can individually allow unpaid fallback so only that range is converted to unpaid days if balances are insufficient. + +## Data + +Runtime database files are ignored by git. Back up `data/go.sqlite` if needed. diff --git a/api.php b/api.php new file mode 100644 index 0000000..493b000 --- /dev/null +++ b/api.php @@ -0,0 +1,301 @@ + get_settings($db), + 'entries' => get_entries($db), + 'corrections' => get_corrections($db), + ]); + } + fail('Unknown GET action', 404); + } + + if ($method === 'POST') { + $payload = json_decode(file_get_contents('php://input') ?: '{}', true); + if (!is_array($payload)) { + fail('Invalid JSON payload', 400); + } + $action = $payload['action'] ?? $action; + + switch ($action) { + case 'saveSettings': + save_settings($db, $payload['settings'] ?? []); + respond(['ok' => true, 'settings' => get_settings($db)]); + break; + + case 'saveCorrection': + save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), (float)($payload['value'] ?? 0)); + respond(['ok' => true, 'corrections' => get_corrections($db)]); + break; + + case 'createEntry': + $id = save_entry($db, $payload['entry'] ?? []); + respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]); + break; + + case 'updateEntry': + $id = (int)($payload['id'] ?? 0); + if ($id <= 0) { + fail('Missing entry id', 400); + } + save_entry($db, $payload['entry'] ?? [], $id); + respond(['ok' => true, 'entries' => get_entries($db)]); + break; + + case 'deleteEntry': + $id = (int)($payload['id'] ?? 0); + if ($id <= 0) { + fail('Missing entry id', 400); + } + $stmt = $db->prepare('DELETE FROM entries WHERE id = :id'); + $stmt->execute([':id' => $id]); + respond(['ok' => true, 'entries' => get_entries($db)]); + break; + + default: + fail('Unknown POST action', 404); + } + } + + fail('Unsupported method', 405); +} catch (Throwable $e) { + fail($e->getMessage(), 500); +} + +function db(): PDO +{ + if (!is_dir(DB_DIR) && !mkdir(DB_DIR, 0775, true) && !is_dir(DB_DIR)) { + throw new RuntimeException('Could not create data directory'); + } + + $db = new PDO('sqlite:' . DB_FILE); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $db->exec('PRAGMA foreign_keys = ON'); + return $db; +} + +function migrate(PDO $db): void +{ + $db->exec("CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )"); + + $db->exec("CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'auto', + status TEXT NOT NULL DEFAULT 'planned', + allow_unpaid INTEGER 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 + )"); + + $db->exec("CREATE TABLE IF NOT EXISTS monthly_corrections ( + year INTEGER NOT NULL, + month INTEGER NOT NULL, + vacation_days REAL NOT NULL DEFAULT 0, + PRIMARY KEY (year, month) + )"); + + $defaults = [ + 'planning_start_year' => '2026', + 'planning_start_month' => '1', + 'planning_years' => '3', + 'planning_months' => '36', + 'vacation_days_per_month' => '2.08', + 'feriefridage_per_year' => '5', + 'feriefridage_expiry_month' => '8', + 'normal_day_hours' => '7.5', + 'friday_hours' => '7', + 'opening_vacation_days' => '-1.97', + 'opening_feriefridage_days' => '0', + 'opening_flex_hours' => '0', + 'current_flex_hours' => '46.5', + ]; + + $stmt = $db->prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)'); + foreach ($defaults as $key => $value) { + $stmt->execute([':key' => $key, ':value' => $value]); + } +} + +function get_settings(PDO $db): array +{ + $rows = $db->query('SELECT key, value FROM settings')->fetchAll(); + $settings = []; + foreach ($rows as $row) { + $settings[$row['key']] = numeric_value($row['value']); + } + return $settings; +} + +function save_settings(PDO $db, array $settings): void +{ + $allowed = [ + 'planning_start_year', + 'planning_start_month', + 'planning_years', + 'planning_months', + 'vacation_days_per_month', + 'feriefridage_per_year', + 'feriefridage_expiry_month', + 'normal_day_hours', + 'friday_hours', + 'opening_vacation_days', + 'opening_feriefridage_days', + 'opening_flex_hours', + 'current_flex_hours', + ]; + + $stmt = $db->prepare('INSERT INTO settings (key, value) VALUES (:key, :value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value'); + + foreach ($allowed as $key) { + if (array_key_exists($key, $settings)) { + $stmt->execute([':key' => $key, ':value' => (string)$settings[$key]]); + } + } +} + +function get_corrections(PDO $db): array +{ + $rows = $db->query('SELECT year, month, vacation_days FROM monthly_corrections ORDER BY year, month')->fetchAll(); + $corrections = []; + foreach ($rows as $row) { + $corrections[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['vacation_days']; + } + return $corrections; +} + +function save_correction(PDO $db, int $year, int $month, float $value): void +{ + if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) { + fail('Invalid correction year/month', 400); + } + + if (abs($value) < 0.000001) { + $stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month'); + $stmt->execute([':year' => $year, ':month' => $month]); + return; + } + + $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]); +} + +function get_entries(PDO $db): array +{ + return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, note + FROM entries ORDER BY start_date, end_date, id')->fetchAll(); +} + +function save_entry(PDO $db, array $entry, ?int $id = null): int +{ + $title = trim((string)($entry['title'] ?? '')); + $start = trim((string)($entry['start_date'] ?? '')); + $end = trim((string)($entry['end_date'] ?? '')); + $source = (string)($entry['source'] ?? 'auto'); + $status = (string)($entry['status'] ?? 'planned'); + $allowUnpaid = !empty($entry['allow_unpaid']) ? 1 : 0; + $note = trim((string)($entry['note'] ?? '')); + + if ($title === '') { + fail('Title is required', 400); + } + if (!valid_date($start) || !valid_date($end)) { + fail('Start/end date must be YYYY-MM-DD', 400); + } + if ($start > $end) { + fail('Start date must be before end date', 400); + } + if (!in_array($source, ['auto', 'vacation', 'feriefridag', 'flex', 'unpaid'], true)) { + fail('Invalid source', 400); + } + if ($status === 'confirmed') { + $status = 'approved'; + } + if (!in_array($status, ['planned', 'approved'], true)) { + fail('Invalid status', 400); + } + + $params = [ + ':title' => $title, + ':start_date' => $start, + ':end_date' => $end, + ':source' => $source, + ':status' => $status, + ':allow_unpaid' => $allowUnpaid, + ':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)'); + $stmt->execute($params); + return (int)$db->lastInsertId(); + } + + $params[':id'] = $id; + $stmt = $db->prepare('UPDATE entries SET + title = :title, + start_date = :start_date, + end_date = :end_date, + source = :source, + status = :status, + allow_unpaid = :allow_unpaid, + note = :note, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id'); + $stmt->execute($params); + return $id; +} + +function valid_date(string $date): bool +{ + $d = DateTime::createFromFormat('Y-m-d', $date); + return $d && $d->format('Y-m-d') === $date; +} + +function numeric_value(string $value): int|float|string +{ + if (is_numeric($value)) { + return str_contains($value, '.') ? (float)$value : (int)$value; + } + return $value; +} + +function respond(array $data): never +{ + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + exit; +} + +function fail(string $message, int $status): never +{ + http_response_code($status); + echo json_encode(['error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + exit; +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..3cdc86c --- /dev/null +++ b/app.js @@ -0,0 +1,733 @@ +const state = { + settings: {}, + entries: [], + corrections: {}, + plan: null, +}; + +const settingKeys = [ + 'planning_start_year', 'planning_start_month', 'planning_months', + 'vacation_days_per_month', 'feriefridage_per_year', 'feriefridage_expiry_month', + 'normal_day_hours', 'friday_hours', 'opening_vacation_days', + 'opening_feriefridage_days', 'opening_flex_hours' +]; + +const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; +const sourceLabels = { + auto: 'Smart', + vacation: 'Vacation', + feriefridag: 'Feriefridage', + flex: 'FLEX', + unpaid: 'Unpaid', +}; + +const $ = (id) => document.getElementById(id); + +init().catch((error) => toast(error.message || String(error))); + +async function init() { + bindEvents(); + await loadState(); +} + +function bindEvents() { + $('addEntryBtn').addEventListener('click', () => openEntryDialog()); + $('settingsBtn').addEventListener('click', openSettingsDialog); + $('closeEntryDialogBtn').addEventListener('click', closeEntryDialog); + $('closeSettingsDialogBtn').addEventListener('click', closeSettingsDialog); + $('entryDialog').addEventListener('click', closeDialogOnBackdrop); + $('settingsDialog').addEventListener('click', closeDialogOnBackdrop); + $('entryForm').addEventListener('submit', saveEntry); + $('settingsForm').addEventListener('submit', saveSettings); + $('cancelEditBtn').addEventListener('click', resetEntryForm); +} + +async function loadState() { + const res = await fetch('api.php?action=state'); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not load state'); + state.settings = normalizeSettings(data.settings); + state.entries = data.entries.map(normalizeEntry); + state.corrections = data.corrections || {}; + state.plan = calculatePlan(state.settings, state.entries, state.corrections); + renderAll(); +} + +function normalizeSettings(settings) { + const normalized = { ...settings }; + normalized.planning_start_month = Number(normalized.planning_start_month || 1); + normalized.planning_months = Number(normalized.planning_months || (Number(normalized.planning_years || 3) * 12)); + return normalized; +} + +function renderAll() { + fillSettingsForm(); + renderDashboard(); + renderHeaderWarnings(); + renderWarnings(); + renderEntries(); + renderPlanner(); +} + +function fillSettingsForm() { + for (const key of settingKeys) { + const node = $(key); + if (node) node.value = state.settings[key] ?? ''; + } +} + +async function saveSettings(event) { + event.preventDefault(); + const settings = {}; + for (const key of settingKeys) { + settings[key] = Number($(key).value); + } + const data = await post({ action: 'saveSettings', settings }); + state.settings = normalizeSettings(data.settings); + state.plan = calculatePlan(state.settings, state.entries, state.corrections); + 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) { + 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); + renderAll(); + toast('Correction saved'); +} + +async function saveEntry(event) { + event.preventDefault(); + const id = Number($('entryId').value || 0); + const entry = { + title: $('title').value.trim(), + start_date: $('startDate').value, + end_date: $('endDate').value, + source: $('source').value, + status: $('status').value, + allow_unpaid: $('allowUnpaid').checked ? 1 : 0, + note: $('note').value.trim(), + }; + + 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); + resetEntryForm(); + closeEntryDialog(); + renderAll(); + toast(id ? 'Entry updated' : 'Entry added'); +} + +async function approveEntry(id) { + const entry = state.entries.find((item) => item.id === 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); + renderAll(); + toast('Entry approved'); +} + +async function deleteEntry(id) { + const entry = state.entries.find((item) => item.id === 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); + renderAll(); + toast('Entry deleted'); +} + +async function post(payload) { + const res = await fetch('api.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; +} + +function openEntryDialog(entryId = null) { + if (!entryId) resetEntryForm(); + $('entryDialog').showModal(); +} + +function closeEntryDialog() { + $('entryDialog').close(); +} + +function openSettingsDialog() { + fillSettingsForm(); + $('settingsDialog').showModal(); +} + +function closeSettingsDialog() { + $('settingsDialog').close(); +} + +function closeDialogOnBackdrop(event) { + if (event.target === event.currentTarget) event.currentTarget.close(); +} + +function editEntry(id) { + const entry = state.entries.find((item) => item.id === id); + if (!entry) return; + $('entryId').value = entry.id; + $('title').value = entry.title; + $('startDate').value = entry.start_date; + $('endDate').value = entry.end_date; + $('source').value = entry.source; + $('status').value = entry.status; + $('allowUnpaid').checked = Boolean(Number(entry.allow_unpaid)); + $('note').value = entry.note || ''; + $('entryFormTitle').textContent = 'Edit date-range entry'; + $('saveEntryBtn').textContent = 'Update entry'; + $('cancelEditBtn').classList.remove('hidden'); + openEntryDialog(id); +} + +function resetEntryForm() { + $('entryForm').reset(); + $('entryId').value = ''; + $('source').value = 'auto'; + $('status').value = 'planned'; + $('entryFormTitle').textContent = 'Add date-range entry'; + $('saveEntryBtn').textContent = 'Save entry'; + $('cancelEditBtn').classList.add('hidden'); +} + +function renderDashboard() { + const current = state.plan.current; + const cards = [ + ['Feriefridage', days(current.feriefridage), 'Use before 31 August', current.feriefridage > 0.001 ? 'warn' : ''], + ['Vacation days', days(current.vacation), 'Available today', current.vacation < -0.001 ? 'bad' : 'good'], + ]; + + $('dashboard').innerHTML = ` + ${cards.map(([label, value, hint, tone]) => ` +
+
${escapeHtml(label)}
+
${value}
+
${escapeHtml(hint)}
+
+ `).join('')} +
+
FLEX hours
+
+ + +
+
Current, used from today
+
+
+
Total days
+
${days(current.totalDays)}
+
Vacation + FF + FLEX
+
+ `; + $('quickFlexForm').addEventListener('submit', saveQuickFlex); +} + +function renderHeaderWarnings() { + const warnings = state.plan.current.warnings; + $('headerWarnings').innerHTML = warnings.length + ? warnings.map((warning) => `${escapeHtml(warning.text)}`).join('') + : ''; +} + +function renderWarnings() { + const warningsNode = $('warnings'); + if (!warningsNode) return; + const warnings = state.plan.warnings; + if (!warnings.length) { + warningsNode.innerHTML = '
No warnings. Your current plan looks safe.
'; + return; + } + warningsNode.innerHTML = warnings.map((warning) => ` +
${escapeHtml(warning.text)}
+ `).join(''); +} + +function renderEntries() { + const tbody = $('entriesTable').querySelector('tbody'); + if (!state.entries.length) { + tbody.innerHTML = 'No entries yet.'; + return; + } + + tbody.innerHTML = state.entries.map((entry) => { + const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 }; + const source = sourceLabels[entry.source] || entry.source; + return ` + + ${escapeHtml(entry.title)} ${escapeHtml(source)}
${Number(entry.allow_unpaid) ? ' unpaid' : ''} + ${days(summary.workdays)} + ${days(summary.usedVacation)} + ${days(summary.usedFeriefridage)} + ${hours(summary.usedFlex)}
${days(summary.usedFlexDays)}d + ${escapeHtml(entry.status)} + +
+ ${entry.status === 'planned' ? `` : ''} + + +
+ + + `; + }).join(''); +} + +function renderPlanner() { + $('planner').innerHTML = ` +
+ + + ${state.plan.months.map(renderMonthRow).join('')} + +
+
+ `; + document.querySelectorAll('.correction-input').forEach((input) => { + input.addEventListener('change', saveCorrection); + }); +} + +function renderMonthRow(month) { + const balanceClass = month.availableDays < -0.001 ? 'bad-text' : 'good-text'; + const entries = month.entries.length + ? month.entries.map((item) => ` + + ${escapeHtml(item.title)} + + + + `).join('') + : ''; + + return ` + + ${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 = {}) { + const startYear = Number(settings.planning_start_year || new Date().getFullYear()); + const startMonth = Number(settings.planning_start_month || 1); + const monthCount = Number(settings.planning_months || (Number(settings.planning_years || 3) * 12)); + const startDate = dateFromParts(startYear, startMonth, 1); + const endDate = dateFromParts(startYear, startMonth + monthCount, 0); + const months = []; + const warnings = []; + const entrySummaries = {}; + const entriesById = new Map(entries.map((entry) => [entry.id, entry])); + + let vacationBalance = Number(settings.opening_vacation_days || 0); + let feriefridageBalance = Number(settings.opening_feriefridage_days || 0); + let flexBalance = Number(settings.opening_flex_hours || 0); + + 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 }); + } + } + allWorkdays.sort((a, b) => a.date - b.date || a.entryId - b.entryId); + + const workdaysByMonth = new Map(); + for (const item of allWorkdays) { + const key = monthKey(item.date.getFullYear(), item.date.getMonth() + 1); + if (!workdaysByMonth.has(key)) workdaysByMonth.set(key, []); + workdaysByMonth.get(key).push(item); + } + + const avgDayHours = averageDayHours(settings); + const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); + const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance); + + for (let i = 0; i < monthCount; i++) { + const cursor = dateFromParts(startYear, startMonth + i, 1); + const year = cursor.getFullYear(); + const month = cursor.getMonth() + 1; + const current = { + year, + month, + earnedVacation: Number(settings.vacation_days_per_month || 0), + balanceCorrection: correctionFor(corrections, year, month), + grantedFeriefridage: month === 1 ? Number(settings.feriefridage_per_year || 0) : 0, + expiredFeriefridage: 0, + usedVacation: 0, + usedFeriefridage: 0, + usedFlex: 0, + usedUnpaid: 0, + vacationBalance: 0, + feriefridageBalance: 0, + flexBalance: 0, + availableDays: 0, + 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.` }); + feriefridageBalance = 0; + } + + const key = monthKey(year, month); + const monthItems = workdaysByMonth.get(key) || []; + const isThisMonth = year === today.getFullYear() && month === today.getMonth() + 1; + const applyWorkday = (item) => { + const entry = entriesById.get(item.entryId); + const allocation = allocateDay(entry, item.date, { + vacationBalance, + feriefridageBalance, + flexBalance, + }, settings); + + vacationBalance = allocation.balances.vacationBalance; + feriefridageBalance = allocation.balances.feriefridageBalance; + flexBalance = allocation.balances.flexBalance; + + current.usedVacation += allocation.usedVacation; + current.usedFeriefridage += allocation.usedFeriefridage; + current.usedFlex += allocation.usedFlex; + current.usedUnpaid += allocation.usedUnpaid; + + const summary = entrySummaries[entry.id]; + summary.usedVacation += allocation.usedVacation; + summary.usedFeriefridage += allocation.usedFeriefridage; + summary.usedFlex += allocation.usedFlex; + summary.usedFlexDays += allocation.usedFlexDays; + summary.usedUnpaid += allocation.usedUnpaid; + + let monthEntry = current.entries.find((row) => row.id === entry.id); + if (!monthEntry) { + monthEntry = { + id: entry.id, + title: entry.title, + source: sourceLabels[entry.source] || entry.source, + start_date: entry.start_date, + end_date: entry.end_date, + workdays: 0, + usedVacation: 0, + usedFeriefridage: 0, + usedFlex: 0, + usedFlexDays: 0, + }; + current.entries.push(monthEntry); + } + monthEntry.workdays += 1; + monthEntry.usedVacation += allocation.usedVacation; + monthEntry.usedFeriefridage += allocation.usedFeriefridage; + monthEntry.usedFlex += allocation.usedFlex; + monthEntry.usedFlexDays += allocation.usedFlexDays; + }; + + if (isThisMonth) { + monthItems.filter((item) => item.date <= today).forEach(applyWorkday); + flexBalance = currentFlexHours; + monthItems.filter((item) => item.date > today).forEach(applyWorkday); + } else { + monthItems.forEach(applyWorkday); + } + + if (month === Number(settings.feriefridage_expiry_month || 8) && 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.` }); + } + + current.vacationBalance = vacationBalance; + current.feriefridageBalance = feriefridageBalance; + current.flexBalance = flexBalance; + current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); + months.push(current); + + if (vacationBalance < -0.001) { + warnings.push({ level: 'bad', text: `Vacation balance is negative in ${monthNames[month - 1]} ${year}: ${days(vacationBalance)} days.` }); + } + if (feriefridageBalance < -0.001) { + warnings.push({ level: 'bad', text: `Feriefridage balance is negative in ${monthNames[month - 1]} ${year}: ${days(feriefridageBalance)} days.` }); + } + if (flexBalance < -0.001) { + warnings.push({ level: 'bad', text: `FLEX balance is negative in ${monthNames[month - 1]} ${year}: ${hours(flexBalance)}.` }); + } + } + + for (const entry of entries) { + const summary = entrySummaries[entry.id]; + if (!summary) continue; + if (summary.usedUnpaid > 0.001) { + warnings.push({ level: 'warn', text: `${entry.title}: ${days(summary.usedUnpaid)} unpaid days are used because unpaid fallback is enabled for that date range.` }); + } + } + + const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); + return { + months, + warnings: dedupeWarnings(warnings), + current: calculateCurrentSnapshot(settings, entries, corrections), + entrySummaries, + final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays }, + }; +} + +function calculateCurrentSnapshot(settings, entries, corrections = {}) { + const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); + const startYear = Number(settings.planning_start_year || today.getFullYear()); + const startMonth = Number(settings.planning_start_month || 1); + const startDate = dateFromParts(startYear, startMonth, 1); + const avgDayHours = averageDayHours(settings); + const warnings = []; + + let vacationBalance = Number(settings.opening_vacation_days || 0); + let feriefridageBalance = Number(settings.opening_feriefridage_days || 0); + let flexBalance = Number(settings.opening_flex_hours || 0); + const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance); + + if (today < startDate) { + flexBalance = currentFlexHours; + const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); + return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings }; + } + + 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 }); + } + } + workdays.sort((a, b) => a.date - b.date || a.entry.id - b.entry.id); + + let workdayIndex = 0; + const monthCount = ((today.getFullYear() - startYear) * 12) + (today.getMonth() + 1 - startMonth) + 1; + for (let i = 0; i < monthCount; i++) { + const cursor = dateFromParts(startYear, startMonth + i, 1); + 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) { + feriefridageBalance = 0; + } + + const monthStart = dateFromParts(year, month, 1); + const monthEnd = dateFromParts(year, month + 1, 0); + 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); + vacationBalance = allocation.balances.vacationBalance; + feriefridageBalance = allocation.balances.feriefridageBalance; + flexBalance = allocation.balances.flexBalance; + } + } + + 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.` }); + if (flexBalance < -0.001) warnings.push({ level: 'bad', text: `FLEX balance is negative today: ${hours(flexBalance)}.` }); + if (today.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8) && feriefridageBalance > 0.001) { + warnings.push({ level: 'warn', text: `${days(feriefridageBalance)} feriefridage must be used before 31 August.` }); + } + + const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours); + return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings: dedupeWarnings(warnings) }; +} + +function allocateDay(entry, date, balances, settings) { + const source = entry.source; + const allowUnpaid = Boolean(Number(entry.allow_unpaid)); + const dayHours = hoursForDate(date, settings); + let { vacationBalance, feriefridageBalance, flexBalance } = balances; + const result = { usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 }; + + const useVacation = () => { + if (allowUnpaid && vacationBalance < 1) { + const use = Math.max(0, vacationBalance); + result.usedVacation += use; + result.usedUnpaid += 1 - use; + vacationBalance = 0; + } else { + result.usedVacation += 1; + vacationBalance -= 1; + } + }; + + const useFeriefridag = () => { + if (allowUnpaid && feriefridageBalance < 1) { + const use = Math.max(0, feriefridageBalance); + result.usedFeriefridage += use; + result.usedUnpaid += 1 - use; + feriefridageBalance = 0; + } else { + result.usedFeriefridage += 1; + feriefridageBalance -= 1; + } + }; + + const useFlex = () => { + if (allowUnpaid && flexBalance < dayHours) { + const useHours = Math.max(0, flexBalance); + result.usedFlex += useHours; + result.usedFlexDays += useHours / dayHours; + result.usedUnpaid += 1 - (useHours / dayHours); + flexBalance = 0; + } else { + result.usedFlex += dayHours; + result.usedFlexDays += 1; + flexBalance -= dayHours; + } + }; + + if (source === 'unpaid') { + result.usedUnpaid = 1; + } else if (source === 'vacation') { + useVacation(); + } else if (source === 'feriefridag') { + useFeriefridag(); + } else if (source === 'flex') { + useFlex(); + } else { + if (feriefridageBalance >= 1 && isBeforeFeriefridageExpiry(date, settings)) { + useFeriefridag(); + } else if (vacationBalance >= 1) { + useVacation(); + } else if (flexBalance >= dayHours) { + useFlex(); + } else if (allowUnpaid) { + const use = Math.max(0, vacationBalance); + result.usedVacation += use; + result.usedUnpaid += 1 - use; + vacationBalance = 0; + } else { + useVacation(); + } + } + + return { ...result, balances: { vacationBalance, feriefridageBalance, flexBalance } }; +} + +function isBeforeFeriefridageExpiry(date, settings) { + return date.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8); +} + +function workdaysBetween(start, end) { + const days = []; + let current = parseDate(start); + const stop = parseDate(end); + while (current <= stop) { + const dow = current.getDay(); + if (dow >= 1 && dow <= 5) days.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + return days; +} + +function hoursForDate(date, settings) { + return date.getDay() === 5 ? Number(settings.friday_hours || 7) : Number(settings.normal_day_hours || 7.5); +} + +function averageDayHours(settings) { + return ((Number(settings.normal_day_hours || 7.5) * 4) + Number(settings.friday_hours || 7)) / 5; +} + +function dateFromParts(year, month, day) { + return new Date(year, month - 1, day, 12, 0, 0); +} + +function parseDate(value) { + const [year, month, day] = value.split('-').map(Number); + return dateFromParts(year, month, day); +} + +function monthKey(year, month) { + return `${year}-${String(month).padStart(2, '0')}`; +} + +function correctionFor(corrections, year, month) { + return Number(corrections[monthKey(year, month)] || 0); +} + +function normalizeEntry(entry) { + return { + ...entry, + id: Number(entry.id), + allow_unpaid: Number(entry.allow_unpaid), + status: entry.status === 'confirmed' ? 'approved' : entry.status, + }; +} + +function days(value) { + return formatNumber(value, 2); +} + +function hours(value) { + return `${formatNumber(value, 2)}h`; +} + +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); + return new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(rounded); +} + +function dedupeWarnings(warnings) { + const seen = new Set(); + return warnings.filter((warning) => { + if (seen.has(warning.text)) return false; + seen.add(warning.text); + return true; + }); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' + }[char])); +} + +function toast(message) { + const node = $('toast'); + node.textContent = message; + node.classList.remove('hidden'); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => node.classList.add('hidden'), 2200); +} + +window.editEntry = editEntry; +window.approveEntry = approveEntry; +window.deleteEntry = deleteEntry; diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/index.html b/index.html new file mode 100644 index 0000000..61e451e --- /dev/null +++ b/index.html @@ -0,0 +1,138 @@ + + + + + + GO Vacation Planner + + + +
+
+
+
+ + + + + + + + + + + + + +
EntryDaysVac. daysFFFLEXStatus
+
+
+
+ + + + + +
+
+ + + + + + + + + + + + +
MonthEntriesVacation daysFeriefridageFLEXCorrectionAvailable days
+
+
+ +
+
+
+
+
+ + + +
+ + + + + + + + +
+ + +
+
+
+ + + +
+ + + + + + + + + + + +

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

+
+ +
+
+
+ + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..77af4fb --- /dev/null +++ b/style.css @@ -0,0 +1,378 @@ +:root { + --bg: #111315; + --panel: #1B1E22; + --ink: #F3F4F6; + --muted: #A0A4AB; + --brand: #C46A3A; + --brand-dark: #d97b48; + --accent: #3BA7A0; + --good: #3BA7A0; + --warn: #C46A3A; + --bad: #B32626; + --line: rgba(59,167,160,.35); + --shadow: 0 2px 16px rgba(0, 0, 0, 0.55); + --radius: 14px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: radial-gradient(circle at top left, rgba(59,167,160,.16), transparent 32rem), radial-gradient(circle at top right, rgba(196,106,58,.14), transparent 30rem), var(--bg); + color: var(--ink); +} + +.floating-header { + position: sticky; + top: 0; + z-index: 50; + padding: .35rem clamp(.45rem, 2vw, 1.25rem); + background: linear-gradient(180deg, rgba(27,30,34,.94), rgba(27,30,34,.78)); + border-bottom: 1px solid rgba(59,167,160,.35); + box-shadow: 0 2px 16px #0008; + backdrop-filter: blur(12px); +} + +.header-top { + display: flex; + justify-content: flex-end; + gap: .4rem; + align-items: center; + margin-bottom: .25rem; +} + +.eyebrow { + margin: 0 0 .15rem; + color: var(--brand); + font-weight: 800; + text-transform: uppercase; + letter-spacing: .1em; + font-size: .68rem; +} + +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(1.05rem, 2.2vw, 1.45rem); line-height: 1; } +h2 { margin-bottom: 0; font-size: 1.1rem; } +.subhead { color: var(--muted); max-width: 58rem; margin-bottom: 0; } +.hero-actions { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-end; } +.header-warning-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .45rem; + margin-top: .25rem; +} +.header-warning-list, +.header-action-list { + display: flex; + flex-wrap: wrap; + gap: .3rem; +} +.header-warning-list { justify-content: flex-start; min-width: 0; } +.header-action-list { justify-content: flex-end; flex: none; } +.header-entries { + margin: .25rem 0 .25rem; + padding: .25rem .35rem; + border: 1px solid rgba(59,167,160,.28); + border-radius: 10px; + background: rgba(17,19,21,.72); +} +.header-entries .table-wrap { overflow-x: auto; overflow-y: visible; } +.compact-entries-table { font-size: .74rem; } +.compact-entries-table th, +.compact-entries-table td { padding: .22rem .32rem; white-space: nowrap; line-height: 1.05; } +.compact-entries-table .pill { padding: .1rem .38rem; font-size: .68rem; } +.compact-entries-table .entry-actions { gap: .25rem; } +.compact-entries-table button.small { padding: .22rem .38rem; font-size: .68rem; } +.header-warning { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.45rem; + padding: .22rem .45rem; + border-radius: 999px; + background: rgba(196,106,58,.18); + color: var(--brand-dark); + border: 1px solid rgba(196,106,58,.55); + font-size: .72rem; + font-weight: 800; + line-height: 1; +} +.header-warning.bad { background: rgba(139,30,30,.35); color: #ffb4b4; border-color: rgba(179,38,38,.7); } +.header-action-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.45rem; + padding: .22rem .45rem; + border-radius: 999px; + background: #111315; + color: var(--ink); + border: 1px solid var(--accent); + font-size: .72rem; + font-weight: 800; + cursor: pointer; + line-height: 1; +} +.header-action-button:hover { background: var(--accent); color: #111315; border-color: var(--accent); transform: none; } +.planner-column-header { + margin-top: .25rem; + border-top: 1px solid rgba(59,167,160,.25); + padding-top: .2rem; +} +.planner-header-table th { border-bottom: 0; } +.planner-header-table th, +.planner-body-table td { padding: .55rem .45rem; } +.panel.planner-panel { + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +main { + padding: 0 clamp(.45rem, 2vw, 1.25rem) 3rem; +} + +.cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: .35rem; + margin-bottom: .25rem; +} + +.card, .panel { + background: rgba(27,30,34,.92); + border: 1px solid rgba(59,167,160,.35); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.card { padding: .36rem .45rem; } +.card .label { color: var(--muted); font-size: .64rem; line-height: 1.05; } +.card .value { font-size: 1rem; font-weight: 800; margin-top: .02rem; } +.card .hint { color: var(--muted); font-size: .6rem; margin-top: .02rem; line-height: 1.05; } +.card.bad .value { color: var(--bad); } +.card.warn .value { color: var(--warn); } +.card.good .value { color: var(--good); } + +.grid { display: grid; gap: 1rem; margin-bottom: 1rem; } +.two-col { grid-template-columns: minmax(0, 1.1fr) minmax(22rem, .9fr); } +.panel { padding: 1rem; margin-bottom: 1rem; } +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.panel-heading p { color: var(--muted); margin-bottom: 0; font-size: .9rem; } + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .8rem; +} +.form-grid.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.full { grid-column: 1 / -1; } + +label { + display: grid; + gap: .35rem; + color: var(--muted); + font-size: .88rem; + font-weight: 650; +} +input, select, textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: .7rem .8rem; + font: inherit; + color: var(--ink); + background: #111315; +} +textarea { resize: vertical; } +input:focus, select:focus, textarea:focus { outline: 3px solid rgba(196,106,58,.22); border-color: var(--brand); } + +.checkbox-row { + display: flex; + align-items: flex-start; + gap: .55rem; + grid-column: 1 / -1; + color: var(--ink); + font-weight: 600; +} +.checkbox-row input { width: auto; margin-top: .2rem; } + +button { + border: 0; + border-radius: 999px; + background: var(--brand); + color: #fff; + padding: .55rem .8rem; + font: inherit; + font-weight: 800; + cursor: pointer; + transition: transform .12s ease, background .12s ease; +} +button:hover { background: var(--brand-dark); transform: translateY(-1px); } +button.ghost { + background: #111315; + color: var(--ink); + border: 1px solid var(--accent); +} +button.ghost:hover { background: var(--accent); color: #111315; } +button.small { padding: .36rem .55rem; font-size: .78rem; } +button.danger { background: #8B1E1E; color: var(--ink); border: 1px solid #8B1E1E; } +.actions { display: flex; justify-content: flex-end; gap: .6rem; } +.help-text { color: var(--muted); margin: 0; font-size: .9rem; } +.hidden { display: none !important; } + +.quick-flex-form { + display: flex; + gap: .3rem; + align-items: center; + margin-top: .15rem; +} +.quick-flex-form input { min-width: 0; font-size: 1rem; font-weight: 800; padding: .36rem .42rem; } +.quick-flex-form button { flex: none; } + +.modal { + width: min(46rem, calc(100vw - 2rem)); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1rem; + color: var(--ink); + background: var(--panel); + box-shadow: 0 24px 80px rgba(0, 0, 0, .7); +} +.modal.wide { width: min(64rem, calc(100vw - 2rem)); } +.modal::backdrop { background: rgba(11, 13, 15, .78); backdrop-filter: blur(4px); } +.modal-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .92rem; } +.month-table { table-layout: fixed; min-width: 760px; } +.month-table th:nth-child(1), .month-table td:nth-child(1) { width: 8.5rem; } +.month-table th:nth-child(2), .month-table td:nth-child(2) { width: auto; } +.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; } +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); } +.num { text-align: right; font-variant-numeric: tabular-nums; } +.strong-num { font-weight: 900; color: var(--ink); } +.muted { color: var(--muted); } +.bad-text { color: var(--bad); font-weight: 800; } +.warn-text { color: var(--warn); font-weight: 800; } +.good-text { color: var(--good); font-weight: 800; } + +.entry-actions { display: flex; gap: .4rem; justify-content: flex-end; } +.pill { + display: inline-flex; + align-items: center; + padding: .2rem .55rem; + border-radius: 999px; + background: #26383A; + color: var(--ink); + font-size: .78rem; + font-weight: 800; +} +.pill.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); } +.pill.unpaid { background: rgba(196,106,58,.22); color: var(--brand-dark); } +.important-col { background: rgba(59,167,160,.12); font-size: 1rem; } +.correction-cell { min-width: 5.2rem; } +.correction-input { + width: 4.8rem; + padding: .28rem .35rem; + text-align: right; + font-size: .85rem; +} + +.warnings { display: grid; gap: .55rem; } +.warning { + border-left: 4px solid var(--warn); + background: rgba(196,106,58,.16); + padding: .65rem .75rem; + border-radius: 12px; +} +.warning.bad { border-left-color: var(--bad); background: rgba(139,30,30,.32); } +.warning.good { border-left-color: var(--good); background: rgba(31,59,50,.75); } + +.years { display: grid; gap: 1.25rem; } +.year-block h3 { margin: 0 0 .65rem; } +.month-table td.entries { min-width: 15rem; } +.month-entry { + display: block; + margin: .15rem 0; + color: var(--ink); +} +.month-entry small { color: var(--muted); } +.entry-date { font-size: .68rem; } +.planner-entry-meta { + display: inline; + margin-left: .35rem; + font-size: .72rem; + line-height: 1.15; +} +.planner-entry-dates { + display: block; + font-size: .66rem; + line-height: 1.15; +} + +.toast { + position: fixed; + right: 1rem; + bottom: 1rem; + background: var(--panel); + color: var(--ink); + border: 1px solid var(--accent); + padding: .8rem 1rem; + border-radius: 14px; + box-shadow: var(--shadow); +} + +@media (max-width: 1050px) { + .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .two-col { grid-template-columns: 1fr; } + .form-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 680px) { + .floating-header { padding: .3rem .4rem; } + .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .28rem; } + .card { padding: .34rem .4rem; } + .card .label { font-size: .6rem; } + .card .value { font-size: .95rem; } + .card .hint { display: none; } + .quick-flex-form { margin-top: .06rem; } + .quick-flex-form input { padding: .26rem .32rem; font-size: .9rem; } + .quick-flex-form button { padding: .26rem .38rem; } + .header-entries { padding: .18rem .25rem; margin: .18rem 0; } + .header-entries .table-wrap { overflow-x: auto; overflow-y: visible; } + .compact-entries-table { font-size: .68rem; } + .compact-entries-table th, .compact-entries-table td { padding: .18rem .25rem; } + .header-warning-row { gap: .3rem; } + .header-warning, .header-action-button { min-height: 1.32rem; font-size: .68rem; padding: .18rem .36rem; } + main { padding: 0 .4rem 3rem; } + .planner-column-header { margin-top: .18rem; padding-top: .16rem; } + .planner-header-table th, + .planner-body-table td { padding: .42rem .32rem; } + .planner-header-table th { font-size: .68rem; } + .month-table { min-width: 680px; } + .form-grid, .form-grid.compact { grid-template-columns: 1fr; } + th, td { padding: .55rem .45rem; } +}