303 lines
12 KiB
Python
303 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
DEFAULT_STATE = {
|
|
'users': {
|
|
'john': {
|
|
'id': 'john',
|
|
'display_name': 'John',
|
|
'is_stub': False,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': 68.5,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Seeded with John default height assumption from current intake.',
|
|
},
|
|
'manndra': {
|
|
'id': 'manndra',
|
|
'display_name': 'Manndra',
|
|
'is_stub': True,
|
|
'measurement_system': 'imperial',
|
|
'height_inches': None,
|
|
'goal_weight_lbs': None,
|
|
'notes': 'Stubbed for future shared use.',
|
|
},
|
|
},
|
|
'exercise_templates': [],
|
|
'routines': [],
|
|
'weight_entries': [],
|
|
'measurement_entries': [],
|
|
'workout_sessions': [],
|
|
}
|
|
|
|
|
|
class JSONStore:
|
|
def __init__(self, state_dir: Path) -> None:
|
|
self.state_dir = Path(state_dir)
|
|
self.state_path = self.state_dir / 'state.json'
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
if not self.state_path.exists():
|
|
self._write(deepcopy(DEFAULT_STATE))
|
|
|
|
def _read(self) -> dict[str, Any]:
|
|
return json.loads(self.state_path.read_text())
|
|
|
|
def _write(self, payload: dict[str, Any]) -> None:
|
|
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
|
|
def update_profile(self, user_id: str, changes: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
user = state['users'].get(user_id)
|
|
if user is None:
|
|
raise KeyError(user_id)
|
|
for key, value in changes.items():
|
|
if value is not None:
|
|
user[key] = value
|
|
state['users'][user_id] = user
|
|
self._write(state)
|
|
return user
|
|
|
|
def add_weight_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
user = self._user_or_raise(state, user_id)
|
|
saved = {
|
|
'id': uuid4().hex,
|
|
'user_id': user_id,
|
|
'recorded_at': entry['recorded_at'],
|
|
'weight_lbs': entry['weight_lbs'],
|
|
'body_fat_percent': entry.get('body_fat_percent'),
|
|
'notes': entry.get('notes'),
|
|
'bmi': _calculate_bmi(entry['weight_lbs'], user.get('height_inches')),
|
|
}
|
|
state['weight_entries'].append(saved)
|
|
self._write(state)
|
|
return saved
|
|
|
|
def weight_history(self, user_id: str) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
self._user_or_raise(state, user_id)
|
|
return _history_for_user(state['weight_entries'], user_id, 'recorded_at')
|
|
|
|
def add_measurement_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
self._user_or_raise(state, user_id)
|
|
saved = {
|
|
'id': uuid4().hex,
|
|
'user_id': user_id,
|
|
'recorded_at': entry['recorded_at'],
|
|
'waist_inches': entry.get('waist_inches'),
|
|
'hip_inches': entry.get('hip_inches'),
|
|
'chest_inches': entry.get('chest_inches'),
|
|
'neck_inches': entry.get('neck_inches'),
|
|
'notes': entry.get('notes'),
|
|
}
|
|
state['measurement_entries'].append(saved)
|
|
self._write(state)
|
|
return saved
|
|
|
|
def measurement_history(self, user_id: str) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
self._user_or_raise(state, user_id)
|
|
return _history_for_user(state['measurement_entries'], user_id, 'recorded_at')
|
|
|
|
def add_exercise_template(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
saved = {
|
|
'id': uuid4().hex,
|
|
'name': payload['name'],
|
|
'primary_muscle': payload.get('primary_muscle'),
|
|
'equipment': payload.get('equipment'),
|
|
'notes': payload.get('notes'),
|
|
}
|
|
state['exercise_templates'].append(saved)
|
|
self._write(state)
|
|
return saved
|
|
|
|
def list_exercise_templates(self) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
return sorted(state['exercise_templates'], key=lambda item: item['name'].lower())
|
|
|
|
def add_routine(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
self._user_or_raise(state, payload['user_id'])
|
|
saved = {
|
|
'id': uuid4().hex,
|
|
'user_id': payload['user_id'],
|
|
'name': payload['name'],
|
|
'notes': payload.get('notes'),
|
|
'exercises': [dict(exercise) for exercise in payload['exercises']],
|
|
}
|
|
state['routines'].append(saved)
|
|
self._write(state)
|
|
return saved
|
|
|
|
def list_routines(self) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
return sorted(state['routines'], key=lambda item: item['name'].lower())
|
|
|
|
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
state = self._read()
|
|
self._user_or_raise(state, payload['user_id'])
|
|
|
|
set_count = 0
|
|
total_volume_lbs = 0.0
|
|
exercises: list[dict[str, Any]] = []
|
|
for exercise in payload['exercises']:
|
|
normalized_sets = []
|
|
for item in exercise['sets']:
|
|
set_count += 1
|
|
total_volume_lbs += item['reps'] * item['weight_lbs']
|
|
normalized_sets.append(dict(item))
|
|
exercises.append({
|
|
'exercise_name': exercise['exercise_name'],
|
|
'notes': exercise.get('notes'),
|
|
'sets': normalized_sets,
|
|
})
|
|
|
|
saved = {
|
|
'id': uuid4().hex,
|
|
'user_id': payload['user_id'],
|
|
'performed_at': payload['performed_at'],
|
|
'routine_name': payload['routine_name'],
|
|
'notes': payload.get('notes'),
|
|
'exercise_count': len(exercises),
|
|
'set_count': set_count,
|
|
'total_volume_lbs': int(total_volume_lbs) if total_volume_lbs.is_integer() else total_volume_lbs,
|
|
'exercises': exercises,
|
|
}
|
|
state['workout_sessions'].append(saved)
|
|
self._write(state)
|
|
return saved
|
|
|
|
def workout_history(self, user_id: str) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
self._user_or_raise(state, user_id)
|
|
return _history_for_user(state['workout_sessions'], user_id, 'performed_at')
|
|
|
|
def exercise_progression(self, user_id: str) -> list[dict[str, Any]]:
|
|
state = self._read()
|
|
self._user_or_raise(state, user_id)
|
|
progression: dict[str, dict[str, Any]] = {}
|
|
for workout in _history_for_user(state['workout_sessions'], user_id, 'performed_at'):
|
|
for exercise in workout['exercises']:
|
|
name = exercise['exercise_name']
|
|
stats = progression.setdefault(
|
|
name,
|
|
{
|
|
'exercise_name': name,
|
|
'sessions': 0,
|
|
'set_count': 0,
|
|
'max_weight_lbs': 0.0,
|
|
'best_set_estimated_1rm': 0.0,
|
|
'last_performed_at': workout['performed_at'],
|
|
},
|
|
)
|
|
stats['sessions'] += 1
|
|
if workout['performed_at'] > stats['last_performed_at']:
|
|
stats['last_performed_at'] = workout['performed_at']
|
|
for item in exercise['sets']:
|
|
stats['set_count'] += 1
|
|
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], item['weight_lbs'])
|
|
stats['best_set_estimated_1rm'] = max(
|
|
stats['best_set_estimated_1rm'],
|
|
_estimated_1rm(item['weight_lbs'], item['reps']),
|
|
)
|
|
results = []
|
|
for item in progression.values():
|
|
results.append(
|
|
{
|
|
**item,
|
|
'max_weight_lbs': _normalize_number(item['max_weight_lbs']),
|
|
'best_set_estimated_1rm': _normalize_number(item['best_set_estimated_1rm']),
|
|
}
|
|
)
|
|
return sorted(results, key=lambda item: item['exercise_name'].lower())
|
|
|
|
def dashboard_summary(self) -> dict[str, Any]:
|
|
state = self._read()
|
|
users = []
|
|
weight_entries = state['weight_entries']
|
|
measurement_entries = state['measurement_entries']
|
|
for user in state['users'].values():
|
|
latest_weight = _latest_entry_for_user(weight_entries, user['id'])
|
|
latest_measurements = _latest_entry_for_user(measurement_entries, user['id'])
|
|
users.append({
|
|
'id': user['id'],
|
|
'display_name': user['display_name'],
|
|
'is_stub': user['is_stub'],
|
|
'measurement_system': user.get('measurement_system', 'imperial'),
|
|
'height_inches': user['height_inches'],
|
|
'goal_weight_lbs': user['goal_weight_lbs'],
|
|
'current_weight_lbs': latest_weight['weight_lbs'] if latest_weight else None,
|
|
'current_bmi': latest_weight['bmi'] if latest_weight else None,
|
|
'last_weight_recorded_at': latest_weight['recorded_at'] if latest_weight else None,
|
|
'latest_measurements': _trim_measurement_entry(latest_measurements),
|
|
})
|
|
|
|
recent_workouts = sorted(
|
|
state['workout_sessions'],
|
|
key=lambda item: item['performed_at'],
|
|
reverse=True,
|
|
)[:5]
|
|
return {
|
|
'users': users,
|
|
'recent_workouts': recent_workouts,
|
|
'exercise_templates': self.list_exercise_templates()[:5],
|
|
'routines': self.list_routines()[:5],
|
|
'counts': {
|
|
'exercise_templates': len(state['exercise_templates']),
|
|
'measurement_entries': len(state['measurement_entries']),
|
|
'routines': len(state['routines']),
|
|
'weight_entries': len(state['weight_entries']),
|
|
'workout_sessions': len(state['workout_sessions']),
|
|
},
|
|
}
|
|
|
|
def _user_or_raise(self, state: dict[str, Any], user_id: str) -> dict[str, Any]:
|
|
user = state['users'].get(user_id)
|
|
if user is None:
|
|
raise KeyError(user_id)
|
|
return user
|
|
|
|
|
|
def _latest_entry_for_user(entries: list[dict[str, Any]], user_id: str) -> dict[str, Any] | None:
|
|
matches = [entry for entry in entries if entry['user_id'] == user_id]
|
|
if not matches:
|
|
return None
|
|
return max(matches, key=lambda item: item['recorded_at'])
|
|
|
|
|
|
def _history_for_user(entries: list[dict[str, Any]], user_id: str, sort_key: str) -> list[dict[str, Any]]:
|
|
matches = [entry for entry in entries if entry['user_id'] == user_id]
|
|
return sorted(matches, key=lambda item: item[sort_key], reverse=True)
|
|
|
|
|
|
def _trim_measurement_entry(entry: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if entry is None:
|
|
return None
|
|
return {
|
|
'recorded_at': entry['recorded_at'],
|
|
'waist_inches': entry['waist_inches'],
|
|
'hip_inches': entry['hip_inches'],
|
|
'chest_inches': entry['chest_inches'],
|
|
'neck_inches': entry['neck_inches'],
|
|
}
|
|
|
|
|
|
def _normalize_number(value: float) -> int | float:
|
|
return int(value) if float(value).is_integer() else round(value, 1)
|
|
|
|
|
|
def _estimated_1rm(weight_lbs: float, reps: int) -> float:
|
|
return weight_lbs * (1 + reps / 30)
|
|
|
|
|
|
def _calculate_bmi(weight_lbs: float, height_inches: float | None) -> float | None:
|
|
if not height_inches:
|
|
return None
|
|
return 703 * weight_lbs / (height_inches * height_inches)
|