Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,32 @@ This MyTraL **minor** release brings:
- .

### Fixed
- .
- Cloning/copying/extending an activity no longer shares the source's photos and
recordings, so deleting one no longer corrupts the other.
- Fixed a crash when switching to a freshly created dataset after a Strava sync
or a date-range filter (wrong argument passed to profile update).
- Login now validates the submitted form (CSRF and field validators are enforced).
- Strava OAuth callback now validates a `state` token to prevent OAuth CSRF.
- State-changing actions (clone activity, Strava token/secret reset, dataset
merge/optimize/filter) are now POST-only with CSRF protection.
- Merge-all-datasets and date-range filter now persist the active dataset switch.
- Invalid date/number URL parameters now return 400 instead of a 500 error.
- Deleting/viewing an out-of-range exercise or symptom now returns 404, not 500.
- Editing an exercise with blank repetitions no longer crashes.
- Missing/deleted task pages now return 404 instead of 500.
- Recording download now handles storage errors gracefully and shows the correct
message; a negative log `tail` no longer drops the newest lines.
- Year-to-date month comparison no longer blanks months when the latest data year
is a past year.
- Pace sorting now orders paces numerically (e.g. 9:45 before 10:00).
- Single-file FIT/GPX/TCX import no longer leaves an orphan activity when the
upload fails.
- Various CRUD forms (gear, goal, exercise, outfit, activity/lap/symptom types,
gear components) now handle missing keys and empty inputs without 500 errors.
- Avatar upload now rejects non-image files; notification redirect is same-host only.
- Merging Strava gear is now a CSRF-protected POST action.
- Replaced non-ASCII typographic characters across the codebase with ASCII
equivalents (sport icons and accented names are kept).

### Documentation
- .
Expand Down
4 changes: 2 additions & 2 deletions mytral/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
# feature flags
ff = releng.FeatureFlags()

# configuration must be created first so debug flag is available for structlog
# configuration - must be created first so debug flag is available for structlog
app_config = config.MytralConfig(
port=0, # let config resolve: MYTRAL_PORT env var > DEFAULT_PORT
persistence_data_dir=None, # let config use env var or default
Expand All @@ -44,7 +44,7 @@
debug=None, # let config use env var or default
)

# configure structlog globally must happen before any logger is obtained
# configure structlog globally - must happen before any logger is obtained
loggers.configure_structlog(
debug=app_config.debug or app_config.incarnation == config.MytralIncarnation.DESKTOP
)
Expand Down
20 changes: 10 additions & 10 deletions mytral/ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@


#
# Structured response model enforced at the Pydantic level
# Structured response model - enforced at the Pydantic level
#


Expand All @@ -43,7 +43,7 @@
# than a structured Pydantic result model.
#
# Reason: local models (e.g. llama3.2 via Ollama) do not reliably produce
# strict JSON matching a schema with min_length constraints they return
# strict JSON matching a schema with min_length constraints - they return
# conversational prose, which PydanticAI cannot parse, causing
# UnexpectedModelBehavior after exhausting retries.
#
Expand All @@ -63,7 +63,7 @@
class CoachDeps:
"""Runtime dependencies injected into agent tools."""

# avoid circular imports typed as object
# avoid circular imports - typed as object
user_profile: object # settings.UserProfile
dataset: object # dataset.UserDataset

Expand Down Expand Up @@ -418,7 +418,7 @@ async def get_personal_bests(
ctx: pydantic_ai.RunContext[CoachDeps],
activity_type_key: str | None = None,
) -> str:
"""Get personal bests longest and fastest efforts per activity_type_key.
"""Get personal bests - longest and fastest efforts per activity_type_key.

