Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import sqlite3 | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from zoneinfo import ZoneInfo | |
| from sqlmodel import Session, select | |
| from tracker.active_program import resolve_active_program | |
| from tracker.config import TrackerConfig | |
| from tracker.models import BodyWeightLog, Exercise, ProgramExercise, WorkoutLog | |
| from tracker.planner_schemas import ( | |
| CalendarEventBrief, | |
| CalendarViewResponse, | |
| DailyWeighinResponse, | |
| DeleteEventResponse, | |
| FreeBlock, | |
| PantryItem, | |
| PantryResponse, | |
| RecipeBrief, | |
| RecipeSearchResponse, | |
| ScheduleEventInput, | |
| ScheduledEvent, | |
| ScheduleWorkoutsInput, | |
| ShoppingListItem, | |
| TrackerStateResponse, | |
| ) | |
| from tracker.progression import suggest_next | |
| from tracker.training_service import latest_body_weight_kg | |
| from tracker.workout_completion import find_last_completed_workout | |
| from tracker.workout_day_text import build_program_day_summary_text | |
| from tracker.scheduler import ( | |
| create_daily_weighin_reminder, | |
| create_event, | |
| find_free_blocks, | |
| get_calendar_service, | |
| list_events, | |
| schedule_workouts, | |
| schedule_workouts_at_fixed_times, | |
| ) | |
| CHICAGO_TZ = ZoneInfo("America/Chicago") | |
| def read_only_connect(db_path: Path) -> sqlite3.Connection: | |
| return sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) | |
| def fetch_calendar_view(days_ahead: int) -> CalendarViewResponse: | |
| service = get_calendar_service() | |
| now = datetime.now(tz=CHICAGO_TZ) | |
| time_max = now + timedelta(days=days_ahead) | |
| calendars = service.calendarList().list().execute().get("items", []) | |
| all_events: list[CalendarEventBrief] = [] | |
| for cal in calendars: | |
| cal_id = cal["id"] | |
| cal_name = cal.get("summary", cal_id) | |
| events = list_events(service, cal_id, now, time_max) | |
| for ev in events: | |
| start_raw = ev.get("start", {}) | |
| start_str = start_raw.get("dateTime", start_raw.get("date", "")) | |
| end_raw = ev.get("end", {}) | |
| end_str = end_raw.get("dateTime", end_raw.get("date", "")) | |
| all_events.append(CalendarEventBrief( | |
| event_id=ev.get("id", ""), | |
| summary=ev.get("summary", "(no title)"), | |
| start=start_str, | |
| end=end_str, | |
| calendar=cal_name, | |
| )) | |
| all_events.sort(key=lambda e: e.start) | |
| free = find_free_blocks(service, now, time_max, 30) | |
| free_blocks = [ | |
| FreeBlock( | |
| start=s.isoformat(), | |
| end=e.isoformat(), | |
| duration_minutes=int((e - s).total_seconds() / 60), | |
| ) | |
| for s, e in free | |
| ] | |
| return CalendarViewResponse(events=all_events, free_blocks=free_blocks) | |
| def run_schedule_workouts( | |
| session: Session, | |
| cfg: TrackerConfig, | |
| body: ScheduleWorkoutsInput, | |
| ) -> tuple[list[ScheduledEvent] | None, str | None]: | |
| service = get_calendar_service() | |
| program = resolve_active_program(session, cfg) | |
| if program is None: | |
| return None, "No program found" | |
| pe_list = session.exec( | |
| select(ProgramExercise) | |
| .where(ProgramExercise.program_id == program.program_id), | |
| ).all() | |
| day_labels = sorted({pe.day_label for pe in pe_list}) | |
| exercise_descriptions: dict[str, str] = {} | |
| for day in day_labels: | |
| day_exercises = sorted( | |
| [pe for pe in pe_list if pe.day_label == day], | |
| key=lambda pe: pe.order_index, | |
| ) | |
| exercise_descriptions[day] = build_program_day_summary_text( | |
| day_exercises, | |
| session, | |
| tracker_base_url=cfg.tracker_app_url, | |
| ) | |
| target_calendar = body.calendar_id or cfg.workout_calendar_id or cfg.default_calendar_id | |
| if body.workout_starts_iso is not None: | |
| event_ids_result, sched_err = schedule_workouts_at_fixed_times( | |
| service, | |
| target_calendar, | |
| program.name, | |
| day_labels, | |
| body.workout_starts_iso, | |
| body.duration_minutes, | |
| exercise_descriptions, | |
| body.allow_overlap_with_existing, | |
| ) | |
| if sched_err is not None: | |
| return None, sched_err | |
| event_ids = event_ids_result or [] | |
| else: | |
| event_ids = schedule_workouts( | |
| service, | |
| target_calendar, | |
| program.name, | |
| day_labels, | |
| body.days_ahead, | |
| body.duration_minutes, | |
| exercise_descriptions, | |
| ) | |
| created: list[ScheduledEvent] = [] | |
| for eid in event_ids: | |
| ev = service.events().get( | |
| calendarId=target_calendar, eventId=eid, | |
| ).execute() | |
| start_raw = ev.get("start", {}) | |
| end_raw = ev.get("end", {}) | |
| created.append(ScheduledEvent( | |
| event_id=eid, | |
| summary=ev.get("summary", ""), | |
| start=start_raw.get("dateTime", start_raw.get("date", "")), | |
| end=end_raw.get("dateTime", end_raw.get("date", "")), | |
| )) | |
| return created, None | |
| def run_schedule_event(cfg: TrackerConfig, body: ScheduleEventInput) -> ScheduledEvent: | |
| service = get_calendar_service() | |
| start_dt = datetime.fromisoformat(body.start) | |
| end_dt = datetime.fromisoformat(body.end) | |
| target_calendar = body.calendar_id or cfg.default_calendar_id | |
| event_id = create_event( | |
| service, target_calendar, | |
| body.summary, start_dt, end_dt, body.description, | |
| ) | |
| return ScheduledEvent( | |
| event_id=event_id, | |
| summary=body.summary, | |
| start=body.start, | |
| end=body.end, | |
| ) | |
| def delete_calendar_event(cfg: TrackerConfig, event_id: str) -> DeleteEventResponse: | |
| service = get_calendar_service() | |
| service.events().delete( | |
| calendarId=cfg.default_calendar_id, eventId=event_id, | |
| ).execute() | |
| return DeleteEventResponse(deleted=event_id) | |
| def create_daily_weighin(cfg: TrackerConfig) -> DailyWeighinResponse: | |
| service = get_calendar_service() | |
| event_id = create_daily_weighin_reminder(service, cfg.default_calendar_id) | |
| return DailyWeighinResponse(event_id=event_id) | |
| def fetch_pantry(cfg: TrackerConfig, user_id: int) -> PantryResponse: | |
| conn = read_only_connect(cfg.user_db_path) | |
| rows = conn.execute( | |
| "SELECT display_text, quantity, unit FROM user_pantry_items WHERE user_id = ?", | |
| (user_id,), | |
| ).fetchall() | |
| conn.close() | |
| items = [PantryItem(display_text=r[0], quantity=r[1], unit=r[2]) for r in rows] | |
| return PantryResponse(user_id=user_id, item_count=len(items), items=items) | |
| def compute_recipe_nutrition( | |
| recipe_ids: list[int], | |
| query_conn: sqlite3.Connection, | |
| cache_conn: sqlite3.Connection, | |
| ) -> dict[int, tuple[int | None, float | None]]: | |
| if not recipe_ids: | |
| return {} | |
| placeholders = ",".join("?" for _ in recipe_ids) | |
| ingredient_rows = query_conn.execute( | |
| f"SELECT recipe_id, fdc_id, grams_per_serving FROM recipe_ingredients WHERE recipe_id IN ({placeholders}) AND fdc_id > 0", | |
| recipe_ids, | |
| ).fetchall() | |
| fdc_ids = list({row[1] for row in ingredient_rows}) | |
| if not fdc_ids: | |
| return {} | |
| fdc_placeholders = ",".join("?" for _ in fdc_ids) | |
| nutrient_rows = cache_conn.execute( | |
| f"SELECT i.fdc_id, n.nutrient_name, n.amount_per_100g " | |
| f"FROM ingredients i " | |
| f"JOIN ingredient_nutrients n ON i.ingredient_id = n.ingredient_id " | |
| f"WHERE i.fdc_id IN ({fdc_placeholders}) " | |
| f"AND ((n.nutrient_name = 'Energy' AND n.unit_name = 'kcal') OR (n.nutrient_name = 'Protein' AND n.unit_name = 'g'))", | |
| fdc_ids, | |
| ).fetchall() | |
| nutrient_map: dict[int, dict[str, float]] = {} | |
| for fdc_id, name, amount in nutrient_rows: | |
| nutrient_map.setdefault(fdc_id, {})[name] = amount | |
| recipe_nutrition: dict[int, tuple[float, float]] = {} | |
| for recipe_id, fdc_id, grams in ingredient_rows: | |
| if grams is None or grams <= 0: | |
| continue | |
| nutrients = nutrient_map.get(fdc_id) | |
| if nutrients is None: | |
| continue | |
| cals, prot = recipe_nutrition.get(recipe_id, (0.0, 0.0)) | |
| cals += grams * nutrients.get("Energy", 0.0) / 100.0 | |
| prot += grams * nutrients.get("Protein", 0.0) / 100.0 | |
| recipe_nutrition[recipe_id] = (cals, prot) | |
| return { | |
| rid: (round(cals) if cals > 0 else None, round(prot, 1) if prot > 0 else None) | |
| for rid, (cals, prot) in recipe_nutrition.items() | |
| } | |
| def search_recipes( | |
| cfg: TrackerConfig, | |
| q: str, | |
| max_active_minutes: int | None, | |
| max_total_minutes: int | None, | |
| min_calories: int | None, | |
| max_calories: int | None, | |
| min_protein: float | None, | |
| limit: int, | |
| ) -> RecipeSearchResponse: | |
| query_conn = read_only_connect(cfg.query_db_path) | |
| cache_conn = read_only_connect(cfg.cache_db_path) | |
| query_lower = q.lower() | |
| sql = "SELECT recipe_id, title, active_minutes, total_minutes, satiety FROM recipe_search_doc WHERE title_lower LIKE ? OR full_text_lower LIKE ?" | |
| params: list[str | int] = [f"%{query_lower}%", f"%{query_lower}%"] | |
| if max_active_minutes is not None: | |
| sql += " AND active_minutes IS NOT NULL AND active_minutes <= ?" | |
| params.append(max_active_minutes) | |
| if max_total_minutes is not None: | |
| sql += " AND total_minutes IS NOT NULL AND total_minutes <= ?" | |
| params.append(max_total_minutes) | |
| fetch_limit = limit * 5 if (min_calories or max_calories or min_protein) else limit | |
| sql += " ORDER BY satiety DESC NULLS LAST LIMIT ?" | |
| params.append(fetch_limit) | |
| rows = query_conn.execute(sql, params).fetchall() | |
| recipe_ids = [r[0] for r in rows] | |
| nutrition = compute_recipe_nutrition(recipe_ids, query_conn, cache_conn) | |
| query_conn.close() | |
| cache_conn.close() | |
| results: list[RecipeBrief] = [] | |
| for r in rows: | |
| rid = r[0] | |
| cals, prot = nutrition.get(rid, (None, None)) | |
| if min_calories is not None and (cals is None or cals < min_calories): | |
| continue | |
| if max_calories is not None and (cals is None or cals > max_calories): | |
| continue | |
| if min_protein is not None and (prot is None or prot < min_protein): | |
| continue | |
| results.append(RecipeBrief( | |
| recipe_id=rid, title=r[1], active_minutes=r[2], | |
| total_minutes=r[3], satiety=r[4], | |
| calories_per_serving=cals, protein_per_serving=prot, | |
| )) | |
| if len(results) >= limit: | |
| break | |
| return RecipeSearchResponse(query=q, results=results, total=len(results)) | |
| def build_tracker_state(session: Session, cfg: TrackerConfig) -> TrackerStateResponse: | |
| cutoff = datetime.now() - timedelta(days=30) | |
| weights = session.exec( | |
| select(BodyWeightLog) | |
| .where(BodyWeightLog.logged_at >= cutoff) | |
| .order_by(BodyWeightLog.logged_at), | |
| ).all() | |
| current_weight: float | None = None | |
| weight_trend: list[dict[str, float | str]] = [] | |
| if weights: | |
| current_weight = weights[-1].weight_kg | |
| weight_trend = [ | |
| {"date": w.logged_at.strftime("%Y-%m-%d"), "kg": w.weight_kg} | |
| for w in weights | |
| ] | |
| last_day: str | None = None | |
| last_date: str | None = None | |
| program_name: str | None = None | |
| program = resolve_active_program(session, cfg) | |
| if program is not None: | |
| last_day, last_date = find_last_completed_workout(session, program.program_id) | |
| program_name = program.name | |
| progression_summary: list[dict[str, str | float | None]] = [] | |
| if program is not None: | |
| pe_list = session.exec( | |
| select(ProgramExercise) | |
| .where(ProgramExercise.program_id == program.program_id) | |
| .order_by(ProgramExercise.day_label, ProgramExercise.order_index), | |
| ).all() | |
| bw_kg = latest_body_weight_kg(session) | |
| for pe in pe_list: | |
| exercise = session.get(Exercise, pe.exercise_id) | |
| if exercise is None: | |
| continue | |
| logs = session.exec( | |
| select(WorkoutLog) | |
| .where(WorkoutLog.exercise_id == pe.exercise_id) | |
| .where(WorkoutLog.program_id == program.program_id) | |
| .where(WorkoutLog.day_label == pe.day_label) | |
| .order_by(WorkoutLog.logged_at.desc()) | |
| .limit(pe.target_sets), | |
| ).all() | |
| suggestion = suggest_next( | |
| pe, | |
| logs, | |
| exercise, | |
| bw_kg, | |
| calibration_bench_lb_per_hand=cfg.calibration_dumbbell_bench_lb_per_hand, | |
| calibration_squat_lb_per_hand=cfg.calibration_dumbbell_squat_lb_per_hand, | |
| dumbbell_weight_quantum_lb=cfg.dumbbell_weight_quantum_lb, | |
| ) | |
| progression_summary.append({ | |
| "day": pe.day_label, | |
| "exercise": exercise.name, | |
| "suggested_weight": suggestion.weight_kg, | |
| "suggested_reps": suggestion.target_reps, | |
| "note": suggestion.note, | |
| "load_reference": suggestion.load_reference, | |
| }) | |
| return TrackerStateResponse( | |
| current_weight_kg=current_weight, | |
| weight_trend=weight_trend, | |
| last_workout_day=last_day, | |
| last_workout_date=last_date, | |
| program_name=program_name, | |
| progression_summary=progression_summary, | |
| ) | |
| def build_shopping_gap(cfg: TrackerConfig, ingredients: str, user_id: int) -> list[ShoppingListItem]: | |
| conn = read_only_connect(cfg.user_db_path) | |
| rows = conn.execute( | |
| "SELECT LOWER(display_text) FROM user_pantry_items WHERE user_id = ?", | |
| (user_id,), | |
| ).fetchall() | |
| conn.close() | |
| pantry_lower = {r[0] for r in rows} | |
| ingredient_list = [i.strip() for i in ingredients.split(",") if i.strip()] | |
| result: list[ShoppingListItem] = [] | |
| for ing in ingredient_list: | |
| in_pantry = any(ing.lower() in p or p in ing.lower() for p in pantry_lower) | |
| result.append(ShoppingListItem(ingredient=ing, in_pantry=in_pantry)) | |
| return result | |