diff --git a/api.php b/api.php index 2ec4730..0e99fb2 100644 --- a/api.php +++ b/api.php @@ -5,6 +5,15 @@ declare(strict_types=1); const DB_DIR = __DIR__ . '/data'; const DB_FILE = DB_DIR . '/go.sqlite'; +if (PHP_SAPI === 'cli') { + try { + run_cli($argv ?? []); + } catch (Throwable $e) { + fwrite(STDERR, $e->getMessage() . PHP_EOL); + exit(1); + } +} + header('Content-Type: application/json; charset=utf-8'); try { @@ -451,6 +460,487 @@ function save_entry(PDO $db, array $entry, ?int $id = null): int 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);