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 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; }