| from datetime import date |
| import logging |
| import os |
| import threading |
|
|
| import gradio as gr |
|
|
| from training_coach.env import load_local_env |
| from training_coach.engine import ( |
| build_session_for_day, |
| readiness_score, |
| suggest_next_training_day, |
| ) |
| from training_coach.models import CheckIn, CompletedSession, CompletedSet, PainIssue |
| from training_coach.parser_service import ( |
| parse_check_in_with_configured_backend, |
| warm_up_parser_backend, |
| ) |
| from training_coach.storage import create_history_store |
|
|
|
|
| load_local_env() |
| logging.basicConfig( |
| level=os.getenv("LOG_LEVEL", "INFO").upper(), |
| format="%(asctime)s level=%(levelname)s logger=%(name)s %(message)s", |
| ) |
| logger = logging.getLogger(__name__) |
| history_store = create_history_store() |
| LOG_HEADERS = [ |
| "exercise_id", |
| "set_number", |
| "target_reps", |
| "actual_reps", |
| "actual_load", |
| "rpe", |
| "notes", |
| ] |
| def _first_or_default(value, default): |
| return value if value is not None else default |
|
|
|
|
| def _default_parse_result(message): |
| return ( |
| 60, |
| "medium", |
| "okay", |
| None, |
| "", |
| "unsure", |
| "neutral", |
| [], |
| message, |
| ) |
|
|
|
|
| def _format_follow_up_reply(parsed): |
| if parsed.follow_up_questions: |
| questions = "\n".join( |
| f"- {question}" for question in parsed.follow_up_questions |
| ) |
| return f"**Follow-up questions**\n{questions}" |
| return ( |
| "Got it. I have enough check-in data to build today's session. " |
| "You can still edit the structured fields before building." |
| ) |
|
|
|
|
| def _format_pain_issue(issue): |
| muscle = issue.affected_muscle or "unclear" |
| if issue.severity == "unsure": |
| return f"- {muscle}: {issue.notes}" |
| return f"- {muscle} ({issue.severity}): {issue.notes}" |
|
|
|
|
| def _format_parser_panel(parsed): |
| context_signals = "\n".join( |
| f"- {signal.label}: {signal.evidence}" |
| for signal in parsed.context_signals |
| ) |
| pain_issues = "\n".join( |
| _format_pain_issue(issue) |
| for issue in parsed.check_in.pain_issues |
| ) |
| sections = [] |
| if context_signals: |
| sections.append(f"**Context signals**\n{context_signals}") |
| if pain_issues: |
| sections.append(f"**Pain issues**\n{pain_issues}") |
|
|
| return "\n\n".join(sections) or "No extra parser notes." |
|
|
|
|
| def _pain_issue_key(issue): |
| if issue.affected_muscle is not None: |
| return str(issue.affected_muscle) |
| return issue.notes.strip().lower() |
|
|
|
|
| def _merge_pain_issues(previous_issues_state, parsed_issues): |
| merged = {} |
| for issue_data in previous_issues_state or []: |
| issue = PainIssue.model_validate(issue_data) |
| merged[_pain_issue_key(issue)] = issue |
| for issue in parsed_issues: |
| merged[_pain_issue_key(issue)] = issue |
| return list(merged.values()) |
|
|
|
|
| def _parse_check_in_details(check_in, previous_pain_issues_state=None): |
| if not check_in.strip(): |
| logger.info("event=parse_skipped reason=empty_check_in") |
| default_result = _default_parse_result("Write a check-in first.") |
| return default_result, default_result[-1] |
|
|
| logger.info( |
| "event=parse_request text_chars=%s previous_pain_issues=%s", |
| len(check_in), |
| len(previous_pain_issues_state or []), |
| ) |
| try: |
| parsed = parse_check_in_with_configured_backend(check_in) |
| except Exception as error: |
| logger.exception("event=parse_failed error_type=%s", type(error).__name__) |
| default_result = _default_parse_result(f"Parser failed: {error}") |
| return default_result, default_result[-1] |
|
|
| parsed_check_in = parsed.check_in |
| parsed_check_in.pain_issues = _merge_pain_issues( |
| previous_pain_issues_state, |
| parsed_check_in.pain_issues, |
| ) |
| if parsed_check_in.pain_issues: |
| parsed_check_in.pain_or_injury = "yes" |
| parse_result = ( |
| _first_or_default(parsed_check_in.time_available_minutes, 60), |
| _first_or_default(parsed_check_in.energy_level, "medium"), |
| _first_or_default(parsed_check_in.sleep_quality, "okay"), |
| parsed_check_in.sleep_hours, |
| parsed_check_in.soreness, |
| parsed_check_in.pain_or_injury, |
| _first_or_default(parsed_check_in.mood_stress, "neutral"), |
| [ |
| issue.model_dump(mode="json") |
| for issue in parsed_check_in.pain_issues |
| ], |
| _format_parser_panel(parsed), |
| ) |
| logger.info( |
| "event=parse_complete missing_fields=%s follow_up_questions=%s " |
| "pain_issues=%s context_signals=%s", |
| len(parsed.missing_fields), |
| len(parsed.follow_up_questions), |
| len(parsed_check_in.pain_issues), |
| len(parsed.context_signals), |
| ) |
| return parse_result, _format_follow_up_reply(parsed) |
|
|
|
|
| def parse_check_in(check_in): |
| parse_result, _ = _parse_check_in_details(check_in) |
| return parse_result |
|
|
|
|
| def _append_user_message(transcript, message): |
| message = message.strip() |
| if not transcript.strip(): |
| return message |
| return f"{transcript.strip()}\n{message}" |
|
|
|
|
| def _assistant_reply_from_summary(parser_summary): |
| if parser_summary.startswith("Parser failed:"): |
| return parser_summary |
| return parser_summary |
|
|
|
|
| def send_check_in_message(message, chat_history, transcript, pain_issues_state=None): |
| if not message.strip(): |
| parsed_result, assistant_reply = _parse_check_in_details( |
| transcript or "", |
| pain_issues_state, |
| ) |
| return ( |
| "", |
| chat_history or [], |
| transcript or "", |
| *parsed_result, |
| ) |
|
|
| updated_transcript = _append_user_message(transcript or "", message) |
| parsed_result, assistant_reply = _parse_check_in_details( |
| updated_transcript, |
| pain_issues_state, |
| ) |
| updated_chat_history = [ |
| *(chat_history or []), |
| {"role": "user", "content": message.strip()}, |
| { |
| "role": "assistant", |
| "content": _assistant_reply_from_summary(assistant_reply), |
| }, |
| ] |
|
|
| return ( |
| "", |
| updated_chat_history, |
| updated_transcript, |
| *parsed_result, |
| ) |
|
|
|
|
| def reset_check_in_conversation(): |
| logger.info("event=check_in_reset") |
| return ( |
| "", |
| [], |
| "", |
| *_default_parse_result("Write a check-in first."), |
| ) |
|
|
|
|
| def _pain_issues_from_state(pain_issues_state): |
| return [ |
| PainIssue.model_validate(issue) |
| for issue in (pain_issues_state or []) |
| ] |
|
|
|
|
| def _target_reps_value(prescribed_set): |
| return prescribed_set.target_reps or prescribed_set.target_reps_high |
|
|
|
|
| def _target_reps_text(prescribed_set): |
| range_text = ( |
| str(prescribed_set.target_reps_low) |
| if prescribed_set.target_reps_low == prescribed_set.target_reps_high |
| else f"{prescribed_set.target_reps_low}-{prescribed_set.target_reps_high}" |
| ) |
| if prescribed_set.target_reps is None: |
| return range_text |
| if prescribed_set.target_reps_low == prescribed_set.target_reps_high: |
| return str(prescribed_set.target_reps) |
| return f"{prescribed_set.target_reps} (range {range_text})" |
|
|
|
|
| def build_preview( |
| check_in, |
| time_minutes, |
| energy, |
| sleep, |
| sleep_hours, |
| soreness, |
| pain_or_injury, |
| mood, |
| pain_issues_state=None, |
| ): |
| logger.info( |
| "event=session_build_start time_minutes=%s energy=%s sleep=%s " |
| "sleep_hours_present=%s pain_or_injury=%s mood=%s pain_issues=%s", |
| time_minutes, |
| energy, |
| sleep, |
| sleep_hours is not None, |
| pain_or_injury, |
| mood, |
| len(pain_issues_state or []), |
| ) |
| structured_check_in = CheckIn( |
| raw_text=check_in, |
| time_available_minutes=time_minutes, |
| energy_level=energy, |
| sleep_quality=sleep, |
| sleep_hours=sleep_hours or None, |
| soreness=soreness, |
| pain_or_injury=pain_or_injury, |
| pain_issues=_pain_issues_from_state(pain_issues_state), |
| mood_stress=mood, |
| ) |
| completed_sessions = history_store.load_completed_sessions() |
| day_number = suggest_next_training_day(completed_sessions) |
| session_plan = build_session_for_day( |
| day_number=day_number, |
| session_date=date.today(), |
| check_in=structured_check_in, |
| completed_sessions=completed_sessions, |
| ) |
| exercise_count = len(session_plan.planned_exercises) |
| set_count = sum( |
| len(exercise.prescribed_sets) |
| for exercise in session_plan.planned_exercises |
| ) |
| logger.info( |
| "event=session_build_complete day_number=%s history_sessions=%s exercises=%s sets=%s", |
| day_number, |
| len(completed_sessions), |
| exercise_count, |
| set_count, |
| ) |
|
|
| check_in_text = structured_check_in.raw_text.strip() or "No check-in text yet." |
| soreness_text = structured_check_in.soreness.strip() or "None noted." |
| sleep_hours_text = ( |
| f"{structured_check_in.sleep_hours:g} hours" |
| if structured_check_in.sleep_hours is not None |
| else "Not specified" |
| ) |
| exercise_lines = [] |
| log_rows = [] |
| for exercise in session_plan.planned_exercises: |
| first_set = exercise.prescribed_sets[0] |
| reps = _target_reps_text(first_set) |
| exercise_name = exercise.exercise_id.replace("-", " ").title() |
| exercise_lines.append( |
| f"- {exercise.order}. {exercise_name}: " |
| f"{len(exercise.prescribed_sets)} sets of {reps} reps, " |
| f"rest {exercise.rest_seconds // 60} min" |
| ) |
| target_loads = [ |
| prescribed_set.target_load |
| for prescribed_set in exercise.prescribed_sets |
| if prescribed_set.target_load is not None |
| ] |
| if target_loads: |
| unique_loads = sorted(set(target_loads)) |
| load_text = ( |
| f"{unique_loads[0]:g} kg" |
| if len(unique_loads) == 1 |
| else ", ".join(f"{load:g} kg" for load in target_loads) |
| ) |
| exercise_lines.append(f" - Target load: {load_text}") |
| target_rirs = [ |
| prescribed_set.target_rir |
| for prescribed_set in exercise.prescribed_sets |
| if prescribed_set.target_rir is not None |
| ] |
| if target_rirs: |
| unique_rirs = sorted(set(target_rirs)) |
| rir_text = ( |
| str(unique_rirs[0]) |
| if len(unique_rirs) == 1 |
| else ", ".join(str(rir) for rir in target_rirs) |
| ) |
| exercise_lines.append(f" - Target RIR: {rir_text}") |
| if exercise.notes: |
| exercise_lines.append(f" - Note: {exercise.notes}") |
| for prescribed_set in exercise.prescribed_sets: |
| target_reps = _target_reps_text(prescribed_set) |
| log_rows.append( |
| [ |
| exercise.exercise_id, |
| str(prescribed_set.set_number), |
| target_reps, |
| str(_target_reps_value(prescribed_set)), |
| ( |
| "" |
| if prescribed_set.target_load is None |
| else f"{prescribed_set.target_load:g}" |
| ), |
| "", |
| "", |
| ] |
| ) |
| exercises_text = "\n".join(exercise_lines) |
|
|
| session_markdown = f"""## Today's session |
| |
| **Check-in** |
| {check_in_text} |
| |
| **Structured fields** |
| - Time available: {structured_check_in.time_available_minutes} minutes |
| - Energy: {structured_check_in.energy_level} |
| - Sleep quality: {structured_check_in.sleep_quality} |
| - Sleep hours: {sleep_hours_text} |
| - Soreness / constraints: {soreness_text} |
| - Pain or injury: {structured_check_in.pain_or_injury} |
| - Mood / stress: {structured_check_in.mood_stress} |
| |
| **Suggested day** |
| Day {day_number} |
| |
| **Session plan** |
| {exercises_text} |
| |
| **Plan notes** |
| {session_plan.notes} |
| """ |
| return session_markdown, log_rows, day_number |
|
|
|
|
| def _exercise_label(exercise_id): |
| return exercise_id.replace("-", " ").title() |
|
|
|
|
| def _cell_is_empty(value): |
| return value is None or value == "" |
|
|
|
|
| def _log_rows_to_completed_sets(log_rows) -> list[CompletedSet]: |
| if hasattr(log_rows, "to_dict"): |
| rows = log_rows.to_dict(orient="records") |
| else: |
| rows = log_rows or [] |
|
|
| completed_sets = [] |
| for row in rows: |
| row_data = row if isinstance(row, dict) else dict(zip(LOG_HEADERS, row)) |
| actual_reps = row_data.get("actual_reps") |
| actual_load = row_data.get("actual_load") |
| if _cell_is_empty(actual_load): |
| continue |
| completed_sets.append( |
| CompletedSet( |
| exercise_id=str(row_data["exercise_id"]), |
| set_number=int(row_data["set_number"]), |
| actual_reps=int(actual_reps), |
| actual_load=float(actual_load), |
| rpe=None if _cell_is_empty(row_data.get("rpe")) else float(row_data["rpe"]), |
| notes=str(row_data.get("notes") or ""), |
| ) |
| ) |
| return completed_sets |
|
|
|
|
| def save_completed_session(day_number, log_rows): |
| if day_number is None: |
| logger.info("event=session_save_skipped reason=missing_day") |
| return "Build today's session before saving." |
|
|
| logger.info( |
| "event=session_save_start day_number=%s log_rows=%s", |
| day_number, |
| len(log_rows or []), |
| ) |
| try: |
| completed_sets = _log_rows_to_completed_sets(log_rows) |
| if not completed_sets: |
| logger.info("event=session_save_skipped reason=no_completed_sets") |
| return "Add at least one completed set with reps and load before saving." |
|
|
| completed_session = CompletedSession( |
| date=date.today(), |
| day_number=int(day_number), |
| completed_sets=completed_sets, |
| ) |
| history_store.append_completed_session(completed_session) |
| except Exception as error: |
| logger.exception("event=session_save_failed error_type=%s", type(error).__name__) |
| return f"Could not save completed session: {error}" |
|
|
| logger.info( |
| "event=session_save_complete day_number=%s completed_sets=%s", |
| day_number, |
| len(completed_sets), |
| ) |
| return ( |
| f"Saved Day {day_number} with {len(completed_sets)} completed sets. " |
| "The next build will suggest the following training day." |
| ) |
|
|
|
|
| |
|
|
|
|
| def _safe_next_day(): |
| try: |
| completed_sessions = history_store.load_completed_sessions() |
| day_number = suggest_next_training_day(completed_sessions) |
| logger.info( |
| "event=next_day_lookup_complete day_number=%s history_sessions=%s", |
| day_number, |
| len(completed_sessions), |
| ) |
| return day_number |
| except Exception: |
| logger.exception("event=next_day_lookup_failed") |
| return 1 |
|
|
|
|
| def render_current_hero(): |
| return render_hero(_safe_next_day()) |
|
|
|
|
| def render_hero(day_number): |
| day_text = f"Day {day_number}" if day_number else "Day 1" |
| return f""" |
| <div class="tc-hero"> |
| <div class="tc-hero-left"> |
| <div class="tc-hero-kicker">SMALL MODELS · BIG ADVENTURES</div> |
| <h1>STRENGTH<span class="tc-accent">COACH</span></h1> |
| <div class="tc-hero-sub">Daily hypertrophy sessions, built from how you actually feel today.</div> |
| </div> |
| <div class="tc-hero-right"> |
| <div class="tc-badge"><span class="tc-dot"></span>NEXT UP</div> |
| <div class="tc-day">{day_text}</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def _readiness_band(score): |
| if score < 2.5: |
| return "VERY LOW", "#ff4d4d" |
| if score < 3.0: |
| return "LOW", "#ffb020" |
| if score > 4.2: |
| return "PRIMED", "#c3ff00" |
| return "NORMAL", "#5ad1ff" |
|
|
|
|
| def render_readiness(time_minutes, energy, sleep, sleep_hours, soreness, pain_or_injury, mood): |
| try: |
| check_in = CheckIn( |
| time_available_minutes=int(time_minutes) if time_minutes else 60, |
| energy_level=energy or "medium", |
| sleep_quality=sleep or "okay", |
| sleep_hours=sleep_hours or None, |
| soreness=soreness or "", |
| pain_or_injury=pain_or_injury or "unsure", |
| mood_stress=mood or "neutral", |
| ) |
| score = readiness_score(check_in) |
| except Exception: |
| logger.exception("event=readiness_render_failed") |
| score = 0.0 |
|
|
| score = max(0.0, min(5.0, score)) |
| pct = score / 5.0 * 100 |
| label, color = _readiness_band(score) |
| return f""" |
| <div class="tc-gauge"> |
| <div class="tc-gauge-top"> |
| <span class="tc-gauge-label">READINESS</span> |
| <span class="tc-gauge-val" style="color:{color}">{label}</span> |
| </div> |
| <div class="tc-gauge-track"> |
| <div class="tc-gauge-fill" style="width:{pct:.0f}%;background:{color};box-shadow:0 0 14px {color}aa"></div> |
| </div> |
| <div class="tc-gauge-score">{score:.1f} <span>/ 5.0</span></div> |
| </div> |
| """ |
|
|
|
|
| def build_preview_for_ui(*inputs): |
| session_markdown, log_rows, day_number = build_preview(*inputs) |
| readiness_html = render_readiness(*inputs[1:8]) |
| return ( |
| session_markdown, |
| log_rows, |
| day_number, |
| readiness_html, |
| render_hero(day_number), |
| ) |
|
|
|
|
| def _prefill_reps(value): |
| return None if _cell_is_empty(value) else int(float(value)) |
|
|
|
|
| def _prefill_load(value): |
| return None if _cell_is_empty(value) else float(value) |
|
|
|
|
| def persist_logged_sets( |
| day_number, planned_rows, reps_values, load_values, rpe_values, note_values, done_values |
| ): |
| """Merge the edited per-set cards back onto the planned rows, then save. |
| |
| Only sets marked complete are recorded. |
| """ |
| merged = [] |
| for index, base in enumerate(planned_rows or []): |
| if not done_values[index]: |
| continue |
| merged.append( |
| [ |
| base[0], |
| base[1], |
| base[2], |
| "" if _cell_is_empty(reps_values[index]) else str(int(float(reps_values[index]))), |
| "" if _cell_is_empty(load_values[index]) else str(float(load_values[index])), |
| "" if _cell_is_empty(rpe_values[index]) else str(float(rpe_values[index])), |
| "" if note_values[index] is None else str(note_values[index]), |
| ] |
| ) |
| message = save_completed_session(day_number, merged) |
| next_day = _safe_next_day() |
| if message.startswith("Saved Day"): |
| message = f"{message}\n\nNext up: Day {next_day}." |
| return message, render_hero(next_day) |
|
|
|
|
| CUSTOM_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Inter:wght@400;500;600&display=swap'); |
| |
| :root { |
| --tc-neon:#c3ff00; --tc-bg:#0e0f11; --tc-panel:#16181c; --tc-panel-2:#1c1f24; |
| --tc-border:#262a30; --tc-text:#e7e9ec; --tc-dim:#9aa0a6; |
| } |
| |
| .gradio-container { background: var(--tc-bg) !important; max-width: 1180px !important; } |
| .gradio-container, body { color: var(--tc-text); } |
| |
| /* ---- Hero ---- */ |
| .tc-hero { |
| display:flex; justify-content:space-between; align-items:center; gap:24px; |
| padding:26px 30px; border-radius:20px; margin-bottom:8px; |
| background: |
| radial-gradient(120% 160% at 0% 0%, rgba(195,255,0,0.10) 0%, rgba(195,255,0,0) 45%), |
| linear-gradient(135deg, #15171b 0%, #101216 100%); |
| border:1px solid var(--tc-border); |
| box-shadow: inset 0 0 0 1px rgba(195,255,0,0.04), 0 14px 40px rgba(0,0,0,0.45); |
| } |
| .tc-hero-kicker { font-family:'Oswald',sans-serif; letter-spacing:.28em; font-size:.7rem; color:var(--tc-dim); margin-bottom:6px; } |
| .tc-hero h1 { |
| font-family:'Oswald',sans-serif; font-weight:700; font-size:2.5rem; line-height:1; |
| margin:0; letter-spacing:.02em; color:#f3f5f7; |
| } |
| .tc-hero h1 .tc-accent { color:var(--tc-neon); margin-left:.18em; text-shadow:0 0 22px rgba(195,255,0,0.45); } |
| .tc-hero-sub { color:var(--tc-dim); margin-top:10px; font-size:.95rem; max-width:30ch; } |
| .tc-hero-right { text-align:right; flex-shrink:0; } |
| .tc-badge { |
| display:inline-flex; align-items:center; gap:7px; font-family:'Oswald',sans-serif; |
| letter-spacing:.16em; font-size:.7rem; color:var(--tc-neon); |
| border:1px solid rgba(195,255,0,0.35); padding:5px 12px; border-radius:999px; |
| } |
| .tc-dot { width:7px; height:7px; border-radius:50%; background:var(--tc-neon); box-shadow:0 0 10px var(--tc-neon); } |
| .tc-day { font-family:'Oswald',sans-serif; font-weight:700; font-size:2.6rem; line-height:1; color:#f3f5f7; margin-top:6px; } |
| |
| /* ---- Cards ---- */ |
| .tc-card { |
| background: var(--tc-panel) !important; border:1px solid var(--tc-border) !important; |
| border-radius:18px !important; padding:20px !important; |
| box-shadow: 0 10px 34px rgba(0,0,0,0.38) !important; |
| } |
| .tc-sectitle { |
| font-family:'Oswald',sans-serif; text-transform:uppercase; letter-spacing:.16em; |
| font-size:.82rem; color:var(--tc-dim); margin:0 0 4px 2px; |
| } |
| |
| /* ---- Readiness gauge ---- */ |
| .tc-gauge { padding:4px 2px 2px; } |
| .tc-gauge-top { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:9px; } |
| .tc-gauge-label { font-family:'Oswald',sans-serif; letter-spacing:.18em; color:var(--tc-dim); font-size:.78rem; } |
| .tc-gauge-val { font-family:'Oswald',sans-serif; font-weight:700; letter-spacing:.06em; font-size:1.05rem; } |
| .tc-gauge-track { background:#23262c; border-radius:999px; height:14px; overflow:hidden; border:1px solid #2c3036; } |
| .tc-gauge-fill { height:100%; border-radius:999px; transition:width .45s cubic-bezier(.2,.7,.3,1); } |
| .tc-gauge-score { color:#f3f5f7; font-family:'Oswald',sans-serif; font-size:1.15rem; margin-top:9px; } |
| .tc-gauge-score span { color:var(--tc-dim); font-size:.85rem; } |
| |
| /* ---- Glow primary button ---- */ |
| .tc-glow button, button.tc-glow { |
| background: var(--tc-neon) !important; color:#0e0f11 !important; |
| font-family:'Oswald',sans-serif !important; text-transform:uppercase; |
| letter-spacing:.10em; font-weight:600 !important; font-size:1rem !important; |
| border:none !important; border-radius:12px !important; |
| box-shadow: 0 0 0 rgba(195,255,0,0); transition: box-shadow .2s, transform .12s; |
| } |
| .tc-glow button:hover, button.tc-glow:hover { |
| box-shadow: 0 0 24px rgba(195,255,0,0.55) !important; transform: translateY(-1px); |
| } |
| |
| /* ---- Session preview markdown ---- */ |
| .tc-session h2 { font-family:'Oswald',sans-serif; letter-spacing:.04em; color:#f3f5f7; } |
| .tc-session strong { color: var(--tc-neon); } |
| |
| /* ---- Performed-set log: compact Hevy-style table ---- */ |
| .tc-exhead { |
| font-family:'Oswald',sans-serif; text-transform:uppercase; letter-spacing:.10em; |
| font-size:.98rem; color:#f3f5f7; margin:16px 2px 0; |
| padding-left:10px; border-left:3px solid var(--tc-neon); |
| } |
| .tc-exhead:first-of-type { margin-top:2px; } |
| .tc-exhead span { color:var(--tc-dim); font-size:.76rem; letter-spacing:.04em; margin-left:8px; } |
| |
| /* column header + set rows share the same column widths so they line up */ |
| .tc-colhead { |
| gap:8px !important; align-items:center !important; |
| margin:6px 0 0 !important; padding:0 2px 4px !important; min-height:0 !important; |
| border-bottom:1px solid var(--tc-border) !important; |
| } |
| .tc-col { |
| font-family:'Oswald',sans-serif; text-transform:uppercase; letter-spacing:.05em; |
| font-size:.6rem; color:var(--tc-dim); text-align:center; white-space:nowrap; |
| } |
| |
| .tc-logrow { |
| gap:8px !important; align-items:center !important; |
| padding:3px 2px !important; margin:0 !important; border-radius:8px !important; |
| border-bottom:1px solid rgba(255,255,255,0.05) !important; |
| transition: background .15s ease, box-shadow .15s ease; |
| } |
| .tc-logrow:has(.tc-complete-toggle input[type="checkbox"]:checked) { |
| background:rgba(195,255,0,0.10) !important; |
| box-shadow:inset 3px 0 0 var(--tc-neon) !important; |
| } |
| .tc-cell { text-align:center; line-height:1.1; } |
| .tc-cell.tc-setnum { font-family:'Oswald',sans-serif; color:#f3f5f7; font-size:.95rem; } |
| .tc-cell.tc-target { color:var(--tc-dim); font-size:.82rem; white-space:nowrap; } |
| |
| /* compact inputs inside each row */ |
| .tc-logrow input, .tc-logrow textarea { |
| text-align:center; padding:6px 8px !important; min-height:34px !important; |
| } |
| .tc-notes textarea { text-align:left !important; } |
| |
| /* square complete checkbox that fills green */ |
| .tc-complete-toggle { margin:0 !important; display:flex; justify-content:center; } |
| .tc-complete-toggle label { |
| width:40px; height:34px; padding:0 !important; border-radius:8px !important; |
| display:flex !important; align-items:center !important; justify-content:center !important; |
| background:#262b32 !important; border:1px solid #3a424c !important; cursor:pointer; |
| transition: background .15s ease, border-color .15s ease; |
| } |
| .tc-complete-toggle label span { font-size:0 !important; } |
| .tc-complete-toggle label::after { content:"\\2713"; font-size:1rem; color:#6b7280; line-height:1; } |
| .tc-complete-toggle input[type="checkbox"] { position:absolute; opacity:0; pointer-events:none; } |
| .tc-complete-toggle label:has(input[type="checkbox"]:checked) { |
| background:var(--tc-neon) !important; border-color:var(--tc-neon) !important; |
| } |
| .tc-complete-toggle label:has(input[type="checkbox"]:checked)::after { color:#0e0f11 !important; } |
| |
| /* --- deep polish: make inputs read as flush table cells, kill row gaps --- */ |
| /* the flex container holding the exercise headers + rows: tighten its spacing */ |
| #tc-setlog div:has(> .tc-logrow), #tc-setlog div:has(> .tc-colhead) { |
| gap:1px !important; |
| } |
| #tc-setlog .tc-logrow { padding:1px 2px !important; } |
| |
| /* strip Gradio's default field chrome inside rows so cells look flush */ |
| #tc-setlog .tc-logrow .block, |
| #tc-setlog .tc-logrow .wrap, |
| #tc-setlog .tc-logrow .container { |
| background:transparent !important; border:none !important; |
| box-shadow:none !important; padding:0 !important; |
| } |
| #tc-setlog .tc-logrow input, |
| #tc-setlog .tc-logrow textarea { |
| background:transparent !important; border:1px solid transparent !important; |
| box-shadow:none !important; border-radius:7px !important; |
| color:var(--tc-text) !important; font-size:.92rem !important; |
| transition: background .12s ease, border-color .12s ease; |
| } |
| #tc-setlog .tc-logrow input:hover, |
| #tc-setlog .tc-logrow textarea:hover { |
| background:rgba(255,255,255,0.035) !important; |
| } |
| #tc-setlog .tc-logrow input:focus, |
| #tc-setlog .tc-logrow textarea:focus { |
| background:rgba(255,255,255,0.06) !important; |
| border-color:rgba(195,255,0,0.55) !important; |
| } |
| /* drop the number spinner arrows */ |
| #tc-setlog .tc-logrow input[type="number"]::-webkit-inner-spin-button, |
| #tc-setlog .tc-logrow input[type="number"]::-webkit-outer-spin-button { |
| -webkit-appearance:none; margin:0; |
| } |
| #tc-setlog .tc-logrow input[type="number"] { -moz-appearance:textfield; } |
| |
| /* completed row: brighten the inputs slightly so the green reads as "locked in" */ |
| #tc-setlog .tc-logrow:has(.tc-complete-toggle input:checked) input, |
| #tc-setlog .tc-logrow:has(.tc-complete-toggle input:checked) textarea { |
| color:#f3f5f7 !important; |
| } |
| #tc-setlog .tc-logrow:has(.tc-complete-toggle input:checked) .tc-setnum { color:var(--tc-neon); } |
| |
| /* ---- Chatbot bubbles (force readable on dark) ---- */ |
| #tc-chat, #tc-chat * { color: var(--tc-text); } |
| #tc-chat .user-row .message, #tc-chat .message.user, #tc-chat .bubble.user, |
| #tc-chat [data-testid="user"] { |
| background: #20303a !important; color: #eaf6ff !important; |
| border: 1px solid #2f4654 !important; |
| } |
| #tc-chat .bot-row .message, #tc-chat .message.bot, #tc-chat .bubble.bot, |
| #tc-chat [data-testid="bot"] { |
| background: #1c1f24 !important; color: var(--tc-text) !important; |
| border: 1px solid var(--tc-border) !important; |
| } |
| #tc-chat strong { color: var(--tc-neon) !important; } |
| |
| footer { display:none !important; } |
| """ |
|
|
|
|
| THEME = gr.themes.Base( |
| primary_hue=gr.themes.colors.lime, |
| secondary_hue=gr.themes.colors.lime, |
| neutral_hue=gr.themes.colors.gray, |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"], |
| radius_size=gr.themes.sizes.radius_lg, |
| ).set( |
| body_background_fill="#0e0f11", |
| body_background_fill_dark="#0e0f11", |
| body_text_color="#e7e9ec", |
| background_fill_primary="#16181c", |
| background_fill_secondary="#1c1f24", |
| block_background_fill="#16181c", |
| block_border_color="#262a30", |
| block_label_text_color="#9aa0a6", |
| block_title_text_color="#cfd3d8", |
| border_color_primary="#262a30", |
| input_background_fill="#1c1f24", |
| input_border_color="#2c3036", |
| button_primary_background_fill="#c3ff00", |
| button_primary_background_fill_hover="#d4ff3d", |
| button_primary_text_color="#0e0f11", |
| button_secondary_background_fill="#22262c", |
| button_secondary_text_color="#e7e9ec", |
| button_secondary_background_fill_hover="#2b3037", |
| color_accent_soft="#20303a", |
| ) |
|
|
|
|
| STRUCTURED_INPUTS_NOTE = ( |
| "These are auto-filled by the parser. Tweak anything before building." |
| ) |
|
|
|
|
| with gr.Blocks(title="Strength Coach") as demo: |
| check_in_state = gr.State(value="") |
| day_state = gr.State(value=None) |
| pain_issues_state = gr.State(value=[]) |
| log_state = gr.State(value=[]) |
|
|
| hero_html = gr.HTML(render_hero(_safe_next_day())) |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=2): |
| with gr.Group(elem_classes=["tc-card"]): |
| gr.HTML('<div class="tc-sectitle">Check-in</div>') |
| check_in_chat = gr.Chatbot( |
| label=None, |
| height=300, |
| show_label=False, |
| elem_id="tc-chat", |
| placeholder=( |
| "👋 Tell me how today feels — time, sleep, energy, " |
| "soreness, any niggles. I'll fill in the fields on the right." |
| ), |
| ) |
| check_in = gr.Textbox( |
| label=None, |
| show_label=False, |
| lines=3, |
| placeholder=( |
| "e.g. 45 min today, slept badly, low energy, back feels tight." |
| ), |
| ) |
| with gr.Row(): |
| parse_button = gr.Button("Send", variant="primary", scale=2) |
| reset_chat_button = gr.Button("Reset", scale=1) |
| parser_output = gr.Markdown() |
|
|
| with gr.Column(scale=1): |
| with gr.Group(elem_classes=["tc-card"]): |
| readiness_html = gr.HTML( |
| render_readiness(60, "medium", "okay", None, "", "unsure", "neutral") |
| ) |
| with gr.Group(elem_classes=["tc-card"]): |
| gr.HTML('<div class="tc-sectitle">Today\'s inputs</div>') |
| time_minutes = gr.Slider( |
| minimum=20, maximum=120, value=60, step=5, label="Time available (min)" |
| ) |
| with gr.Row(): |
| energy = gr.Radio( |
| choices=["low", "medium", "high"], value="medium", label="Energy" |
| ) |
| sleep = gr.Radio( |
| choices=["poor", "okay", "good"], value="okay", label="Sleep quality" |
| ) |
| with gr.Row(): |
| sleep_hours = gr.Number( |
| value=None, minimum=0, maximum=24, label="Sleep hours" |
| ) |
| mood = gr.Radio( |
| choices=["stressed", "neutral", "ready"], |
| value="neutral", |
| label="Mood / stress", |
| ) |
| soreness = gr.Textbox( |
| label="Soreness / constraints", placeholder="e.g. tight lower back" |
| ) |
| pain_or_injury = gr.Radio( |
| choices=["yes", "no", "unsure"], value="unsure", label="Pain or injury?" |
| ) |
|
|
| build_button = gr.Button( |
| "⚡ Build today's session", variant="primary", elem_classes=["tc-glow"] |
| ) |
|
|
| with gr.Group(elem_classes=["tc-card", "tc-session"]): |
| session_preview = gr.Markdown("*Build a session to see today's plan here.*") |
|
|
| with gr.Group(elem_classes=["tc-card"], elem_id="tc-setlog"): |
| gr.HTML('<div class="tc-sectitle">Performed sets · log what you did, then save</div>') |
|
|
| @gr.render(inputs=[log_state]) |
| def render_set_log(planned_rows): |
| if not planned_rows: |
| gr.Markdown( |
| "*Build a session above — each set appears here as a card to log.*" |
| ) |
| return |
|
|
| reps_inputs, load_inputs, rpe_inputs, note_inputs, done_inputs = ( |
| [], [], [], [], [] |
| ) |
| |
| col_widths = {"set": 50, "target": 64, "load": 92, "reps": 84, "rpe": 58, "done": 52} |
|
|
| current_exercise = None |
| for row in planned_rows: |
| exercise_id, set_number, target_reps = row[0], row[1], row[2] |
| if exercise_id != current_exercise: |
| current_exercise = exercise_id |
| gr.HTML( |
| f'<div class="tc-exhead">{_exercise_label(exercise_id)}' |
| f"<span>target {target_reps} reps</span></div>" |
| ) |
| with gr.Row(elem_classes=["tc-colhead"], equal_height=True): |
| with gr.Column(scale=0, min_width=col_widths["set"]): |
| gr.HTML('<div class="tc-col">Set</div>') |
| with gr.Column(scale=0, min_width=col_widths["target"]): |
| gr.HTML('<div class="tc-col">Target</div>') |
| with gr.Column(scale=0, min_width=col_widths["load"]): |
| gr.HTML('<div class="tc-col">Load kg</div>') |
| with gr.Column(scale=0, min_width=col_widths["reps"]): |
| gr.HTML('<div class="tc-col">Reps</div>') |
| with gr.Column(scale=0, min_width=col_widths["rpe"]): |
| gr.HTML('<div class="tc-col">RPE</div>') |
| with gr.Column(scale=1, min_width=140): |
| gr.HTML('<div class="tc-col" style="text-align:left">Notes</div>') |
| with gr.Column(scale=0, min_width=col_widths["done"]): |
| gr.HTML('<div class="tc-col">Done</div>') |
|
|
| with gr.Row(elem_classes=["tc-logrow"], equal_height=True): |
| with gr.Column(scale=0, min_width=col_widths["set"]): |
| gr.HTML(f'<div class="tc-cell tc-setnum">{set_number}</div>') |
| with gr.Column(scale=0, min_width=col_widths["target"]): |
| |
| target_label = target_reps.split(" (")[0] |
| gr.HTML(f'<div class="tc-cell tc-target">{target_label}</div>') |
| with gr.Column(scale=0, min_width=col_widths["load"]): |
| load = gr.Number( |
| value=_prefill_load(row[4]), |
| show_label=False, |
| container=False, |
| minimum=0, |
| interactive=True, |
| ) |
| with gr.Column(scale=0, min_width=col_widths["reps"]): |
| reps = gr.Number( |
| value=_prefill_reps(row[3]), |
| show_label=False, |
| container=False, |
| precision=0, |
| minimum=0, |
| interactive=True, |
| ) |
| with gr.Column(scale=0, min_width=col_widths["rpe"]): |
| rpe = gr.Textbox( |
| value="", |
| placeholder="–", |
| show_label=False, |
| container=False, |
| interactive=True, |
| ) |
| with gr.Column(scale=1, min_width=140): |
| notes = gr.Textbox( |
| value="", |
| placeholder="Notes", |
| show_label=False, |
| container=False, |
| interactive=True, |
| elem_classes=["tc-notes"], |
| ) |
| with gr.Column(scale=0, min_width=col_widths["done"]): |
| done = gr.Checkbox( |
| value=False, |
| label="Done", |
| interactive=True, |
| elem_classes=["tc-complete-toggle"], |
| ) |
|
|
| reps_inputs.append(reps) |
| load_inputs.append(load) |
| rpe_inputs.append(rpe) |
| note_inputs.append(notes) |
| done_inputs.append(done) |
|
|
| save_button = gr.Button( |
| "⚡ Save completed session", |
| variant="primary", |
| elem_classes=["tc-glow"], |
| ) |
| save_output = gr.Markdown() |
|
|
| def do_save(day_number, *values): |
| count = len(planned_rows) |
| return persist_logged_sets( |
| day_number, |
| planned_rows, |
| values[0:count], |
| values[count : count * 2], |
| values[count * 2 : count * 3], |
| values[count * 3 : count * 4], |
| values[count * 4 : count * 5], |
| ) |
|
|
| save_button.click( |
| fn=do_save, |
| inputs=[ |
| day_state, |
| *reps_inputs, |
| *load_inputs, |
| *rpe_inputs, |
| *note_inputs, |
| *done_inputs, |
| ], |
| outputs=[save_output, hero_html], |
| ) |
|
|
| |
| structured_inputs = [ |
| time_minutes, |
| energy, |
| sleep, |
| sleep_hours, |
| soreness, |
| pain_or_injury, |
| mood, |
| ] |
| |
| check_in_outputs = [ |
| check_in, |
| check_in_chat, |
| check_in_state, |
| time_minutes, |
| energy, |
| sleep, |
| sleep_hours, |
| soreness, |
| pain_or_injury, |
| mood, |
| pain_issues_state, |
| parser_output, |
| ] |
|
|
| parse_button.click( |
| fn=send_check_in_message, |
| inputs=[check_in, check_in_chat, check_in_state, pain_issues_state], |
| outputs=check_in_outputs, |
| ).then( |
| fn=render_readiness, inputs=structured_inputs, outputs=readiness_html |
| ) |
| check_in.submit( |
| fn=send_check_in_message, |
| inputs=[check_in, check_in_chat, check_in_state, pain_issues_state], |
| outputs=check_in_outputs, |
| ).then( |
| fn=render_readiness, inputs=structured_inputs, outputs=readiness_html |
| ) |
| reset_chat_button.click( |
| fn=reset_check_in_conversation, |
| inputs=[], |
| outputs=check_in_outputs, |
| ).then( |
| fn=render_readiness, inputs=structured_inputs, outputs=readiness_html |
| ) |
|
|
| for component in structured_inputs: |
| component.change( |
| fn=render_readiness, inputs=structured_inputs, outputs=readiness_html |
| ) |
|
|
| build_button.click( |
| fn=build_preview_for_ui, |
| inputs=[ |
| check_in_state, |
| time_minutes, |
| energy, |
| sleep, |
| sleep_hours, |
| soreness, |
| pain_or_injury, |
| mood, |
| pain_issues_state, |
| ], |
| outputs=[ |
| session_preview, |
| log_state, |
| day_state, |
| readiness_html, |
| hero_html, |
| ], |
| ) |
| demo.load(fn=render_current_hero, inputs=[], outputs=hero_html) |
|
|
|
|
| if __name__ == "__main__": |
| logger.info("event=app_start") |
| threading.Thread( |
| target=warm_up_parser_backend, name="parser-warmup", daemon=True |
| ).start() |
| demo.launch(theme=THEME, css=CUSTOM_CSS) |
|
|