Parameters
----------
Expand Down Expand Up @@ -588,7 +588,7 @@ async def get_athlete_profile(
async def get_today(
ctx: pydantic_ai.RunContext[CoachDeps],
) -> str:
"""Return today's date and the current ISO week's MondaySunday range.
"""Return today's date and the current ISO week's Monday-Sunday range.

Parameters
----------
Expand All @@ -614,7 +614,7 @@ async def get_today(
async def get_this_week_activities(
ctx: pydantic_ai.RunContext[CoachDeps],
) -> str:
"""Get all activities for the current ISO week (Montoday), including
"""Get all activities for the current ISO week (Mon-today), including
sickness and injury entries.

Parameters
Expand Down Expand Up @@ -780,7 +780,7 @@ async def get_sport_trends(
by_year[yr]["km"] += a.distance / 1000
by_year[yr]["hours"] += a.duration_seconds / 3600
by_year[yr]["count"] += 1
lines = [f"{sport} trends ({first_year}{current_year}):"]
lines = [f"{sport} trends ({first_year}-{current_year}):"]
for yr in sorted(by_year.keys()):
t = by_year[yr]
lines.append(
Expand Down Expand Up @@ -850,14 +850,14 @@ def run_agent(
raise ValueError("No user message found in message list")

# pre-build athlete context and inject it into the prompt so the model always
# has the data local models (e.g. llama3.2 via Ollama) do not reliably use
# has the data - local models (e.g. llama3.2 via Ollama) do not reliably use
# the OpenAI function-calling protocol and would otherwise output raw JSON
data_context = ai_context.build_user_context(
user_profile, dataset, n_recent=coach.n_recent_activities
)
today = datetime.date.today()
enriched_message = (
f"[DATA CONTEXT {today.isoformat()}]\n"
f"[DATA CONTEXT - {today.isoformat()}]\n"
f"{data_context}\n\n"
f"[QUESTION]\n{user_message}"
)
Expand All @@ -877,7 +877,7 @@ def run_agent(
result = agent.run_sync(enriched_message, deps=deps)
except pydantic_ai.exceptions.UnexpectedModelBehavior as exc:
_logger.error(
"agent: unexpected model behavior likely malformed structured output",
"agent: unexpected model behavior - likely malformed structured output",
coach=coach.name,
model=model_name,
error=exc.message,
Expand Down
12 changes: 6 additions & 6 deletions mytral/ai/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def build_user_context(user_profile, dataset, n_recent: int = 15) -> str:
profile_lines.append(f"Height: {user_profile.height:.2f} m")
sections.append("## ATHLETE PROFILE\n" + "\n".join(profile_lines))

# load all activities once reused for multiple sections below
# load all activities once - reused for multiple sections below
sorted_acts = []
try:
all_acts = dataset.all_activities(
Expand All @@ -74,7 +74,7 @@ def build_user_context(user_profile, dataset, n_recent: int = 15) -> str:
except Exception as exc:
_logger.warning("context: failed to load activities", error=str(exc))

# this week section (Montoday) always included, flagging health events
# this week section (Mon-today) - always included, flagging health events
if sorted_acts:
today = datetime.date.today()
monday = today - datetime.timedelta(days=today.weekday())
Expand All @@ -96,11 +96,11 @@ def build_user_context(user_profile, dataset, n_recent: int = 15) -> str:
f" | {dist} | {dur}{symptoms}"
)
sections.append(
f"## THIS WEEK ({monday.isoformat()} {today.isoformat()})\n"
f"## THIS WEEK ({monday.isoformat()} - {today.isoformat()})\n"
+ "\n".join(week_rows)
)

# health section placed before recent activities so truncation never cuts it
# health section - placed before recent activities so truncation never cuts it
if sorted_acts:
cutoff = datetime.datetime.now() - datetime.timedelta(days=90)
cutoff_str = cutoff.strftime("%Y-%m-%d")
Expand Down Expand Up @@ -143,7 +143,7 @@ def build_user_context(user_profile, dataset, n_recent: int = 15) -> str:
except Exception as exc:
_logger.warning("context: failed to load goals", error=str(exc))

# recent activities section (kept last truncation hits here first)
# recent activities section (kept last - truncation hits here first)
if sorted_acts:
recent = sorted_acts[:n_recent]
rows = []
Expand All @@ -160,7 +160,7 @@ def build_user_context(user_profile, dataset, n_recent: int = 15) -> str:
)
sections.append(f"## RECENT ACTIVITIES (last {n_recent})\n" + "\n".join(rows))

# personal bests section also kept late, can be truncated
# personal bests section - also kept late, can be truncated
if sorted_acts:
by_sport: dict[str, list] = {}
for a in sorted_acts:
Expand Down
16 changes: 8 additions & 8 deletions mytral/ai/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
OOTB_TONY_D_PROMPT = (
"You are Tony D'Amato, a legendary American football coach known for "
"inspirational speeches and deep psychological insight into performance. "
"You talk directly to the person in front of you warm, personal, "
"You talk directly to the person in front of you - warm, personal, "
'occasionally intense. Use "you" and "your", never refer to them as '
'"the athlete" or in third person. Push hard, demand excellence, but '
"always acknowledge the human behind the numbers. Reference specific "
Expand All @@ -31,7 +31,7 @@
OOTB_BOHOUS_PROMPT = (
"Jsi trenér Bohouš Kolibrk, praktický a přátelský Čech s hlubokými "
"znalostmi vytrvalostního sportu. Komunikuješ česky, používáš přirozený "
'hovorový jazyk. Mluvíš přímo k člověku před sebou říkáš "ty", "tvůj", '
'hovorový jazyk. Mluvíš přímo k člověku před sebou - říkáš "ty", "tvůj", '
'nikdy "sportovec" ani ve třetí osobě. Jsi upřímný, někdy přímý až drsný, '
"ale vždy lidský and konstruktivní. Začni odpověď lidsky, osobně, "
"než se pustíš do čísel."
Expand All @@ -40,25 +40,25 @@
OOTB_EMIL_PROMPT = (
"You are inspired by Emil Zátopek, the legendary Czech long-distance "
"runner who revolutionized training through systematic hard work and "
'self-experimentation. You talk directly to the person use "you" and '
'self-experimentation. You talk directly to the person - use "you" and '
'"your", never "the athlete" or third person. Bring Zátopek\'s warmth '
"and lived experience into every reply. Embrace hard work, experiment "
"boldly, recover wisely. Open with a personal, human line that connects "
"to what you see in their data."
)

RESPONSE_FORMAT_INSTRUCTION = """
Talk directly to the person use "you" and "your" throughout. Never say "the
Talk directly to the person - use "you" and "your" throughout. Never say "the
athlete" or refer to them in third person.
Start your response with a brief warm, personal opener (1-2 sentences) that
reflects your personality and addresses what they asked.
Then structure your response in exactly four sections using bold headings (not
large headers):

**Observations** 3 specific things you notice in their training data
**Insights** 3 deeper conclusions drawn from those observations
**Advice** 3 concrete recommendations addressed directly to them
**Action Items** 3 immediate, specific things they should do next
**Observations** - 3 specific things you notice in their training data
**Insights** - 3 deeper conclusions drawn from those observations
**Advice** - 3 concrete recommendations addressed directly to them
**Action Items** - 3 immediate, specific things they should do next

Use exactly 3 numbered items per section. Be direct, personal, and concise.
"""
Expand Down
14 changes: 7 additions & 7 deletions mytral/athlete_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@
# power-duration anchor table: (duration_minutes, fraction_of_ftp).
# Each entry represents the fraction of FTP an athlete can sustain for that
# duration in a genuine all-out effort. Values are linearly interpolated.
# Basis: activity_types-science literature + "2.5 h 7582% FTP" coaching rule.
# Basis: activity_types-science literature + "2.5 h ~= 75-82% FTP" coaching rule.
POWER_DURATION_ANCHORS: list[tuple[float, float]] = [
(10.0, 1.150), # 10 min: VO2max territory
(20.0, 1.053), # 20 min: standard field-test protocol (avg × 0.95 = FTP)
(20.0, 1.053), # 20 min: standard field-test protocol (avg x 0.95 = FTP)
(60.0, 1.000), # 60 min: FTP by definition
(90.0, 0.900), # 90 min: empirical coaching rule
(120.0, 0.850), # 2h: empirical coaching rule
(150.0, 0.785), # 2.5h: midpoint of 7582% hint
(150.0, 0.785), # 2.5h: midpoint of 75-82% hint
(240.0, 0.720), # 4h: ultra-endurance extrapolation
]

Expand Down Expand Up @@ -163,11 +163,11 @@ def estimate_ftp_from_activities(
) -> float:
"""Estimate FTP using a unified power-duration model across all activities.

For every qualifying activity (has avg_watts > 0, duration 10240 min), a FTP
For every qualifying activity (has avg_watts > 0, duration 10-240 min), a FTP
candidate is derived by scaling avg_watts by the inverse of the power-duration
fraction at that effort length. The best (highest) candidate is returned.

A 20-min effort at X W produces X / 1.053 X × 0.95 identical to the
A 20-min effort at X W produces X / 1.053 ~= X x 0.95 - identical to the
classic field-test formula. A 2.5-hour effort at Y W produces Y / 0.785,
correctly scaling up to a FTP estimate.

Expand Down Expand Up @@ -294,7 +294,7 @@ def calculate_zones(
Returns
-------
list[tuple[int, int]]
List of 5 (low, high) BPM tuples for zones Z1Z5.
List of 5 (low, high) BPM tuples for zones Z1-Z5.

"""
lthr = e_anaerobic_threshold_hr
Expand All @@ -317,7 +317,7 @@ def calculate_power_zones(e_ftp: float) -> list[tuple[int, int]]:
Returns
-------
list[tuple[int, int]]
List of 7 (low, high) watt tuples for zones PZ1PZ7.
List of 7 (low, high) watt tuples for zones PZ1-PZ7.

"""
zones = []
Expand Down
4 changes: 2 additions & 2 deletions mytral/backends/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ def recompute_gear_service_intervals(
Iterates activity records to derive accurate per-interval km and hours
for every service history entry, and updates ``last_service_km``,
``last_service_hours``, ``distance_meters`` and ``time_seconds`` on each
component dict. Mutates *gear* in memory only does NOT persist to disk.
component dict. Mutates *gear* in memory only - does NOT persist to disk.

This is the authoritative calculation path. It is immune to stale
component odometer values caused by bulk-imported activities that
Expand All @@ -593,7 +593,7 @@ def recompute_gear_service_intervals(
install_date = component_dict.get("installed_date", "")
status = component_dict.get("status", "active")

# gear km/hours at component install the zero baseline for this component
# gear km/hours at component install - the zero baseline for this component
if install_date:
install_km, install_h = self.gear_km_at_date(
user_id, dataset_name, gear.key, install_date
Expand Down
12 changes: 6 additions & 6 deletions mytral/backends/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,18 +565,18 @@ def validate_activity(entity: ActivityEntity) -> list[tuple[str, str]]:

Validation checks performed:

**Errors internal consistency (average must not exceed maximum):**
**Errors - internal consistency (average must not exceed maximum):**
- ``avg_cadence > max_cadence`` when both are non-zero
- ``avg_hr > max_hr`` when both are non-zero
- ``avg_watts > max_watts`` when both are non-zero
- ``avg_speed > max_speed`` when both are non-zero
- ``elevation_min > elevation_max`` when both are non-zero

**Errors zero-value anomalies (one field set, related field missing):**
**Errors - zero-value anomalies (one field set, related field missing):**
- ``distance > 0`` but ``duration_seconds == 0`` (no time recorded)
- ``duration_seconds > 0`` but ``distance == 0`` for distance-based activity types

**Warnings out-of-range values (suspiciously high or low):**
**Warnings - out-of-range values (suspiciously high or low):**
- ``distance > 500 000 m`` (500 km in a single activity)
- ``duration_seconds > 172 800 s`` (48 hours)
- ``max_speed > 100 km/h`` (suspicious for any human-powered activity)
Expand Down Expand Up @@ -677,12 +677,12 @@ def validate_activity(entity: ActivityEntity) -> list[tuple[str, str]]:
if entity.duration_seconds > 0 and entity.distance == 0:
if at in _DISTANCE_ACTIVITY_TYPES:
# when HR data is present the activity clearly happened (e.g. Polar
# S720i without GPS/footpod) warn, don't error
# S720i without GPS/footpod) - warn, don't error
if entity.avg_hr > 0:
problems.append(
(
f"Duration {entity.duration} recorded but distance is zero "
f"({at} activity HR data present, activity is real)",
f"({at} activity - HR data present, activity is real)",
SEVERITY_WARNING,
)
)
Expand Down Expand Up @@ -913,7 +913,7 @@ def validate_activity(entity: ActivityEntity) -> list[tuple[str, str]]:
)
)

# elevation gain per km skip when distance is tiny (< 500 m) because
# elevation gain per km - skip when distance is tiny (< 500 m) because
# short distances produce meaningless m/km values (e.g. 193 m / 0.1 km).
if entity.elevation_gain > 0 and entity.distance >= 500:
elev_per_km = entity.elevation_gain / (entity.distance / 1000)
Expand Down
4 changes: 2 additions & 2 deletions mytral/blobstore/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class BlobStoreAbc(abc.ABC):
.. warning::
This layer does **not** enforce authorization. Every method accepts a
``user_id`` parameter that scopes the lookup, but it trusts the caller
to supply the correct value. Authorization verifying that the
requesting user owns the resource is the responsibility of the
to supply the correct value. Authorization - verifying that the
requesting user owns the resource - is the responsibility of the
**service layer** (``ActivityBlobService``). Any mistake at that
boundary is a potential confidentiality breach.
"""
Expand Down
6 changes: 3 additions & 3 deletions mytral/blobstore/activity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def upload_photos(
max_bytes=self._config.blobstore_max_photo_size_bytes,
)

# normalize upfront: strip EXIF, auto-rotate, resize the stored bytes
# normalize upfront: strip EXIF, auto-rotate, resize - the stored bytes
# ARE the normalized version; no separate "original" is kept
norm_bytes, norm_w, norm_h = self._try_normalize_photo(data, ext)
stored_w = norm_w or width
Expand Down Expand Up @@ -448,7 +448,7 @@ def delete_all_activity_blobs(self, user_id: str, activity_key: str) -> None:

Intended to be called just before the activity itself is deleted so
that no orphaned blob data is left on the storage backend. Errors per
individual blob are silently ignored the caller should proceed with
individual blob are silently ignored - the caller should proceed with
activity deletion regardless.

Parameters
Expand Down Expand Up @@ -502,7 +502,7 @@ def cleanup_orphan_recordings(self, user_id: str) -> int:
try:
self._get_activity(user_id, activity_key)
except BlobValidationError:
# activity no longer exists delete orphan blobs
# activity no longer exists - delete orphan blobs
try:
shutil.rmtree(child, ignore_errors=True)
cleaned += 1
Expand Down
Loading
Loading