1363 lines
50 KiB
PHP
1363 lines
50 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
date_default_timezone_set('America/New_York');
|
|
|
|
const APP_ROOT = __DIR__ . '/..';
|
|
const DATA_DIR = APP_ROOT . '/data';
|
|
const DB_PATH = DATA_DIR . '/health_tracker.sqlite';
|
|
|
|
function app_db(): PDO
|
|
{
|
|
static $pdo = null;
|
|
|
|
if ($pdo instanceof PDO) {
|
|
return $pdo;
|
|
}
|
|
|
|
if (!is_dir(DATA_DIR)) {
|
|
mkdir(DATA_DIR, 0775, true);
|
|
}
|
|
|
|
$pdo = new PDO('sqlite:' . DB_PATH);
|
|
$pdo->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(<<<SQL
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
email TEXT DEFAULT '',
|
|
fitness_focus TEXT DEFAULT '',
|
|
role 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 '',
|
|
theme_mode TEXT NOT NULL DEFAULT 'dark',
|
|
theme_accent TEXT NOT NULL DEFAULT 'green',
|
|
updated_at TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS exercises (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
muscle_group TEXT DEFAULT '',
|
|
default_unit TEXT DEFAULT '',
|
|
notes TEXT DEFAULT '',
|
|
is_builtin INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS supplements (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
timing TEXT NOT NULL,
|
|
default_dose TEXT DEFAULT '',
|
|
purpose TEXT DEFAULT '',
|
|
notes TEXT DEFAULT '',
|
|
is_builtin INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS nutrition_goals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
label TEXT NOT NULL,
|
|
scope TEXT NOT NULL CHECK (scope IN ('daily', 'weekly', 'monthly')),
|
|
calories REAL NOT NULL DEFAULT 0,
|
|
protein_g REAL NOT NULL DEFAULT 0,
|
|
carbs_g REAL NOT NULL DEFAULT 0,
|
|
fat_g REAL NOT NULL DEFAULT 0,
|
|
water_ml REAL NOT NULL DEFAULT 0,
|
|
start_date TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS routines (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
nutrition_goal_id INTEGER REFERENCES nutrition_goals(id) ON DELETE SET NULL,
|
|
name TEXT NOT NULL,
|
|
workout_type TEXT NOT NULL,
|
|
planner_scope TEXT NOT NULL CHECK (planner_scope IN ('daily', 'weekly', 'monthly')),
|
|
start_date TEXT NOT NULL,
|
|
end_date TEXT DEFAULT '',
|
|
intensity TEXT DEFAULT '',
|
|
notes TEXT DEFAULT '',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS workouts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
routine_id INTEGER REFERENCES routines(id) ON DELETE SET NULL,
|
|
exercise_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL,
|
|
scheduled_on TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
workout_type TEXT NOT NULL,
|
|
sets INTEGER DEFAULT 0,
|
|
reps INTEGER DEFAULT 0,
|
|
weight_lbs REAL DEFAULT 0,
|
|
duration_min REAL DEFAULT 0,
|
|
distance_mi REAL DEFAULT 0,
|
|
intensity TEXT DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'complete', 'skipped')),
|
|
notes TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS nutrition_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
logged_on TEXT NOT NULL,
|
|
calories REAL NOT NULL DEFAULT 0,
|
|
protein_g REAL NOT NULL DEFAULT 0,
|
|
carbs_g REAL NOT NULL DEFAULT 0,
|
|
fat_g REAL NOT NULL DEFAULT 0,
|
|
water_ml REAL NOT NULL DEFAULT 0,
|
|
notes TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS supplement_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
supplement_id INTEGER REFERENCES supplements(id) ON DELETE SET NULL,
|
|
taken_on TEXT NOT NULL,
|
|
timing TEXT NOT NULL,
|
|
dose TEXT DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'taken', 'skipped')),
|
|
notes TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS body_metrics (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
measured_on TEXT NOT NULL,
|
|
weight_lbs REAL DEFAULT 0,
|
|
body_fat_pct REAL DEFAULT 0,
|
|
sleep_hours REAL DEFAULT 0,
|
|
resting_hr INTEGER DEFAULT 0,
|
|
mood TEXT DEFAULT '',
|
|
notes TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_workouts_user_date ON workouts(user_id, scheduled_on);
|
|
CREATE INDEX IF NOT EXISTS idx_nutrition_logs_user_date ON nutrition_logs(user_id, logged_on);
|
|
CREATE INDEX IF NOT EXISTS idx_supplement_logs_user_date ON supplement_logs(user_id, taken_on);
|
|
CREATE INDEX IF NOT EXISTS idx_body_metrics_user_date ON body_metrics(user_id, measured_on);
|
|
SQL);
|
|
|
|
migrate_users_table($pdo);
|
|
seed_libraries($pdo);
|
|
seed_demo_data($pdo);
|
|
ensure_first_user_is_admin($pdo);
|
|
}
|
|
|
|
function migrate_users_table(PDO $pdo): void
|
|
{
|
|
$columns = db_table_columns($pdo, 'users');
|
|
$definitions = [
|
|
'role' => "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);
|
|
}
|