getMessage() . PHP_EOL); exit(1); } } header('Content-Type: application/json; charset=utf-8'); try { $db = db(); migrate($db); $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $action = $_GET['action'] ?? null; if ($method === 'GET') { if ($action === null || $action === 'state') { respond([ '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); } 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 'publishHa': publish_ha_state($payload['state'] ?? []); respond(['ok' => true]); break; case 'saveCorrection': 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)]); 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 app_config(): array { $defaults = [ 'name' => 'GO Vacation Planner', 'topic_prefix' => 'go', 'mqtt_host' => '192.168.0.203', 'mqtt_port' => 1883, ]; $file = __DIR__ . '/config.php'; if (is_file($file)) { $config = require $file; if (is_array($config)) return array_replace($defaults, $config); } return $defaults; } function mqtt_pub(array $config, string $topic, string $payload, bool $retain = true): void { $host = $config['mqtt_host']; $port = (int)($config['mqtt_port'] ?? 1883); $cmd = 'mosquitto_pub -h ' . escapeshellarg($host) . ' -p ' . escapeshellarg((string)$port) . ' -t ' . escapeshellarg($topic) . ' -m ' . escapeshellarg($payload) . ($retain ? ' -r' : ''); @shell_exec($cmd . ' 2>/dev/null'); } function publish_ha_discovery(array $config): void { $p = trim($config['topic_prefix'], '/'); $dev = ['identifiers' => ['go_vacation_planner'], 'name' => $config['name']]; $sensors = [ 'feriefridage' => ['name' => 'Feriefridage', 'unit' => 'd', 'topic' => "$p/feriefridage"], 'vacation_days' => ['name' => 'Vacation days', 'unit' => 'd', 'topic' => "$p/vacation_days"], 'flex_hours' => ['name' => 'FLEX hours', 'unit' => 'h', 'topic' => "$p/flex_hours"], 'total_days' => ['name' => 'Total available days', 'unit' => 'd', 'topic' => "$p/total_days"], 'upcoming_vacations' => ['name' => 'Upcoming vacations', 'unit' => '', 'topic' => "$p/upcoming_vacations", 'attributes_topic' => "$p/upcoming_vacations/attributes"], ]; foreach ($sensors as $id => $sensor) { $payload = [ 'name' => $sensor['name'], 'state_topic' => $sensor['topic'], 'unique_id' => "go_$id", 'device' => $dev, ]; if ($sensor['unit'] !== '') $payload['unit_of_measurement'] = $sensor['unit']; if (isset($sensor['attributes_topic'])) $payload['json_attributes_topic'] = $sensor['attributes_topic']; mqtt_pub($config, "homeassistant/sensor/go_$id/config", json_encode($payload, JSON_UNESCAPED_SLASHES)); } } function publish_ha_state(array $state): void { $config = app_config(); $p = trim($config['topic_prefix'], '/'); publish_ha_discovery($config); $numeric = ['feriefridage', 'vacation_days', 'flex_hours', 'total_days']; foreach ($numeric as $key) { $value = isset($state[$key]) && is_numeric($state[$key]) ? round((float)$state[$key], 2) : 0; mqtt_pub($config, "$p/$key", (string)$value); } $upcoming = is_array($state['upcoming_vacations'] ?? null) ? $state['upcoming_vacations'] : []; $first = $upcoming[0] ?? null; $summary = is_array($first) ? (($first['title'] ?? 'Vacation') . ' ' . ($first['start'] ?? '') . ' → ' . ($first['end'] ?? '')) : 'none'; mqtt_pub($config, "$p/upcoming_vacations", $summary); mqtt_pub($config, "$p/upcoming_vacations/attributes", json_encode(['vacations' => $upcoming], JSON_UNESCAPED_SLASHES)); mqtt_pub($config, "$p/state", json_encode($state, JSON_UNESCAPED_SLASHES)); } 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, non_working_days REAL 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 )"); $columns = array_column($db->query('PRAGMA table_info(entries)')->fetchAll(), 'name'); if (!in_array('non_working_days', $columns, true)) { $db->exec('ALTER TABLE entries ADD COLUMN non_working_days REAL NOT NULL DEFAULT 0'); } $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) )"); $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', 'planning_years' => '3', 'planning_months' => '36', 'previous_months' => '5', 'future_months' => '30', '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', 'previous_months', 'future_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, mixed $value): void { if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) { fail('Invalid correction year/month', 400); } if ($value === null || $value === '') { $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' => (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 { return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, non_working_days, 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; $nonWorkingDays = (float)($entry['non_working_days'] ?? 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 ($nonWorkingDays < 0 || fmod($nonWorkingDays * 2, 1.0) !== 0.0) { fail('Additional non-working days must be in steps of 0.5', 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, ':non_working_days' => $nonWorkingDays, ':note' => $note, ]; if ($id === null) { $stmt = $db->prepare('INSERT INTO entries (title, start_date, end_date, source, status, allow_unpaid, non_working_days, note) VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :non_working_days, :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, non_working_days = :non_working_days, note = :note, updated_at = CURRENT_TIMESTAMP WHERE id = :id'); $stmt->execute($params); return $id; } function build_ha_state(PDO $db): array { $settings = get_settings($db); $entries = array_map('normalize_entry_for_plan', get_entries($db)); $corrections = get_corrections($db); $feriefridageOverrides = get_feriefridage_overrides($db); $flexOverrides = get_flex_overrides($db); $current = calculate_current_snapshot($settings, $entries, $corrections, $feriefridageOverrides, $flexOverrides); $entrySummaries = calculate_entry_summaries($settings, $entries, $corrections, $feriefridageOverrides, $flexOverrides); return [ 'feriefridage' => (float)$current['feriefridage'], 'vacation_days' => (float)$current['vacation'], 'flex_hours' => (float)$current['flex'], 'total_days' => (float)$current['totalDays'], 'upcoming_vacations' => upcoming_vacations_for_ha($entries, $entrySummaries), 'updated_at' => gmdate('Y-m-d\\TH:i:s\\Z'), ]; } function run_cli(array $argv): never { $command = $argv[1] ?? ''; if (!in_array($command, ['ha-state', 'publish-ha', 'publishHaNow', 'publishHa'], true)) { fwrite(STDERR, "Usage: php api.php ha-state|publish-ha\n"); exit(1); } $db = db(); migrate($db); $state = build_ha_state($db); if ($command !== 'ha-state') { publish_ha_state($state); } echo json_encode(['ok' => true, 'published' => $command !== 'ha-state', 'state' => $state], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; exit(0); } function normalize_entry_for_plan(array $entry): array { $entry['id'] = (int)$entry['id']; $entry['allow_unpaid'] = (int)$entry['allow_unpaid']; $entry['non_working_days'] = (float)($entry['non_working_days'] ?? 0); if (($entry['status'] ?? '') === 'confirmed') { $entry['status'] = 'approved'; } return $entry; } function calculate_entry_summaries(array $settings, array $entries, array $corrections = [], array $feriefridageOverrides = [], array $flexOverrides = []): array { $today = date_from_parts((int)date('Y'), (int)date('n'), (int)date('j')); $previousMonths = (int)($settings['previous_months'] ?? 5); $futureMonths = (int)($settings['future_months'] ?? 30); $startCursor = date_from_parts((int)$today->format('Y'), (int)$today->format('n') + 1 - $previousMonths, 1); $startYear = (int)$startCursor->format('Y'); $startMonth = (int)$startCursor->format('n'); $monthCount = $previousMonths + $futureMonths + 1; $startDate = date_from_parts($startYear, $startMonth, 1); $endDate = date_from_parts($startYear, $startMonth + $monthCount, 0); $entrySummaries = []; $entriesById = []; $allWorkdays = []; foreach ($entries as $entry) { $entriesById[(int)$entry['id']] = $entry; $grossDates = workdays_between($entry['start_date'], $entry['end_date']); $units = workday_units_for_entry($entry); $entrySummaries[(int)$entry['id']] = [ 'workdays' => sum_fractions($units), 'grossWorkdays' => count($grossDates), 'nonWorkingDays' => (float)($entry['non_working_days'] ?? 0), 'usedVacation' => 0.0, 'usedFeriefridage' => 0.0, 'usedFlex' => 0.0, 'usedFlexDays' => 0.0, 'usedUnpaid' => 0.0, ]; foreach ($units as $unit) { if ($unit['date'] < $startDate || $unit['date'] > $endDate) continue; $allWorkdays[] = ['date' => $unit['date'], 'fraction' => $unit['fraction'], 'entryId' => (int)$entry['id']]; } } usort($allWorkdays, fn($a, $b) => ($a['date'] <=> $b['date']) ?: ($a['entryId'] <=> $b['entryId'])); $workdaysByMonth = []; foreach ($allWorkdays as $item) { $key = month_key((int)$item['date']->format('Y'), (int)$item['date']->format('n')); $workdaysByMonth[$key][] = $item; } $vacationBalance = (float)($settings['opening_vacation_days'] ?? 0); $feriefridageBalance = (float)($settings['opening_feriefridage_days'] ?? 0); $flexBalance = (float)($settings['opening_flex_hours'] ?? 0); $currentFlexHours = (float)($settings['current_flex_hours'] ?? $flexBalance); for ($i = 0; $i < $monthCount; $i++) { $cursor = date_from_parts($startYear, $startMonth + $i, 1); $year = (int)$cursor->format('Y'); $month = (int)$cursor->format('n'); $current = ['usedVacation' => 0.0, 'usedFeriefridage' => 0.0, 'usedFlex' => 0.0, 'usedUnpaid' => 0.0, 'entries' => []]; if ($month === 1) { $feriefridageBalance += (float)($settings['feriefridage_per_year'] ?? 0); } if ($month === (int)($settings['feriefridage_expiry_month'] ?? 8) + 1 && $feriefridageBalance > 0.001) { $feriefridageBalance = 0.0; } $key = month_key($year, $month); $monthItems = $workdaysByMonth[$key] ?? []; $isThisMonth = $year === (int)$today->format('Y') && $month === (int)$today->format('n'); $applyWorkday = function (array $item) use (&$vacationBalance, &$feriefridageBalance, &$flexBalance, &$current, &$entrySummaries, $entriesById, $settings): void { $entry = $entriesById[$item['entryId']]; $allocation = allocate_day($entry, $item['date'], [ 'vacationBalance' => $vacationBalance, 'feriefridageBalance' => $feriefridageBalance, 'flexBalance' => $flexBalance, ], $settings, (float)$item['fraction']); $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']; $id = (int)$entry['id']; $entrySummaries[$id]['usedVacation'] += $allocation['usedVacation']; $entrySummaries[$id]['usedFeriefridage'] += $allocation['usedFeriefridage']; $entrySummaries[$id]['usedFlex'] += $allocation['usedFlex']; $entrySummaries[$id]['usedFlexDays'] += $allocation['usedFlexDays']; $entrySummaries[$id]['usedUnpaid'] += $allocation['usedUnpaid']; $monthEntryIndex = null; foreach ($current['entries'] as $index => $row) { if ($row['id'] === $id) { $monthEntryIndex = $index; break; } } if ($monthEntryIndex === null) { $current['entries'][] = ['id' => $id, 'usedVacation' => 0.0, 'usedUnpaid' => 0.0, 'allowUnpaid' => (bool)$entry['allow_unpaid']]; $monthEntryIndex = count($current['entries']) - 1; } $current['entries'][$monthEntryIndex]['usedVacation'] += $allocation['usedVacation']; $current['entries'][$monthEntryIndex]['usedUnpaid'] += $allocation['usedUnpaid']; }; if ($isThisMonth) { foreach ($monthItems as $item) { if ($item['date'] <= $today) $applyWorkday($item); } $flexBalance = $currentFlexHours; foreach ($monthItems as $item) { if ($item['date'] > $today) $applyWorkday($item); } } else { foreach ($monthItems as $item) $applyWorkday($item); } $vacationBalance += (float)($settings['vacation_days_per_month'] ?? 0); if ($vacationBalance < -0.001) { $vacationBalance = convert_negative_vacation_to_unpaid($current, $entrySummaries, $vacationBalance); } if (has_correction($corrections, $year, $month)) $vacationBalance = correction_for($corrections, $year, $month); if (has_correction($feriefridageOverrides, $year, $month)) $feriefridageBalance = correction_for($feriefridageOverrides, $year, $month); if (has_correction($flexOverrides, $year, $month)) $flexBalance = correction_for($flexOverrides, $year, $month); } return $entrySummaries; } function convert_negative_vacation_to_unpaid(array &$month, array &$entrySummaries, float $vacationBalance): float { $remaining = -$vacationBalance; for ($i = count($month['entries']) - 1; $i >= 0 && $remaining > 0.001; $i--) { if (!$month['entries'][$i]['allowUnpaid'] || $month['entries'][$i]['usedVacation'] <= 0) continue; $convert = min($month['entries'][$i]['usedVacation'], $remaining); $month['entries'][$i]['usedVacation'] -= $convert; $month['entries'][$i]['usedUnpaid'] += $convert; $month['usedVacation'] -= $convert; $month['usedUnpaid'] += $convert; $id = (int)$month['entries'][$i]['id']; if (isset($entrySummaries[$id])) { $entrySummaries[$id]['usedVacation'] -= $convert; $entrySummaries[$id]['usedUnpaid'] += $convert; } $vacationBalance += $convert; $remaining -= $convert; } return $vacationBalance; } function calculate_current_snapshot(array $settings, array $entries, array $corrections = [], array $feriefridageOverrides = [], array $flexOverrides = []): array { $today = date_from_parts((int)date('Y'), (int)date('n'), (int)date('j')); $previousMonths = (int)($settings['previous_months'] ?? 5); $startCursor = date_from_parts((int)$today->format('Y'), (int)$today->format('n') + 1 - $previousMonths, 1); $startYear = (int)$startCursor->format('Y'); $startMonth = (int)$startCursor->format('n'); $startDate = date_from_parts($startYear, $startMonth, 1); $avgDayHours = average_day_hours($settings); $vacationBalance = (float)($settings['opening_vacation_days'] ?? 0); $feriefridageBalance = (float)($settings['opening_feriefridage_days'] ?? 0); $flexBalance = (float)($settings['opening_flex_hours'] ?? 0); $currentFlexHours = (float)($settings['current_flex_hours'] ?? $flexBalance); if ($today < $startDate) { $flexBalance = $currentFlexHours; return current_snapshot_result($vacationBalance, $feriefridageBalance, $flexBalance, $avgDayHours); } $workdays = []; foreach ($entries as $entry) { foreach (workday_units_for_entry($entry) as $unit) { if ($unit['date'] >= $startDate && $unit['date'] <= $today) { $workdays[] = ['date' => $unit['date'], 'fraction' => $unit['fraction'], 'entry' => $entry]; } } } usort($workdays, fn($a, $b) => ($a['date'] <=> $b['date']) ?: ((int)$a['entry']['id'] <=> (int)$b['entry']['id'])); $workdayIndex = 0; $monthCount = (((int)$today->format('Y') - $startYear) * 12) + ((int)$today->format('n') - $startMonth) + 1; for ($i = 0; $i < $monthCount; $i++) { $cursor = date_from_parts($startYear, $startMonth + $i, 1); $year = (int)$cursor->format('Y'); $month = (int)$cursor->format('n'); if ($month === 1) $feriefridageBalance += (float)($settings['feriefridage_per_year'] ?? 0); if ($month === (int)($settings['feriefridage_expiry_month'] ?? 8) + 1 && $feriefridageBalance > 0.001) { $feriefridageBalance = 0.0; } $monthStart = date_from_parts($year, $month, 1); $monthEnd = date_from_parts($year, $month + 1, 0); $limit = $today < $monthEnd ? $today : $monthEnd; while ($workdayIndex < count($workdays) && $workdays[$workdayIndex]['date'] >= $monthStart && $workdays[$workdayIndex]['date'] <= $limit) { $item = $workdays[$workdayIndex++]; $allocation = allocate_day($item['entry'], $item['date'], [ 'vacationBalance' => $vacationBalance, 'feriefridageBalance' => $feriefridageBalance, 'flexBalance' => $flexBalance, ], $settings, (float)$item['fraction']); $vacationBalance = $allocation['balances']['vacationBalance']; $feriefridageBalance = $allocation['balances']['feriefridageBalance']; $flexBalance = $allocation['balances']['flexBalance']; } if ($today >= $monthEnd) { $vacationBalance += (float)($settings['vacation_days_per_month'] ?? 0); } if ($year === (int)$today->format('Y') && $month === (int)$today->format('n')) { $flexBalance = $currentFlexHours; } if (has_correction($corrections, $year, $month)) $vacationBalance = correction_for($corrections, $year, $month); if (has_correction($feriefridageOverrides, $year, $month)) $feriefridageBalance = correction_for($feriefridageOverrides, $year, $month); if (has_correction($flexOverrides, $year, $month)) $flexBalance = correction_for($flexOverrides, $year, $month); } if (!has_correction($flexOverrides, (int)$today->format('Y'), (int)$today->format('n'))) { $flexBalance = $currentFlexHours; } return current_snapshot_result($vacationBalance, $feriefridageBalance, $flexBalance, $avgDayHours); } function current_snapshot_result(float $vacationBalance, float $feriefridageBalance, float $flexBalance, float $avgDayHours): array { return [ 'vacation' => $vacationBalance, 'feriefridage' => $feriefridageBalance, 'flex' => $flexBalance, 'totalDays' => $vacationBalance + $feriefridageBalance + ($flexBalance / $avgDayHours), ]; } function allocate_day(array $entry, DateTimeImmutable $date, array $balances, array $settings, float $fraction = 1.0): array { $source = (string)($entry['source'] ?? 'auto'); $allowUnpaid = (bool)($entry['allow_unpaid'] ?? false); $baseDayHours = hours_for_date($date, $settings); $dayHours = $baseDayHours * $fraction; $vacationBalance = (float)$balances['vacationBalance']; $feriefridageBalance = (float)$balances['feriefridageBalance']; $flexBalance = (float)$balances['flexBalance']; $result = ['usedVacation' => 0.0, 'usedFeriefridage' => 0.0, 'usedFlex' => 0.0, 'usedFlexDays' => 0.0, 'usedUnpaid' => 0.0]; $useVacation = function () use (&$result, &$vacationBalance, $allowUnpaid, $fraction): void { if ($allowUnpaid && $vacationBalance < $fraction) { $use = max(0.0, $vacationBalance); $result['usedVacation'] += $use; $result['usedUnpaid'] += $fraction - $use; $vacationBalance = 0.0; } else { $result['usedVacation'] += $fraction; $vacationBalance -= $fraction; } }; $useFeriefridag = function () use (&$result, &$feriefridageBalance, $allowUnpaid, $fraction): void { if ($allowUnpaid && $feriefridageBalance < $fraction) { $use = max(0.0, $feriefridageBalance); $result['usedFeriefridage'] += $use; $result['usedUnpaid'] += $fraction - $use; $feriefridageBalance = 0.0; } else { $result['usedFeriefridage'] += $fraction; $feriefridageBalance -= $fraction; } }; $useFlex = function () use (&$result, &$flexBalance, $allowUnpaid, $dayHours, $baseDayHours, $fraction): void { if ($allowUnpaid && $flexBalance < $dayHours) { $useHours = max(0.0, $flexBalance); $result['usedFlex'] += $useHours; $result['usedFlexDays'] += $useHours / $baseDayHours; $result['usedUnpaid'] += $fraction - ($useHours / $baseDayHours); $flexBalance = 0.0; } else { $result['usedFlex'] += $dayHours; $result['usedFlexDays'] += $fraction; $flexBalance -= $dayHours; } }; if ($source === 'unpaid') { $result['usedUnpaid'] = $fraction; } elseif ($source === 'vacation') { $useVacation(); } elseif ($source === 'feriefridag') { $useFeriefridag(); } elseif ($source === 'flex') { $useFlex(); } else { $remaining = $fraction; if ($remaining > 0 && $feriefridageBalance > 0 && is_before_feriefridage_expiry($date, $settings)) { $use = min($feriefridageBalance, $remaining); $result['usedFeriefridage'] += $use; $feriefridageBalance -= $use; $remaining -= $use; } if ($remaining > 0 && $vacationBalance > 0) { $use = min($vacationBalance, $remaining); $result['usedVacation'] += $use; $vacationBalance -= $use; $remaining -= $use; } if ($remaining > 0 && $flexBalance > 0) { $use = min($flexBalance / $baseDayHours, $remaining); $result['usedFlex'] += $use * $baseDayHours; $result['usedFlexDays'] += $use; $flexBalance -= $use * $baseDayHours; $remaining -= $use; } if ($remaining > 0) { $result['usedVacation'] += $remaining; $vacationBalance -= $remaining; } } $result['balances'] = ['vacationBalance' => $vacationBalance, 'feriefridageBalance' => $feriefridageBalance, 'flexBalance' => $flexBalance]; return $result; } function upcoming_vacations_for_ha(array $entries, array $entrySummaries): array { $today = date('Y-m-d'); $upcoming = array_values(array_filter($entries, fn($entry) => (string)$entry['end_date'] >= $today)); usort($upcoming, fn($a, $b) => strcmp((string)$a['start_date'], (string)$b['start_date']) ?: ((int)$a['id'] <=> (int)$b['id'])); return array_map(function (array $entry) use ($entrySummaries): array { $summary = $entrySummaries[(int)$entry['id']] ?? ['workdays' => 0, 'usedVacation' => 0, 'usedFeriefridage' => 0, 'usedFlex' => 0, 'usedFlexDays' => 0]; return [ 'title' => $entry['title'], 'start' => $entry['start_date'], 'end' => $entry['end_date'], 'start_display' => format_date_for_ha((string)$entry['start_date']), 'end_display' => format_date_for_ha((string)$entry['end_date']), 'status' => $entry['status'], 'source' => $entry['source'], 'workdays' => (float)($summary['workdays'] ?? 0), 'vacation_days' => (float)($summary['usedVacation'] ?? 0), 'feriefridage' => (float)($summary['usedFeriefridage'] ?? 0), 'flex_hours' => (float)($summary['usedFlex'] ?? 0), 'flex_days' => (float)($summary['usedFlexDays'] ?? 0), ]; }, $upcoming); } function workday_units_for_entry(array $entry): array { $units = array_map(fn($date) => ['date' => $date, 'fraction' => 1.0], workdays_between((string)$entry['start_date'], (string)$entry['end_date'])); $remaining = max(0.0, (float)($entry['non_working_days'] ?? 0)); for ($i = count($units) - 1; $i >= 0 && $remaining > 0; $i--) { $reduction = min($units[$i]['fraction'], $remaining); $units[$i]['fraction'] -= $reduction; $remaining -= $reduction; } return array_values(array_filter($units, fn($unit) => $unit['fraction'] > 0.0001)); } function workdays_between(string $start, string $end): array { $days = []; $current = parse_date($start); $stop = parse_date($end); while ($current <= $stop) { $dow = (int)$current->format('N'); if ($dow >= 1 && $dow <= 5) $days[] = $current; $current = $current->modify('+1 day'); } return $days; } function sum_fractions(array $units): float { return array_reduce($units, fn($sum, $unit) => $sum + (float)$unit['fraction'], 0.0); } function hours_for_date(DateTimeImmutable $date, array $settings): float { return (int)$date->format('N') === 5 ? (float)($settings['friday_hours'] ?? 7) : (float)($settings['normal_day_hours'] ?? 7.5); } function average_day_hours(array $settings): float { return ((((float)($settings['normal_day_hours'] ?? 7.5)) * 4) + (float)($settings['friday_hours'] ?? 7)) / 5; } function is_before_feriefridage_expiry(DateTimeImmutable $date, array $settings): bool { return (int)$date->format('n') <= (int)($settings['feriefridage_expiry_month'] ?? 8); } function date_from_parts(int $year, int $month, int $day): DateTimeImmutable { return (new DateTimeImmutable('now'))->setDate($year, $month, $day)->setTime(12, 0, 0); } function parse_date(string $value): DateTimeImmutable { [$year, $month, $day] = array_map('intval', explode('-', $value)); return date_from_parts($year, $month, $day); } function month_key(int $year, int $month): string { return $year . '-' . str_pad((string)$month, 2, '0', STR_PAD_LEFT); } function correction_for(array $corrections, int $year, int $month): float { return (float)($corrections[month_key($year, $month)] ?? 0); } function has_correction(array $corrections, int $year, int $month): bool { return array_key_exists(month_key($year, $month), $corrections); } function format_date_for_ha(string $value): string { $parts = explode('-', $value); if (count($parts) !== 3) return $value; return $parts[2] . '/' . $parts[1] . '/' . $parts[0]; } 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; }