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." ) # --- UI presentation helpers --------------------------------------------- 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"""
SMALL MODELS · BIG ADVENTURES

STRENGTHCOACH

Daily hypertrophy sessions, built from how you actually feel today.
NEXT UP
{day_text}
""" 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"""
READINESS {label}
{score:.1f} / 5.0
""" 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('
Check-in
') 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('
Today\'s inputs
') 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('
Performed sets · log what you did, then save
') @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 = ( [], [], [], [], [] ) # Column widths shared by the header and every set row so they align. 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'
{_exercise_label(exercise_id)}' f"target {target_reps} reps
" ) with gr.Row(elem_classes=["tc-colhead"], equal_height=True): with gr.Column(scale=0, min_width=col_widths["set"]): gr.HTML('
Set
') with gr.Column(scale=0, min_width=col_widths["target"]): gr.HTML('
Target
') with gr.Column(scale=0, min_width=col_widths["load"]): gr.HTML('
Load kg
') with gr.Column(scale=0, min_width=col_widths["reps"]): gr.HTML('
Reps
') with gr.Column(scale=0, min_width=col_widths["rpe"]): gr.HTML('
RPE
') with gr.Column(scale=1, min_width=140): gr.HTML('
Notes
') with gr.Column(scale=0, min_width=col_widths["done"]): gr.HTML('
Done
') with gr.Row(elem_classes=["tc-logrow"], equal_height=True): with gr.Column(scale=0, min_width=col_widths["set"]): gr.HTML(f'
{set_number}
') with gr.Column(scale=0, min_width=col_widths["target"]): # full "(range …)" is already in the exercise header; keep the cell concise target_label = target_reps.split(" (")[0] gr.HTML(f'
{target_label}
') 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 field list reused by several handlers --- structured_inputs = [ time_minutes, energy, sleep, sleep_hours, soreness, pain_or_injury, mood, ] # Order must match send_check_in_message's return tuple. 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)