From 0fe5a92f9046b54a8ecc51a30e885698ed8f394e Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 30 Jun 2026 11:14:42 -0400 Subject: [PATCH] - --- README.md | 54 ++ app/bootstrap.php | 1362 +++++++++++++++++++++++++++++++ data/health_tracker.sqlite | Bin 0 -> 69632 bytes public/admin.php | 67 ++ public/api.php | 48 ++ public/assets/admin.js | 248 ++++++ public/assets/app.js | 678 +++++++++++++++ public/assets/site.css | 736 +++++++++++++++++ public/assets/site.js | 43 + public/assets/styles.css | 867 ++++++++++++++++++++ public/index.php | 82 ++ public/nutrition.php | 48 ++ public/partials/site-footer.php | 14 + public/partials/site-header.php | 32 + public/recovery.php | 48 ++ public/signup.php | 47 ++ public/tracker.php | 353 ++++++++ public/workouts.php | 48 ++ 18 files changed, 4775 insertions(+) create mode 100644 README.md create mode 100644 app/bootstrap.php create mode 100644 data/health_tracker.sqlite create mode 100644 public/admin.php create mode 100644 public/api.php create mode 100644 public/assets/admin.js create mode 100644 public/assets/app.js create mode 100644 public/assets/site.css create mode 100644 public/assets/site.js create mode 100644 public/assets/styles.css create mode 100644 public/index.php create mode 100644 public/nutrition.php create mode 100644 public/partials/site-footer.php create mode 100644 public/partials/site-header.php create mode 100644 public/recovery.php create mode 100644 public/signup.php create mode 100644 public/tracker.php create mode 100644 public/workouts.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..28754ee --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Neon Health Tracker + +A dependency-free PHP, HTML5, JavaScript, and SQLite tracker for workouts, nutrition, supplements, and body metrics. + +## Run + +```sh +/Users/tyemeclifford/frankenphp php-server --root public --listen 127.0.0.1:8080 +``` + +Then open: + +```text +http://127.0.0.1:8080 +``` + +## Public Routes + +- `/` - public homepage for the service. +- `/workouts.php` - workout planning feature page. +- `/nutrition.php` - nutrition tracking feature page. +- `/recovery.php` - recovery, supplements, and health monitoring feature page. +- `/signup.php` - public profile signup. +- `/tracker.php` - the workout, nutrition, supplement, and health dashboard. +- `/admin.php` - admin user-management dashboard. + +## Storage + +SQLite is created automatically at: + +```text +data/health_tracker.sqlite +``` + +The app seeds two sample profiles, built-in exercise movements, common gym supplements, starter routines, planner entries, nutrition logs, supplement logs, and body metrics. + +## Included + +- Multi-user profiles with separate routines, logs, goals, and theme preferences. +- Admin dashboard for creating, editing, activating/deactivating, and deleting user profiles. +- First user created in a database is automatically promoted to `admin`; later users default to `member`. +- Daily, weekly, and monthly workout planner views. +- Routine planning for weight lifting, cycling, running, swimming, conditioning, mobility, core, outdoor, and hybrid programs. +- Configurable exercise and supplement libraries. +- Nutrition goals scoped by day, week, or month, compared against actual logs. +- Supplement tracking for pre-workout, intra-workout, post-workout, daily, and evening use. +- Health metrics for weight, body fat, sleep, resting heart rate, mood, and notes. +- Neon dark theme by default, light mode option, and green, blue, red, pink, and orange accents. + +## Photo Credits + +- `Jogging Woman in Grass` by Mike Baird, CC BY 2.0. +- `Cycling in Amsterdam (893)` by FaceMePLS, CC BY-SA 2.0. +- `A large mixed salad` by Jmabel, CC BY-SA 4.0. diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..97e051e --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,1362 @@ +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); +} diff --git a/data/health_tracker.sqlite b/data/health_tracker.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..eaa4c70cd254b3581bdd31064b25fde4b8a4ef1d GIT binary patch literal 69632 zcmeHQZ)_V!cHbo>QItfk?L^1wR8^y@3eCv0CGq4$PKt=4){rbTLk@3T>8BQ@}*bc zzVyxPF1btZ(sAOP?O-TKTJFrgdGnh$GyC4md$V_SQ?W2Q)Q!4ip&WOL<9TiwA&%o- zgWuEe>wZSzqR;&T|9Q`K*yU^7`J3|NS=i7i{Wp?Z^jn-u1DVEzRi6r>_NCc zFf>3@-%#SQ)a)$(R@{;fRBSdI4Heh1W|dXFYIc1cDipK3KcpY64ucQ2aX=h^_p5GF;OXq0LriqQRC;RqJ32pChZla=iSuBd% z1#u6NJmy(N&Y+!bv?^|jPtf*wI&0dw zH@4B)w3~Jgr72TsxG;~DMg#I^GB$Nhx3ELJP17m3VqnR_av3DfSCyg68*78HR3^i} zxk1}nYg&e4DY{0R+W#%?YiWPl!)a&`$12n+xPdB?sv8O*0`j@pZoD?!b2exV15i`6 zauT@PK2=NJc_|i4O-%6byiQy4ST~OJre(VS2Ys#Q z-hDXjW&mS~hP@r#(>tBi-dz*l!bU|g!)Un4y@{BW8kU=Cs0dk#r8-^N-}xQ*%2ulp zm~LXrY!7*EXLKaaz>UzfW2{tbFw7n}Qrq>Y+%zQGIbrCrC8#@t0)vKD!R5LVn679R z)=b4}eU9T(MX$qjVc`chMBUC0P{DqCp9;1s9`65`Pq5v)4@a;&gJM;e)DZdhXX-7X znp6)IuzLxhJzp4Qv!ZuPs5`y*v(Q;amK3!`+U*$QBm1hZYgP>|-AM-I%dpBa3}gP8 z#2!ttCVSFc;dH0Jdq`i8QBSH^%1zwvQEq2!59afB@!`|l?KA!ME6poi(;R6hv#Ui0 zd=k}__;3VEl@72Pgm{kzwv#?-!#>8-Wp9}b7mBa9`eLc+Y5vX<6$;N=q35#SCk&p1 z&nyU@Des=Iv|;0b75)~A52dCGT{ZM=w9zygp@}^@qhgj1nu-cDczB0&=DHr(zZ#9D zCMWqj1&V!pzHVRi`EYOFek4$@!0=bq4QCd0M$ce6P(dLroA^c(!>fbo{~Gl*Ls#NiB9`ai$;{W&d@Pkp@wc;- zCY5LUC*pga-n#E53=@7_hxsZP<3q*LuxXYL^@^)-b%SixK&LI&b-8OMYfma^Su&0s z1~d7Fco6%gqt=<}Lz1ME{Qq3MYIl{7m>a;RnLs2=5Dj zAp9=eVjqS8Lx3T`5MT%}1Q-Gg0fqoWfFZyTUP~=Y2l6Bqim5gRlT>($T01omgAdsGzAa%H| zS0%KiA1JV+wdMXV;))I{POb1tY<&qxWM;;)K%%H06SuxCsZs+ErO=uXhQP>+B;V2Pb zg@{*Kfb?7(0diBLqlBx1ZeX+vPNqlXiu{JNAr8ZH)0t7iMhTQB;-NYlA>6ZP)Lbq>^z5Z8o(k_S4(M;O|U)`9cAHDF%ctWTxz~qb_L*tXHpMNhTQi ziLFa@6BJR+M>34T%>kQ1iX3b@&+fn&2BYh-pXQ2GIr1maVvxHc11MKQ140sB3@k&inv`;|uDi1X0GozG2Z~-P=kwAs^ZZxH?=IxS| zFgA()e|qT8IpMDaMHo%~eNsv>JKG|A%Wn%-Y{C^ojmI-wY6!wCYxU5h~7Svnp^_zw!qI_ zxv5wZ=(D|;qjn&viUYX2Otkl@)Jz7*y<}5Dt2vw4+}s>&<(->D8~bY*_Bu-{0_8`w zY5{ciiTa)!pIRhrNmcMcR+@FRrnV}uKiR_KO-u|eKrQYf{-2%Ux{qR{vDkdjlx@ahba4*@o~~51<8b{N}$fs=t^$2 z7qhuVRNYhcmA@z2aqg4b%e<&yKt%z{nRDF{Jyk}B@Ss=LI3OPcyX)pZ#iLPG`Bn^*#7rc*?ARy^I%f~q8@6w z^B|1t5X&eFBI86H0zrcCMG#>ifMk&v0v1zGyg;}lvC1fqx~A7?6)}}aB07$f0A;eF zAA?B*LP<56K?6>K+5@JiCJ9aH@DSwD%i_Gk6c7y=9d zh5$o=A;1t|2rvW~0t^9$07Kw$MW8S^!tv9oNF>5XrgHszBt9#s#j@0riJdO~HSU{y z-<9F-byU&LRokV1y&s5V#(+o!h(ttJRYxQctQ`z?c_sSSgRk=ccvFa#I^3;~7!Lx3T`5MT%} z1Rik&ZblOv&x6CuRASyL6drxAK1Q+L*GUHQ;(JB<|>h$d;{S{TO z9Ql0-997>XH#bkV6u?#wh)igjGdIm7F28>upmxe;|U|rj1dIN zz{qYB_R|oj2t;=90}jNCXuWk{C~_DK;Ch0w>jgthEe;Mk?5uF27orA`4Jw}K|9#2l zdErfAD*4^y^NBw4^N33gV~-)g5MT%}1Q-Gg0fqoWfFZyT_yQszOmT;wf3?0EI}J)G zKb4*s9312aN5^M!k?51Qn)gh0F4yt@@WjmcH4-JlIR|^MuIr$8l0(#kbS$XBhlKv@ zIH4b#9M1y%Gj00wcFYR0HQgKj;_2m6|3p6jOw>EGrmM13S`K`zU6aM1@(i3$k{L2e!e6=+0BfzVKP zfvNH5a*=k4^HgbukOnkvzh86jkMhqwzmBDwZi{W|;79?mTIc{u7Ljx(j*gnbPk9g0R(ZW1{HG5cU4Tx zMcqOcmZ(V}xMF<++}}P^+!fKY&hhJcSw@Qxu>vWF=s+@%*}%|(M0x^iw@BCj1&I^> zLXg1!|6f1<-o{E`2rvW~0t^9$07HNwzz|>vFa#I^3;~9~14H1mMW%l-I?VA?;K9I0 zUT`f8xzY}8g@fPR8-x>YAcA+9#1I|uJ$^VmInGX?mJJ%F0Fup;pk8@t#in% zNk`Z@u<#!2+5Z1e690c$cry9@2i5|N5JP|=zz|>vFa#I^3;~7!Lx3T`5MT%}1RfXy zC5Zh;Hf~IgXL6B&&Wd?(fI?@p!DWcJPj^lbzkwh{dmF<4!7hWD(ec?_yhGF*=P?HX z869%@LlWQs;x*Ffj6w8D5c-d7lNfJrkRZl{yTkQ41XnO@B!OKJ#0S76o&|a!m#Yv9 z5V%Y|k(%{x7I0$|T3`aut`T<#vh#o%27-9A*Z)OM_)|el{?7wz7)FR8zz|>vFa#I^ z3;~7!Lx3T`5MT%}1Q-Gj5rKWWupf)X_!JlnI;#Eh*WRVB7R$LmCZ9i9p3KqD;$`A` zu|`fGFr0H+gOup&bd5j8$M~@qIxF)F%hhR0MOYrU$vwYk!7+ucEjW4scIG=L6Cw+u z2+)B4L8QM*7ySvx<4YaVOUu=PL-JY9It>3R{d#!mG6{o#Sf| zh4WTaT!*7q%CIBC?D{%XD2n-#h)VgDO_3fF(W8V;(q}Bg!w)(;gx~{bUTkca#5J*q zc8eQZ`QmkSMZAvk`=yU zj-eTha$W6R+NWyCJ1@mzsfh{xo!4ngI%g`G?*Bnw>$!IyPP@So)8vE~r=#DV;A5#& zihp~IGETcU{S)!AOmE#cznWBcI-|e);yOgf4eq6f3Y{Fvc7Pk&S-bla>>sP+avh@L z`1?~^y3(>_966oO&MZ|b7ogQPvm#boYW+ZJC|@lAX;stCY>W0|x{t6YId zz9dZ0UBxyVvTuZPsi)JK+xY=1*l+Js`EZ2CbpOYEQsUmdZ$TmA^_gd$&Y)P;CDlvL zGyC{6^`3#k#MkM+WvkWb5(sBl1CLHPi>M5z5_M;Arx$xlakJ1_hDMAgQ;Qw=h+H`# znA(#J2$x}%Nn80v*)A3iMe?L|+R#qzoCVe0{B-W`G3wbu+kS~~yK=j`&D#ZpPjk1= z^xLmZuXIgwq@B#J78&qKR9E7|5iC`Lv!{a)@6o_^(kE@$$9THzEtBCw@%2_;EHypN z-&vwU;dv|cT=x5f!ISWr1;H~U(7CPGD{a^~V1>Vh;zJ3}MJ$KEjW(J_BlK0ZM`u*b zG6}>EGkAE1bl$FdVE<|~mYSU8?-VHZ_!c%Q3M{_1FZz7Aw{JfZs8?Y4tLla`N4let zJqFdAW<|y2DojL;yQVoh&dE;8G&R_Xe!nB(0|9?GGC*|5>$ce6P!$W8O?;yXQ#3aH zU!%Te=t?|G#PS?Gnc+D8Y4Q$tJ4(X60Mu16^*F>)0}sidm*=BvFsN zcTYQ$=*IS{_%c{S!=51qP49DODK$kthw4}|n+90$bS-ldPsCDdv-}AhuIsSSc`c=O zyABs!38(I2q$@+`Ny1B4Vuu~qFq%0T7>T9C41Y4)=B1-A5sP$(p#Q#b7bpJo&U1tj ze_Dr+wgE76V>p&tn&3~y+I+Yst_~&lPV%m@-IPvVQmT8mQ=VC=VY#W=?eNBL48>A2 zGyI#w^qt4saNg@-r!?CL?@js4@3QV&1sG&LxpmwlN`+cG{p; z@1aMAs))VqjP8nsYi@FKv(=2yWvljJ7SuqGv3aJ4DIvVqKDMhFbd}RH9=itVF6)ke zq0u#OgjkH-UzfdrT#A6OI%hON}J5 zV$f6~D==t)Ndhtirr*P!{6NMhH#8%$g+Gu5*CBYj?Gt{;2_Fj|2|pEnDEvP8d6Zvy z8E*^$h5$o=A;1t|2rvW~0t^9$07HNwzz}%c5jfSyPw|~24RSH4(64)L%#&UBr`;PQ zia@{67oXy7PX=;UMW6rseAM&(Vm3DyNkV>4Py?d>M}&_!(EdLbej@zjaYvBx&k$e; zFa#I^3;~7!Lx3T`5MT%}1Q-GgfiEZmr@Zy@S`dhRz_0%=JR79{M}?od>;FIS(qh*P z0fqoWfFZyTU +
+
+
+

