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.
"""
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)