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 '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 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) )"); $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, 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; }