Admin dashboard

+

Manage users and their profiles.

+

Use an active admin profile to create users, update profile details, assign roles, activate or deactivate accounts, and remove users.

+
+ +
+ + Open tracker +
+ + + + +
+
+
+ + diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..4f3fad6 --- /dev/null +++ b/public/api.php @@ -0,0 +1,48 @@ + 'Unsupported method.'], 405); + exit; + } + + $data = json_input(); + $routes = [ + 'create_user' => 'create_user', + 'public_signup' => 'public_signup', + 'update_user' => 'update_user', + 'delete_user' => 'delete_user', + 'update_theme' => 'update_theme', + 'create_exercise' => 'create_exercise', + 'create_supplement' => 'create_supplement', + 'create_nutrition_goal' => 'create_nutrition_goal', + 'create_routine' => 'create_routine', + 'create_workout' => 'create_workout', + 'update_workout' => 'update_workout', + 'create_nutrition_log' => 'create_nutrition_log', + 'create_supplement_log' => 'create_supplement_log', + 'create_body_metric' => 'create_body_metric', + ]; + + if (!isset($routes[$action])) { + json_response(['error' => 'Unknown API action.'], 404); + exit; + } + + json_response($routes[$action]($data)); +} catch (InvalidArgumentException $exception) { + json_response(['error' => $exception->getMessage()], 400); +} catch (Throwable $exception) { + json_response(['error' => 'Server error: ' . $exception->getMessage()], 500); +} diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..0b36519 --- /dev/null +++ b/public/assets/admin.js @@ -0,0 +1,248 @@ +const adminState = { + data: null, + userId: localStorage.getItem("nht:adminUserId") || localStorage.getItem("nht:userId") || "", +}; + +const admin$ = (selector, root = document) => root.querySelector(selector); + +function adminEscape(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function adminNumber(value, digits = 0) { + return Number(value || 0).toLocaleString(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }); +} + +function adminToast(message) { + const toast = admin$("#siteToast"); + if (!toast) return; + toast.textContent = message; + toast.classList.add("show"); + window.clearTimeout(adminToast.timer); + adminToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400); +} + +async function adminApi(action, payload = null, method = "POST") { + const options = { method }; + if (payload) { + options.headers = { "Content-Type": "application/json" }; + options.body = JSON.stringify(payload); + } + const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options); + const json = await response.json(); + if (!response.ok || json.error) { + throw new Error(json.error || "Request failed."); + } + return json; +} + +async function loadAdminState() { + const params = new URLSearchParams({ + action: "state", + view: "weekly", + }); + if (adminState.userId) { + params.set("user_id", adminState.userId); + } + + const response = await fetch(`/api.php?${params.toString()}`); + const json = await response.json(); + if (!response.ok || json.error) { + throw new Error(json.error || "Unable to load admin dashboard."); + } + + adminState.data = json; + adminState.userId = String(json.currentUser.id); + localStorage.setItem("nht:adminUserId", adminState.userId); + renderAdminPage(); +} + +function renderAdminPage() { + const data = adminState.data; + const profileSelect = admin$("#adminProfileSelect"); + profileSelect.innerHTML = data.users.map((user) => ` + + `).join(""); + + admin$("#adminPanel").hidden = !data.currentUserIsAdmin; + admin$("#adminDenied").hidden = data.currentUserIsAdmin; + if (!data.currentUserIsAdmin) { + return; + } + + renderAdminStats(); + renderAdminUsers(); +} + +function renderAdminStats() { + const summary = adminState.data.adminSummary || {}; + const stats = [ + ["Total", summary.total_users || 0], + ["Active", summary.active_users || 0], + ["Inactive", summary.inactive_users || 0], + ["Admins", summary.admins || 0], + ["Members", summary.members || 0], + ]; + + admin$("#adminStats").innerHTML = stats.map(([label, value]) => ` +
+ ${adminEscape(label)} + ${adminNumber(value)} +
+ `).join(""); +} + +function renderAdminUsers() { + const currentId = Number(adminState.data.currentUser.id); + const rows = adminState.data.adminUsers || []; + admin$("#adminUserList").innerHTML = rows.length ? rows.map((user) => { + const isCurrent = Number(user.id) === currentId; + const nextStatus = user.status === "active" ? "inactive" : "active"; + const height = Number(user.height_in || 0) > 0 ? ` / ${adminNumber(user.height_in, 1)} in` : ""; + const training = user.training_level ? ` / ${user.training_level}` : ""; + return ` +
+
+
+

${adminEscape(user.name)}${isCurrent ? " (current)" : ""}

+

${adminEscape(user.email || "No email")}

+

${adminEscape(user.fitness_focus || "No focus set")}

+

${adminEscape(user.phone || "No phone")}${adminEscape(training)}${adminEscape(height)}

+
+
+ ${adminEscape(user.role)} + ${adminEscape(user.status)} +
+
+
+ ${adminNumber(user.routine_count)} routines + ${adminNumber(user.workout_count)} workouts + ${adminNumber(user.nutrition_log_count)} nutrition logs + ${adminNumber(user.supplement_log_count)} supplement logs + ${adminNumber(user.metric_count)} metrics +
+
+ + + +
+
+ `; + }).join("") : `
No users found.
`; +} + +function resetAdminForm() { + const form = admin$("#adminUserForm"); + form.reset(); + form.elements.target_user_id.value = ""; + form.elements.role.value = "member"; + form.elements.status.value = "active"; + form.elements.theme_mode.value = "dark"; + form.elements.theme_accent.value = "green"; + admin$("#adminUserFormTitle").textContent = "Create Profile"; +} + +function fillAdminForm(user) { + const form = admin$("#adminUserForm"); + form.elements.target_user_id.value = user.id || ""; + form.elements.name.value = user.name || ""; + form.elements.email.value = user.email || ""; + form.elements.role.value = user.role || "member"; + form.elements.status.value = user.status || "active"; + form.elements.phone.value = user.phone || ""; + form.elements.birthday.value = user.birthday || ""; + form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : ""; + form.elements.training_level.value = user.training_level || ""; + form.elements.theme_mode.value = user.theme_mode || "dark"; + form.elements.theme_accent.value = user.theme_accent || "green"; + form.elements.fitness_focus.value = user.fitness_focus || ""; + form.elements.emergency_contact.value = user.emergency_contact || ""; + admin$("#adminUserFormTitle").textContent = `Edit ${user.name}`; +} + +function adminFormPayload(form) { + const payload = Object.fromEntries(new FormData(form).entries()); + payload.acting_user_id = adminState.data.currentUser.id; + return payload; +} + +document.addEventListener("DOMContentLoaded", async () => { + admin$("#adminProfileSelect").addEventListener("change", async (event) => { + adminState.userId = event.target.value; + localStorage.setItem("nht:adminUserId", adminState.userId); + await loadAdminState(); + }); + + admin$("#clearAdminUserFormButton").addEventListener("click", resetAdminForm); + + admin$("#adminUserList").addEventListener("click", async (event) => { + const button = event.target.closest("[data-admin-action]"); + if (!button) return; + const user = (adminState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId); + if (!user) return; + + if (button.dataset.adminAction === "edit") { + fillAdminForm(user); + admin$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + try { + if (button.dataset.adminAction === "status") { + await adminApi("update_user", { + acting_user_id: adminState.data.currentUser.id, + target_user_id: user.id, + status: button.dataset.nextStatus, + }); + adminToast("Profile status updated"); + } + + if (button.dataset.adminAction === "delete") { + const ok = window.confirm(`Delete ${user.name} and all tracker data attached to this profile?`); + if (!ok) return; + await adminApi("delete_user", { + acting_user_id: adminState.data.currentUser.id, + target_user_id: user.id, + }); + adminToast("Profile deleted"); + } + + resetAdminForm(); + await loadAdminState(); + } catch (error) { + adminToast(error.message); + } + }); + + admin$("#adminUserForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const payload = adminFormPayload(event.currentTarget); + const action = payload.target_user_id ? "update_user" : "create_user"; + try { + await adminApi(action, payload); + resetAdminForm(); + await loadAdminState(); + adminToast(action === "create_user" ? "Profile created" : "Profile updated"); + } catch (error) { + adminToast(error.message); + } + }); + + try { + await loadAdminState(); + } catch (error) { + adminToast(error.message); + } +}); diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..e8d6221 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,678 @@ +const appState = { + data: null, + userId: localStorage.getItem("nht:userId") || "", + view: localStorage.getItem("nht:view") || "weekly", + date: localStorage.getItem("nht:date") || new Date().toISOString().slice(0, 10), +}; + +const accentChoices = ["green", "blue", "red", "pink", "orange"]; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function number(value, digits = 0) { + const numeric = Number(value || 0); + return numeric.toLocaleString(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }); +} + +function shortDate(value) { + const date = new Date(`${value}T00:00:00`); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function longDate(value) { + const date = new Date(`${value}T00:00:00`); + return date.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function addDays(dateString, days) { + const date = new Date(`${dateString}T00:00:00`); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0, 10); +} + +function dateRange(start, end) { + const days = []; + let cursor = start; + while (cursor <= end) { + days.push(cursor); + cursor = addDays(cursor, 1); + } + return days; +} + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.classList.add("show"); + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400); +} + +async function api(action, payload = null, method = "POST") { + const options = { method }; + if (payload) { + options.headers = { "Content-Type": "application/json" }; + options.body = JSON.stringify(payload); + } + + const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options); + const json = await response.json(); + if (!response.ok || json.error) { + throw new Error(json.error || "Request failed."); + } + return json; +} + +async function loadState() { + const params = new URLSearchParams({ + action: "state", + view: appState.view, + date: appState.date, + }); + + if (appState.userId) { + params.set("user_id", appState.userId); + } + + const response = await fetch(`/api.php?${params.toString()}`); + const json = await response.json(); + if (!response.ok || json.error) { + throw new Error(json.error || "Unable to load tracker state."); + } + + appState.data = json; + appState.userId = String(json.currentUser.id); + appState.view = json.view; + appState.date = json.range.anchor; + localStorage.setItem("nht:userId", appState.userId); + localStorage.setItem("nht:view", appState.view); + localStorage.setItem("nht:date", appState.date); + render(); +} + +function render() { + if (!appState.data) return; + + applyTheme(); + renderControls(); + renderSummaries(); + renderTimeline(); + renderNutrition(); + renderRoutines(); + renderSupplements(); + renderAdmin(); + renderLibraries(); + populateForms(); +} + +function applyTheme() { + const user = appState.data.currentUser; + const mode = user.theme_mode || "dark"; + const accent = accentChoices.includes(user.theme_accent) ? user.theme_accent : "green"; + + document.body.dataset.theme = mode; + document.body.dataset.accent = accent; + + $("#darkModeButton").classList.toggle("active", mode === "dark"); + $("#lightModeButton").classList.toggle("active", mode === "light"); + $$("[data-accent]").forEach((button) => { + button.classList.toggle("active", button.dataset.accent === accent); + }); +} + +function renderControls() { + const data = appState.data; + const user = data.currentUser; + $("#profileFocus").textContent = user.fitness_focus || "Multi-profile training dashboard"; + if (data.currentUserIsAdmin) { + $("#profileFocus").textContent += " / admin"; + } + $("#rangeCopy").textContent = `${data.view[0].toUpperCase()}${data.view.slice(1)} view: ${longDate(data.range.start)} to ${longDate(data.range.end)}`; + $("#datePicker").value = data.range.anchor; + + $("#profileSelect").innerHTML = data.users + .map((profile) => ``) + .join(""); + + $$("[data-view]").forEach((button) => { + button.classList.toggle("active", button.dataset.view === data.view); + }); +} + +function renderSummaries() { + const { workoutSummary, nutritionSummary, latestMetric, supplementLogs } = appState.data; + const actual = nutritionSummary.actual; + const target = nutritionSummary.target; + const supplementTaken = supplementLogs.filter((item) => item.status === "taken").length; + + const cards = [ + ["Completion", `${number(workoutSummary.completion_rate, 1)}%`, `${workoutSummary.completed}/${workoutSummary.planned} workouts`], + ["Volume", number(workoutSummary.weight_volume_lbs), "lb lifted"], + ["Cardio", `${number(workoutSummary.minutes, 0)} min`, `${number(workoutSummary.distance_mi, 2)} mi`], + ["Calories", number(actual.calories), `${number(target.calories)} target`], + ["Protein", `${number(actual.protein_g)} g`, `${number(target.protein_g)} g target`], + ["Weight", latestMetric ? `${number(latestMetric.weight_lbs, 1)} lb` : "No log", latestMetric ? `${number(latestMetric.sleep_hours, 1)} hr sleep` : `${supplementTaken} doses taken`], + ]; + + $("#summaryGrid").innerHTML = cards + .map(([label, value, detail]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} + ${escapeHtml(detail)} +
+ `) + .join(""); +} + +function renderTimeline() { + const { range, workouts } = appState.data; + const byDate = new Map(); + workouts.forEach((workout) => { + if (!byDate.has(workout.scheduled_on)) byDate.set(workout.scheduled_on, []); + byDate.get(workout.scheduled_on).push(workout); + }); + + const html = dateRange(range.start, range.end).map((date) => { + const entries = byDate.get(date) || []; + const volume = entries.reduce((sum, workout) => sum + Number(workout.sets || 0) * Number(workout.reps || 0) * Number(workout.weight_lbs || 0), 0); + const minutes = entries.reduce((sum, workout) => sum + Number(workout.duration_min || 0), 0); + return ` +
+
+ ${longDate(date)} + ${entries.length} workouts / ${number(minutes)} min / ${number(volume)} lb +
+
+ ${entries.length ? entries.map(renderWorkoutItem).join("") : `
No workouts scheduled
`} +
+
+ `; + }).join(""); + + $("#workoutTimeline").innerHTML = html || `
No planner days found
`; +} + +function renderWorkoutItem(workout) { + const liftLine = Number(workout.weight_lbs) > 0 + ? `${number(workout.sets)} x ${number(workout.reps)} @ ${number(workout.weight_lbs, 1)} lb` + : ""; + const cardioLine = Number(workout.duration_min) || Number(workout.distance_mi) + ? `${number(workout.duration_min)} min${Number(workout.distance_mi) ? ` / ${number(workout.distance_mi, 2)} mi` : ""}` + : ""; + const details = [workout.workout_type, workout.exercise_name, liftLine, cardioLine, workout.intensity] + .filter(Boolean) + .join(" | "); + + return ` +
+
+
+

${escapeHtml(workout.title)}

+

${escapeHtml(details || "Open workout")}

+ ${workout.routine_name ? `

${escapeHtml(workout.routine_name)}

` : ""} +
+ ${escapeHtml(workout.status)} +
+ ${workout.notes ? `

${escapeHtml(workout.notes)}

` : ""} +
+ ${["planned", "complete", "skipped"].map((status) => ` + + `).join("")} +
+
+ `; +} + +function renderNutrition() { + const { nutritionSummary, nutritionLogs } = appState.data; + const target = nutritionSummary.target; + const actual = nutritionSummary.actual; + const macros = [ + ["Calories", "calories", "kcal"], + ["Protein", "protein_g", "g"], + ["Carbs", "carbs_g", "g"], + ["Fat", "fat_g", "g"], + ["Water", "water_ml", "ml"], + ]; + + const macroHtml = macros.map(([label, key, unit]) => { + const targetValue = Number(target[key] || 0); + const actualValue = Number(actual[key] || 0); + const percent = targetValue > 0 ? Math.min((actualValue / targetValue) * 100, 140) : 0; + return ` +
+
+ ${label} + ${number(actualValue)} / ${number(targetValue)} ${unit} +
+
+
+ `; + }).join(""); + + const logHtml = nutritionLogs.length + ? nutritionLogs.map((log) => ` +
+
+ ${shortDate(log.logged_on)} + ${number(log.calories)} kcal +
+

${number(log.protein_g)}g protein / ${number(log.carbs_g)}g carbs / ${number(log.fat_g)}g fat / ${number(log.water_ml)}ml water

+ ${log.notes ? `

${escapeHtml(log.notes)}

` : ""} +
+ `).join("") + : `
No nutrition logs in this range
`; + + $("#nutritionCompare").innerHTML = ` +
${macroHtml}
+
+
+

Selected Target

+

${escapeHtml(target.label)} (${escapeHtml(target.scope)})

+
+ ${escapeHtml(target.scaled_from)} basis +
+
${logHtml}
+ `; +} + +function renderRoutines() { + const routines = appState.data.routines; + $("#routineList").innerHTML = routines.length + ? routines.map((routine) => ` +
+
+
+

${escapeHtml(routine.name)}

+

${escapeHtml(routine.workout_type)} / ${escapeHtml(routine.planner_scope)} / ${escapeHtml(routine.intensity || "open intensity")}

+
+ ${routine.active ? "active" : "paused"} +
+

Nutrition: ${escapeHtml(routine.nutrition_goal_label || "separate target")}

+ ${routine.notes ? `

${escapeHtml(routine.notes)}

` : ""} +
+ `).join("") + : `
No routines yet
`; +} + +function renderSupplements() { + const logs = appState.data.supplementLogs; + $("#supplementLogList").innerHTML = logs.length + ? logs.map((log) => ` +
+
+
+

${escapeHtml(log.supplement_name || "Custom supplement")}

+

${shortDate(log.taken_on)} / ${escapeHtml(log.timing)} / ${escapeHtml(log.dose || "open dose")}

+
+ ${escapeHtml(log.status)} +
+ ${log.supplement_purpose ? `

${escapeHtml(log.supplement_purpose)}

` : ""} + ${log.notes ? `

${escapeHtml(log.notes)}

` : ""} +
+ `).join("") + : `
No supplement logs in this range
`; +} + +function renderAdmin() { + const panel = $("#adminPanel"); + if (!panel) return; + + const isAdminUser = Boolean(appState.data.currentUserIsAdmin); + panel.hidden = !isAdminUser; + if (!isAdminUser) { + return; + } + + const summary = appState.data.adminSummary || {}; + const statCards = [ + ["Total", summary.total_users || 0], + ["Active", summary.active_users || 0], + ["Inactive", summary.inactive_users || 0], + ["Admins", summary.admins || 0], + ["Members", summary.members || 0], + ]; + $("#adminStats").innerHTML = statCards.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${number(value)} +
+ `).join(""); + + const currentId = Number(appState.data.currentUser.id); + const rows = appState.data.adminUsers || []; + $("#adminUserList").innerHTML = rows.length + ? rows.map((user) => { + const isCurrent = Number(user.id) === currentId; + const toggleStatus = user.status === "active" ? "inactive" : "active"; + const training = user.training_level ? ` / ${user.training_level}` : ""; + const height = Number(user.height_in) > 0 ? ` / ${number(user.height_in, 1)} in` : ""; + return ` +
+
+
+

${escapeHtml(user.name)}${isCurrent ? " (current)" : ""}

+

${escapeHtml(user.email || "No email")}

+

${escapeHtml(user.fitness_focus || "No focus set")}

+

${escapeHtml(user.phone || "No phone")}${escapeHtml(training)}${escapeHtml(height)}

+
+
+ ${escapeHtml(user.role)} + ${escapeHtml(user.status)} +
+
+
+ ${number(user.routine_count)} routines + ${number(user.workout_count)} workouts + ${number(user.nutrition_log_count)} nutrition logs + ${number(user.supplement_log_count)} supplement logs + ${number(user.metric_count)} metrics +
+
+ + + +
+
+ `; + }).join("") + : `
No profiles found
`; +} + +function resetAdminUserForm() { + const form = $("#adminUserForm"); + if (!form) return; + form.reset(); + form.elements.target_user_id.value = ""; + form.elements.role.value = "member"; + form.elements.status.value = "active"; + form.elements.theme_mode.value = "dark"; + form.elements.theme_accent.value = "green"; + $("#adminUserFormTitle").textContent = "Create Profile"; +} + +function fillAdminUserForm(user) { + const form = $("#adminUserForm"); + if (!form || !user) return; + + form.elements.target_user_id.value = user.id || ""; + form.elements.name.value = user.name || ""; + form.elements.email.value = user.email || ""; + form.elements.role.value = user.role || "member"; + form.elements.status.value = user.status || "active"; + form.elements.phone.value = user.phone || ""; + form.elements.birthday.value = user.birthday || ""; + form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : ""; + form.elements.training_level.value = user.training_level || ""; + form.elements.theme_mode.value = user.theme_mode || "dark"; + form.elements.theme_accent.value = user.theme_accent || "green"; + form.elements.fitness_focus.value = user.fitness_focus || ""; + form.elements.emergency_contact.value = user.emergency_contact || ""; + $("#adminUserFormTitle").textContent = `Edit ${user.name}`; +} + +function renderLibraries() { + $("#exerciseLibrary").innerHTML = appState.data.exercises.map((exercise) => ` + + ${escapeHtml(exercise.name)} + ${escapeHtml(exercise.category)} / ${escapeHtml(exercise.default_unit || "custom")} + + `).join(""); + + $("#supplementLibrary").innerHTML = appState.data.supplements.map((supplement) => ` + + ${escapeHtml(supplement.name)} + ${escapeHtml(supplement.timing)} / ${escapeHtml(supplement.default_dose || "custom")} + + `).join(""); +} + +function option(value, label, selected = false) { + return ``; +} + +function populateForms() { + const { routines, exercises, supplements, nutritionGoals, range } = appState.data; + const routineOptions = option("", "No routine") + routines.map((routine) => option(routine.id, `${routine.name} (${routine.planner_scope})`)).join(""); + const goalOptions = option("", "Separate target") + nutritionGoals.map((goal) => option(goal.id, `${goal.label} (${goal.scope})`)).join(""); + const exerciseOptions = option("", "Choose exercise") + exercises.map((exercise) => option(exercise.id, `${exercise.name} (${exercise.category})`)).join(""); + const supplementOptions = option("", "Choose supplement") + supplements.map((supplement) => option(supplement.id, `${supplement.name} (${supplement.timing})`)).join(""); + + $$("#workoutForm [name='routine_id']").forEach((select) => select.innerHTML = routineOptions); + $$("#routineForm [name='nutrition_goal_id']").forEach((select) => select.innerHTML = goalOptions); + $$("#workoutForm [name='exercise_id']").forEach((select) => select.innerHTML = exerciseOptions); + $$("#supplementLogForm [name='supplement_id']").forEach((select) => select.innerHTML = supplementOptions); + + const dateFields = [ + "#workoutForm [name='scheduled_on']", + "#routineForm [name='start_date']", + "#nutritionGoalForm [name='start_date']", + "#nutritionLogForm [name='logged_on']", + "#supplementLogForm [name='taken_on']", + "#metricForm [name='measured_on']", + ]; + dateFields.forEach((selector) => { + const input = $(selector); + if (input && !input.value) input.value = range.anchor; + }); +} + +function formPayload(form) { + const payload = Object.fromEntries(new FormData(form).entries()); + payload.user_id = appState.data.currentUser.id; + return payload; +} + +function adminUserPayload(form) { + const payload = Object.fromEntries(new FormData(form).entries()); + payload.acting_user_id = appState.data.currentUser.id; + return payload; +} + +function wireForm(selector, action, message, afterSave = null) { + const form = $(selector); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const result = await api(action, formPayload(form)); + if (afterSave) afterSave(result); + form.reset(); + await loadState(); + showToast(message); + } catch (error) { + showToast(error.message); + } + }); +} + +function wireControls() { + $("#profileSelect").addEventListener("change", async (event) => { + appState.userId = event.target.value; + localStorage.setItem("nht:userId", appState.userId); + await loadState(); + }); + + $("#datePicker").addEventListener("change", async (event) => { + appState.date = event.target.value; + localStorage.setItem("nht:date", appState.date); + await loadState(); + }); + + $$("[data-view]").forEach((button) => { + button.addEventListener("click", async () => { + appState.view = button.dataset.view; + localStorage.setItem("nht:view", appState.view); + await loadState(); + }); + }); + + $("#darkModeButton").addEventListener("click", () => saveTheme("dark")); + $("#lightModeButton").addEventListener("click", () => saveTheme("light")); + + $$("[data-accent]").forEach((button) => { + button.addEventListener("click", () => saveTheme(null, button.dataset.accent)); + }); + + $("#workoutTimeline").addEventListener("click", async (event) => { + const button = event.target.closest("[data-workout-id]"); + if (!button) return; + try { + await api("update_workout", { + id: button.dataset.workoutId, + status: button.dataset.status, + }); + await loadState(); + showToast("Workout updated"); + } catch (error) { + showToast(error.message); + } + }); + + document.addEventListener("click", (event) => { + const jump = event.target.closest("[data-jump]"); + if (!jump) return; + const target = $(jump.dataset.jump); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + + $("#workoutForm [name='exercise_id']").addEventListener("change", (event) => { + const exercise = appState.data.exercises.find((item) => String(item.id) === event.target.value); + const title = $("#workoutForm [name='title']"); + const type = $("#workoutForm [name='workout_type']"); + if (exercise && !title.value) title.value = exercise.name; + if (exercise) type.value = exercise.category; + }); + + $("#supplementLogForm [name='supplement_id']").addEventListener("change", (event) => { + const supplement = appState.data.supplements.find((item) => String(item.id) === event.target.value); + if (!supplement) return; + $("#supplementLogForm [name='timing']").value = supplement.timing; + $("#supplementLogForm [name='dose']").value = supplement.default_dose; + }); + + $("#newAdminUserButton").addEventListener("click", () => { + resetAdminUserForm(); + $("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" }); + }); + + $("#clearAdminUserFormButton").addEventListener("click", resetAdminUserForm); + + $("#adminUserList").addEventListener("click", async (event) => { + const button = event.target.closest("[data-admin-action]"); + if (!button) return; + + const user = (appState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId); + if (!user) return; + + if (button.dataset.adminAction === "edit") { + fillAdminUserForm(user); + $("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + if (button.dataset.adminAction === "status") { + try { + await api("update_user", { + acting_user_id: appState.data.currentUser.id, + target_user_id: user.id, + status: button.dataset.nextStatus, + }); + await loadState(); + showToast("Profile status updated"); + } catch (error) { + showToast(error.message); + } + return; + } + + if (button.dataset.adminAction === "delete") { + const ok = window.confirm(`Delete ${user.name} and all of this profile's tracker data?`); + if (!ok) return; + try { + await api("delete_user", { + acting_user_id: appState.data.currentUser.id, + target_user_id: user.id, + }); + await loadState(); + showToast("Profile deleted"); + } catch (error) { + showToast(error.message); + } + } + }); +} + +async function saveTheme(mode = null, accent = null) { + const current = appState.data.currentUser; + try { + await api("update_theme", { + user_id: current.id, + theme_mode: mode || current.theme_mode || "dark", + theme_accent: accent || current.theme_accent || "green", + }); + await loadState(); + } catch (error) { + showToast(error.message); + } +} + +function wireForms() { + wireForm("#workoutForm", "create_workout", "Workout saved"); + wireForm("#routineForm", "create_routine", "Routine saved"); + wireForm("#nutritionGoalForm", "create_nutrition_goal", "Nutrition target saved"); + wireForm("#nutritionLogForm", "create_nutrition_log", "Nutrition logged"); + wireForm("#supplementLogForm", "create_supplement_log", "Supplement logged"); + wireForm("#metricForm", "create_body_metric", "Metrics logged"); + wireForm("#exerciseForm", "create_exercise", "Exercise added"); + wireForm("#supplementForm", "create_supplement", "Supplement added"); + + $("#adminUserForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const payload = adminUserPayload(form); + const action = payload.target_user_id ? "update_user" : "create_user"; + try { + await api(action, payload); + resetAdminUserForm(); + await loadState(); + showToast(action === "create_user" ? "Profile created" : "Profile updated"); + } catch (error) { + showToast(error.message); + } + }); +} + +document.addEventListener("DOMContentLoaded", async () => { + wireControls(); + wireForms(); + try { + await loadState(); + } catch (error) { + showToast(error.message); + } +}); diff --git a/public/assets/site.css b/public/assets/site.css new file mode 100644 index 0000000..b49ce25 --- /dev/null +++ b/public/assets/site.css @@ -0,0 +1,736 @@ +:root { + color-scheme: dark; + --bg: #08090d; + --bg-2: #11131a; + --panel: rgba(18, 21, 29, 0.92); + --panel-2: rgba(24, 29, 38, 0.94); + --line: rgba(255, 255, 255, 0.13); + --line-strong: rgba(255, 255, 255, 0.23); + --text: #f5f7fb; + --muted: #98a1b3; + --muted-2: #c0c7d5; + --accent: #2cf296; + --accent-2: #2cb5ff; + --danger: #ff4d68; + --warning: #ffb547; + --shadow: 0 20px 60px rgba(0, 0, 0, 0.34); + --radius: 8px; + --radius-sm: 6px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: var(--text); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(0deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(135deg, var(--bg), var(--bg-2)); + font-family: var(--font); + letter-spacing: 0; +} + +a { + color: inherit; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +img { + display: block; + max-width: 100%; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: clamp(2.2rem, 7vw, 5.8rem); + line-height: 0.95; +} + +h2 { + font-size: clamp(1.35rem, 3vw, 2.5rem); + line-height: 1.05; +} + +h3 { + font-size: 1rem; +} + +p { + line-height: 1.65; +} + +.site-header { + position: sticky; + top: 12px; + z-index: 20; + width: min(1440px, calc(100% - 32px)); + margin: 14px auto 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel), transparent 4%); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.site-logo, +.site-nav, +.site-actions, +.admin-access-row, +.admin-user-main, +.admin-badges, +.status-row, +.footer-links { + display: flex; + align-items: center; +} + +.site-logo { + gap: 10px; + text-decoration: none; + font-weight: 800; +} + +.site-logo-bars { + width: 34px; + height: 34px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--accent), transparent 40%); + border-radius: var(--radius-sm); + box-shadow: 0 0 16px color-mix(in srgb, var(--accent), transparent 62%); +} + +.site-logo-bars i { + border-radius: 2px; + background: var(--accent); + box-shadow: 0 0 12px var(--accent); +} + +.site-logo-bars i:nth-child(1) { + height: 50%; + align-self: end; +} + +.site-logo-bars i:nth-child(2) { + height: 100%; +} + +.site-logo-bars i:nth-child(3) { + height: 70%; + align-self: end; + background: var(--accent-2); + box-shadow: 0 0 12px var(--accent-2); +} + +.site-nav { + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.site-nav a, +.site-secondary, +.text-button, +.status-button { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + background: rgba(255, 255, 255, 0.035); + padding: 8px 11px; + text-decoration: none; + line-height: 1.1; +} + +.site-nav a.active, +.site-nav a:hover, +.site-secondary:hover, +.text-button:hover, +.status-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.site-primary { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--radius-sm); + color: #06100b; + background: var(--accent); + box-shadow: 0 0 22px color-mix(in srgb, var(--accent), transparent 55%); + font-weight: 800; + padding: 10px 14px; + text-decoration: none; +} + +.site-primary.full { + width: 100%; +} + +.site-hero { + width: min(1440px, calc(100% - 32px)); + min-height: min(760px, calc(100vh - 44px)); + margin: 12px auto 0; + position: relative; + display: flex; + align-items: flex-end; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.site-hero::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(8, 9, 13, 0.92), rgba(8, 9, 13, 0.35) 55%, rgba(8, 9, 13, 0.2)); +} + +.site-hero img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.site-hero-copy { + position: relative; + z-index: 1; + width: min(780px, 100%); + padding: clamp(24px, 6vw, 72px); + display: grid; + gap: 18px; +} + +.site-lede, +.site-hero-copy p:not(.site-eyebrow), +.detail-hero p, +.signup-copy p, +.site-split p, +.site-section-heading p { + color: var(--muted-2); +} + +.site-eyebrow { + color: var(--accent); + font-size: 0.78rem; + font-weight: 800; + line-height: 1.1; +} + +.site-actions { + gap: 10px; + flex-wrap: wrap; +} + +.photo-credit { + position: absolute; + right: 14px; + bottom: 10px; + z-index: 1; + max-width: min(460px, calc(100% - 28px)); + color: rgba(255, 255, 255, 0.78); + font-size: 0.75rem; + line-height: 1.35; +} + +.photo-credit a, +figcaption a { + color: var(--accent); +} + +.site-band, +.site-split, +.cta-strip, +.detail-hero, +.detail-grid, +.signup-layout, +.admin-public-shell, +.site-footer { + width: min(1180px, calc(100% - 32px)); + margin: 14px auto 0; +} + +.site-band, +.site-split, +.cta-strip, +.signup-layout, +.admin-public-shell, +.site-footer { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + padding: clamp(18px, 4vw, 36px); +} + +.site-section-heading { + max-width: 780px; + display: grid; + gap: 10px; + margin-bottom: 18px; +} + +.feature-grid, +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.feature-grid article, +.detail-grid article, +.site-metric-stack article, +.signup-steps article, +.admin-denied, +.admin-stat, +.admin-user-card, +.admin-editor { + border: 1px solid var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel-2), transparent 4%); + padding: 14px; +} + +.feature-grid article { + min-height: 190px; +} + +.feature-grid span { + display: inline-flex; + color: var(--accent); + margin-bottom: 20px; + font-weight: 800; +} + +.feature-grid p, +.detail-grid p, +.site-metric-stack span, +.signup-steps span, +.details-line, +.timeline-meta { + color: var(--muted); + font-size: 0.88rem; + line-height: 1.5; +} + +.site-split, +.detail-hero, +.signup-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr); + gap: 18px; + align-items: center; +} + +.site-split > div:first-child, +.detail-hero > div, +.signup-copy { + display: grid; + gap: 14px; +} + +.site-metric-stack { + display: grid; + gap: 10px; +} + +.site-metric-stack article { + display: grid; + gap: 5px; +} + +.cta-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.detail-hero { + align-items: center; +} + +.detail-hero figure { + margin: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); +} + +.detail-hero img { + width: 100%; + height: 480px; + object-fit: cover; +} + +figcaption { + color: var(--muted); + font-size: 0.76rem; + line-height: 1.35; + padding: 10px; +} + +.signup-card { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel-2), transparent 2%); + padding: 18px; +} + +.signup-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.signup-steps { + display: grid; + gap: 9px; +} + +.signup-steps article { + display: flex; + align-items: center; + gap: 10px; +} + +.signup-steps strong { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #06100b; + background: var(--accent); +} + +.signup-result { + min-height: 24px; + color: var(--muted-2); +} + +.site-field, +.field { + display: grid; + gap: 6px; +} + +.site-field span, +.field span { + color: var(--muted-2); + font-size: 0.76rem; + line-height: 1; +} + +input, +select, +textarea { + width: 100%; + min-height: 40px; + color: var(--text); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel-2), transparent 8%); + padding: 9px 10px; + outline: none; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 78%); +} + +.admin-access-row { + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.admin-access-row .site-field { + width: min(360px, 100%); +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.admin-stat span { + display: block; + color: var(--muted); + font-size: 0.76rem; + margin-bottom: 8px; +} + +.admin-stat strong { + font-size: 1.5rem; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr); + gap: 12px; + align-items: start; +} + +.admin-user-list { + display: grid; + gap: 9px; + max-height: 680px; + overflow: auto; + padding-right: 4px; +} + +.admin-user-card { + display: grid; + gap: 10px; +} + +.admin-user-main { + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.admin-badges, +.status-row { + gap: 7px; + flex-wrap: wrap; +} + +.admin-badges { + justify-content: flex-end; +} + +.admin-user-metrics { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.admin-user-metrics span, +.badge { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted-2); + padding: 4px 8px; + font-size: 0.76rem; +} + +.badge.active, +.badge.admin { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent), transparent 50%); +} + +.badge.inactive { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger), transparent 50%); +} + +.badge.member { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning), transparent 50%); +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.form-grid { + display: grid; + gap: 10px; +} + +.form-grid.two { + grid-template-columns: 1fr 1fr; +} + +.field.wide { + grid-column: 1 / -1; +} + +.danger-action { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger), transparent 55%); +} + +.status-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.site-toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 30; + max-width: min(360px, calc(100vw - 36px)); + padding: 12px 14px; + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + transform: translateY(16px); + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease, transform 160ms ease; +} + +.site-toast.show { + opacity: 1; + transform: translateY(0); +} + +.site-footer { + display: flex; + justify-content: space-between; + gap: 18px; + margin-bottom: 24px; +} + +.site-footer p { + color: var(--muted); +} + +.footer-links { + gap: 10px; + flex-wrap: wrap; +} + +.footer-links a { + color: var(--accent); + text-decoration: none; +} + +@media (max-width: 1020px) { + .site-header, + .site-footer, + .cta-strip { + align-items: stretch; + flex-direction: column; + } + + .site-nav { + justify-content: flex-start; + } + + .feature-grid, + .detail-grid, + .admin-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .site-split, + .detail-hero, + .signup-layout, + .admin-layout { + grid-template-columns: 1fr; + } + + .detail-hero.reverse figure { + order: 2; + } +} + +@media (max-width: 640px) { + .site-header, + .site-hero, + .site-band, + .site-split, + .cta-strip, + .detail-hero, + .detail-grid, + .signup-layout, + .admin-public-shell, + .site-footer { + width: min(100% - 20px, 620px); + } + + .site-hero { + min-height: 720px; + } + + .site-hero-copy { + padding: 22px; + padding-bottom: 74px; + } + + .feature-grid, + .detail-grid, + .admin-stats, + .signup-row, + .form-grid.two { + grid-template-columns: 1fr; + } + + .admin-access-row, + .admin-user-main, + .panel-heading { + align-items: stretch; + flex-direction: column; + } + + .admin-badges { + justify-content: flex-start; + } + + .detail-hero img { + height: 320px; + } +} diff --git a/public/assets/site.js b/public/assets/site.js new file mode 100644 index 0000000..a059ea0 --- /dev/null +++ b/public/assets/site.js @@ -0,0 +1,43 @@ +const siteForm = document.querySelector("#publicSignupForm"); +const siteResult = document.querySelector("#signupResult"); + +function showSignupMessage(message) { + if (siteResult) { + siteResult.textContent = message; + } +} + +async function siteApi(action, payload) { + const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const json = await response.json(); + if (!response.ok || json.error) { + throw new Error(json.error || "Request failed."); + } + return json; +} + +if (siteForm) { + siteForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(siteForm).entries()); + showSignupMessage("Creating profile..."); + try { + const result = await siteApi("public_signup", payload); + if (result.user?.id) { + localStorage.setItem("nht:userId", String(result.user.id)); + } + siteForm.reset(); + const adminCopy = result.first_admin ? " You are the first user, so this profile is an admin." : ""; + showSignupMessage(`Profile created.${adminCopy} Opening tracker...`); + window.setTimeout(() => { + window.location.href = "/tracker.php"; + }, 900); + } catch (error) { + showSignupMessage(error.message); + } + }); +} diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..5d6a1ff --- /dev/null +++ b/public/assets/styles.css @@ -0,0 +1,867 @@ +:root { + color-scheme: dark; + --bg: #08090d; + --bg-2: #11131a; + --panel: rgba(18, 21, 29, 0.92); + --panel-2: rgba(24, 29, 38, 0.94); + --line: rgba(255, 255, 255, 0.11); + --line-strong: rgba(255, 255, 255, 0.2); + --text: #f5f7fb; + --muted: #98a1b3; + --muted-2: #c0c7d5; + --danger: #ff4d68; + --warning: #ffb547; + --ok: #2cf296; + --accent: #2cf296; + --accent-2: #2cb5ff; + --accent-3: #ff4d68; + --shadow: 0 20px 60px rgba(0, 0, 0, 0.35); + --radius: 8px; + --radius-sm: 6px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body[data-theme="light"] { + color-scheme: light; + --bg: #f6f8fb; + --bg-2: #e8edf4; + --panel: rgba(255, 255, 255, 0.94); + --panel-2: rgba(246, 249, 253, 0.96); + --line: rgba(31, 40, 55, 0.12); + --line-strong: rgba(31, 40, 55, 0.2); + --text: #111827; + --muted: #5e6677; + --muted-2: #394152; + --shadow: 0 18px 50px rgba(36, 47, 66, 0.12); +} + +body[data-accent="green"] { + --accent: #2cf296; + --accent-2: #29d8ff; + --accent-3: #ff4d68; +} + +body[data-accent="blue"] { + --accent: #2cb5ff; + --accent-2: #48f2d6; + --accent-3: #ff9a3d; +} + +body[data-accent="red"] { + --accent: #ff4d68; + --accent-2: #ffd166; + --accent-3: #2cb5ff; +} + +body[data-accent="pink"] { + --accent: #ff4fd8; + --accent-2: #2cf296; + --accent-3: #ffb547; +} + +body[data-accent="orange"] { + --accent: #ff9a3d; + --accent-2: #2cb5ff; + --accent-3: #2cf296; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html { + min-width: 320px; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(0deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(135deg, var(--bg), var(--bg-2)); + font-family: var(--font); + letter-spacing: 0; +} + +body[data-theme="light"] { + background: + linear-gradient(90deg, rgba(17, 24, 39, 0.045) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(0deg, rgba(17, 24, 39, 0.045) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(135deg, var(--bg), var(--bg-2)); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + width: min(1560px, calc(100% - 32px)); + margin: 0 auto; + padding: 20px 0 40px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + position: sticky; + top: 12px; + z-index: 10; + backdrop-filter: blur(18px); +} + +.brand-block, +.topbar-controls, +.control-strip, +.theme-controls, +.summary-card, +.panel-heading, +.timeline-date, +.admin-user-main, +.admin-badges, +.status-row, +.toggle-group, +.accent-swatches { + display: flex; + align-items: center; +} + +.brand-block { + gap: 12px; + min-width: 240px; +} + +.brand-mark { + width: 46px; + height: 46px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + padding: 7px; + border: 1px solid color-mix(in srgb, var(--accent), transparent 40%); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.04); + box-shadow: 0 0 20px color-mix(in srgb, var(--accent), transparent 65%); +} + +.brand-mark span { + min-width: 0; + border-radius: 3px; + background: var(--accent); + box-shadow: 0 0 16px var(--accent); +} + +.brand-mark span:nth-child(1) { + height: 55%; + align-self: end; +} + +.brand-mark span:nth-child(2) { + height: 100%; +} + +.brand-mark span:nth-child(3) { + height: 72%; + align-self: end; + background: var(--accent-2); + box-shadow: 0 0 16px var(--accent-2); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: clamp(1.2rem, 2vw, 1.75rem); + line-height: 1.05; +} + +h2 { + font-size: 1rem; + line-height: 1.2; +} + +h3 { + font-size: 0.95rem; + line-height: 1.3; +} + +.brand-block p, +.range-copy, +.muted, +.routine-meta, +.timeline-meta, +.chip small { + color: var(--muted); +} + +.topbar-controls { + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.field { + display: grid; + gap: 6px; +} + +.field span { + color: var(--muted-2); + font-size: 0.76rem; + line-height: 1; +} + +.compact-field { + width: min(220px, 40vw); +} + +.date-field { + width: 160px; +} + +input, +select, +textarea { + width: 100%; + min-height: 40px; + color: var(--text); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel-2), transparent 10%); + padding: 9px 10px; + outline: none; +} + +textarea { + resize: vertical; + min-height: 88px; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 78%); +} + +.segmented, +.toggle-group { + min-height: 40px; + padding: 3px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.04); +} + +.segmented button, +.toggle-group button, +.text-button, +.primary-button, +.status-button { + min-height: 34px; + border: 0; + border-radius: var(--radius-sm); + color: var(--text); + background: transparent; + padding: 7px 11px; + line-height: 1.1; +} + +.segmented button.active, +.toggle-group button.active { + color: #06100b; + background: var(--accent); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent), transparent 50%); +} + +.control-strip { + justify-content: space-between; + gap: 16px; + padding: 18px 2px 14px; + flex-wrap: wrap; +} + +.range-copy { + font-size: 0.95rem; +} + +.theme-controls { + gap: 12px; + flex-wrap: wrap; +} + +.accent-swatches { + gap: 7px; +} + +.accent-swatches button { + width: 28px; + height: 28px; + border: 1px solid var(--line-strong); + border-radius: 50%; + background: var(--swatch); + box-shadow: 0 0 16px color-mix(in srgb, var(--swatch), transparent 55%); +} + +.accent-swatches button[data-accent="green"] { + --swatch: #2cf296; +} + +.accent-swatches button[data-accent="blue"] { + --swatch: #2cb5ff; +} + +.accent-swatches button[data-accent="red"] { + --swatch: #ff4d68; +} + +.accent-swatches button[data-accent="pink"] { + --swatch: #ff4fd8; +} + +.accent-swatches button[data-accent="orange"] { + --swatch: #ff9a3d; +} + +.accent-swatches button.active { + outline: 2px solid var(--text); + outline-offset: 2px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.summary-card, +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.summary-card { + min-height: 104px; + align-items: stretch; + justify-content: space-between; + flex-direction: column; + padding: 14px; + overflow: hidden; + position: relative; +} + +.summary-card::after { + content: ""; + position: absolute; + inset: auto 12px 10px 12px; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent, var(--accent-2)); + opacity: 0.85; +} + +.summary-card span { + color: var(--muted); + font-size: 0.76rem; +} + +.summary-card strong { + display: block; + font-size: clamp(1.45rem, 2vw, 2.1rem); + line-height: 1; + margin-top: 8px; +} + +.summary-card small { + color: var(--muted-2); +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); + gap: 12px; + align-items: start; +} + +.admin-panel { + margin-bottom: 12px; +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.admin-stat { + min-height: 76px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.035); +} + +.admin-stat span { + display: block; + color: var(--muted); + font-size: 0.76rem; + margin-bottom: 8px; +} + +.admin-stat strong { + font-size: 1.5rem; + line-height: 1; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr); + gap: 12px; + align-items: start; +} + +.admin-user-list { + display: grid; + gap: 9px; + max-height: 660px; + overflow: auto; + padding-right: 4px; +} + +.admin-user-card, +.admin-editor { + border: 1px solid var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel-2), transparent 4%); + padding: 12px; +} + +.admin-user-card { + display: grid; + gap: 10px; +} + +.admin-user-main { + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.admin-badges { + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.admin-user-metrics { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.admin-user-metrics span { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted-2); + padding: 4px 8px; + font-size: 0.76rem; +} + +.panel { + padding: 14px; + min-width: 0; +} + +.planner-panel { + grid-row: span 2; +} + +.panel-heading { + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.eyebrow { + color: var(--accent); + font-size: 0.76rem; + line-height: 1; + margin-bottom: 5px; +} + +.text-button { + border: 1px solid var(--line); + white-space: nowrap; +} + +.text-button:hover, +.status-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.primary-button { + width: 100%; + margin-top: 12px; + color: #06100b; + background: var(--accent); + font-weight: 700; + box-shadow: 0 0 20px color-mix(in srgb, var(--accent), transparent 55%); +} + +.timeline { + display: grid; + gap: 10px; +} + +.timeline-day { + border: 1px solid var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel-2), transparent 4%); + overflow: hidden; +} + +.timeline-date { + justify-content: space-between; + gap: 10px; + min-height: 42px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.timeline-date strong { + font-size: 0.92rem; +} + +.timeline-meta { + font-size: 0.78rem; +} + +.workout-list, +.routine-list, +.supplement-list, +.nutrition-list, +.metric-list { + display: grid; + gap: 9px; +} + +.workout-item, +.routine-item, +.supplement-item, +.log-item { + display: grid; + gap: 8px; + padding: 11px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.035); +} + +.workout-top, +.routine-top, +.supplement-top, +.log-top { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.routine-meta, +.timeline-meta, +.details-line, +.log-item p, +.supplement-item p { + font-size: 0.82rem; + line-height: 1.35; +} + +.details-line { + color: var(--muted-2); +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted-2); + padding: 3px 8px; + font-size: 0.76rem; + white-space: nowrap; +} + +.badge.complete, +.badge.taken, +.badge.active, +.badge.admin { + color: var(--ok); + border-color: color-mix(in srgb, var(--ok), transparent 50%); +} + +.badge.skipped, +.badge.inactive { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger), transparent 50%); +} + +.badge.planned, +.badge.member { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning), transparent 50%); +} + +.status-row { + gap: 8px; + flex-wrap: wrap; +} + +.status-button { + min-height: 30px; + border: 1px solid var(--line); + padding: 5px 8px; + font-size: 0.78rem; +} + +.status-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.danger-action { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger), transparent 55%); +} + +.macro-list { + display: grid; + gap: 10px; +} + +.macro-row { + display: grid; + gap: 6px; +} + +.macro-copy { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 0.84rem; +} + +.bar { + height: 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.bar span { + display: block; + height: 100%; + width: min(var(--value), 100%); + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + box-shadow: 0 0 14px color-mix(in srgb, var(--accent), transparent 45%); +} + +.forms-grid, +.library-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; + margin-top: 12px; +} + +.library-grid { + grid-template-columns: 1fr 1fr; +} + +.form-panel { + min-height: 100%; +} + +.form-grid { + display: grid; + gap: 10px; +} + +.form-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field.wide { + grid-column: 1 / -1; +} + +.chip-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 430px; + overflow: auto; + padding-right: 4px; +} + +.chip { + display: inline-grid; + gap: 2px; + max-width: 240px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 7px 10px; + background: rgba(255, 255, 255, 0.035); + font-size: 0.8rem; +} + +.empty-state { + min-height: 110px; + display: grid; + place-items: center; + color: var(--muted); + border: 1px dashed var(--line-strong); + border-radius: var(--radius); + text-align: center; + padding: 18px; +} + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 30; + max-width: min(360px, calc(100vw - 36px)); + padding: 12px 14px; + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + transform: translateY(16px); + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease, transform 160ms ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 1180px) { + .summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .admin-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .admin-layout { + grid-template-columns: 1fr; + } + + .workspace-grid, + .forms-grid { + grid-template-columns: 1fr 1fr; + } + + .planner-panel { + grid-row: auto; + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .app-shell { + width: min(100% - 20px, 720px); + padding-top: 10px; + } + + .topbar { + position: static; + align-items: stretch; + flex-direction: column; + } + + .topbar-controls, + .control-strip, + .theme-controls { + align-items: stretch; + justify-content: stretch; + } + + .topbar-controls > *, + .compact-field, + .date-field { + width: 100%; + } + + .segmented, + .toggle-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .toggle-group { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .summary-grid, + .admin-stats, + .workspace-grid, + .forms-grid, + .library-grid { + grid-template-columns: 1fr; + } + + .form-grid.two { + grid-template-columns: 1fr; + } + + .workout-top, + .routine-top, + .supplement-top, + .log-top, + .admin-user-main, + .panel-heading { + align-items: stretch; + flex-direction: column; + } + + .admin-badges { + justify-content: flex-start; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..6c228e5 --- /dev/null +++ b/public/index.php @@ -0,0 +1,82 @@ + +
+
+ Runner training outdoors in bright green grass +
+

Workout planning, nutrition balance, supplement tracking

+

Neon Health Tracker

+

A multi-user fitness planner for people who want routines, meals, recovery, and body metrics in one fast web dashboard.

+ +
+

+ Photo: Jogging Woman in Grass + by Mike Baird, CC BY 2.0. +

+
+ +
+
+

Built for daily use

+

One workspace for training, food, recovery, and progress.

+
+
+
+ 01 +

Routine planning

+

Schedule weight lifting, cycling, running, swimming, conditioning, mobility, and custom workouts by day, week, or month.

+
+
+ 02 +

Nutrition targets

+

Compare actual logs against daily, weekly, or monthly calories, protein, carbs, fat, and hydration goals.

+
+
+ 03 +

Supplement timing

+

Track pre-workout, intra-workout, post-workout, daily, and evening supplements without mixing them into meal logs.

+
+
+ 04 +

Health monitoring

+

Keep weight, body fat, sleep, resting heart rate, mood, and notes connected to the training plan.

+
+
+
+ +
+
+

For individuals and small teams

+

Profiles stay separate, admins stay in control.

+

Each user gets a profile, theme preference, routines, logs, goals, and metric history. Admins can create users, edit details, activate or deactivate accounts, and remove profiles from a dedicated dashboard.

+ Open admin dashboard +
+
+
DailyPlan today’s workout, meals, supplements, and body check-in.
+
WeeklySee volume, cardio minutes, macro targets, and completion rate.
+
MonthlyCompare long-range goals against real nutrition and training behavior.
+
+
+ +
+
+

Ready when you are

+

Create your profile and open the tracker.

+
+ Sign up +
+
+ diff --git a/public/nutrition.php b/public/nutrition.php new file mode 100644 index 0000000..cfedfa2 --- /dev/null +++ b/public/nutrition.php @@ -0,0 +1,48 @@ + +
+
+
+ Large mixed salad with vegetables and protein +
+ Photo: A large mixed salad + by Jmabel, CC BY-SA 4.0. +
+
+
+

Targets vs actuals

+

Keep nutrition flexible without losing the plan.

+

Set daily, weekly, or monthly goals for calories, protein, carbs, fat, and hydration. Log actual intake separately so deviations stay visible instead of buried.

+ Start a nutrition plan +
+
+ +
+
+

Separate goals

+

Nutrition goals can be attached to routines or kept independent for users who want to experiment.

+
+
+

Macro balance

+

Progress bars show how far actual logs are from the chosen target for the current planner range.

+
+
+

Hydration

+

Water targets stay beside macros so recovery and performance habits remain part of the daily flow.

+
+
+

Range scaling

+

Use daily, weekly, or monthly target scopes, with daily goals scaling when a broader target is not set.

+
+
+
+ diff --git a/public/partials/site-footer.php b/public/partials/site-footer.php new file mode 100644 index 0000000..e793ead --- /dev/null +++ b/public/partials/site-footer.php @@ -0,0 +1,14 @@ + + + + diff --git a/public/partials/site-header.php b/public/partials/site-header.php new file mode 100644 index 0000000..5b0e957 --- /dev/null +++ b/public/partials/site-header.php @@ -0,0 +1,32 @@ + + + + + + + <?= htmlspecialchars($pageTitle, ENT_QUOTES) ?> + + + + diff --git a/public/recovery.php b/public/recovery.php new file mode 100644 index 0000000..789b017 --- /dev/null +++ b/public/recovery.php @@ -0,0 +1,48 @@ + +
+
+
+

Recovery stack

+

Track supplements and body signals beside the training plan.

+

Plan pre-workout, intra-workout, post-workout, daily, and evening supplements. Pair that with weight, body fat, sleep, resting heart rate, mood, and notes.

+ Build a recovery log +
+
+ Runner training outdoors +
+ Photo: Jogging Woman in Grass + by Mike Baird, CC BY 2.0. +
+
+
+ +
+
+

Supplement library

+

Start with creatine, caffeine, citrulline, electrolytes, whey, casein, magnesium, omega-3s, greens, and more.

+
+
+

Timing windows

+

Separate pre-workout, intra-workout, post-workout, daily, and evening routines so supplement habits stay clear.

+
+
+

Health metrics

+

Log weight, sleep, resting heart rate, body fat, mood, and notes to understand how training is landing.

+
+
+

Custom additions

+

Add custom supplements and doses as your recovery plan evolves.

+
+
+
+ diff --git a/public/signup.php b/public/signup.php new file mode 100644 index 0000000..be4a7b2 --- /dev/null +++ b/public/signup.php @@ -0,0 +1,47 @@ + +
+ +
+ diff --git a/public/tracker.php b/public/tracker.php new file mode 100644 index 0000000..9653472 --- /dev/null +++ b/public/tracker.php @@ -0,0 +1,353 @@ + + + + + + + Neon Health Tracker + + + +
+
+
+ +
+

Neon Health Tracker

+

Loading profile

+
+
+ +
+ +
+ + + +
+ +
+
+ +
+
+
Loading range
+
+
+ + +
+
+ + + + + +
+
+
+ +
+ + + +
+
+
+
+

Planner

+

Workout Schedule

+
+ +
+
+
+ +
+
+
+

Balance

+

Nutrition Target vs Actual

+
+ +
+
+
+ +
+
+
+

Plan Library

+

Routines

+
+ +
+
+
+ +
+
+
+

Recovery Stack

+

Supplements

+
+ +
+
+
+
+ +
+
+
+
+

Schedule

+

Add Workout

+
+
+
+ + + + + + + + + + + + + +
+ +
+ +
+
+
+

Program

+

Add Routine

+
+
+
+ + + + + + + +
+ +
+ +
+
+
+

Targets

+

Add Nutrition Goal

+
+
+
+ + + + + + + + +
+ +
+ +
+
+
+

Food

+

Log Nutrition

+
+
+
+ + + + + + + +
+ +
+ +
+
+
+

Dose

+

Log Supplement

+
+
+
+ + + + + + +
+ +
+ +
+
+
+

Health

+

Log Body Metrics

+
+
+
+ + + + + + + +
+ +
+ +
+
+
+

Exercises

+

Add Custom Exercise

+
+
+
+ + + + + +
+ +
+ +
+
+
+

Supplements

+

Add Custom Supplement

+
+
+
+ + + + + +
+ +
+
+ +
+
+
+
+

Known Movements

+

Exercise Library

+
+
+
+
+
+
+
+

Known Stack

+

Supplement Library

+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/public/workouts.php b/public/workouts.php new file mode 100644 index 0000000..efda3cd --- /dev/null +++ b/public/workouts.php @@ -0,0 +1,48 @@ + +
+
+
+

Daily, weekly, monthly

+

Plan training without losing the details that matter.

+

Build routines around weight lifting, cycling, running, swimming, mobility, conditioning, or anything custom. Track volume, duration, distance, intensity, status, and notes from one dashboard.

+ Create a profile +
+
+ Cyclists riding through Amsterdam +
+ Photo: Cycling in Amsterdam (893) + by FaceMePLS, CC BY-SA 2.0. +
+
+
+ +
+
+

Routine templates

+

Create recurring programs for strength blocks, endurance weeks, hybrid plans, or recovery cycles.

+
+
+

Workout library

+

Start with known movements like squats, deadlifts, bench press, pull-ups, runs, rides, swims, rows, HIIT, and mobility work.

+
+
+

Completion tracking

+

Mark workouts as planned, complete, or skipped and watch completion rate update across the selected range.

+
+
+

Volume and cardio totals

+

Lift volume, training minutes, and distance are summarized automatically for the current day, week, or month.

+
+
+
+