setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA foreign_keys = ON'); app_init_db($pdo); return $pdo; } function app_init_db(PDO $pdo): void { $pdo->exec(<< "TEXT NOT NULL DEFAULT 'member'", 'status' => "TEXT NOT NULL DEFAULT 'active'", 'phone' => "TEXT DEFAULT ''", 'birthday' => "TEXT DEFAULT ''", 'height_in' => "REAL DEFAULT 0", 'training_level' => "TEXT DEFAULT ''", 'emergency_contact' => "TEXT DEFAULT ''", 'updated_at' => "TEXT DEFAULT ''", ]; foreach ($definitions as $column => $definition) { if (!in_array($column, $columns, true)) { $pdo->exec("ALTER TABLE users ADD COLUMN {$column} {$definition}"); } } $pdo->exec("UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''"); $pdo->exec("UPDATE users SET status = 'active' WHERE status IS NULL OR status = ''"); $pdo->exec("UPDATE users SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''"); } function db_table_columns(PDO $pdo, string $table): array { $rows = db_all($pdo, "PRAGMA table_info({$table})"); return array_map(static fn(array $row): string => $row['name'], $rows); } function ensure_first_user_is_admin(PDO $pdo): void { $firstUserId = (int) db_value($pdo, 'SELECT id FROM users ORDER BY id LIMIT 1'); if ($firstUserId <= 0) { return; } $adminCount = (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE role = 'admin'"); if ($adminCount === 0) { $stmt = $pdo->prepare("UPDATE users SET role = 'admin', status = 'active', updated_at = ? WHERE id = ?"); $stmt->execute([app_now(), $firstUserId]); } } function seed_libraries(PDO $pdo): void { $exerciseCount = (int) $pdo->query("SELECT COUNT(*) FROM exercises WHERE is_builtin = 1")->fetchColumn(); if ($exerciseCount === 0) { $exercises = [ ['Barbell Back Squat', 'Weight lifting', 'Legs', 'sets x reps x lb'], ['Deadlift', 'Weight lifting', 'Posterior chain', 'sets x reps x lb'], ['Bench Press', 'Weight lifting', 'Chest', 'sets x reps x lb'], ['Overhead Press', 'Weight lifting', 'Shoulders', 'sets x reps x lb'], ['Pull-Up', 'Weight lifting', 'Back', 'sets x reps'], ['Bent-Over Row', 'Weight lifting', 'Back', 'sets x reps x lb'], ['Romanian Deadlift', 'Weight lifting', 'Hamstrings', 'sets x reps x lb'], ['Walking Lunge', 'Weight lifting', 'Legs', 'sets x reps'], ['Hip Thrust', 'Weight lifting', 'Glutes', 'sets x reps x lb'], ['Biceps Curl', 'Weight lifting', 'Arms', 'sets x reps x lb'], ['Triceps Dip', 'Weight lifting', 'Arms', 'sets x reps'], ['Core Plank', 'Core', 'Core', 'minutes'], ['Kettlebell Swing', 'Conditioning', 'Full body', 'sets x reps'], ['HIIT Circuit', 'Conditioning', 'Full body', 'minutes'], ['Indoor Cycling', 'Cycling', 'Cardio', 'minutes + miles'], ['Road Ride', 'Cycling', 'Cardio', 'minutes + miles'], ['Easy Run', 'Running', 'Cardio', 'minutes + miles'], ['Tempo Run', 'Running', 'Cardio', 'minutes + miles'], ['Sprint Intervals', 'Running', 'Cardio', 'rounds + minutes'], ['Pool Laps', 'Swimming', 'Cardio', 'minutes + distance'], ['Open-Water Swim', 'Swimming', 'Cardio', 'minutes + distance'], ['Rowing Machine', 'Conditioning', 'Cardio', 'minutes + meters'], ['Yoga Mobility', 'Mobility', 'Recovery', 'minutes'], ['Hiking', 'Outdoor', 'Cardio', 'minutes + miles'], ['Jump Rope', 'Conditioning', 'Cardio', 'minutes'], ['Elliptical', 'Conditioning', 'Cardio', 'minutes'], ]; $stmt = $pdo->prepare( "INSERT INTO exercises (name, category, muscle_group, default_unit, notes, is_builtin, created_at) VALUES (?, ?, ?, ?, '', 1, ?)" ); foreach ($exercises as $exercise) { $stmt->execute([...$exercise, app_now()]); } } $supplementCount = (int) $pdo->query("SELECT COUNT(*) FROM supplements WHERE is_builtin = 1")->fetchColumn(); if ($supplementCount === 0) { $supplements = [ ['Pre-Workout Blend', 'Pre-workout', '1 scoop', 'Energy and focus'], ['Caffeine', 'Pre-workout', '100-200 mg', 'Alertness'], ['Creatine Monohydrate', 'Daily', '5 g', 'Strength and power output'], ['Beta-Alanine', 'Pre-workout', '3.2 g', 'High-intensity endurance'], ['Citrulline Malate', 'Pre-workout', '6-8 g', 'Pump and blood flow'], ['Electrolytes', 'Intra-workout', '1 serving', 'Hydration support'], ['BCAAs / EAAs', 'Intra-workout', '1 serving', 'Amino acid support'], ['Carb Powder', 'Intra-workout', '25-50 g', 'Training fuel'], ['Whey Protein', 'Post-workout', '25-35 g', 'Protein target support'], ['Casein Protein', 'Evening', '25-35 g', 'Slow-digesting protein'], ['Collagen Peptides', 'Daily', '10-20 g', 'Tendon and joint support'], ['Fish Oil / Omega-3', 'Daily', '1-2 g EPA/DHA', 'General health'], ['Magnesium Glycinate', 'Evening', '200-400 mg', 'Relaxation and sleep'], ['Vitamin D3', 'Daily', '1000-2000 IU', 'General health'], ['Multivitamin', 'Daily', '1 serving', 'Micronutrient coverage'], ['Tart Cherry', 'Post-workout', '1 serving', 'Recovery support'], ['Greens Powder', 'Daily', '1 serving', 'Micronutrient support'], ]; $stmt = $pdo->prepare( "INSERT INTO supplements (name, timing, default_dose, purpose, notes, is_builtin, created_at) VALUES (?, ?, ?, ?, '', 1, ?)" ); foreach ($supplements as $supplement) { $stmt->execute([...$supplement, app_now()]); } } } function seed_demo_data(PDO $pdo): void { $userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(); if ($userCount > 0) { return; } $created = app_now(); $today = new DateTimeImmutable('today'); $weekStart = $today->modify('monday this week'); $monthStart = $today->modify('first day of this month'); $stmt = $pdo->prepare( "INSERT INTO users (name, email, fitness_focus, role, status, phone, birthday, height_in, training_level, emergency_contact, theme_mode, theme_accent, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ 'Alex Rivera', 'alex@example.test', 'Strength, cycling, and lean muscle', 'admin', 'active', '', '', 70, 'Intermediate', '', 'dark', 'green', $created, $created, ]); $alexId = (int) $pdo->lastInsertId(); $stmt->execute([ 'Maya Chen', 'maya@example.test', 'Running base, swimming, and recovery', 'member', 'active', '', '', 64, 'Advanced', '', 'dark', 'pink', $created, $created, ]); $mayaId = (int) $pdo->lastInsertId(); $goalStmt = $pdo->prepare( "INSERT INTO nutrition_goals (user_id, label, scope, calories, protein_g, carbs_g, fat_g, water_ml, start_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $goals = [ [$alexId, 'Lean bulk day', 'daily', 2900, 190, 330, 85, 3600, app_date($weekStart)], [$alexId, 'Lean bulk week', 'weekly', 20300, 1330, 2310, 595, 25200, app_date($weekStart)], [$alexId, 'Monthly build', 'monthly', 87000, 5700, 9900, 2550, 108000, app_date($monthStart)], [$mayaId, 'Endurance day', 'daily', 2350, 135, 310, 65, 3200, app_date($weekStart)], [$mayaId, 'Endurance week', 'weekly', 16450, 945, 2170, 455, 22400, app_date($weekStart)], ]; foreach ($goals as $goal) { $goalStmt->execute([...$goal, $created]); } $alexGoalId = (int) db_value($pdo, "SELECT id FROM nutrition_goals WHERE user_id = ? AND scope = 'weekly' LIMIT 1", [$alexId]); $mayaGoalId = (int) db_value($pdo, "SELECT id FROM nutrition_goals WHERE user_id = ? AND scope = 'weekly' LIMIT 1", [$mayaId]); $routineStmt = $pdo->prepare( "INSERT INTO routines (user_id, nutrition_goal_id, name, workout_type, planner_scope, start_date, end_date, intensity, notes, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)" ); $routineStmt->execute([$alexId, $alexGoalId, 'Push Pull Legs + Ride', 'Hybrid', 'weekly', app_date($weekStart), '', 'Moderate', 'Three lifting days with one cycling push.', $created]); $alexRoutineId = (int) $pdo->lastInsertId(); $routineStmt->execute([$alexId, $alexGoalId, 'Monthly Strength Block', 'Weight lifting', 'monthly', app_date($monthStart), '', 'High', 'Progressive overload with deload flexibility.', $created]); $routineStmt->execute([$mayaId, $mayaGoalId, '5K Base + Swim', 'Running', 'weekly', app_date($weekStart), '', 'Moderate', 'Aerobic run focus with low-impact swim.', $created]); $mayaRoutineId = (int) $pdo->lastInsertId(); $exerciseIds = db_key_values($pdo, "SELECT name, id FROM exercises WHERE is_builtin = 1"); $workoutStmt = $pdo->prepare( "INSERT INTO workouts (user_id, routine_id, exercise_id, scheduled_on, title, workout_type, sets, reps, weight_lbs, duration_min, distance_mi, intensity, status, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $workouts = [ [$alexId, $alexRoutineId, $exerciseIds['Bench Press'], app_date($weekStart), 'Push strength', 'Weight lifting', 4, 6, 185, 55, 0, 'RPE 8', 'complete', 'Add 5 lb if bar speed holds.'], [$alexId, $alexRoutineId, $exerciseIds['Indoor Cycling'], app_date($weekStart->modify('+1 day')), 'Zone 2 ride', 'Cycling', 0, 0, 0, 45, 14, 'Easy', 'planned', 'Keep nasal breathing.'], [$alexId, $alexRoutineId, $exerciseIds['Deadlift'], app_date($weekStart->modify('+2 days')), 'Pull strength', 'Weight lifting', 5, 3, 315, 60, 0, 'Heavy', 'planned', 'Long rest between sets.'], [$alexId, $alexRoutineId, $exerciseIds['Barbell Back Squat'], app_date($weekStart->modify('+4 days')), 'Legs volume', 'Weight lifting', 4, 8, 225, 65, 0, 'Moderate', 'planned', 'Superset core plank.'], [$mayaId, $mayaRoutineId, $exerciseIds['Easy Run'], app_date($weekStart), 'Easy miles', 'Running', 0, 0, 0, 35, 3.5, 'Easy', 'complete', 'Relaxed pace.'], [$mayaId, $mayaRoutineId, $exerciseIds['Tempo Run'], app_date($weekStart->modify('+2 days')), 'Tempo intervals', 'Running', 0, 0, 0, 42, 4.2, 'Threshold', 'planned', '3 x 8 min tempo.'], [$mayaId, $mayaRoutineId, $exerciseIds['Pool Laps'], app_date($weekStart->modify('+4 days')), 'Recovery swim', 'Swimming', 0, 0, 0, 30, 0, 'Easy', 'planned', 'Smooth form.'], ]; foreach ($workouts as $workout) { $workoutStmt->execute([...$workout, $created]); } $nutritionStmt = $pdo->prepare( "INSERT INTO nutrition_logs (user_id, logged_on, calories, protein_g, carbs_g, fat_g, water_ml, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $nutritionLogs = [ [$alexId, app_date($today), 1540, 110, 165, 42, 1850, 'Breakfast, shake, and lunch logged.'], [$alexId, app_date($today->modify('-1 day')), 2865, 186, 325, 82, 3700, 'Hit the target closely.'], [$mayaId, app_date($today), 1210, 74, 164, 31, 1700, 'Morning and lunch logged.'], ]; foreach ($nutritionLogs as $log) { $nutritionStmt->execute([...$log, $created]); } $supplementIds = db_key_values($pdo, "SELECT name, id FROM supplements WHERE is_builtin = 1"); $supplementLogStmt = $pdo->prepare( "INSERT INTO supplement_logs (user_id, supplement_id, taken_on, timing, dose, status, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); $supplementLogs = [ [$alexId, $supplementIds['Creatine Monohydrate'], app_date($today), 'Daily', '5 g', 'taken', 'With breakfast.'], [$alexId, $supplementIds['Pre-Workout Blend'], app_date($today), 'Pre-workout', '1 scoop', 'planned', 'Before pull session.'], [$alexId, $supplementIds['Whey Protein'], app_date($today), 'Post-workout', '30 g', 'planned', 'After training.'], [$mayaId, $supplementIds['Electrolytes'], app_date($today), 'Intra-workout', '1 serving', 'planned', 'For tempo run.'], ]; foreach ($supplementLogs as $log) { $supplementLogStmt->execute([...$log, $created]); } $metricStmt = $pdo->prepare( "INSERT INTO body_metrics (user_id, measured_on, weight_lbs, body_fat_pct, sleep_hours, resting_hr, mood, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $metrics = [ [$alexId, app_date($today), 184.2, 14.8, 7.1, 58, 'Focused', 'Lower back feels good.'], [$alexId, app_date($today->modify('-7 days')), 185.1, 15.0, 6.8, 60, 'Steady', 'Minor shoulder tightness.'], [$mayaId, app_date($today), 132.4, 19.6, 7.8, 53, 'Fresh', 'Good recovery.'], ]; foreach ($metrics as $metric) { $metricStmt->execute([...$metric, $created]); } } function app_now(): string { return (new DateTimeImmutable())->format('Y-m-d H:i:s'); } function app_date(DateTimeImmutable $date): string { return $date->format('Y-m-d'); } function parse_app_date(?string $value = null): DateTimeImmutable { if (!$value) { return new DateTimeImmutable('today'); } $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $parsed ?: new DateTimeImmutable('today'); } function range_for_view(string $view, DateTimeImmutable $anchor): array { if ($view === 'weekly') { $start = $anchor->modify('monday this week'); return [$start, $start->modify('+6 days')]; } if ($view === 'monthly') { $start = $anchor->modify('first day of this month'); return [$start, $anchor->modify('last day of this month')]; } return [$anchor, $anchor]; } function db_all(PDO $pdo, string $sql, array $params = []): array { $stmt = $pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(); } function db_one(PDO $pdo, string $sql, array $params = []): ?array { $stmt = $pdo->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(); return $row ?: null; } function db_value(PDO $pdo, string $sql, array $params = []): mixed { $stmt = $pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchColumn(); } function db_key_values(PDO $pdo, string $sql, array $params = []): array { $rows = db_all($pdo, $sql, $params); $values = []; foreach ($rows as $row) { $values[$row['name']] = (int) $row['id']; } return $values; } function text_value(array $data, string $key, string $default = ''): string { if (!array_key_exists($key, $data) || $data[$key] === null) { return $default; } $value = trim((string) $data[$key]); return $value === '' ? $default : $value; } function number_value(array $data, string $key, float $default = 0): float { if (!array_key_exists($key, $data) || $data[$key] === '' || $data[$key] === null) { return $default; } return is_numeric($data[$key]) ? (float) $data[$key] : $default; } function int_value(array $data, string $key, int $default = 0): int { return (int) number_value($data, $key, $default); } function user_id_value(array $data): int { $userId = int_value($data, 'user_id'); if ($userId <= 0) { throw new InvalidArgumentException('A valid profile is required.'); } return $userId; } function clamp_choice(string $value, array $choices, string $fallback): string { return in_array($value, $choices, true) ? $value : $fallback; } function nutrition_goal_for(PDO $pdo, int $userId, string $view, DateTimeImmutable $start, DateTimeImmutable $end): array { $goal = db_one( $pdo, "SELECT * FROM nutrition_goals WHERE user_id = ? AND scope = ? AND start_date <= ? ORDER BY start_date DESC, id DESC LIMIT 1", [$userId, $view, app_date($end)] ); if ($goal) { $goal['scaled_from'] = $goal['scope']; return $goal; } $daily = db_one( $pdo, "SELECT * FROM nutrition_goals WHERE user_id = ? AND scope = 'daily' AND start_date <= ? ORDER BY start_date DESC, id DESC LIMIT 1", [$userId, app_date($end)] ); if ($daily) { $days = ((int) $start->diff($end)->format('%a')) + 1; foreach (['calories', 'protein_g', 'carbs_g', 'fat_g', 'water_ml'] as $field) { $daily[$field] = (float) $daily[$field] * $days; } $daily['label'] = $daily['label'] . ' x ' . $days . ' days'; $daily['scope'] = $view; $daily['scaled_from'] = 'daily'; return $daily; } return [ 'id' => null, 'label' => 'No target', 'scope' => $view, 'calories' => 0, 'protein_g' => 0, 'carbs_g' => 0, 'fat_g' => 0, 'water_ml' => 0, 'start_date' => app_date($start), 'scaled_from' => 'none', ]; } function nutrition_totals(PDO $pdo, int $userId, DateTimeImmutable $start, DateTimeImmutable $end): array { $row = db_one( $pdo, "SELECT COALESCE(SUM(calories), 0) AS calories, COALESCE(SUM(protein_g), 0) AS protein_g, COALESCE(SUM(carbs_g), 0) AS carbs_g, COALESCE(SUM(fat_g), 0) AS fat_g, COALESCE(SUM(water_ml), 0) AS water_ml FROM nutrition_logs WHERE user_id = ? AND logged_on BETWEEN ? AND ?", [$userId, app_date($start), app_date($end)] ) ?? []; foreach (['calories', 'protein_g', 'carbs_g', 'fat_g', 'water_ml'] as $field) { $row[$field] = (float) ($row[$field] ?? 0); } return $row; } function workout_summary(array $workouts): array { $planned = count($workouts); $completed = 0; $skipped = 0; $minutes = 0.0; $distance = 0.0; $volume = 0.0; foreach ($workouts as $workout) { if ($workout['status'] === 'complete') { $completed++; } if ($workout['status'] === 'skipped') { $skipped++; } $minutes += (float) $workout['duration_min']; $distance += (float) $workout['distance_mi']; $volume += (float) $workout['sets'] * (float) $workout['reps'] * (float) $workout['weight_lbs']; } return [ 'planned' => $planned, 'completed' => $completed, 'skipped' => $skipped, 'minutes' => round($minutes, 1), 'distance_mi' => round($distance, 2), 'weight_volume_lbs' => round($volume, 1), 'completion_rate' => $planned > 0 ? round(($completed / $planned) * 100, 1) : 0, ]; } function is_admin(PDO $pdo, int $userId): bool { if ($userId <= 0) { return false; } return (bool) db_value( $pdo, "SELECT COUNT(*) FROM users WHERE id = ? AND role = 'admin' AND status = 'active'", [$userId] ); } function admin_user_rows(PDO $pdo): array { return db_all( $pdo, "SELECT users.*, (SELECT COUNT(*) FROM routines WHERE routines.user_id = users.id) AS routine_count, (SELECT COUNT(*) FROM workouts WHERE workouts.user_id = users.id) AS workout_count, (SELECT COUNT(*) FROM nutrition_logs WHERE nutrition_logs.user_id = users.id) AS nutrition_log_count, (SELECT COUNT(*) FROM supplement_logs WHERE supplement_logs.user_id = users.id) AS supplement_log_count, (SELECT COUNT(*) FROM body_metrics WHERE body_metrics.user_id = users.id) AS metric_count, (SELECT MAX(measured_on) FROM body_metrics WHERE body_metrics.user_id = users.id) AS last_metric_on FROM users ORDER BY CASE users.status WHEN 'active' THEN 0 ELSE 1 END, CASE users.role WHEN 'admin' THEN 0 ELSE 1 END, users.name" ); } function admin_summary(PDO $pdo): array { return [ 'total_users' => (int) db_value($pdo, 'SELECT COUNT(*) FROM users'), 'active_users' => (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE status = 'active'"), 'inactive_users' => (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE status <> 'active'"), 'admins' => (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE role = 'admin'"), 'members' => (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE role = 'member'"), ]; } function require_admin(PDO $pdo, array $data): int { $actingUserId = int_value($data, 'acting_user_id') ?: int_value($data, 'user_id'); if (!is_admin($pdo, $actingUserId)) { throw new InvalidArgumentException('Admin access is required.'); } return $actingUserId; } function last_admin_guard(PDO $pdo, int $targetUserId, string $nextRole, string $nextStatus): void { $target = db_one($pdo, 'SELECT id, role, status FROM users WHERE id = ?', [$targetUserId]); if (!$target || $target['role'] !== 'admin' || $target['status'] !== 'active') { return; } $wouldRemainActiveAdmin = $nextRole === 'admin' && $nextStatus === 'active'; if ($wouldRemainActiveAdmin) { return; } $activeAdmins = (int) db_value($pdo, "SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'"); if ($activeAdmins <= 1) { throw new InvalidArgumentException('At least one active admin profile is required.'); } } function state_payload(array $query): array { $pdo = app_db(); $view = clamp_choice((string) ($query['view'] ?? 'daily'), ['daily', 'weekly', 'monthly'], 'daily'); $anchor = parse_app_date(isset($query['date']) ? (string) $query['date'] : null); [$start, $end] = range_for_view($view, $anchor); $users = db_all($pdo, "SELECT * FROM users WHERE status = 'active' ORDER BY id"); if (!$users) { seed_demo_data($pdo); ensure_first_user_is_admin($pdo); $users = db_all($pdo, "SELECT * FROM users WHERE status = 'active' ORDER BY id"); } if (!$users) { $users = db_all($pdo, 'SELECT * FROM users ORDER BY id'); } $requestedUserId = isset($query['user_id']) ? (int) $query['user_id'] : (int) $users[0]['id']; $currentUser = $users[0]; foreach ($users as $user) { if ((int) $user['id'] === $requestedUserId) { $currentUser = $user; break; } } $userId = (int) $currentUser['id']; $currentUserIsAdmin = is_admin($pdo, $userId); $exercises = db_all( $pdo, "SELECT * FROM exercises WHERE is_builtin = 1 OR user_id = ? ORDER BY is_builtin DESC, category, name", [$userId] ); $supplements = db_all( $pdo, "SELECT * FROM supplements WHERE is_builtin = 1 OR user_id = ? ORDER BY timing, name", [$userId] ); $nutritionGoals = db_all( $pdo, "SELECT * FROM nutrition_goals WHERE user_id = ? ORDER BY start_date DESC, scope, label", [$userId] ); $routines = db_all( $pdo, "SELECT routines.*, nutrition_goals.label AS nutrition_goal_label FROM routines LEFT JOIN nutrition_goals ON nutrition_goals.id = routines.nutrition_goal_id WHERE routines.user_id = ? ORDER BY routines.active DESC, routines.start_date DESC, routines.name", [$userId] ); $workouts = db_all( $pdo, "SELECT workouts.*, exercises.name AS exercise_name, routines.name AS routine_name FROM workouts LEFT JOIN exercises ON exercises.id = workouts.exercise_id LEFT JOIN routines ON routines.id = workouts.routine_id WHERE workouts.user_id = ? AND workouts.scheduled_on BETWEEN ? AND ? ORDER BY workouts.scheduled_on, workouts.id", [$userId, app_date($start), app_date($end)] ); $nutritionLogs = db_all( $pdo, "SELECT * FROM nutrition_logs WHERE user_id = ? AND logged_on BETWEEN ? AND ? ORDER BY logged_on DESC, id DESC", [$userId, app_date($start), app_date($end)] ); $supplementLogs = db_all( $pdo, "SELECT supplement_logs.*, supplements.name AS supplement_name, supplements.purpose AS supplement_purpose FROM supplement_logs LEFT JOIN supplements ON supplements.id = supplement_logs.supplement_id WHERE supplement_logs.user_id = ? AND supplement_logs.taken_on BETWEEN ? AND ? ORDER BY supplement_logs.taken_on DESC, supplement_logs.id DESC", [$userId, app_date($start), app_date($end)] ); $bodyMetrics = db_all( $pdo, "SELECT * FROM body_metrics WHERE user_id = ? AND measured_on BETWEEN ? AND ? ORDER BY measured_on DESC, id DESC", [$userId, app_date($start), app_date($end)] ); $latestMetric = db_one( $pdo, "SELECT * FROM body_metrics WHERE user_id = ? ORDER BY measured_on DESC, id DESC LIMIT 1", [$userId] ); return [ 'users' => $users, 'adminUsers' => $currentUserIsAdmin ? admin_user_rows($pdo) : [], 'adminSummary' => $currentUserIsAdmin ? admin_summary($pdo) : null, 'currentUser' => $currentUser, 'currentUserIsAdmin' => $currentUserIsAdmin, 'view' => $view, 'range' => [ 'anchor' => app_date($anchor), 'start' => app_date($start), 'end' => app_date($end), 'days' => ((int) $start->diff($end)->format('%a')) + 1, ], 'exercises' => $exercises, 'supplements' => $supplements, 'nutritionGoals' => $nutritionGoals, 'routines' => $routines, 'workouts' => $workouts, 'nutritionLogs' => $nutritionLogs, 'supplementLogs' => $supplementLogs, 'bodyMetrics' => $bodyMetrics, 'latestMetric' => $latestMetric, 'nutritionSummary' => [ 'target' => nutrition_goal_for($pdo, $userId, $view, $start, $end), 'actual' => nutrition_totals($pdo, $userId, $start, $end), ], 'workoutSummary' => workout_summary($workouts), ]; } function create_user(array $data): array { $pdo = app_db(); $name = text_value($data, 'name'); if ($name === '') { throw new InvalidArgumentException('Profile name is required.'); } $userCount = (int) db_value($pdo, 'SELECT COUNT(*) FROM users'); if ($userCount > 0) { require_admin($pdo, $data); } $role = $userCount === 0 ? 'admin' : clamp_choice(text_value($data, 'role', 'member'), ['admin', 'member'], 'member'); $status = clamp_choice(text_value($data, 'status', 'active'), ['active', 'inactive'], 'active'); $mode = clamp_choice(text_value($data, 'theme_mode', 'dark'), ['dark', 'light'], 'dark'); $accent = clamp_choice(text_value($data, 'theme_accent', 'green'), ['green', 'blue', 'red', 'pink', 'orange'], 'green'); $stmt = $pdo->prepare( "INSERT INTO users (name, email, fitness_focus, role, status, phone, birthday, height_in, training_level, emergency_contact, theme_mode, theme_accent, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $name, text_value($data, 'email'), text_value($data, 'fitness_focus'), $role, $status, text_value($data, 'phone'), text_value($data, 'birthday'), number_value($data, 'height_in'), text_value($data, 'training_level'), text_value($data, 'emergency_contact'), $mode, $accent, app_now(), app_now(), ]); $userId = (int) $pdo->lastInsertId(); $goalStmt = $pdo->prepare( "INSERT INTO nutrition_goals (user_id, label, scope, calories, protein_g, carbs_g, fat_g, water_ml, start_date, created_at) VALUES (?, 'Starter day', 'daily', 2400, 150, 260, 75, 3000, ?, ?)" ); $goalStmt->execute([$userId, app_date(new DateTimeImmutable('today')), app_now()]); return ['user' => db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$userId])]; } function public_signup(array $data): array { $pdo = app_db(); $name = text_value($data, 'name'); if ($name === '') { throw new InvalidArgumentException('Name is required.'); } $userCount = (int) db_value($pdo, 'SELECT COUNT(*) FROM users'); $role = $userCount === 0 ? 'admin' : 'member'; $created = app_now(); $mode = clamp_choice(text_value($data, 'theme_mode', 'dark'), ['dark', 'light'], 'dark'); $accent = clamp_choice(text_value($data, 'theme_accent', 'green'), ['green', 'blue', 'red', 'pink', 'orange'], 'green'); $stmt = $pdo->prepare( "INSERT INTO users (name, email, fitness_focus, role, status, phone, birthday, height_in, training_level, emergency_contact, theme_mode, theme_accent, updated_at, created_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $name, text_value($data, 'email'), text_value($data, 'fitness_focus'), $role, text_value($data, 'phone'), text_value($data, 'birthday'), number_value($data, 'height_in'), text_value($data, 'training_level'), text_value($data, 'emergency_contact'), $mode, $accent, $created, $created, ]); $userId = (int) $pdo->lastInsertId(); $goalStmt = $pdo->prepare( "INSERT INTO nutrition_goals (user_id, label, scope, calories, protein_g, carbs_g, fat_g, water_ml, start_date, created_at) VALUES (?, 'Starter day', 'daily', 2400, 150, 260, 75, 3000, ?, ?)" ); $goalStmt->execute([$userId, app_date(new DateTimeImmutable('today')), $created]); return [ 'user' => db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$userId]), 'first_admin' => $role === 'admin', ]; } function update_user(array $data): array { $pdo = app_db(); require_admin($pdo, $data); $targetUserId = int_value($data, 'target_user_id'); if ($targetUserId <= 0) { throw new InvalidArgumentException('Target profile is required.'); } $current = db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$targetUserId]); if (!$current) { throw new InvalidArgumentException('Profile not found.'); } $nextRole = array_key_exists('role', $data) ? clamp_choice(text_value($data, 'role', $current['role']), ['admin', 'member'], $current['role']) : $current['role']; $nextStatus = array_key_exists('status', $data) ? clamp_choice(text_value($data, 'status', $current['status']), ['active', 'inactive'], $current['status']) : $current['status']; last_admin_guard($pdo, $targetUserId, $nextRole, $nextStatus); $fields = [ 'name' => array_key_exists('name', $data) ? text_value($data, 'name', $current['name']) : $current['name'], 'email' => array_key_exists('email', $data) ? text_value($data, 'email') : $current['email'], 'fitness_focus' => array_key_exists('fitness_focus', $data) ? text_value($data, 'fitness_focus') : $current['fitness_focus'], 'role' => $nextRole, 'status' => $nextStatus, 'phone' => array_key_exists('phone', $data) ? text_value($data, 'phone') : $current['phone'], 'birthday' => array_key_exists('birthday', $data) ? text_value($data, 'birthday') : $current['birthday'], 'height_in' => array_key_exists('height_in', $data) ? number_value($data, 'height_in') : (float) $current['height_in'], 'training_level' => array_key_exists('training_level', $data) ? text_value($data, 'training_level') : $current['training_level'], 'emergency_contact' => array_key_exists('emergency_contact', $data) ? text_value($data, 'emergency_contact') : $current['emergency_contact'], 'theme_mode' => array_key_exists('theme_mode', $data) ? clamp_choice(text_value($data, 'theme_mode', $current['theme_mode']), ['dark', 'light'], $current['theme_mode']) : $current['theme_mode'], 'theme_accent' => array_key_exists('theme_accent', $data) ? clamp_choice(text_value($data, 'theme_accent', $current['theme_accent']), ['green', 'blue', 'red', 'pink', 'orange'], $current['theme_accent']) : $current['theme_accent'], ]; if ($fields['name'] === '') { throw new InvalidArgumentException('Profile name is required.'); } $stmt = $pdo->prepare( "UPDATE users SET name = ?, email = ?, fitness_focus = ?, role = ?, status = ?, phone = ?, birthday = ?, height_in = ?, training_level = ?, emergency_contact = ?, theme_mode = ?, theme_accent = ?, updated_at = ? WHERE id = ?" ); $stmt->execute([ $fields['name'], $fields['email'], $fields['fitness_focus'], $fields['role'], $fields['status'], $fields['phone'], $fields['birthday'], $fields['height_in'], $fields['training_level'], $fields['emergency_contact'], $fields['theme_mode'], $fields['theme_accent'], app_now(), $targetUserId, ]); return ['user' => db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$targetUserId])]; } function delete_user(array $data): array { $pdo = app_db(); $actingUserId = require_admin($pdo, $data); $targetUserId = int_value($data, 'target_user_id'); if ($targetUserId <= 0) { throw new InvalidArgumentException('Target profile is required.'); } if ($targetUserId === $actingUserId) { throw new InvalidArgumentException('You cannot delete the profile you are currently using.'); } $target = db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$targetUserId]); if (!$target) { throw new InvalidArgumentException('Profile not found.'); } last_admin_guard($pdo, $targetUserId, 'member', 'inactive'); $stmt = $pdo->prepare('DELETE FROM users WHERE id = ?'); $stmt->execute([$targetUserId]); return ['deleted' => true, 'target_user_id' => $targetUserId]; } function update_theme(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $mode = clamp_choice(text_value($data, 'theme_mode', 'dark'), ['dark', 'light'], 'dark'); $accent = clamp_choice(text_value($data, 'theme_accent', 'green'), ['green', 'blue', 'red', 'pink', 'orange'], 'green'); $stmt = $pdo->prepare('UPDATE users SET theme_mode = ?, theme_accent = ? WHERE id = ?'); $stmt->execute([$mode, $accent, $userId]); return ['user' => db_one($pdo, 'SELECT * FROM users WHERE id = ?', [$userId])]; } function create_exercise(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $name = text_value($data, 'name'); if ($name === '') { throw new InvalidArgumentException('Exercise name is required.'); } $stmt = $pdo->prepare( "INSERT INTO exercises (user_id, name, category, muscle_group, default_unit, notes, is_builtin, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)" ); $stmt->execute([ $userId, $name, text_value($data, 'category', 'Custom'), text_value($data, 'muscle_group'), text_value($data, 'default_unit'), text_value($data, 'notes'), app_now(), ]); return ['exercise' => db_one($pdo, 'SELECT * FROM exercises WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_supplement(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $name = text_value($data, 'name'); if ($name === '') { throw new InvalidArgumentException('Supplement name is required.'); } $stmt = $pdo->prepare( "INSERT INTO supplements (user_id, name, timing, default_dose, purpose, notes, is_builtin, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)" ); $stmt->execute([ $userId, $name, text_value($data, 'timing', 'Daily'), text_value($data, 'default_dose'), text_value($data, 'purpose'), text_value($data, 'notes'), app_now(), ]); return ['supplement' => db_one($pdo, 'SELECT * FROM supplements WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_nutrition_goal(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $scope = clamp_choice(text_value($data, 'scope', 'daily'), ['daily', 'weekly', 'monthly'], 'daily'); $label = text_value($data, 'label', ucfirst($scope) . ' target'); $stmt = $pdo->prepare( "INSERT INTO nutrition_goals (user_id, label, scope, calories, protein_g, carbs_g, fat_g, water_ml, start_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $userId, $label, $scope, number_value($data, 'calories'), number_value($data, 'protein_g'), number_value($data, 'carbs_g'), number_value($data, 'fat_g'), number_value($data, 'water_ml'), app_date(parse_app_date(text_value($data, 'start_date'))), app_now(), ]); return ['nutritionGoal' => db_one($pdo, 'SELECT * FROM nutrition_goals WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_routine(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $name = text_value($data, 'name'); if ($name === '') { throw new InvalidArgumentException('Routine name is required.'); } $scope = clamp_choice(text_value($data, 'planner_scope', 'weekly'), ['daily', 'weekly', 'monthly'], 'weekly'); $goalId = int_value($data, 'nutrition_goal_id') ?: null; $stmt = $pdo->prepare( "INSERT INTO routines (user_id, nutrition_goal_id, name, workout_type, planner_scope, start_date, end_date, intensity, notes, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)" ); $stmt->execute([ $userId, $goalId, $name, text_value($data, 'workout_type', 'Hybrid'), $scope, app_date(parse_app_date(text_value($data, 'start_date'))), text_value($data, 'end_date'), text_value($data, 'intensity'), text_value($data, 'notes'), app_now(), ]); return ['routine' => db_one($pdo, 'SELECT * FROM routines WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_workout(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $title = text_value($data, 'title'); if ($title === '') { throw new InvalidArgumentException('Workout title is required.'); } $status = clamp_choice(text_value($data, 'status', 'planned'), ['planned', 'complete', 'skipped'], 'planned'); $stmt = $pdo->prepare( "INSERT INTO workouts (user_id, routine_id, exercise_id, scheduled_on, title, workout_type, sets, reps, weight_lbs, duration_min, distance_mi, intensity, status, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $userId, int_value($data, 'routine_id') ?: null, int_value($data, 'exercise_id') ?: null, app_date(parse_app_date(text_value($data, 'scheduled_on'))), $title, text_value($data, 'workout_type', 'Weight lifting'), int_value($data, 'sets'), int_value($data, 'reps'), number_value($data, 'weight_lbs'), number_value($data, 'duration_min'), number_value($data, 'distance_mi'), text_value($data, 'intensity'), $status, text_value($data, 'notes'), app_now(), ]); return ['workout' => db_one($pdo, 'SELECT * FROM workouts WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function update_workout(array $data): array { $pdo = app_db(); $workoutId = int_value($data, 'id'); if ($workoutId <= 0) { throw new InvalidArgumentException('Workout id is required.'); } $allowed = [ 'title' => 'text', 'workout_type' => 'text', 'intensity' => 'text', 'notes' => 'text', 'scheduled_on' => 'date', 'status' => 'status', 'sets' => 'int', 'reps' => 'int', 'weight_lbs' => 'float', 'duration_min' => 'float', 'distance_mi' => 'float', ]; $fields = []; $values = []; foreach ($allowed as $field => $type) { if (!array_key_exists($field, $data)) { continue; } $fields[] = $field . ' = ?'; if ($type === 'date') { $values[] = app_date(parse_app_date(text_value($data, $field))); } elseif ($type === 'status') { $values[] = clamp_choice(text_value($data, $field, 'planned'), ['planned', 'complete', 'skipped'], 'planned'); } elseif ($type === 'int') { $values[] = int_value($data, $field); } elseif ($type === 'float') { $values[] = number_value($data, $field); } else { $values[] = text_value($data, $field); } } if (!$fields) { throw new InvalidArgumentException('No workout updates supplied.'); } $values[] = $workoutId; $stmt = $pdo->prepare('UPDATE workouts SET ' . implode(', ', $fields) . ' WHERE id = ?'); $stmt->execute($values); return ['workout' => db_one($pdo, 'SELECT * FROM workouts WHERE id = ?', [$workoutId])]; } function create_nutrition_log(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $stmt = $pdo->prepare( "INSERT INTO nutrition_logs (user_id, logged_on, calories, protein_g, carbs_g, fat_g, water_ml, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $userId, app_date(parse_app_date(text_value($data, 'logged_on'))), number_value($data, 'calories'), number_value($data, 'protein_g'), number_value($data, 'carbs_g'), number_value($data, 'fat_g'), number_value($data, 'water_ml'), text_value($data, 'notes'), app_now(), ]); return ['nutritionLog' => db_one($pdo, 'SELECT * FROM nutrition_logs WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_supplement_log(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $status = clamp_choice(text_value($data, 'status', 'planned'), ['planned', 'taken', 'skipped'], 'planned'); $stmt = $pdo->prepare( "INSERT INTO supplement_logs (user_id, supplement_id, taken_on, timing, dose, status, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $userId, int_value($data, 'supplement_id') ?: null, app_date(parse_app_date(text_value($data, 'taken_on'))), text_value($data, 'timing', 'Daily'), text_value($data, 'dose'), $status, text_value($data, 'notes'), app_now(), ]); return ['supplementLog' => db_one($pdo, 'SELECT * FROM supplement_logs WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function create_body_metric(array $data): array { $pdo = app_db(); $userId = user_id_value($data); $stmt = $pdo->prepare( "INSERT INTO body_metrics (user_id, measured_on, weight_lbs, body_fat_pct, sleep_hours, resting_hr, mood, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); $stmt->execute([ $userId, app_date(parse_app_date(text_value($data, 'measured_on'))), number_value($data, 'weight_lbs'), number_value($data, 'body_fat_pct'), number_value($data, 'sleep_hours'), int_value($data, 'resting_hr'), text_value($data, 'mood'), text_value($data, 'notes'), app_now(), ]); return ['bodyMetric' => db_one($pdo, 'SELECT * FROM body_metrics WHERE id = ?', [(int) $pdo->lastInsertId()])]; } function json_input(): array { $raw = file_get_contents('php://input'); if (!$raw) { return []; } $data = json_decode($raw, true); if (!is_array($data)) { throw new InvalidArgumentException('Invalid JSON payload.'); } return $data; } function json_response(array $payload, int $status = 200): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($payload, JSON_THROW_ON_ERROR); }