Files
medicaltracker/app/bootstrap.php
T
Ty Clifford 0fe5a92f90 -
2026-06-30 11:14:42 -04:00

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