(string) $projectName, 'description' => (string) $projectDescription, 'image' => (string) $projectImageUrl, ]; } function ensure_recliner_schema(): void { db()->exec( "CREATE TABLE IF NOT EXISTS recliner_presets ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, angle_deg TINYINT UNSIGNED NOT NULL, intensity_pct TINYINT UNSIGNED NOT NULL, pattern_mode VARCHAR(20) NOT NULL, duration_ms SMALLINT UNSIGNED NOT NULL, notes VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } function default_preset(): array { return [ 'name' => 'Demo Lounge', 'angle_deg' => 112, 'intensity_pct' => 44, 'pattern_mode' => 'continuous', 'duration_ms' => 1400, 'notes' => 'Balanced demo profile for a calm showroom preview.', ]; } function validate_preset_input(array $input): array { $errors = []; $name = trim((string) ($input['name'] ?? '')); if ($name === '' || strlen($name) < 2 || strlen($name) > 120) { $errors['name'] = 'Preset name must be between 2 and 120 characters.'; } $angle = filter_var($input['angle_deg'] ?? null, FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 0, 'max_range' => 160], ]); if ($angle === false) { $errors['angle_deg'] = 'Angle must be between 0° and 160°.'; } $intensity = filter_var($input['intensity_pct'] ?? null, FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 0, 'max_range' => 100], ]); if ($intensity === false) { $errors['intensity_pct'] = 'Intensity must be between 0% and 100%.'; } $duration = filter_var($input['duration_ms'] ?? null, FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 100, 'max_range' => 5000], ]); if ($duration === false) { $errors['duration_ms'] = 'Duration must be between 100 ms and 5000 ms.'; } $pattern = (string) ($input['pattern_mode'] ?? 'continuous'); if (!in_array($pattern, RECLINER_PATTERNS, true)) { $errors['pattern_mode'] = 'Choose a supported vibration pattern.'; } $notes = trim((string) ($input['notes'] ?? '')); if (strlen($notes) > 255) { $errors['notes'] = 'Notes must stay under 255 characters.'; } return [ 'errors' => $errors, 'data' => [ 'name' => $name, 'angle_deg' => $angle === false ? 0 : (int) $angle, 'intensity_pct' => $intensity === false ? 0 : (int) $intensity, 'pattern_mode' => $pattern, 'duration_ms' => $duration === false ? 1000 : (int) $duration, 'notes' => $notes, ], ]; } function save_preset(array $data): int { $stmt = db()->prepare( 'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes) VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :duration_ms, :notes)' ); $stmt->bindValue(':name', $data['name'], PDO::PARAM_STR); $stmt->bindValue(':angle_deg', $data['angle_deg'], PDO::PARAM_INT); $stmt->bindValue(':intensity_pct', $data['intensity_pct'], PDO::PARAM_INT); $stmt->bindValue(':pattern_mode', $data['pattern_mode'], PDO::PARAM_STR); $stmt->bindValue(':duration_ms', $data['duration_ms'], PDO::PARAM_INT); $stmt->bindValue(':notes', $data['notes'] !== '' ? $data['notes'] : null, $data['notes'] !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL); $stmt->execute(); return (int) db()->lastInsertId(); } function get_recent_presets(int $limit = 8): array { $limit = max(1, min(24, $limit)); $stmt = db()->query( 'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at FROM recliner_presets ORDER BY created_at DESC, id DESC LIMIT ' . $limit ); return $stmt->fetchAll() ?: []; } function get_preset(int $id): ?array { $stmt = db()->prepare( 'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at FROM recliner_presets WHERE id = :id LIMIT 1' ); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); $preset = $stmt->fetch(); return $preset ?: null; } function preset_tone(int $angle): string { if ($angle >= 135) { return 'Deep recline'; } if ($angle >= 90) { return 'Lounge'; } if ($angle >= 45) { return 'Reading'; } return 'Upright'; } function e(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); }