Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d87ba87c4a | |||
| 0e3e0264d6 | |||
| 7e3e1a772c | |||
| 700cad57ef | |||
| e5b9606bb8 | |||
| 91dace2e7c | |||
| 95d7681b03 | |||
| 055dc8be21 | |||
| 9a66183d39 | |||
| e18e05e02c | |||
| 65585c53b5 | |||
| 28119f1761 | |||
| e724be283f | |||
| cfd968aced | |||
| a949d0a5de | |||
| 36022cd5f4 | |||
| 272519d8b0 | |||
| 7f8500199d | |||
| f1ac56609e | |||
| ba441aa1ee | |||
| f2159d3ca5 | |||
| 74fb09972a | |||
| 407239296b | |||
| f766e68d0d |
+28
-16
@@ -3,6 +3,7 @@ name: build-image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -18,13 +19,14 @@ jobs:
|
|||||||
ATHLETE_ID: i1
|
ATHLETE_ID: i1
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- name: Install uv (self-contained; avoids the cold-cache setup-python failure)
|
||||||
with:
|
run: |
|
||||||
python-version: "3.12"
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
- name: Install (editable, with dev extras)
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
run: pip install --quiet -e ".[dev]"
|
- name: Install dependencies (Python 3.12, locked)
|
||||||
|
run: uv sync --all-extras --locked --python 3.12
|
||||||
- name: Test + coverage gate (>=90%)
|
- name: Test + coverage gate (>=90%)
|
||||||
run: pytest
|
run: uv run --locked pytest
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -46,15 +48,25 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
|
||||||
|
|
||||||
- name: Build image
|
- name: Build and push image
|
||||||
run: |
|
run: |
|
||||||
docker build --progress=plain \
|
# Always tag the immutable commit SHA. On a version tag (refs/tags/vX.Y.Z)
|
||||||
-t "${IMAGE}:latest" \
|
# also publish the semver (X.Y.Z) so images can be pinned, and move :latest.
|
||||||
-t "${IMAGE}:${GITHUB_SHA}" \
|
# On a main-branch push, publish :latest.
|
||||||
.
|
REFS="${IMAGE}:${GITHUB_SHA}"
|
||||||
|
case "${GITHUB_REF}" in
|
||||||
|
refs/tags/v*)
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
REFS="${REFS} ${IMAGE}:${VERSION} ${IMAGE}:latest"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
REFS="${REFS} ${IMAGE}:latest"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
- name: Push image
|
BUILD_ARGS=""
|
||||||
run: |
|
for r in ${REFS}; do BUILD_ARGS="${BUILD_ARGS} -t ${r}"; done
|
||||||
docker push "${IMAGE}:latest"
|
docker build --progress=plain ${BUILD_ARGS} .
|
||||||
docker push "${IMAGE}:${GITHUB_SHA}"
|
|
||||||
echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}"
|
for r in ${REFS}; do docker push "${r}"; done
|
||||||
|
echo "pushed: ${REFS}"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Cut a release as an action, not a local command. Manually triggered ("Run
|
||||||
|
# workflow"): bumps the version from conventional commits (or a forced level),
|
||||||
|
# updates CHANGELOG.md, tags vX.Y.Z, pushes both, and creates a Gitea release.
|
||||||
|
# Uses uv/commitizen because actions/setup-python is broken on these runners.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
increment:
|
||||||
|
description: "Bump level (auto = detect from conventional commits)"
|
||||||
|
required: false
|
||||||
|
default: auto
|
||||||
|
type: choice
|
||||||
|
options: [auto, PATCH, MINOR, MAJOR]
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run — compute + show, but do NOT push or release"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history + tags)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# Needs push + release rights. The auto token works with contents:write;
|
||||||
|
# if your runner's token can't push, set a PAT secret RELEASE_TOKEN.
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN || github.token }}
|
||||||
|
|
||||||
|
- name: Install uv (self-contained; avoids the broken setup-python)
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Git identity
|
||||||
|
run: |
|
||||||
|
git config user.name "release-bot"
|
||||||
|
git config user.email "release-bot@farhoodlabs.com"
|
||||||
|
|
||||||
|
- name: Bump version + changelog + tag
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
before=$(uvx --from commitizen cz version --project)
|
||||||
|
args="--yes"
|
||||||
|
[ "${{ inputs.increment }}" != "auto" ] && args="$args --increment ${{ inputs.increment }}"
|
||||||
|
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||||
|
echo "== DRY RUN =="
|
||||||
|
uvx --from commitizen cz bump $args --dry-run || true
|
||||||
|
echo "do_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if uvx --from commitizen cz bump $args; then
|
||||||
|
after=$(uvx --from commitizen cz version --project)
|
||||||
|
# cz bump updates pyproject's version but not uv.lock's own-package
|
||||||
|
# entry, which would leave `uv sync --locked` failing at the release
|
||||||
|
# tag. Refresh the lock and fold it into the bump commit + tag.
|
||||||
|
uv lock
|
||||||
|
if ! git diff --quiet uv.lock; then
|
||||||
|
git add uv.lock
|
||||||
|
git commit --amend --no-edit
|
||||||
|
git tag -f "v$after"
|
||||||
|
fi
|
||||||
|
echo "version=$after" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "do_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Bumped $before -> $after"
|
||||||
|
else
|
||||||
|
echo "No releasable commits since the last tag — nothing to do."
|
||||||
|
echo "do_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push commit + tag
|
||||||
|
if: ${{ steps.bump.outputs.do_release == 'true' }}
|
||||||
|
run: git push origin HEAD:${{ github.ref_name }} --follow-tags
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
if: ${{ steps.bump.outputs.do_release == 'true' }}
|
||||||
|
run: |
|
||||||
|
v="${{ steps.bump.outputs.version }}"
|
||||||
|
# Body = this version's section from CHANGELOG.md (fallback to a stub).
|
||||||
|
# Commitizen writes headings as "## vX.Y.Z (date)", so match that form
|
||||||
|
# (not the Keep-a-Changelog "## [X.Y.Z]" brackets) and stop at the next.
|
||||||
|
# Escape dots and anchor on the trailing space so the start pattern is an
|
||||||
|
# exact version match ("## v0.3.0 " won't re-arm on "## v0.3.01 ...").
|
||||||
|
ve=$(printf '%s' "$v" | sed 's/\./\\./g')
|
||||||
|
body=$(awk "/^## v$ve /{f=1;next} /^## v/{f=0} f" CHANGELOG.md)
|
||||||
|
[ -z "$body" ] && body="Release v$v"
|
||||||
|
jq -n --arg tag "v$v" --arg name "v$v" --arg body "$body" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}' \
|
||||||
|
| curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.RELEASE_TOKEN || github.token }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data @- \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
echo "Created release v$v"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
||||||
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
|
||||||
|
project is pre-1.0, new features bump the **minor** version and fixes bump the **patch**
|
||||||
|
version. Versions are cut with Commitizen (`uvx --from commitizen cz bump`), which updates
|
||||||
|
this file and tags `vX.Y.Z`.
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-07-20
|
||||||
|
|
||||||
|
Baseline: multi-tenant fork of `intervals-mcp-server`. Per-user OAuth credentials with
|
||||||
|
AES-256-GCM-encrypted Intervals.icu API keys (Better Auth), streamable-HTTP transport with
|
||||||
|
CORS, and the full activity / event / wellness / power-curve / gear / custom-item toolset.
|
||||||
|
|
||||||
|
[0.1.0]: https://git.farh.net/farhoodlabs/intervalsicu-mcp/releases/tag/v0.1.0
|
||||||
|
|
||||||
|
## v0.3.0 (2026-07-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **readiness**: add get_training_readiness synthesizer
|
||||||
|
- **writes**: add update_wellness_bulk and update_sport_settings
|
||||||
|
- **workouts**: add workout library read tools (get_workouts, get_workout)
|
||||||
|
- **activities**: add search + best-efforts + interval-stats tools
|
||||||
|
- **athlete**: add profile, sport-settings, and summary read tools
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **athlete**: drop athlete-tag filter, harden write guardrail, guard empty echo
|
||||||
|
- **wellness**: reject unrecognized bulk fields instead of silently dropping
|
||||||
|
- **readiness**: calendar-anchored windows, disjoint RHR baseline, SWC floor
|
||||||
|
|
||||||
|
## v0.2.1 (2026-07-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **release**: sync uv.lock into the bump commit and tag
|
||||||
|
|
||||||
|
## v0.2.0 (2026-07-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **wellness**: add update_wellness write tool, computed Form (TSB), date-label fix
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **wellness**: harden Form/TSB and date rendering from code review
|
||||||
|
- reset version to 0.1.0 baseline; let cz bump own versioning
|
||||||
|
|
||||||
|
## v0.1.0 (2026-07-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **db**: Alembic migration for users table (async env, DATABASE_URL)
|
||||||
|
- **multi-tenant**: resolve per-caller credentials in every tool
|
||||||
|
- **multi-tenant**: data layer, encryption, and per-request credential resolver
|
||||||
+12
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "intervalsicu-mcp"
|
name = "intervalsicu-mcp"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
|
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -124,3 +124,14 @@ asyncio_default_fixture_loop_scope = "function"
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
index-url = "https://pypi.org/simple"
|
index-url = "https://pypi.org/simple"
|
||||||
|
|
||||||
|
# Semantic versioning via Commitizen (conventional commits). Source of truth is the
|
||||||
|
# [project].version above. Bump + changelog + tag with: uvx --from commitizen cz bump
|
||||||
|
# (feat -> minor, fix -> patch; pre-1.0 so breaking changes stay in 0.x). Tags are vX.Y.Z.
|
||||||
|
[tool.commitizen]
|
||||||
|
name = "cz_conventional_commits"
|
||||||
|
version_provider = "pep621"
|
||||||
|
tag_format = "v$version"
|
||||||
|
major_version_zero = true
|
||||||
|
update_changelog_on_bump = true
|
||||||
|
changelog_file = "CHANGELOG.md"
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async def make_intervals_request(
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
data: dict[str, Any] | None = None,
|
data: dict[str, Any] | list[Any] | None = None,
|
||||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Make a request to the Intervals.icu API with proper error handling.
|
Make a request to the Intervals.icu API with proper error handling.
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ config = get_config()
|
|||||||
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
|
# Import tool modules to register them (tools register themselves via @mcp.tool() decorators)
|
||||||
# Import tool functions for re-export
|
# Import tool functions for re-export
|
||||||
from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
get_activity_best_efforts,
|
||||||
|
get_activity_interval_stats,
|
||||||
|
search_activities,
|
||||||
add_activity_message,
|
add_activity_message,
|
||||||
get_activities,
|
get_activities,
|
||||||
get_activity_details,
|
get_activity_details,
|
||||||
@@ -86,7 +89,23 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
|
|||||||
get_events,
|
get_events,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
|
||||||
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
get_training_readiness,
|
||||||
|
get_wellness_data,
|
||||||
|
update_wellness,
|
||||||
|
update_wellness_bulk,
|
||||||
|
)
|
||||||
|
from intervals_mcp_server.tools.athlete import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
get_athlete_profile,
|
||||||
|
get_athlete_summary,
|
||||||
|
get_sport_settings,
|
||||||
|
update_sport_settings,
|
||||||
|
)
|
||||||
|
from intervals_mcp_server.tools.workouts import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
get_workout,
|
||||||
|
get_workouts,
|
||||||
|
)
|
||||||
|
|
||||||
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
|
||||||
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
|
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
create_custom_item,
|
create_custom_item,
|
||||||
@@ -107,12 +126,25 @@ __all__ = [
|
|||||||
"get_activity_intervals",
|
"get_activity_intervals",
|
||||||
"get_activity_messages",
|
"get_activity_messages",
|
||||||
"get_activity_streams",
|
"get_activity_streams",
|
||||||
|
"search_activities",
|
||||||
|
"get_activity_best_efforts",
|
||||||
|
"get_activity_interval_stats",
|
||||||
"get_events",
|
"get_events",
|
||||||
"get_event_by_id",
|
"get_event_by_id",
|
||||||
"delete_event",
|
"delete_event",
|
||||||
"delete_events_by_date_range",
|
"delete_events_by_date_range",
|
||||||
"add_or_update_event",
|
"add_or_update_event",
|
||||||
"get_wellness_data",
|
"get_wellness_data",
|
||||||
|
"update_wellness",
|
||||||
|
"update_wellness_bulk",
|
||||||
|
"get_training_readiness",
|
||||||
|
"get_athlete_profile",
|
||||||
|
"get_sport_settings",
|
||||||
|
"get_athlete_summary",
|
||||||
|
"update_sport_settings",
|
||||||
|
"get_workouts",
|
||||||
|
"get_workout",
|
||||||
|
"get_gear_list",
|
||||||
"get_athlete_power_curves",
|
"get_athlete_power_curves",
|
||||||
"get_custom_items",
|
"get_custom_items",
|
||||||
"get_custom_item_by_id",
|
"get_custom_item_by_id",
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
|
|||||||
# Note: Tools register themselves via @mcp.tool() decorators when imported
|
# Note: Tools register themselves via @mcp.tool() decorators when imported
|
||||||
from intervals_mcp_server.tools.activities import ( # noqa: F401
|
from intervals_mcp_server.tools.activities import ( # noqa: F401
|
||||||
get_activities,
|
get_activities,
|
||||||
|
get_activity_best_efforts,
|
||||||
get_activity_details,
|
get_activity_details,
|
||||||
|
get_activity_interval_stats,
|
||||||
get_activity_intervals,
|
get_activity_intervals,
|
||||||
get_activity_streams,
|
get_activity_streams,
|
||||||
|
search_activities,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.events import ( # noqa: F401
|
from intervals_mcp_server.tools.events import ( # noqa: F401
|
||||||
add_or_update_event,
|
add_or_update_event,
|
||||||
@@ -32,7 +35,19 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
|
|||||||
get_athlete_power_curves,
|
get_athlete_power_curves,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
|
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
|
||||||
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401
|
from intervals_mcp_server.tools.wellness import ( # noqa: F401
|
||||||
|
get_training_readiness,
|
||||||
|
get_wellness_data,
|
||||||
|
update_wellness,
|
||||||
|
update_wellness_bulk,
|
||||||
|
)
|
||||||
|
from intervals_mcp_server.tools.athlete import ( # noqa: F401
|
||||||
|
get_athlete_profile,
|
||||||
|
get_athlete_summary,
|
||||||
|
get_sport_settings,
|
||||||
|
update_sport_settings,
|
||||||
|
)
|
||||||
|
from intervals_mcp_server.tools.workouts import get_workout, get_workouts # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
def register_tools(mcp_instance: FastMCP) -> None:
|
def register_tools(mcp_instance: FastMCP) -> None:
|
||||||
@@ -57,6 +72,9 @@ __all__ = [
|
|||||||
"get_activity_details",
|
"get_activity_details",
|
||||||
"get_activity_intervals",
|
"get_activity_intervals",
|
||||||
"get_activity_streams",
|
"get_activity_streams",
|
||||||
|
"search_activities",
|
||||||
|
"get_activity_best_efforts",
|
||||||
|
"get_activity_interval_stats",
|
||||||
"get_events",
|
"get_events",
|
||||||
"get_event_by_id",
|
"get_event_by_id",
|
||||||
"delete_event",
|
"delete_event",
|
||||||
@@ -70,4 +88,13 @@ __all__ = [
|
|||||||
"get_athlete_power_curves",
|
"get_athlete_power_curves",
|
||||||
"get_gear_list",
|
"get_gear_list",
|
||||||
"get_wellness_data",
|
"get_wellness_data",
|
||||||
|
"update_wellness",
|
||||||
|
"update_wellness_bulk",
|
||||||
|
"get_training_readiness",
|
||||||
|
"get_athlete_profile",
|
||||||
|
"get_sport_settings",
|
||||||
|
"get_athlete_summary",
|
||||||
|
"update_sport_settings",
|
||||||
|
"get_workouts",
|
||||||
|
"get_workout",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ from intervals_mcp_server.tools.gear import (
|
|||||||
resolve_gear_for_activity,
|
resolve_gear_for_activity,
|
||||||
resolve_gear_for_activities,
|
resolve_gear_for_activities,
|
||||||
)
|
)
|
||||||
from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals
|
from intervals_mcp_server.utils.formatting import (
|
||||||
|
format_activity_message,
|
||||||
|
format_activity_search_results,
|
||||||
|
format_activity_summary,
|
||||||
|
format_best_efforts,
|
||||||
|
format_interval_stats,
|
||||||
|
format_intervals,
|
||||||
|
)
|
||||||
from intervals_mcp_server.utils.validation import resolve_date_params
|
from intervals_mcp_server.utils.validation import resolve_date_params
|
||||||
|
|
||||||
# Import mcp instance from shared module for tool registration
|
# Import mcp instance from shared module for tool registration
|
||||||
@@ -405,3 +412,110 @@ async def add_activity_message(
|
|||||||
if msg_id is not None:
|
if msg_id is not None:
|
||||||
return f"Successfully added message (ID: {msg_id}) to activity {activity_id}."
|
return f"Successfully added message (ID: {msg_id}) to activity {activity_id}."
|
||||||
return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually."
|
return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_activities(query: str, limit: int = 20) -> str:
|
||||||
|
"""Search the athlete's activities by name/keyword.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search text matched against activity name/description (required).
|
||||||
|
limit: Maximum number of results to return (default 20).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
if not query or not query.strip():
|
||||||
|
return "Error: a non-empty search query is required."
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"q": query.strip(), "limit": limit}
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id_to_use}/activities/search", api_key=api_key, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error searching activities: {result.get('message')}"
|
||||||
|
|
||||||
|
results = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
|
||||||
|
if not results:
|
||||||
|
return f"No activities found matching '{query}'."
|
||||||
|
return format_activity_search_results(results)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_activity_best_efforts(
|
||||||
|
activity_id: str,
|
||||||
|
stream: str = "watts",
|
||||||
|
duration: int | None = None,
|
||||||
|
distance: float | None = None,
|
||||||
|
count: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Get the best efforts (peak values over windows) for an activity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
activity_id: The Intervals.icu activity ID.
|
||||||
|
stream: Data stream to analyze — e.g. "watts", "heartrate", "pace" (default "watts").
|
||||||
|
duration: Optional window duration in seconds to target.
|
||||||
|
distance: Optional window distance in meters to target.
|
||||||
|
count: Optional maximum number of efforts to return.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"stream": stream}
|
||||||
|
if duration is not None:
|
||||||
|
params["duration"] = duration
|
||||||
|
if distance is not None:
|
||||||
|
params["distance"] = distance
|
||||||
|
if count is not None:
|
||||||
|
params["count"] = count
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/activity/{activity_id}/best-efforts", api_key=api_key, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching best efforts: {result.get('message')}"
|
||||||
|
|
||||||
|
efforts = result.get("efforts") if isinstance(result, dict) else None
|
||||||
|
if not efforts:
|
||||||
|
return f"No best-effort data found for activity {activity_id} (stream: {stream})."
|
||||||
|
return format_best_efforts([e for e in efforts if isinstance(e, dict)], stream)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_activity_interval_stats(activity_id: str, start_index: int, end_index: int) -> str:
|
||||||
|
"""Compute aggregate stats for an index range of an activity's data streams.
|
||||||
|
|
||||||
|
start_index/end_index are positions in the activity's streams (as seen in the
|
||||||
|
streams or interval output). This computes metrics for that slice — it does NOT
|
||||||
|
list the activity's own intervals (use get_activity_intervals for that).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
activity_id: The Intervals.icu activity ID.
|
||||||
|
start_index: Start position in the activity streams (required).
|
||||||
|
end_index: End position in the activity streams (required, > start_index).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
if start_index < 0 or end_index <= start_index:
|
||||||
|
return "Error: end_index must be greater than start_index and both non-negative."
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"start_index": start_index, "end_index": end_index}
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/activity/{activity_id}/interval-stats", api_key=api_key, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching interval stats: {result.get('message')}"
|
||||||
|
|
||||||
|
if not isinstance(result, dict) or not result:
|
||||||
|
return f"No interval stats found for activity {activity_id} ({start_index}-{end_index})."
|
||||||
|
return format_interval_stats(result)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Athlete-profile and configuration MCP tools for Intervals.icu.
|
||||||
|
|
||||||
|
Read tools exposing the athlete's profile, per-sport training settings
|
||||||
|
(FTP / zones / thresholds), and training-load summaries — the context an AI
|
||||||
|
coach needs to reason about intensity and readiness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context # pylint: disable=import-error
|
||||||
|
from pydantic import BaseModel # pylint: disable=import-error
|
||||||
|
|
||||||
|
from intervals_mcp_server import credentials
|
||||||
|
from intervals_mcp_server.api.client import make_intervals_request
|
||||||
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
|
from intervals_mcp_server.utils.formatting import (
|
||||||
|
format_athlete_profile,
|
||||||
|
format_athlete_summary,
|
||||||
|
format_sport_settings,
|
||||||
|
)
|
||||||
|
from intervals_mcp_server.utils.validation import resolve_date_params
|
||||||
|
|
||||||
|
# Import mcp instance from shared module for tool registration
|
||||||
|
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||||
|
|
||||||
|
logger = logging.getLogger("intervals_icu_mcp_server")
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfirmThresholdChange(BaseModel):
|
||||||
|
"""Elicitation schema: the user confirms (or not) a threshold change."""
|
||||||
|
|
||||||
|
confirm: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_athlete_profile() -> str:
|
||||||
|
"""Get the signed-in athlete's profile from Intervals.icu.
|
||||||
|
|
||||||
|
Returns identity and physiology basics (name, sex, weight, resting HR,
|
||||||
|
timezone, units, location). For per-sport FTP / zones / thresholds use
|
||||||
|
get_sport_settings instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
result = await make_intervals_request(url=f"/athlete/{athlete_id}", api_key=api_key)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching athlete profile: {result.get('message')}"
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return "No athlete profile found."
|
||||||
|
return format_athlete_profile(result)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_sport_settings(sport: str | None = None) -> str:
|
||||||
|
"""Get the athlete's per-sport training settings (FTP, zones, thresholds).
|
||||||
|
|
||||||
|
These are the values that drive load, intensity and zone calculations across
|
||||||
|
Intervals.icu. Each record's "Settings ID" is the identifier update_sport_settings
|
||||||
|
uses to target a specific sport.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sport: Optional sport type to filter by (e.g. "Ride", "Run"). Matches the
|
||||||
|
record's sport types case-insensitively. If omitted, all sports are returned.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/sport-settings", api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching sport settings: {result.get('message')}"
|
||||||
|
|
||||||
|
records = [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
|
||||||
|
if not records:
|
||||||
|
return "No sport settings found."
|
||||||
|
|
||||||
|
if sport:
|
||||||
|
want = sport.strip().lower()
|
||||||
|
records = [
|
||||||
|
r for r in records if any(want == str(t).lower() for t in (r.get("types") or []))
|
||||||
|
]
|
||||||
|
if not records:
|
||||||
|
return f"No sport settings found for sport '{sport}'."
|
||||||
|
|
||||||
|
return "\n\n".join(format_sport_settings(r) for r in records)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_athlete_summary(
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Get a training-load summary (fitness/fatigue/form and totals) over a date range.
|
||||||
|
|
||||||
|
Note: the underlying endpoint's ``tags`` parameter filters *athletes* (a
|
||||||
|
coach-facing feature), not activities, so no tag filter is offered here.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago).
|
||||||
|
end_date: End date in YYYY-MM-DD format (optional, defaults to today).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
start_date, end_date = resolve_date_params(start_date, end_date)
|
||||||
|
params: dict[str, Any] = {"start": start_date, "end": end_date}
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/athlete-summary", api_key=api_key, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching athlete summary: {result.get('message')}"
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
summaries = [s for s in result if isinstance(s, dict)]
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
summaries = [result]
|
||||||
|
else:
|
||||||
|
summaries = []
|
||||||
|
|
||||||
|
if not summaries:
|
||||||
|
return "No summary data found for the specified date range."
|
||||||
|
|
||||||
|
header = f"Athlete Summary ({start_date} to {end_date}):\n\n"
|
||||||
|
return header + "\n\n".join(format_athlete_summary(s) for s in summaries)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_sport_settings( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements
|
||||||
|
settings_id: int,
|
||||||
|
ftp: int | None = None,
|
||||||
|
indoor_ftp: int | None = None,
|
||||||
|
w_prime: int | None = None,
|
||||||
|
lthr: int | None = None,
|
||||||
|
max_hr: int | None = None,
|
||||||
|
threshold_pace: float | None = None,
|
||||||
|
recalc_hr_zones: bool = False,
|
||||||
|
confirm: bool = False,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""⚠️ Change the athlete's training thresholds (FTP, LTHR, pace) for one sport.
|
||||||
|
|
||||||
|
These values drive ALL future load, intensity and zone calculations across
|
||||||
|
Intervals.icu. Do NOT call this speculatively — show the athlete the exact
|
||||||
|
old→new values and get their explicit approval first.
|
||||||
|
|
||||||
|
This is a confirmed write. On clients that support MCP elicitation you will be
|
||||||
|
prompted to approve the change; otherwise you MUST pass ``confirm=True`` after the
|
||||||
|
athlete has agreed. Without confirmation the tool refuses and returns the diff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings_id: The sport-settings record ID (from get_sport_settings).
|
||||||
|
ftp: New FTP in watts.
|
||||||
|
indoor_ftp: New indoor FTP in watts.
|
||||||
|
w_prime: New W' in joules.
|
||||||
|
lthr: New lactate-threshold HR in bpm.
|
||||||
|
max_hr: New max HR in bpm.
|
||||||
|
threshold_pace: New threshold pace (in the sport's pace units).
|
||||||
|
recalc_hr_zones: If True, ask Intervals.icu to recompute HR zones from the new LTHR/max HR.
|
||||||
|
confirm: Set True to confirm the change on clients without elicitation support.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
proposed = {
|
||||||
|
"ftp": ftp,
|
||||||
|
"indoor_ftp": indoor_ftp,
|
||||||
|
"w_prime": w_prime,
|
||||||
|
"lthr": lthr,
|
||||||
|
"max_hr": max_hr,
|
||||||
|
"threshold_pace": threshold_pace,
|
||||||
|
}
|
||||||
|
if all(v is None for v in proposed.values()):
|
||||||
|
return "No settings provided. Pass at least one threshold to change."
|
||||||
|
|
||||||
|
current_list = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/sport-settings", api_key=api_key
|
||||||
|
)
|
||||||
|
if isinstance(current_list, dict) and "error" in current_list:
|
||||||
|
return f"Error fetching current sport settings: {current_list.get('message')}"
|
||||||
|
records = [r for r in current_list if isinstance(r, dict)] if isinstance(current_list, list) else []
|
||||||
|
current = next((r for r in records if r.get("id") == settings_id), None)
|
||||||
|
if current is None:
|
||||||
|
return (
|
||||||
|
f"No sport settings found with ID {settings_id}. "
|
||||||
|
"Use get_sport_settings to list valid IDs."
|
||||||
|
)
|
||||||
|
|
||||||
|
changed: dict[str, Any] = {}
|
||||||
|
diff_lines: list[str] = []
|
||||||
|
for key, value in proposed.items():
|
||||||
|
if value is not None and current.get(key) != value:
|
||||||
|
changed[key] = value
|
||||||
|
diff_lines.append(f" {key}: {current.get(key)} -> {value}")
|
||||||
|
if not changed:
|
||||||
|
return "No changes — the provided values already match the current settings."
|
||||||
|
|
||||||
|
sport = ", ".join(str(t) for t in (current.get("types") or [])) or f"settings {settings_id}"
|
||||||
|
diff = "\n".join(diff_lines)
|
||||||
|
|
||||||
|
# Guardrail. If the client answers an elicitation prompt, that answer is
|
||||||
|
# authoritative: anything short of accept-with-confirm is a refusal and we
|
||||||
|
# stop WITHOUT emitting the confirm=true fallback instructions (an agentic
|
||||||
|
# client could otherwise use them to bypass the refusal it just received).
|
||||||
|
# Only when elicitation is unavailable (no ctx, or the request itself fails)
|
||||||
|
# do we fall back to requiring the explicit confirm flag.
|
||||||
|
approved = False
|
||||||
|
elicitation_answered = False
|
||||||
|
if ctx is not None:
|
||||||
|
try:
|
||||||
|
elicited = await ctx.elicit(
|
||||||
|
message=f"Update {sport} thresholds?\n{diff}", schema=_ConfirmThresholdChange
|
||||||
|
)
|
||||||
|
elicitation_answered = True
|
||||||
|
action = getattr(elicited, "action", None)
|
||||||
|
data = getattr(elicited, "data", None)
|
||||||
|
approved = action == "accept" and bool(getattr(data, "confirm", False))
|
||||||
|
except Exception as exc: # noqa: BLE001 - capability absent or elicitation failed
|
||||||
|
logger.warning("Elicitation unavailable, falling back to confirm flag: %s", exc)
|
||||||
|
|
||||||
|
if elicitation_answered and not approved:
|
||||||
|
return "Sport settings unchanged — you did not confirm the change."
|
||||||
|
|
||||||
|
if not approved and not confirm:
|
||||||
|
return (
|
||||||
|
f"⚠️ This will change your {sport} thresholds:\n{diff}\n\n"
|
||||||
|
"These drive ALL future load / intensity / zone calculations. "
|
||||||
|
"If the athlete confirms, re-run with confirm=true."
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = dict(current)
|
||||||
|
updated.update(changed)
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/sport-settings/{settings_id}",
|
||||||
|
api_key=api_key,
|
||||||
|
method="PUT",
|
||||||
|
params={"recalcHrZones": recalc_hr_zones},
|
||||||
|
data=updated,
|
||||||
|
)
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error updating sport settings: {result.get('message')}"
|
||||||
|
|
||||||
|
# An empty-body 200 parses to {}; render the merged record in that case.
|
||||||
|
body = result if isinstance(result, dict) and result else updated
|
||||||
|
return f"Updated {sport} settings:\n\n" + format_sport_settings(body)
|
||||||
@@ -4,15 +4,60 @@ Wellness-related MCP tools for Intervals.icu.
|
|||||||
This module contains tools for retrieving athlete wellness data.
|
This module contains tools for retrieving athlete wellness data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from intervals_mcp_server import credentials
|
from intervals_mcp_server import credentials
|
||||||
from intervals_mcp_server.api.client import make_intervals_request
|
from intervals_mcp_server.api.client import make_intervals_request
|
||||||
from intervals_mcp_server.credentials import CredentialError
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
from intervals_mcp_server.utils.formatting import format_wellness_entry
|
from intervals_mcp_server.utils.formatting import format_wellness_entry
|
||||||
from intervals_mcp_server.utils.validation import resolve_date_params
|
from intervals_mcp_server.utils.readiness import assess_readiness, render_readiness
|
||||||
|
from intervals_mcp_server.utils.validation import resolve_date_params, validate_date
|
||||||
|
|
||||||
# Import mcp instance from shared module for tool registration
|
# Import mcp instance from shared module for tool registration
|
||||||
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||||
|
|
||||||
|
# snake_case tool param -> Intervals.icu camelCase wellness field. `sleep_hours`
|
||||||
|
# is handled separately (converted to sleepSecs). Shared by the single-day and
|
||||||
|
# bulk write tools so their field mapping can never drift.
|
||||||
|
_WELLNESS_FIELD_MAP: list[tuple[str, str]] = [
|
||||||
|
("weight", "weight"),
|
||||||
|
("resting_hr", "restingHR"),
|
||||||
|
("hrv", "hrv"),
|
||||||
|
("sleep_quality", "sleepQuality"),
|
||||||
|
("calories_consumed", "kcalConsumed"),
|
||||||
|
("carbohydrates", "carbohydrates"),
|
||||||
|
("protein", "protein"),
|
||||||
|
("fat", "fatTotal"),
|
||||||
|
("hydration_volume", "hydrationVolume"),
|
||||||
|
("hydration_score", "hydration"),
|
||||||
|
("soreness", "soreness"),
|
||||||
|
("fatigue", "fatigue"),
|
||||||
|
("stress", "stress"),
|
||||||
|
("mood", "mood"),
|
||||||
|
("motivation", "motivation"),
|
||||||
|
("injury", "injury"),
|
||||||
|
("comments", "comments"),
|
||||||
|
("locked", "locked"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wellness_payload(fields: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map snake_case wellness fields to the Intervals.icu camelCase payload.
|
||||||
|
|
||||||
|
Only non-None values are included. ``sleep_hours`` becomes ``sleepSecs`` (with
|
||||||
|
-1 passing through unscaled as the clear sentinel).
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for snake, camel in _WELLNESS_FIELD_MAP:
|
||||||
|
value = fields.get(snake)
|
||||||
|
if value is not None:
|
||||||
|
payload[camel] = value
|
||||||
|
sleep_hours = fields.get("sleep_hours")
|
||||||
|
if sleep_hours is not None:
|
||||||
|
payload["sleepSecs"] = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def get_wellness_data(
|
async def get_wellness_data(
|
||||||
@@ -65,3 +110,234 @@ async def get_wellness_data(
|
|||||||
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
|
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
|
||||||
|
|
||||||
return wellness_summary
|
return wellness_summary
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_wellness( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||||
|
date: str | None = None,
|
||||||
|
weight: float | None = None,
|
||||||
|
resting_hr: int | None = None,
|
||||||
|
hrv: float | None = None,
|
||||||
|
sleep_hours: float | None = None,
|
||||||
|
sleep_quality: int | None = None,
|
||||||
|
calories_consumed: int | None = None,
|
||||||
|
carbohydrates: float | None = None,
|
||||||
|
protein: float | None = None,
|
||||||
|
fat: float | None = None,
|
||||||
|
hydration_volume: float | None = None,
|
||||||
|
hydration_score: int | None = None,
|
||||||
|
soreness: int | None = None,
|
||||||
|
fatigue: int | None = None,
|
||||||
|
stress: int | None = None,
|
||||||
|
mood: int | None = None,
|
||||||
|
motivation: int | None = None,
|
||||||
|
injury: int | None = None,
|
||||||
|
comments: str | None = None,
|
||||||
|
locked: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create or update the signed-in athlete's wellness record for a single day.
|
||||||
|
|
||||||
|
Writes nutrition, hydration, vitals, sleep, and subjective ratings to
|
||||||
|
Intervals.icu (PUT /athlete/{id}/wellness/{date}). Only the fields you pass are
|
||||||
|
sent; anything omitted is left untouched. To CLEAR an existing numeric value,
|
||||||
|
pass ``-1``. Set ``locked=True`` to stop Intervals.icu from overwriting these
|
||||||
|
values on its next sync from a connected device or app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Day to update in YYYY-MM-DD format (optional, defaults to today).
|
||||||
|
weight: Body weight in kg.
|
||||||
|
resting_hr: Resting heart rate in bpm.
|
||||||
|
hrv: Heart rate variability (rMSSD).
|
||||||
|
sleep_hours: Sleep duration in hours (stored by Intervals.icu as seconds).
|
||||||
|
Pass -1 to clear.
|
||||||
|
sleep_quality: Sleep quality rating, as used in the Intervals.icu app.
|
||||||
|
calories_consumed: Energy intake in kcal.
|
||||||
|
carbohydrates: Carbohydrate intake in grams.
|
||||||
|
protein: Protein intake in grams.
|
||||||
|
fat: Fat intake in grams.
|
||||||
|
hydration_volume: Fluid intake volume, in your Intervals.icu units.
|
||||||
|
hydration_score: Subjective hydration score, as used in the app.
|
||||||
|
soreness: Subjective soreness rating, as used in the Intervals.icu app.
|
||||||
|
fatigue: Subjective fatigue rating, as used in the Intervals.icu app.
|
||||||
|
stress: Subjective stress rating, as used in the Intervals.icu app.
|
||||||
|
mood: Subjective mood rating, as used in the Intervals.icu app.
|
||||||
|
motivation: Subjective motivation rating, as used in the Intervals.icu app.
|
||||||
|
injury: Injury level rating, as used in the Intervals.icu app.
|
||||||
|
comments: Free-text note for the day.
|
||||||
|
locked: If True, lock the record so device/app syncs won't overwrite it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
if not date:
|
||||||
|
date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
date = validate_date(date)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
payload = _wellness_payload(
|
||||||
|
{
|
||||||
|
"weight": weight,
|
||||||
|
"resting_hr": resting_hr,
|
||||||
|
"hrv": hrv,
|
||||||
|
"sleep_hours": sleep_hours,
|
||||||
|
"sleep_quality": sleep_quality,
|
||||||
|
"calories_consumed": calories_consumed,
|
||||||
|
"carbohydrates": carbohydrates,
|
||||||
|
"protein": protein,
|
||||||
|
"fat": fat,
|
||||||
|
"hydration_volume": hydration_volume,
|
||||||
|
"hydration_score": hydration_score,
|
||||||
|
"soreness": soreness,
|
||||||
|
"fatigue": fatigue,
|
||||||
|
"stress": stress,
|
||||||
|
"mood": mood,
|
||||||
|
"motivation": motivation,
|
||||||
|
"injury": injury,
|
||||||
|
"comments": comments,
|
||||||
|
"locked": locked,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return "No wellness fields provided. Pass at least one field to update."
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id_to_use}/wellness/{date}",
|
||||||
|
api_key=api_key,
|
||||||
|
method="PUT",
|
||||||
|
data=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error updating wellness data: {result.get('message')}"
|
||||||
|
|
||||||
|
# Intervals.icu echoes back the full updated record; render it for confirmation.
|
||||||
|
# If the echo omits the date, inject the one we wrote to so the confirmation
|
||||||
|
# body doesn't read "Date: N/A" under a dated header.
|
||||||
|
if isinstance(result, dict):
|
||||||
|
if not result.get("id") and not result.get("date"):
|
||||||
|
result["date"] = date
|
||||||
|
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
|
||||||
|
return f"Updated wellness for {date}."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_wellness_bulk(entries: list[dict[str, Any]]) -> str:
|
||||||
|
"""Create or update multiple days of wellness data in a single call.
|
||||||
|
|
||||||
|
Writes to PUT /athlete/{id}/wellness-bulk. Each entry is a dict with a ``date``
|
||||||
|
(YYYY-MM-DD) plus any of the same fields as update_wellness: weight, resting_hr,
|
||||||
|
hrv, sleep_hours, sleep_quality, calories_consumed, carbohydrates, protein, fat,
|
||||||
|
hydration_volume, hydration_score, soreness, fatigue, stress, mood, motivation,
|
||||||
|
injury, comments, locked. Pass -1 to clear a numeric field. Every date is
|
||||||
|
validated up front — if any entry is invalid the whole batch is rejected, so
|
||||||
|
there are no partial writes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: List of per-day wellness dicts, each with a ``date`` and one or more fields.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return "No entries provided. Pass at least one day to update."
|
||||||
|
if len(entries) > 92:
|
||||||
|
return f"Too many entries ({len(entries)}). Limit a bulk update to 92 days."
|
||||||
|
|
||||||
|
# Recognized entry keys: the shared snake_case field names plus date/sleep_hours.
|
||||||
|
# Anything else (e.g. API-style camelCase like "restingHR") is rejected rather
|
||||||
|
# than silently dropped — otherwise values the caller asked to record would be
|
||||||
|
# lost behind a success message.
|
||||||
|
allowed_keys = {snake for snake, _ in _WELLNESS_FIELD_MAP} | {"date", "sleep_hours"}
|
||||||
|
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
summaries: list[str] = []
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return f"Error: entry {i} is not an object."
|
||||||
|
raw_date = entry.get("date")
|
||||||
|
if not raw_date:
|
||||||
|
return f"Error: entry {i} is missing a 'date'."
|
||||||
|
try:
|
||||||
|
date = validate_date(str(raw_date))
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error in entry {i}: {exc}"
|
||||||
|
|
||||||
|
unknown = sorted(set(entry) - allowed_keys)
|
||||||
|
if unknown:
|
||||||
|
return (
|
||||||
|
f"Error: entry {i} ({date}) has unrecognized field(s): {', '.join(unknown)}. "
|
||||||
|
f"Valid fields: {', '.join(sorted(allowed_keys - {'date'}))}."
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _wellness_payload(entry)
|
||||||
|
if not payload:
|
||||||
|
return f"Error: entry {i} ({date}) has no wellness fields to update."
|
||||||
|
payload["id"] = date
|
||||||
|
records.append(payload)
|
||||||
|
summaries.append(f"{date}: {', '.join(k for k in payload if k != 'id')}")
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id_to_use}/wellness-bulk",
|
||||||
|
api_key=api_key,
|
||||||
|
method="PUT",
|
||||||
|
data=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error updating wellness data: {result.get('message')}"
|
||||||
|
|
||||||
|
return f"Updated {len(records)} day(s):\n" + "\n".join(summaries)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_training_readiness(days: int = 45) -> str:
|
||||||
|
"""Assess training readiness from recent wellness data.
|
||||||
|
|
||||||
|
Synthesizes the athlete's recent wellness history into a readiness read:
|
||||||
|
HRV-guided (7-day rolling lnRMSSD vs baseline +/- smallest worthwhile change),
|
||||||
|
resting-HR and sleep trends, and subjective inputs (soreness/fatigue/stress/
|
||||||
|
mood/motivation). When there is too little data — notably fewer than ~2 weeks
|
||||||
|
of HRV — the verdict is withheld rather than guessed, and the report lists which
|
||||||
|
signals it could and could not use.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: How many days of history to analyze (default 45; minimum 14 is enforced).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
end = datetime.now()
|
||||||
|
start = end - timedelta(days=max(days, 14))
|
||||||
|
params = {"oldest": start.strftime("%Y-%m-%d"), "newest": end.strftime("%Y-%m-%d")}
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching wellness data: {result.get('message')}"
|
||||||
|
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for date_str, data in result.items():
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data.setdefault("id", date_str)
|
||||||
|
records.append(data)
|
||||||
|
elif isinstance(result, list):
|
||||||
|
records = [r for r in result if isinstance(r, dict)]
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return "No wellness data found to assess readiness."
|
||||||
|
|
||||||
|
# Anchor the calendar windows on today so weeks-old data reads as "no recent
|
||||||
|
# data" rather than being presented as the athlete's current state.
|
||||||
|
return render_readiness(assess_readiness(records, reference_date=end.strftime("%Y-%m-%d")))
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Workout-library MCP tools for Intervals.icu.
|
||||||
|
|
||||||
|
Read tools exposing the athlete's reusable workout library (distinct from the
|
||||||
|
calendar *events* handled in tools/events.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from intervals_mcp_server import credentials
|
||||||
|
from intervals_mcp_server.api.client import make_intervals_request
|
||||||
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
|
from intervals_mcp_server.utils.formatting import format_workout_details, format_workout_summary
|
||||||
|
|
||||||
|
# Import mcp instance from shared module for tool registration
|
||||||
|
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_workouts(folder_id: int | None = None, sport_type: str | None = None) -> str:
|
||||||
|
"""List the athlete's reusable workout library.
|
||||||
|
|
||||||
|
Filtering is applied client-side (the API returns the full library).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Optional folder ID to restrict results to one folder.
|
||||||
|
sport_type: Optional sport type to filter by (e.g. "Ride", "Run").
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
result = await make_intervals_request(url=f"/athlete/{athlete_id}/workouts", api_key=api_key)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching workouts: {result.get('message')}"
|
||||||
|
|
||||||
|
workouts = [w for w in result if isinstance(w, dict)] if isinstance(result, list) else []
|
||||||
|
if folder_id is not None:
|
||||||
|
workouts = [w for w in workouts if w.get("folder_id") == folder_id]
|
||||||
|
if sport_type:
|
||||||
|
want = sport_type.strip().lower()
|
||||||
|
workouts = [w for w in workouts if str(w.get("type", "")).lower() == want]
|
||||||
|
|
||||||
|
if not workouts:
|
||||||
|
return "No workouts found."
|
||||||
|
|
||||||
|
lines = [f"Workout Library ({len(workouts)}):", ""]
|
||||||
|
lines.extend(format_workout_summary(w) for w in workouts)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_workout(workout_id: int) -> str:
|
||||||
|
"""Get a single library workout's full structure (steps and targets).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workout_id: The Intervals.icu workout ID.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
athlete_id, api_key = await credentials.resolve_caller_credentials()
|
||||||
|
except CredentialError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
result = await make_intervals_request(
|
||||||
|
url=f"/athlete/{athlete_id}/workouts/{workout_id}", api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return f"Error fetching workout: {result.get('message')}"
|
||||||
|
if not isinstance(result, dict) or not result:
|
||||||
|
return f"No workout found with ID {workout_id}."
|
||||||
|
return format_workout_details(result)
|
||||||
@@ -161,6 +161,20 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
|
|||||||
for k, label in [
|
for k, label in [
|
||||||
("ctl", "Fitness (CTL)"),
|
("ctl", "Fitness (CTL)"),
|
||||||
("atl", "Fatigue (ATL)"),
|
("atl", "Fatigue (ATL)"),
|
||||||
|
]:
|
||||||
|
if entries.get(k) is not None:
|
||||||
|
training_metrics.append(f"- {label}: {entries[k]}")
|
||||||
|
|
||||||
|
# Form (a.k.a. TSB, Training Stress Balance) = CTL - ATL. Intervals.icu does
|
||||||
|
# not return this on the wellness record, so compute it when both components
|
||||||
|
# are present AND numeric. The isinstance guard keeps a non-numeric value from
|
||||||
|
# raising out of the formatter and taking down the whole wellness render.
|
||||||
|
# Positive = fresher/tapered, negative = carrying fatigue.
|
||||||
|
ctl, atl = entries.get("ctl"), entries.get("atl")
|
||||||
|
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
|
||||||
|
training_metrics.append(f"- Form (TSB): {ctl - atl:.1f}")
|
||||||
|
|
||||||
|
for k, label in [
|
||||||
("rampRate", "Ramp Rate"),
|
("rampRate", "Ramp Rate"),
|
||||||
("ctlLoad", "CTL Load"),
|
("ctlLoad", "CTL Load"),
|
||||||
("atlLoad", "ATL Load"),
|
("atlLoad", "ATL Load"),
|
||||||
@@ -337,7 +351,11 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
|
|||||||
entries.get("tempRestingHR")
|
entries.get("tempRestingHR")
|
||||||
|
|
||||||
lines = ["Wellness Data:"]
|
lines = ["Wellness Data:"]
|
||||||
lines.append(f"Date: {entries.get('id', 'N/A')}")
|
# The wellness record's own date lives in `id` (e.g. "2025-05-24"); some call
|
||||||
|
# sites also inject an explicit `date`. Prefer `date`, fall back to `id`. Use
|
||||||
|
# `or` chaining (not get-with-default) so a present-but-null `date` still falls
|
||||||
|
# back rather than rendering "Date: None".
|
||||||
|
lines.append(f"Date: {entries.get('date') or entries.get('id') or 'N/A'}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
training_metrics = _format_training_metrics(entries)
|
training_metrics = _format_training_metrics(entries)
|
||||||
@@ -402,6 +420,146 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_athlete_profile(athlete: dict[str, Any]) -> str:
|
||||||
|
"""Format an athlete profile into a readable string.
|
||||||
|
|
||||||
|
Renders identity/physiology basics. The embedded per-sport settings blob is
|
||||||
|
only summarised (a count) — get_sport_settings renders the detail.
|
||||||
|
"""
|
||||||
|
name = athlete.get("name") or " ".join(
|
||||||
|
p for p in [athlete.get("firstname"), athlete.get("lastname")] if p
|
||||||
|
) or "Unknown"
|
||||||
|
lines = ["Athlete Profile:", "", f"Name: {name}", f"ID: {athlete.get('id', 'N/A')}"]
|
||||||
|
|
||||||
|
weight = athlete.get("weight")
|
||||||
|
if weight is None:
|
||||||
|
weight = athlete.get("icu_weight")
|
||||||
|
for label, value, unit in [
|
||||||
|
("Sex", athlete.get("sex"), ""),
|
||||||
|
("Date of Birth", athlete.get("icu_date_of_birth"), ""),
|
||||||
|
("Weight", weight, "kg"),
|
||||||
|
("Resting HR", athlete.get("icu_resting_hr"), "bpm"),
|
||||||
|
("Timezone", athlete.get("timezone"), ""),
|
||||||
|
("Units", athlete.get("measurement_preference"), ""),
|
||||||
|
]:
|
||||||
|
if value is not None and value != "":
|
||||||
|
lines.append(f"{label}: {value}{(' ' + unit) if unit else ''}")
|
||||||
|
|
||||||
|
location = ", ".join(
|
||||||
|
p for p in [athlete.get("city"), athlete.get("state"), athlete.get("country")] if p
|
||||||
|
)
|
||||||
|
if location:
|
||||||
|
lines.append(f"Location: {location}")
|
||||||
|
if athlete.get("icu_coach"):
|
||||||
|
lines.append("Role: Coach")
|
||||||
|
if athlete.get("bio"):
|
||||||
|
lines.append(f"Bio: {athlete['bio']}")
|
||||||
|
|
||||||
|
sport_settings = athlete.get("sportSettings") or athlete.get("icu_type_settings")
|
||||||
|
if isinstance(sport_settings, list) and sport_settings:
|
||||||
|
lines.append(
|
||||||
|
f"Sport Settings: {len(sport_settings)} sport(s) configured "
|
||||||
|
"(use get_sport_settings for FTP/zones/thresholds)"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_zone_line(label: str, zones: Any, names: Any, unit: str = "") -> str | None:
|
||||||
|
"""Render a zone-boundary array, pairing with names when they line up."""
|
||||||
|
if not isinstance(zones, list) or not zones:
|
||||||
|
return None
|
||||||
|
if isinstance(names, list) and len(names) == len(zones):
|
||||||
|
parts = [f"{n}: {z}{unit}" for n, z in zip(names, zones, strict=True)]
|
||||||
|
else:
|
||||||
|
parts = [f"{z}{unit}" for z in zones]
|
||||||
|
return f"{label}: " + ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_sport_settings(settings: dict[str, Any]) -> str:
|
||||||
|
"""Format one per-sport settings record (FTP, zones, thresholds) into text.
|
||||||
|
|
||||||
|
The record ``id`` is always shown — it is the identifier update_sport_settings
|
||||||
|
needs to target a specific sport's settings.
|
||||||
|
"""
|
||||||
|
types = settings.get("types") or []
|
||||||
|
sport = ", ".join(str(t) for t in types) if types else "Unknown"
|
||||||
|
lines = [f"Sport Settings — {sport}:", f"Settings ID: {settings.get('id', 'N/A')}"]
|
||||||
|
|
||||||
|
power_bits = []
|
||||||
|
for key, label, unit in [
|
||||||
|
("ftp", "FTP", "W"),
|
||||||
|
("indoor_ftp", "Indoor FTP", "W"),
|
||||||
|
("w_prime", "W'", "J"),
|
||||||
|
("p_max", "Pmax", "W"),
|
||||||
|
]:
|
||||||
|
if settings.get(key) is not None:
|
||||||
|
power_bits.append(f"{label}: {settings[key]}{unit}")
|
||||||
|
if power_bits:
|
||||||
|
lines += ["", "Power:"] + [f"- {b}" for b in power_bits]
|
||||||
|
zone_line = _format_zone_line("Zones", settings.get("power_zones"), settings.get("power_zone_names"))
|
||||||
|
if zone_line:
|
||||||
|
lines.append(f"- {zone_line}")
|
||||||
|
|
||||||
|
hr_bits = []
|
||||||
|
for key, label in [("lthr", "LTHR"), ("max_hr", "Max HR")]:
|
||||||
|
if settings.get(key) is not None:
|
||||||
|
hr_bits.append(f"{label}: {settings[key]} bpm")
|
||||||
|
if hr_bits:
|
||||||
|
lines += ["", "Heart Rate:"] + [f"- {b}" for b in hr_bits]
|
||||||
|
zone_line = _format_zone_line("Zones", settings.get("hr_zones"), settings.get("hr_zone_names"))
|
||||||
|
if zone_line:
|
||||||
|
lines.append(f"- {zone_line}")
|
||||||
|
|
||||||
|
if settings.get("threshold_pace") is not None:
|
||||||
|
units = settings.get("pace_units", "")
|
||||||
|
lines += ["", "Pace:", f"- Threshold: {settings['threshold_pace']} {units}".rstrip()]
|
||||||
|
zone_line = _format_zone_line("Zones", settings.get("pace_zones"), settings.get("pace_zone_names"))
|
||||||
|
if zone_line:
|
||||||
|
lines.append(f"- {zone_line}")
|
||||||
|
|
||||||
|
defaults = []
|
||||||
|
for key, label in [("warmup_time", "Warmup"), ("cooldown_time", "Cooldown")]:
|
||||||
|
if settings.get(key) is not None:
|
||||||
|
defaults.append(f"{label}: {settings[key]}s")
|
||||||
|
if defaults:
|
||||||
|
lines += ["", "Defaults: " + ", ".join(defaults)]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_athlete_summary(summary: dict[str, Any]) -> str:
|
||||||
|
"""Format a training-load summary (fitness/fatigue/form + totals) into text."""
|
||||||
|
lines: list[str] = []
|
||||||
|
if summary.get("date"):
|
||||||
|
lines.append(f"Period ending {summary['date']}:")
|
||||||
|
for key, label, unit in [
|
||||||
|
("count", "Activities", ""),
|
||||||
|
("moving_time", "Moving Time", "s"),
|
||||||
|
("distance", "Distance", "m"),
|
||||||
|
("total_elevation_gain", "Elevation Gain", "m"),
|
||||||
|
("training_load", "Training Load", ""),
|
||||||
|
("calories", "Calories", "kcal"),
|
||||||
|
("fitness", "Fitness (CTL)", ""),
|
||||||
|
("fatigue", "Fatigue (ATL)", ""),
|
||||||
|
("form", "Form (TSB)", ""),
|
||||||
|
("rampRate", "Ramp Rate", ""),
|
||||||
|
("eftp", "eFTP", "W"),
|
||||||
|
]:
|
||||||
|
if summary.get(key) is not None:
|
||||||
|
lines.append(f"- {label}: {summary[key]}{(' ' + unit) if unit else ''}")
|
||||||
|
|
||||||
|
categories = summary.get("byCategory")
|
||||||
|
if isinstance(categories, list) and categories:
|
||||||
|
lines.append("By category:")
|
||||||
|
for cat in categories:
|
||||||
|
if not isinstance(cat, dict):
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f" - {cat.get('category', '?')}: {cat.get('count', 0)} activities, "
|
||||||
|
f"load {cat.get('training_load', 'N/A')}, {cat.get('moving_time', 'N/A')}s"
|
||||||
|
)
|
||||||
|
return "\n".join(lines) if lines else "No summary metrics available."
|
||||||
|
|
||||||
|
|
||||||
def format_event_summary(event: dict[str, Any]) -> str:
|
def format_event_summary(event: dict[str, Any]) -> str:
|
||||||
"""Format a basic event summary into a readable string."""
|
"""Format a basic event summary into a readable string."""
|
||||||
|
|
||||||
@@ -658,3 +816,163 @@ def format_power_curves(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_activity_search_results(results: list[dict[str, Any]]) -> str:
|
||||||
|
"""Format activity search hits into a compact one-line-per-result list."""
|
||||||
|
lines = [f"Found {len(results)} activit{'y' if len(results) == 1 else 'ies'}:", ""]
|
||||||
|
for r in results:
|
||||||
|
date = r.get("start_date_local", "")
|
||||||
|
if isinstance(date, str) and len(date) > 10:
|
||||||
|
date = date[:10]
|
||||||
|
extra = []
|
||||||
|
if r.get("distance") is not None:
|
||||||
|
extra.append(f"{r['distance']}m")
|
||||||
|
if r.get("moving_time") is not None:
|
||||||
|
extra.append(f"{r['moving_time']}s")
|
||||||
|
if r.get("race"):
|
||||||
|
extra.append("RACE")
|
||||||
|
line = " | ".join([date or "?", str(r.get("type", "?")), str(r.get("name", "Unnamed"))])
|
||||||
|
if extra:
|
||||||
|
line += " (" + ", ".join(extra) + ")"
|
||||||
|
line += f" [id: {r.get('id', 'N/A')}]"
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_best_efforts(efforts: list[dict[str, Any]], stream: str) -> str:
|
||||||
|
"""Format best-effort windows for a stream (power/hr/pace) into text."""
|
||||||
|
lines = [f"Best Efforts ({stream}):", ""]
|
||||||
|
for e in efforts:
|
||||||
|
parts = []
|
||||||
|
if e.get("duration") is not None:
|
||||||
|
parts.append(_format_duration_label(int(e["duration"])))
|
||||||
|
if e.get("distance") is not None:
|
||||||
|
parts.append(f"{e['distance']}m")
|
||||||
|
label = " / ".join(parts) if parts else "effort"
|
||||||
|
idx = f"[idx {e.get('start_index', '?')}-{e.get('end_index', '?')}]"
|
||||||
|
lines.append(f"- {label}: avg {e.get('average', 'N/A')} {idx}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_interval_stats(interval: dict[str, Any]) -> str:
|
||||||
|
"""Format a computed interval-stats block (a single Interval) into text."""
|
||||||
|
lines = ["Interval Stats:", ""]
|
||||||
|
for key, label, unit in [
|
||||||
|
("moving_time", "Moving Time", "s"),
|
||||||
|
("distance", "Distance", "m"),
|
||||||
|
("average_watts", "Avg Power", "W"),
|
||||||
|
("weighted_average_watts", "Weighted Avg Power", "W"),
|
||||||
|
("max_watts", "Max Power", "W"),
|
||||||
|
("average_watts_kg", "Avg Power", "W/kg"),
|
||||||
|
("intensity", "Intensity", ""),
|
||||||
|
("training_load", "Training Load", ""),
|
||||||
|
("joules", "Work", "J"),
|
||||||
|
("decoupling", "Decoupling", "%"),
|
||||||
|
("average_heartrate", "Avg HR", "bpm"),
|
||||||
|
("max_heartrate", "Max HR", "bpm"),
|
||||||
|
("average_cadence", "Avg Cadence", "rpm"),
|
||||||
|
("average_speed", "Avg Speed", "m/s"),
|
||||||
|
("gap", "GAP", "m/s"),
|
||||||
|
]:
|
||||||
|
if interval.get(key) is not None:
|
||||||
|
lines.append(f"- {label}: {interval[key]}{(' ' + unit) if unit else ''}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_step_intensity(step: dict[str, Any]) -> str:
|
||||||
|
"""Render a workout step's intensity target(s) from raw workout_doc JSON."""
|
||||||
|
bits = []
|
||||||
|
for key, label in [("power", ""), ("hr", "HR"), ("pace", "Pace"), ("cadence", "Cad")]:
|
||||||
|
v = step.get(key)
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
continue
|
||||||
|
units = v.get("units", "")
|
||||||
|
if v.get("start") is not None and v.get("end") is not None:
|
||||||
|
val = f"{v['start']}-{v['end']}"
|
||||||
|
elif v.get("value") is not None:
|
||||||
|
val = f"{v['value']}"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
bits.append(f"{(label + ' ') if label else ''}{val}{units}")
|
||||||
|
return ", ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_workout_step(step: dict[str, Any], depth: int = 0) -> list[str]:
|
||||||
|
"""Recursively render one workout_doc step (handles repeat blocks). Depth-capped."""
|
||||||
|
indent = " " * (depth + 1)
|
||||||
|
if depth > 6:
|
||||||
|
return [f"{indent}- ...(nested too deep)"]
|
||||||
|
reps = step.get("reps")
|
||||||
|
substeps = step.get("steps")
|
||||||
|
if reps and isinstance(substeps, list):
|
||||||
|
lines = [f"{indent}{reps}x:"]
|
||||||
|
for sub in substeps:
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
lines.extend(_format_workout_step(sub, depth + 1))
|
||||||
|
return lines
|
||||||
|
parts = []
|
||||||
|
if step.get("duration") is not None:
|
||||||
|
parts.append(_format_duration_label(int(step["duration"])))
|
||||||
|
if step.get("distance") is not None:
|
||||||
|
parts.append(f"{step['distance']}m")
|
||||||
|
tag = ""
|
||||||
|
if step.get("warmup"):
|
||||||
|
tag = " (warmup)"
|
||||||
|
elif step.get("cooldown"):
|
||||||
|
tag = " (cooldown)"
|
||||||
|
elif step.get("freeride"):
|
||||||
|
tag = " (free ride)"
|
||||||
|
intensity = _format_step_intensity(step)
|
||||||
|
if step.get("ramp") and intensity:
|
||||||
|
intensity = "ramp " + intensity
|
||||||
|
label = " ".join(parts) if parts else "step"
|
||||||
|
detail = f" @ {intensity}" if intensity else ""
|
||||||
|
text = step.get("text")
|
||||||
|
return [f"{indent}- {label}{detail}{tag}{(' — ' + text) if text else ''}"]
|
||||||
|
|
||||||
|
|
||||||
|
def format_workout_summary(workout: dict[str, Any]) -> str:
|
||||||
|
"""Format one library workout as a compact one-line list entry."""
|
||||||
|
line = " | ".join([str(workout.get("name", "Unnamed")), str(workout.get("type", "?"))])
|
||||||
|
extra = []
|
||||||
|
if workout.get("icu_training_load") is not None:
|
||||||
|
extra.append(f"load {workout['icu_training_load']}")
|
||||||
|
if workout.get("moving_time") is not None:
|
||||||
|
extra.append(f"{workout['moving_time']}s")
|
||||||
|
if workout.get("folder_id") is not None:
|
||||||
|
extra.append(f"folder {workout['folder_id']}")
|
||||||
|
if extra:
|
||||||
|
line += " (" + ", ".join(extra) + ")"
|
||||||
|
return line + f" [id: {workout.get('id', 'N/A')}]"
|
||||||
|
|
||||||
|
|
||||||
|
def format_workout_details(workout: dict[str, Any]) -> str:
|
||||||
|
"""Format a library workout in full, including its structured steps."""
|
||||||
|
lines = [f"Workout: {workout.get('name', 'Unnamed')}", f"ID: {workout.get('id', 'N/A')}"]
|
||||||
|
for key, label, unit in [
|
||||||
|
("type", "Type", ""),
|
||||||
|
("sub_type", "Sub-type", ""),
|
||||||
|
("indoor", "Indoor", ""),
|
||||||
|
("moving_time", "Duration", "s"),
|
||||||
|
("distance", "Distance", "m"),
|
||||||
|
("icu_training_load", "Training Load", ""),
|
||||||
|
("icu_intensity", "Intensity", ""),
|
||||||
|
("carbs_per_hour", "Carbs", "g/hr"),
|
||||||
|
("folder_id", "Folder", ""),
|
||||||
|
]:
|
||||||
|
if workout.get(key) is not None:
|
||||||
|
lines.append(f"{label}: {workout[key]}{(' ' + unit) if unit else ''}")
|
||||||
|
if workout.get("description"):
|
||||||
|
lines.append(f"Description: {workout['description']}")
|
||||||
|
tags = workout.get("tags")
|
||||||
|
if isinstance(tags, list) and tags:
|
||||||
|
lines.append("Tags: " + ", ".join(str(t) for t in tags))
|
||||||
|
|
||||||
|
doc = workout.get("workout_doc")
|
||||||
|
if isinstance(doc, dict) and isinstance(doc.get("steps"), list) and doc["steps"]:
|
||||||
|
lines += ["", "Steps:"]
|
||||||
|
for step in doc["steps"]:
|
||||||
|
if isinstance(step, dict):
|
||||||
|
lines.extend(_format_workout_step(step))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""
|
||||||
|
Training-readiness computation for Intervals.icu wellness data.
|
||||||
|
|
||||||
|
Pure functions (no I/O) so they can be unit-tested on fixtures. The HRV method
|
||||||
|
follows Plews & Laursen: a rolling mean of ``ln(rMSSD)`` over the last 7 calendar
|
||||||
|
days compared to a baseline from the preceding ~30 days, with a "normal" band of
|
||||||
|
baseline mean +/- the smallest worthwhile change (SWC = 0.5 x baseline SD, with a
|
||||||
|
floor so a near-constant baseline can't produce a zero-width band). Resting HR,
|
||||||
|
sleep and subjective inputs are each compared to their own recent baseline.
|
||||||
|
|
||||||
|
All windows are **calendar-based**, anchored on ``reference_date`` (callers should
|
||||||
|
pass today): a metric whose samples are older than the window reports "no recent
|
||||||
|
data" instead of silently treating stale samples as current. Nothing is
|
||||||
|
fabricated: a metric with too little data in its window reports "no data" rather
|
||||||
|
than defaulting, and the overall verdict is withheld (not guessed) when the
|
||||||
|
objective signals are too sparse to be meaningful. Subjective fields use the
|
||||||
|
conventional Intervals.icu direction (soreness/fatigue/stress/injury: higher is
|
||||||
|
worse; mood/motivation: higher is better) and only ever contribute a soft
|
||||||
|
warning, never a hard alert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_RECENT_DAYS = 7
|
||||||
|
_BASELINE_DAYS = 30
|
||||||
|
_MIN_HRV_RECENT = 4 # samples needed inside the 7-day window
|
||||||
|
_MIN_HRV_BASELINE = 7
|
||||||
|
_MIN_RHR_RECENT = 4
|
||||||
|
_MIN_RHR_BASELINE = 5
|
||||||
|
_MIN_SLEEP_BASELINE = 5
|
||||||
|
_MIN_SUBJ_BASELINE = 5
|
||||||
|
_SUBJ_LATEST_MAX_AGE = 3 # days; older subjective entries aren't "current" feelings
|
||||||
|
|
||||||
|
# Floor for the HRV smallest-worthwhile-change band, in ln(rMSSD) units. A
|
||||||
|
# near-constant baseline (coarsely-rounded device output, very steady athlete)
|
||||||
|
# would otherwise give SWC ~= 0 and flag trivial fluctuations as alerts. 0.05 ln
|
||||||
|
# units is ~5% in rMSSD — on the order of normal day-to-day variation.
|
||||||
|
_SWC_FLOOR = 0.05
|
||||||
|
|
||||||
|
_SUBJ_WORSE_HIGH = ("soreness", "fatigue", "stress", "injury")
|
||||||
|
_SUBJ_WORSE_LOW = ("motivation", "mood")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: Any) -> date | None:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(value)[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dated_series(
|
||||||
|
records: list[dict[str, Any]], key: str, positive: bool = False
|
||||||
|
) -> list[tuple[date, float]]:
|
||||||
|
"""Date-sorted ``(date, value)`` pairs for ``key``; undated/non-numeric skipped."""
|
||||||
|
out: list[tuple[date, float]] = []
|
||||||
|
for r in records:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
d = _parse_date(r.get("id") or r.get("date"))
|
||||||
|
v = r.get(key)
|
||||||
|
if d is None or not isinstance(v, (int, float)) or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if positive and v <= 0:
|
||||||
|
continue
|
||||||
|
out.append((d, float(v)))
|
||||||
|
out.sort(key=lambda p: p[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _windows(
|
||||||
|
pairs: list[tuple[date, float]], ref: date
|
||||||
|
) -> tuple[list[float], list[float]]:
|
||||||
|
"""Split values into recent (last 7 calendar days) and baseline (30 before that)."""
|
||||||
|
recent_start = ref - timedelta(days=_RECENT_DAYS)
|
||||||
|
baseline_start = recent_start - timedelta(days=_BASELINE_DAYS)
|
||||||
|
recent = [v for d, v in pairs if recent_start < d <= ref]
|
||||||
|
baseline = [v for d, v in pairs if baseline_start < d <= recent_start]
|
||||||
|
return recent, baseline
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_date(records: list[dict[str, Any]]) -> date | None:
|
||||||
|
dates = [
|
||||||
|
d
|
||||||
|
for d in (_parse_date(r.get("id") or r.get("date")) for r in records if isinstance(r, dict))
|
||||||
|
if d is not None
|
||||||
|
]
|
||||||
|
return max(dates) if dates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ref(records: list[dict[str, Any]], reference_date: str | None) -> date | None:
|
||||||
|
return _parse_date(reference_date) if reference_date else _newest_date(records)
|
||||||
|
|
||||||
|
|
||||||
|
def hrv_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
|
||||||
|
"""HRV readiness via 7-day rolling lnRMSSD vs baseline band (mean +/- SWC)."""
|
||||||
|
pairs = _dated_series(records, "hrv", positive=True)
|
||||||
|
ref = _resolve_ref(records, reference_date)
|
||||||
|
if ref is None or not pairs:
|
||||||
|
return {"name": "HRV", "level": "nodata", "detail": "no HRV data"}
|
||||||
|
recent_vals, baseline_vals = _windows(pairs, ref)
|
||||||
|
if len(recent_vals) < _MIN_HRV_RECENT:
|
||||||
|
return {
|
||||||
|
"name": "HRV",
|
||||||
|
"level": "nodata",
|
||||||
|
"detail": f"only {len(recent_vals)} HRV sample(s) in the last {_RECENT_DAYS} days",
|
||||||
|
}
|
||||||
|
if len(baseline_vals) < _MIN_HRV_BASELINE:
|
||||||
|
return {
|
||||||
|
"name": "HRV",
|
||||||
|
"level": "nodata",
|
||||||
|
"detail": f"only {len(baseline_vals)} baseline day(s) — need >= {_MIN_HRV_BASELINE}",
|
||||||
|
}
|
||||||
|
recent_mean = statistics.mean(math.log(v) for v in recent_vals)
|
||||||
|
ln_base = [math.log(v) for v in baseline_vals]
|
||||||
|
base_mean = statistics.mean(ln_base)
|
||||||
|
swc = max(0.5 * statistics.pstdev(ln_base), _SWC_FLOOR)
|
||||||
|
if recent_mean < base_mean - swc:
|
||||||
|
return {
|
||||||
|
"name": "HRV",
|
||||||
|
"level": "alert",
|
||||||
|
"detail": "7-day lnHRV below baseline band — parasympathetic suppression",
|
||||||
|
}
|
||||||
|
if recent_mean > base_mean + swc:
|
||||||
|
return {
|
||||||
|
"name": "HRV",
|
||||||
|
"level": "warn",
|
||||||
|
"detail": "7-day lnHRV above baseline band — super-compensation, "
|
||||||
|
"or saturation if resting HR is also elevated",
|
||||||
|
}
|
||||||
|
return {"name": "HRV", "level": "ok", "detail": "7-day lnHRV within normal band"}
|
||||||
|
|
||||||
|
|
||||||
|
def rhr_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Resting-HR readiness: 7-day mean vs a disjoint 30-day baseline, flag if >5% above."""
|
||||||
|
pairs = _dated_series(records, "restingHR", positive=True)
|
||||||
|
ref = _resolve_ref(records, reference_date)
|
||||||
|
if ref is None or not pairs:
|
||||||
|
return {"name": "Resting HR", "level": "nodata", "detail": "no resting-HR data"}
|
||||||
|
recent_vals, baseline_vals = _windows(pairs, ref)
|
||||||
|
if len(recent_vals) < _MIN_RHR_RECENT:
|
||||||
|
return {
|
||||||
|
"name": "Resting HR",
|
||||||
|
"level": "nodata",
|
||||||
|
"detail": f"only {len(recent_vals)} RHR sample(s) in the last {_RECENT_DAYS} days",
|
||||||
|
}
|
||||||
|
if len(baseline_vals) < _MIN_RHR_BASELINE:
|
||||||
|
return {
|
||||||
|
"name": "Resting HR",
|
||||||
|
"level": "nodata",
|
||||||
|
"detail": f"only {len(baseline_vals)} baseline day(s) of RHR — need >= {_MIN_RHR_BASELINE}",
|
||||||
|
}
|
||||||
|
recent = statistics.mean(recent_vals)
|
||||||
|
base = statistics.mean(baseline_vals)
|
||||||
|
if base > 0 and (recent - base) / base > 0.05:
|
||||||
|
return {
|
||||||
|
"name": "Resting HR",
|
||||||
|
"level": "warn",
|
||||||
|
"detail": f"7-day RHR {recent:.0f} is >5% above baseline {base:.0f}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": "Resting HR",
|
||||||
|
"level": "ok",
|
||||||
|
"detail": f"7-day RHR {recent:.0f} near baseline {base:.0f}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Sleep readiness: last night (dated within a day of reference) vs baseline mean."""
|
||||||
|
pairs = _dated_series(records, "sleepSecs", positive=True)
|
||||||
|
ref = _resolve_ref(records, reference_date)
|
||||||
|
if ref is None or not pairs:
|
||||||
|
return {"name": "Sleep", "level": "nodata", "detail": "no sleep data"}
|
||||||
|
last_date, last_secs = pairs[-1]
|
||||||
|
if (ref - last_date).days > 1:
|
||||||
|
return {
|
||||||
|
"name": "Sleep",
|
||||||
|
"level": "nodata",
|
||||||
|
"detail": f"no sleep logged since {last_date.isoformat()}",
|
||||||
|
}
|
||||||
|
baseline = [
|
||||||
|
v / 3600
|
||||||
|
for d, v in pairs
|
||||||
|
if d != last_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
|
||||||
|
]
|
||||||
|
if len(baseline) < _MIN_SLEEP_BASELINE:
|
||||||
|
return {"name": "Sleep", "level": "nodata", "detail": "not enough sleep data"}
|
||||||
|
last = last_secs / 3600
|
||||||
|
mean = statistics.mean(baseline)
|
||||||
|
if mean > 0 and last < 0.85 * mean:
|
||||||
|
return {
|
||||||
|
"name": "Sleep",
|
||||||
|
"level": "warn",
|
||||||
|
"detail": f"last night {last:.1f}h below baseline {mean:.1f}h",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": "Sleep",
|
||||||
|
"level": "ok",
|
||||||
|
"detail": f"last night {last:.1f}h near baseline {mean:.1f}h",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def subjective_signals(
|
||||||
|
records: list[dict[str, Any]], reference_date: str | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Soft warnings when a *current* subjective field has moved off baseline for the worse."""
|
||||||
|
signals: list[dict[str, Any]] = []
|
||||||
|
ref = _resolve_ref(records, reference_date)
|
||||||
|
if ref is None:
|
||||||
|
return signals
|
||||||
|
fields = [(f, True) for f in _SUBJ_WORSE_HIGH] + [(f, False) for f in _SUBJ_WORSE_LOW]
|
||||||
|
for field, worse_high in fields:
|
||||||
|
pairs = _dated_series(records, field)
|
||||||
|
if not pairs:
|
||||||
|
continue
|
||||||
|
latest_date, latest = pairs[-1]
|
||||||
|
if (ref - latest_date).days > _SUBJ_LATEST_MAX_AGE:
|
||||||
|
continue # stale entries aren't current feelings
|
||||||
|
baseline = [
|
||||||
|
v
|
||||||
|
for d, v in pairs
|
||||||
|
if d != latest_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
|
||||||
|
]
|
||||||
|
if len(baseline) < _MIN_SUBJ_BASELINE:
|
||||||
|
continue
|
||||||
|
mean = statistics.mean(baseline)
|
||||||
|
sd = statistics.pstdev(baseline) if len(baseline) > 1 else 0.0
|
||||||
|
threshold = max(sd, 0.5) # require a meaningful move, not noise
|
||||||
|
worse = (latest - mean > threshold) if worse_high else (mean - latest > threshold)
|
||||||
|
if worse:
|
||||||
|
direction = "elevated" if worse_high else "low"
|
||||||
|
signals.append(
|
||||||
|
{
|
||||||
|
"name": field.capitalize(),
|
||||||
|
"level": "warn",
|
||||||
|
"detail": f"{field} {direction} vs baseline ({latest:g} vs {mean:.1f})",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def form_context(records: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
"""Latest Form (TSB = CTL - ATL) if both components are present."""
|
||||||
|
dated = sorted(
|
||||||
|
[r for r in records if isinstance(r, dict)],
|
||||||
|
key=lambda r: str(r.get("id") or r.get("date") or ""),
|
||||||
|
)
|
||||||
|
if not dated:
|
||||||
|
return None
|
||||||
|
latest = dated[-1]
|
||||||
|
ctl, atl = latest.get("ctl"), latest.get("atl")
|
||||||
|
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
|
||||||
|
return {"form": round(ctl - atl, 1), "ctl": ctl, "atl": atl}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_readiness(
|
||||||
|
records: list[dict[str, Any]], reference_date: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Produce a structured readiness assessment from wellness records.
|
||||||
|
|
||||||
|
``reference_date`` (YYYY-MM-DD) anchors the calendar windows — pass today so
|
||||||
|
stale data reads as "no recent data" instead of masquerading as current. If
|
||||||
|
omitted, the newest record's date is used (fixture-friendly, but blind to
|
||||||
|
how old that record is).
|
||||||
|
"""
|
||||||
|
core = [
|
||||||
|
hrv_signal(records, reference_date),
|
||||||
|
rhr_signal(records, reference_date),
|
||||||
|
sleep_signal(records, reference_date),
|
||||||
|
]
|
||||||
|
signals = core + subjective_signals(records, reference_date)
|
||||||
|
|
||||||
|
alerts = [s for s in signals if s["level"] == "alert"]
|
||||||
|
warns = [s for s in signals if s["level"] == "warn"]
|
||||||
|
core_with_data = [s for s in core if s["level"] != "nodata"]
|
||||||
|
|
||||||
|
if core[0]["level"] == "nodata" and len(core_with_data) < 2:
|
||||||
|
verdict = "insufficient"
|
||||||
|
elif alerts or len(warns) >= 3:
|
||||||
|
verdict = "red"
|
||||||
|
elif warns:
|
||||||
|
verdict = "amber"
|
||||||
|
else:
|
||||||
|
verdict = "green"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verdict": verdict,
|
||||||
|
"signals": signals,
|
||||||
|
"form": form_context(records),
|
||||||
|
"days": len(records),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_VERDICT_LABEL = {
|
||||||
|
"green": "🟢 Ready — signals within normal range",
|
||||||
|
"amber": "🟡 Caution — one or more signals off baseline",
|
||||||
|
"red": "🔴 Compromised — strong or multiple negative signals",
|
||||||
|
"insufficient": "⚪ Verdict withheld — not enough data to judge",
|
||||||
|
}
|
||||||
|
_LEVEL_ICON = {"ok": "✓", "warn": "!", "alert": "‼", "nodata": "·"}
|
||||||
|
|
||||||
|
|
||||||
|
def render_readiness(assessment: dict[str, Any]) -> str:
|
||||||
|
"""Render a readiness assessment into a plain-language report."""
|
||||||
|
lines = [
|
||||||
|
"Training Readiness:",
|
||||||
|
"",
|
||||||
|
_VERDICT_LABEL.get(assessment["verdict"], assessment["verdict"]),
|
||||||
|
f"(based on {assessment['days']} day(s) of wellness data)",
|
||||||
|
"",
|
||||||
|
"Signals:",
|
||||||
|
]
|
||||||
|
for s in assessment["signals"]:
|
||||||
|
lines.append(f" {_LEVEL_ICON.get(s['level'], '-')} {s['name']}: {s['detail']}")
|
||||||
|
|
||||||
|
form = assessment["form"]
|
||||||
|
if form:
|
||||||
|
lines += ["", f"Form (TSB): {form['form']} (CTL {form['ctl']} / ATL {form['atl']})"]
|
||||||
|
|
||||||
|
if assessment["verdict"] == "insufficient":
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"Log daily HRV (and resting HR) for ~2+ weeks to enable a readiness verdict.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -4,6 +4,7 @@ Date: 2025-05-24
|
|||||||
Training Metrics:
|
Training Metrics:
|
||||||
- Fitness (CTL): 70.87253
|
- Fitness (CTL): 70.87253
|
||||||
- Fatigue (ATL): 91.97159
|
- Fatigue (ATL): 91.97159
|
||||||
|
- Form (TSB): -21.1
|
||||||
- Ramp Rate: 6.997368
|
- Ramp Rate: 6.997368
|
||||||
- CTL Load: 299
|
- CTL Load: 299
|
||||||
- ATL Load: 299
|
- ATL Load: 299
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Tests for the 0.3.0 activity search + analytics tools in
|
||||||
|
intervals_mcp_server.tools.activities: search_activities,
|
||||||
|
get_activity_best_efforts, get_activity_interval_stats.
|
||||||
|
|
||||||
|
HTTP is stubbed at the module level; the autouse conftest fixture supplies the
|
||||||
|
caller credentials (athlete ``i1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from intervals_mcp_server.tools import activities
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_request(monkeypatch, result):
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(activities, "make_intervals_request", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# search_activities
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
SEARCH_HITS = [
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"name": "Threshold intervals",
|
||||||
|
"start_date_local": "2026-07-18T07:00:00",
|
||||||
|
"type": "Ride",
|
||||||
|
"distance": 42000,
|
||||||
|
"moving_time": 5400,
|
||||||
|
"race": False,
|
||||||
|
},
|
||||||
|
{"id": "a2", "name": "Local crit", "start_date_local": "2026-07-15", "type": "Ride", "race": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_activities_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, SEARCH_HITS)
|
||||||
|
out = asyncio.run(activities.search_activities("threshold", limit=5))
|
||||||
|
assert calls[0]["url"] == "/athlete/i1/activities/search"
|
||||||
|
assert calls[0]["params"] == {"q": "threshold", "limit": 5}
|
||||||
|
assert "Found 2 activities" in out
|
||||||
|
assert "2026-07-18 | Ride | Threshold intervals" in out
|
||||||
|
assert "[id: a1]" in out
|
||||||
|
assert "RACE" in out # the crit
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_activities_empty_query(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, SEARCH_HITS)
|
||||||
|
out = asyncio.run(activities.search_activities(" "))
|
||||||
|
assert "non-empty search query is required" in out
|
||||||
|
assert calls == [] # no request made
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_activities_no_results(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
assert "No activities found matching 'zzz'" in asyncio.run(activities.search_activities("zzz"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_activities_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
assert "Error searching activities: boom" in asyncio.run(activities.search_activities("x"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# get_activity_best_efforts
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
BEST_EFFORTS = {
|
||||||
|
"efforts": [
|
||||||
|
{"duration": 300, "average": 320, "start_index": 100, "end_index": 400},
|
||||||
|
{"distance": 1000, "average": 305, "start_index": 500, "end_index": 700},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_efforts_success_and_param_passthrough(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, BEST_EFFORTS)
|
||||||
|
out = asyncio.run(
|
||||||
|
activities.get_activity_best_efforts("a1", stream="watts", duration=300, count=5)
|
||||||
|
)
|
||||||
|
params = calls[0]["params"]
|
||||||
|
assert calls[0]["url"] == "/activity/a1/best-efforts"
|
||||||
|
assert params["stream"] == "watts"
|
||||||
|
assert params["duration"] == 300
|
||||||
|
assert params["count"] == 5
|
||||||
|
assert "distance" not in params # None omitted
|
||||||
|
assert "Best Efforts (watts)" in out
|
||||||
|
assert "5m: avg 320" in out
|
||||||
|
assert "1000m: avg 305" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_efforts_empty(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"efforts": []})
|
||||||
|
out = asyncio.run(activities.get_activity_best_efforts("a1"))
|
||||||
|
assert "No best-effort data found" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_efforts_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "nope"})
|
||||||
|
assert "Error fetching best efforts: nope" in asyncio.run(
|
||||||
|
activities.get_activity_best_efforts("a1")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# get_activity_interval_stats
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
INTERVAL_STATS = {
|
||||||
|
"moving_time": 1200,
|
||||||
|
"average_watts": 265,
|
||||||
|
"weighted_average_watts": 272,
|
||||||
|
"max_watts": 410,
|
||||||
|
"intensity": 0.88,
|
||||||
|
"training_load": 45,
|
||||||
|
"average_heartrate": 158,
|
||||||
|
"decoupling": 3.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_stats_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, INTERVAL_STATS)
|
||||||
|
out = asyncio.run(activities.get_activity_interval_stats("a1", 100, 500))
|
||||||
|
assert calls[0]["url"] == "/activity/a1/interval-stats"
|
||||||
|
assert calls[0]["params"] == {"start_index": 100, "end_index": 500}
|
||||||
|
assert "Interval Stats:" in out
|
||||||
|
assert "Avg Power: 265 W" in out
|
||||||
|
assert "Weighted Avg Power: 272 W" in out
|
||||||
|
assert "Decoupling: 3.2 %" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_stats_bad_indices(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, INTERVAL_STATS)
|
||||||
|
out = asyncio.run(activities.get_activity_interval_stats("a1", 500, 100))
|
||||||
|
assert "end_index must be greater than start_index" in out
|
||||||
|
assert calls == [] # no request
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_stats_empty(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {})
|
||||||
|
out = asyncio.run(activities.get_activity_interval_stats("a1", 0, 100))
|
||||||
|
assert "No interval stats found" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_interval_stats_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "bad range"})
|
||||||
|
assert "Error fetching interval stats: bad range" in asyncio.run(
|
||||||
|
activities.get_activity_interval_stats("a1", 0, 100)
|
||||||
|
)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Tests for intervals_mcp_server.tools.athlete.
|
||||||
|
|
||||||
|
Covers the athlete-context read tools (get_athlete_profile, get_sport_settings,
|
||||||
|
get_athlete_summary): request shape, sport filtering, formatting of realistic
|
||||||
|
fixtures, and the empty / error / credential branches. Default caller credentials
|
||||||
|
come from the autouse fixture in conftest (athlete ``i1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from intervals_mcp_server import credentials
|
||||||
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
|
from intervals_mcp_server.tools import athlete
|
||||||
|
|
||||||
|
PROFILE = {
|
||||||
|
"id": "i1",
|
||||||
|
"name": "Test Athlete",
|
||||||
|
"sex": "M",
|
||||||
|
"weight": 72.5,
|
||||||
|
"icu_resting_hr": 48,
|
||||||
|
"timezone": "Europe/Madrid",
|
||||||
|
"measurement_preference": "meters",
|
||||||
|
"city": "Girona",
|
||||||
|
"country": "Spain",
|
||||||
|
"icu_coach": True,
|
||||||
|
"icu_type_settings": [{"id": 1}, {"id": 2}],
|
||||||
|
}
|
||||||
|
|
||||||
|
SPORT_SETTINGS = [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"types": ["Ride", "VirtualRide"],
|
||||||
|
"ftp": 280,
|
||||||
|
"indoor_ftp": 275,
|
||||||
|
"w_prime": 22000,
|
||||||
|
"power_zones": [55, 75, 90, 105, 120],
|
||||||
|
"power_zone_names": ["Z1", "Z2", "Z3", "Z4", "Z5"],
|
||||||
|
"lthr": 165,
|
||||||
|
"max_hr": 190,
|
||||||
|
"hr_zones": [120, 145, 160, 175],
|
||||||
|
"threshold_pace": 4.2,
|
||||||
|
"pace_units": "MINS_KM",
|
||||||
|
"pace_zones": [3.5, 4.0, 4.5],
|
||||||
|
"warmup_time": 600,
|
||||||
|
"cooldown_time": 300,
|
||||||
|
},
|
||||||
|
{"id": 101, "types": ["Run"], "threshold_pace": 3.8, "pace_units": "MINS_KM"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SUMMARY = [
|
||||||
|
{
|
||||||
|
"date": "2026-07-20",
|
||||||
|
"count": 12,
|
||||||
|
"moving_time": 43200,
|
||||||
|
"distance": 320000,
|
||||||
|
"training_load": 640,
|
||||||
|
"fitness": 78.5,
|
||||||
|
"fatigue": 71.0,
|
||||||
|
"form": 7.5,
|
||||||
|
"eftp": 285,
|
||||||
|
"byCategory": [{"category": "Ride", "count": 8, "training_load": 500, "moving_time": 32400}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_request(monkeypatch, result):
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(athlete, "make_intervals_request", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_seq(monkeypatch, results):
|
||||||
|
"""Patch make_intervals_request to return queued results, one per call."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
seq = iter(results)
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return next(seq)
|
||||||
|
|
||||||
|
monkeypatch.setattr(athlete, "make_intervals_request", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
class _StubCtx:
|
||||||
|
"""Minimal stand-in for FastMCP Context.elicit used by the guardrail tests."""
|
||||||
|
|
||||||
|
def __init__(self, action="accept", confirm=True, raise_exc=False):
|
||||||
|
self._action = action
|
||||||
|
self._confirm = confirm
|
||||||
|
self._raise = raise_exc
|
||||||
|
self.elicit_calls = 0
|
||||||
|
|
||||||
|
async def elicit(self, message, schema): # noqa: ARG002 - signature parity
|
||||||
|
self.elicit_calls += 1
|
||||||
|
if self._raise:
|
||||||
|
raise RuntimeError("client does not support elicitation")
|
||||||
|
data = type("Data", (), {"confirm": self._confirm})()
|
||||||
|
return type("Result", (), {"action": self._action, "data": data})()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# get_athlete_profile
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_get_athlete_profile_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, PROFILE)
|
||||||
|
out = asyncio.run(athlete.get_athlete_profile())
|
||||||
|
assert calls[0]["url"] == "/athlete/i1"
|
||||||
|
assert "Name: Test Athlete" in out
|
||||||
|
assert "Weight: 72.5 kg" in out
|
||||||
|
assert "Resting HR: 48 bpm" in out
|
||||||
|
assert "Location: Girona, Spain" in out
|
||||||
|
assert "Role: Coach" in out
|
||||||
|
assert "2 sport(s) configured" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_profile_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "nope"})
|
||||||
|
assert "Error fetching athlete profile: nope" in asyncio.run(athlete.get_athlete_profile())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_profile_non_dict(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
assert "No athlete profile found" in asyncio.run(athlete.get_athlete_profile())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_profile_credential_error(monkeypatch):
|
||||||
|
async def _deny():
|
||||||
|
raise CredentialError("not approved")
|
||||||
|
|
||||||
|
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
||||||
|
assert "not approved" in asyncio.run(athlete.get_athlete_profile())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# get_sport_settings
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_get_sport_settings_all(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, SPORT_SETTINGS)
|
||||||
|
out = asyncio.run(athlete.get_sport_settings())
|
||||||
|
assert calls[0]["url"] == "/athlete/i1/sport-settings"
|
||||||
|
assert "Sport Settings — Ride, VirtualRide" in out
|
||||||
|
assert "Settings ID: 100" in out
|
||||||
|
assert "FTP: 280W" in out
|
||||||
|
assert "Z1: 55, Z2: 75" in out # power zones paired with names
|
||||||
|
assert "LTHR: 165 bpm" in out
|
||||||
|
assert "Threshold: 4.2 MINS_KM" in out
|
||||||
|
assert "Settings ID: 101" in out # second record rendered too
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sport_settings_filter_hit(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, SPORT_SETTINGS)
|
||||||
|
out = asyncio.run(athlete.get_sport_settings(sport="run")) # case-insensitive
|
||||||
|
assert "Settings ID: 101" in out
|
||||||
|
assert "Settings ID: 100" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sport_settings_filter_miss(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, SPORT_SETTINGS)
|
||||||
|
out = asyncio.run(athlete.get_sport_settings(sport="Swim"))
|
||||||
|
assert "No sport settings found for sport 'Swim'" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sport_settings_empty(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
assert "No sport settings found" in asyncio.run(athlete.get_sport_settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sport_settings_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
assert "Error fetching sport settings: boom" in asyncio.run(athlete.get_sport_settings())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# get_athlete_summary
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_get_athlete_summary_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, SUMMARY)
|
||||||
|
out = asyncio.run(athlete.get_athlete_summary(start_date="2026-06-20", end_date="2026-07-20"))
|
||||||
|
call = calls[0]
|
||||||
|
assert call["url"] == "/athlete/i1/athlete-summary"
|
||||||
|
assert call["params"]["start"] == "2026-06-20"
|
||||||
|
assert call["params"]["end"] == "2026-07-20"
|
||||||
|
assert "Fitness (CTL): 78.5" in out
|
||||||
|
assert "Form (TSB): 7.5" in out
|
||||||
|
assert "By category:" in out
|
||||||
|
assert "Ride: 8 activities" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_summary_defaults_dates(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, SUMMARY)
|
||||||
|
asyncio.run(athlete.get_athlete_summary())
|
||||||
|
# resolve_date_params fills both ends with YYYY-MM-DD
|
||||||
|
assert len(calls[0]["params"]["start"]) == 10
|
||||||
|
assert len(calls[0]["params"]["end"]) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_summary_empty(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
assert "No summary data found" in asyncio.run(athlete.get_athlete_summary())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_athlete_summary_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "bad"})
|
||||||
|
assert "Error fetching athlete summary: bad" in asyncio.run(athlete.get_athlete_summary())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# update_sport_settings (dual-guardrail write)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
CURRENT_SS = [{"id": 100, "types": ["Ride"], "ftp": 280, "lthr": 165}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_no_fields(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100))
|
||||||
|
assert "No settings provided" in out
|
||||||
|
assert calls == [] # returns before any fetch
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_refuses_without_confirm_or_ctx(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS]) # only the GET happens
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300))
|
||||||
|
assert "⚠️ This will change your Ride thresholds" in out
|
||||||
|
assert "ftp: 280 -> 300" in out
|
||||||
|
assert "re-run with confirm=true" in out
|
||||||
|
assert len(calls) == 1 and calls[0]["url"] == "/athlete/i1/sport-settings" # no PUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_confirm_true_writes(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
|
||||||
|
out = asyncio.run(
|
||||||
|
athlete.update_sport_settings(settings_id=100, ftp=300, recalc_hr_zones=True, confirm=True)
|
||||||
|
)
|
||||||
|
put = calls[1]
|
||||||
|
assert put["method"] == "PUT"
|
||||||
|
assert put["url"] == "/athlete/i1/sport-settings/100"
|
||||||
|
assert put["params"] == {"recalcHrZones": True}
|
||||||
|
assert put["data"]["ftp"] == 300 # merged into the full record
|
||||||
|
assert "Updated Ride settings" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_elicit_accept_writes(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS, {"id": 100, "types": ["Ride"], "ftp": 300}])
|
||||||
|
ctx = _StubCtx(action="accept", confirm=True)
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
|
||||||
|
assert ctx.elicit_calls == 1
|
||||||
|
assert len(calls) == 2 and calls[1]["method"] == "PUT"
|
||||||
|
assert "Updated Ride settings" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_elicit_decline(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
ctx = _StubCtx(action="decline")
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
|
||||||
|
assert "did not confirm" in out
|
||||||
|
assert "confirm=true" not in out # no bypass instructions after a refusal
|
||||||
|
assert len(calls) == 1 # no PUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_elicit_cancel(monkeypatch):
|
||||||
|
_patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
ctx = _StubCtx(action="cancel")
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
|
||||||
|
assert "did not confirm" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_accept_without_confirm_refuses_hard(monkeypatch):
|
||||||
|
# Submitting the elicitation without ticking confirm is a refusal: the tool
|
||||||
|
# must stop and must NOT emit the confirm=true bypass instructions — and an
|
||||||
|
# explicit confirm=True param must not override the answered elicitation.
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
ctx = _StubCtx(action="accept", confirm=False)
|
||||||
|
out = asyncio.run(
|
||||||
|
athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True, ctx=ctx)
|
||||||
|
)
|
||||||
|
assert "did not confirm" in out
|
||||||
|
assert "confirm=true" not in out
|
||||||
|
assert len(calls) == 1 # no PUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_elicit_unsupported_falls_back(monkeypatch):
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
ctx = _StubCtx(raise_exc=True) # client without elicitation capability
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
|
||||||
|
assert "re-run with confirm=true" in out
|
||||||
|
assert len(calls) == 1 # refused, no PUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_unknown_id(monkeypatch):
|
||||||
|
_patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=999, ftp=300, confirm=True))
|
||||||
|
assert "No sport settings found with ID 999" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_no_op(monkeypatch):
|
||||||
|
_patch_seq(monkeypatch, [CURRENT_SS])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=280, confirm=True))
|
||||||
|
assert "No changes" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_fetch_error(monkeypatch):
|
||||||
|
_patch_seq(monkeypatch, [{"error": True, "message": "down"}])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
|
||||||
|
assert "Error fetching current sport settings: down" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_put_error(monkeypatch):
|
||||||
|
_patch_seq(monkeypatch, [CURRENT_SS, {"error": True, "message": "rejected"}])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
|
||||||
|
assert "Error updating sport settings: rejected" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_sport_settings_empty_echo_renders_merged(monkeypatch):
|
||||||
|
# An empty-body 200 parses to {}; the confirmation must render the merged
|
||||||
|
# record (with the new FTP), not format_sport_settings({}).
|
||||||
|
calls = _patch_seq(monkeypatch, [CURRENT_SS, {}])
|
||||||
|
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert "FTP: 300W" in out
|
||||||
|
assert "Settings ID: 100" in out
|
||||||
@@ -12,10 +12,8 @@ import time
|
|||||||
import types
|
import types
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
||||||
|
|
||||||
from intervals_mcp_server import auth as auth_mod
|
|
||||||
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
|
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
|
||||||
|
|
||||||
ISSUER = "https://auth.example/application/o/x/"
|
ISSUER = "https://auth.example/application/o/x/"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ and the error/empty branches.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from intervals_mcp_server.tools import custom_items
|
from intervals_mcp_server.tools import custom_items
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ def test_create_builds_full_payload(monkeypatch):
|
|||||||
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
|
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
|
||||||
out = _run(
|
out = _run(
|
||||||
custom_items.create_custom_item(
|
custom_items.create_custom_item(
|
||||||
name="Chart", item_type="FITNESS_CHART",
|
name="Chart", item_type="FITNESS_CHART",
|
||||||
description="desc", content={"a": 1}, visibility="PRIVATE",
|
description="desc", content={"a": 1}, visibility="PRIVATE",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def test_add_event_posts_when_no_event_id(monkeypatch):
|
|||||||
rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
|
rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
|
||||||
out = _run(
|
out = _run(
|
||||||
events.add_or_update_event(
|
events.add_or_update_event(
|
||||||
workout_type="Ride", name="Threshold",
|
workout_type="Ride", name="Threshold",
|
||||||
start_date="2026-07-10", moving_time=3600, distance=40000,
|
start_date="2026-07-10", moving_time=3600, distance=40000,
|
||||||
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
|
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
|
||||||
)
|
)
|
||||||
@@ -128,7 +128,7 @@ def test_update_event_puts_when_event_id(monkeypatch):
|
|||||||
rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
|
rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
|
||||||
_run(
|
_run(
|
||||||
events.add_or_update_event(
|
events.add_or_update_event(
|
||||||
workout_type="Ride", name="Threshold",
|
workout_type="Ride", name="Threshold",
|
||||||
event_id="e5", start_date="2026-07-10",
|
event_id="e5", start_date="2026-07-10",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ def test_format_wellness_entry():
|
|||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_wellness_entry_computes_form_tsb():
|
||||||
|
"""Form (TSB) is computed as CTL - ATL when both are present."""
|
||||||
|
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50, "atl": 65})
|
||||||
|
assert "Form (TSB): -15.0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_wellness_entry_no_form_without_both_components():
|
||||||
|
"""Form is omitted if either CTL or ATL is missing."""
|
||||||
|
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50})
|
||||||
|
assert "Form (TSB)" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_wellness_entry_prefers_explicit_date_over_id():
|
||||||
|
"""An explicit `date` field wins over `id` for the Date line."""
|
||||||
|
result = format_wellness_entry({"id": "2024-06-01", "date": "2024-06-02", "ctl": 50})
|
||||||
|
assert "Date: 2024-06-02" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_wellness_entry_null_date_falls_back_to_id():
|
||||||
|
"""A present-but-null `date` falls back to `id`, not 'None'."""
|
||||||
|
result = format_wellness_entry({"id": "2024-06-01", "date": None, "ctl": 50})
|
||||||
|
assert "Date: 2024-06-01" in result
|
||||||
|
assert "Date: None" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_wellness_entry_non_numeric_ctl_atl_does_not_crash():
|
||||||
|
"""Non-numeric ctl/atl must not raise; Form is simply omitted."""
|
||||||
|
result = format_wellness_entry({"id": "2024-06-01", "ctl": "n/a", "atl": "n/a"})
|
||||||
|
assert "Form (TSB)" not in result
|
||||||
|
assert "Date: 2024-06-01" in result
|
||||||
|
|
||||||
|
|
||||||
def test_format_wellness_entry_include_all_fields():
|
def test_format_wellness_entry_include_all_fields():
|
||||||
"""
|
"""
|
||||||
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
|
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Tests for the training-readiness feature.
|
||||||
|
|
||||||
|
The pure compute layer (utils/readiness.py) is exercised directly on deterministic
|
||||||
|
fixtures; one integration test drives the get_training_readiness tool with the HTTP
|
||||||
|
layer stubbed. Fixtures are built so verdicts are unambiguous.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from intervals_mcp_server.tools import wellness
|
||||||
|
from intervals_mcp_server.utils import readiness
|
||||||
|
|
||||||
|
|
||||||
|
def _days(specs: list[dict]) -> list[dict]:
|
||||||
|
"""Build wellness records with sequential dates from a list of field dicts."""
|
||||||
|
return [{"id": f"2026-06-{i + 1:02d}", **spec} for i, spec in enumerate(specs)]
|
||||||
|
|
||||||
|
|
||||||
|
def _days_ending_today(specs: list[dict]) -> list[dict]:
|
||||||
|
"""Like _days, but the last record is dated today (for tool-level tests)."""
|
||||||
|
start = date.today() - timedelta(days=len(specs) - 1)
|
||||||
|
return [
|
||||||
|
{"id": (start + timedelta(days=i)).isoformat(), **spec} for i, spec in enumerate(specs)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _stable(n: int, **fields) -> list[dict]:
|
||||||
|
return _days([dict(fields) for _ in range(n)])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# HRV signal
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_hrv_insufficient_data():
|
||||||
|
recs = _stable(10, hrv=50)
|
||||||
|
sig = readiness.hrv_signal(recs)
|
||||||
|
assert sig["level"] == "nodata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hrv_normal_band():
|
||||||
|
# 23 stable baseline days + 7 stable recent days -> within band
|
||||||
|
recs = _days([{"hrv": 50 + (i % 3)} for i in range(30)])
|
||||||
|
assert readiness.hrv_signal(recs)["level"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hrv_suppressed_alert():
|
||||||
|
baseline = [{"hrv": 50 + (i % 3)} for i in range(23)]
|
||||||
|
recent = [{"hrv": 34} for _ in range(7)]
|
||||||
|
assert readiness.hrv_signal(_days(baseline + recent))["level"] == "alert"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hrv_elevated_warn():
|
||||||
|
baseline = [{"hrv": 50 + (i % 3)} for i in range(23)]
|
||||||
|
recent = [{"hrv": 75} for _ in range(7)]
|
||||||
|
assert readiness.hrv_signal(_days(baseline + recent))["level"] == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hrv_constant_baseline_small_dip_is_not_alert():
|
||||||
|
# A near-constant baseline gives SWC ~ 0; the floor must keep a trivial
|
||||||
|
# 50 -> 49 fluctuation from producing a false "Compromised" alert.
|
||||||
|
recs = _days([{"hrv": 50} for _ in range(23)] + [{"hrv": 49} for _ in range(7)])
|
||||||
|
assert readiness.hrv_signal(recs)["level"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# RHR / sleep signals
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_rhr_elevated_warn():
|
||||||
|
recs = _days([{"restingHR": 48} for _ in range(23)] + [{"restingHR": 56} for _ in range(7)])
|
||||||
|
assert readiness.rhr_signal(recs)["level"] == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rhr_normal_ok():
|
||||||
|
assert readiness.rhr_signal(_stable(20, restingHR=48))["level"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rhr_minimum_days_is_nodata_not_self_baseline():
|
||||||
|
# With only 7 samples there is no disjoint baseline; a uniformly-elevated
|
||||||
|
# (ill) week must NOT read "ok" from being compared against itself.
|
||||||
|
sig = readiness.rhr_signal(_stable(7, restingHR=58))
|
||||||
|
assert sig["level"] == "nodata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_short_warn():
|
||||||
|
recs = _days([{"sleepSecs": 28800} for _ in range(10)] + [{"sleepSecs": 18000}])
|
||||||
|
assert readiness.sleep_signal(recs)["level"] == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_nodata():
|
||||||
|
assert readiness.sleep_signal(_stable(3, sleepSecs=28800))["level"] == "nodata"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# subjective signals (conventional direction)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_subjective_fatigue_elevated_warns():
|
||||||
|
recs = _days([{"fatigue": 2} for _ in range(10)] + [{"fatigue": 4}])
|
||||||
|
sigs = readiness.subjective_signals(recs)
|
||||||
|
assert any(s["name"] == "Fatigue" and s["level"] == "warn" for s in sigs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_subjective_stable_no_warning():
|
||||||
|
assert readiness.subjective_signals(_stable(10, fatigue=2, mood=3)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# overall verdict
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_verdict_green_all_stable():
|
||||||
|
recs = _days(
|
||||||
|
[{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(30)]
|
||||||
|
)
|
||||||
|
assert readiness.assess_readiness(recs)["verdict"] == "green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_red_on_hrv_suppression():
|
||||||
|
recs = _days(
|
||||||
|
[{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(23)]
|
||||||
|
+ [{"hrv": 33, "restingHR": 57, "sleepSecs": 28800} for _ in range(7)]
|
||||||
|
)
|
||||||
|
assert readiness.assess_readiness(recs)["verdict"] == "red"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_insufficient_when_hrv_sparse_and_little_else():
|
||||||
|
# Only 3 days total, no HRV baseline and <2 other core signals with data.
|
||||||
|
recs = _stable(3, restingHR=48)
|
||||||
|
out = readiness.assess_readiness(recs)
|
||||||
|
assert out["verdict"] == "insufficient"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_uses_rhr_and_sleep_when_hrv_missing():
|
||||||
|
# No HRV, but RHR + sleep both have data -> a verdict is still produced (green here).
|
||||||
|
recs = _days([{"restingHR": 48, "sleepSecs": 28800} for _ in range(20)])
|
||||||
|
out = readiness.assess_readiness(recs)
|
||||||
|
assert out["verdict"] == "green"
|
||||||
|
assert any(s["name"] == "HRV" and s["level"] == "nodata" for s in out["signals"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_context_computed():
|
||||||
|
recs = _days([{"ctl": 60, "atl": 70}])
|
||||||
|
assert readiness.form_context(recs) == {"form": -10.0, "ctl": 60, "atl": 70}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# render + tool integration
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_stale_data_withholds_verdict():
|
||||||
|
# Daily logging that STOPPED 3 weeks ago must not produce a current verdict:
|
||||||
|
# with today as the reference date every calendar window is empty.
|
||||||
|
old = _days([{"hrv": 50 + (i % 3), "restingHR": 48, "sleepSecs": 28800} for i in range(30)])
|
||||||
|
out = readiness.assess_readiness(old, reference_date=date.today().isoformat())
|
||||||
|
assert out["verdict"] == "insufficient"
|
||||||
|
assert all(s["level"] == "nodata" for s in out["signals"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sleep_not_logged_recently_is_nodata():
|
||||||
|
recs = _days([{"sleepSecs": 28800} for _ in range(10)])
|
||||||
|
sig = readiness.sleep_signal(recs, reference_date="2026-07-01") # 3 weeks later
|
||||||
|
assert sig["level"] == "nodata"
|
||||||
|
assert "no sleep logged since" in sig["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_insufficient_mentions_logging():
|
||||||
|
out = readiness.render_readiness(readiness.assess_readiness(_stable(3, restingHR=48)))
|
||||||
|
assert "Verdict withheld" in out
|
||||||
|
assert "Log daily HRV" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_training_readiness_tool(monkeypatch):
|
||||||
|
# Wellness API returns a date-keyed dict; the tool must normalize and assess it.
|
||||||
|
start = date.today() - timedelta(days=29)
|
||||||
|
records = {
|
||||||
|
(start + timedelta(days=i)).isoformat(): {
|
||||||
|
"hrv": 50 + (i % 3),
|
||||||
|
"restingHR": 48,
|
||||||
|
"sleepSecs": 28800,
|
||||||
|
}
|
||||||
|
for i in range(30)
|
||||||
|
}
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return records
|
||||||
|
|
||||||
|
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
||||||
|
out = asyncio.run(wellness.get_training_readiness(days=45))
|
||||||
|
assert calls[0]["url"] == "/athlete/i1/wellness"
|
||||||
|
assert "Training Readiness:" in out
|
||||||
|
assert "🟢 Ready" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_training_readiness_no_data(monkeypatch):
|
||||||
|
async def fake(**kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
||||||
|
assert "No wellness data found" in asyncio.run(wellness.get_training_readiness())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_training_readiness_error(monkeypatch):
|
||||||
|
async def fake(**kwargs):
|
||||||
|
return {"error": True, "message": "down"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
||||||
|
assert "Error fetching wellness data: down" in asyncio.run(wellness.get_training_readiness())
|
||||||
@@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch):
|
|||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
get_athlete_power_curves(
|
get_athlete_power_curves(
|
||||||
activity_type="Ride",
|
activity_type="Ride",
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "Power Curves (Ride):" in result
|
assert "Power Curves (Ride):" in result
|
||||||
@@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch):
|
|||||||
get_athlete_power_curves(
|
get_athlete_power_curves(
|
||||||
activity_type="Ride",
|
activity_type="Ride",
|
||||||
durations=[5, 60],
|
durations=[5, 60],
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "5s:" in result
|
assert "5s:" in result
|
||||||
@@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch):
|
|||||||
get_athlete_power_curves(
|
get_athlete_power_curves(
|
||||||
activity_type="Ride",
|
activity_type="Ride",
|
||||||
include_normalised=False,
|
include_normalised=False,
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "W/kg" not in result
|
assert "W/kg" not in result
|
||||||
@@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch):
|
|||||||
get_athlete_power_curves(
|
get_athlete_power_curves(
|
||||||
activity_type="Ride",
|
activity_type="Ride",
|
||||||
start_date="2026-01-01",
|
start_date="2026-01-01",
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
@@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
|
|||||||
activity_type="Ride",
|
activity_type="Ride",
|
||||||
this_season=False,
|
this_season=False,
|
||||||
last_season=False,
|
last_season=False,
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
@@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch):
|
|||||||
create_custom_item(
|
create_custom_item(
|
||||||
name="Activity Field",
|
name="Activity Field",
|
||||||
item_type="ACTIVITY_FIELD",
|
item_type="ACTIVITY_FIELD",
|
||||||
|
|
||||||
content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
|
content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -744,7 +744,7 @@ def test_create_custom_item_with_invalid_json_content(monkeypatch):
|
|||||||
create_custom_item(
|
create_custom_item(
|
||||||
name="Bad Item",
|
name="Bad Item",
|
||||||
item_type="FITNESS_CHART",
|
item_type="FITNESS_CHART",
|
||||||
|
|
||||||
content="not valid json", # type: ignore[arg-type]
|
content="not valid json", # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-5
@@ -11,16 +11,28 @@ import pytest
|
|||||||
|
|
||||||
from intervals_mcp_server import credentials
|
from intervals_mcp_server import credentials
|
||||||
from intervals_mcp_server.credentials import CredentialError
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
from intervals_mcp_server.tools import activities, custom_items, events, gear, power_curves, wellness
|
from intervals_mcp_server.tools import (
|
||||||
|
activities,
|
||||||
|
athlete,
|
||||||
|
custom_items,
|
||||||
|
events,
|
||||||
|
gear,
|
||||||
|
power_curves,
|
||||||
|
wellness,
|
||||||
|
workouts,
|
||||||
|
)
|
||||||
|
|
||||||
# (tool callable, minimal required positional args)
|
# (tool callable, minimal required positional args)
|
||||||
TOOL_CALLS = [
|
TOOL_CALLS: list[tuple] = [
|
||||||
(activities.get_activities, ()),
|
(activities.get_activities, ()),
|
||||||
(activities.get_activity_details, ("1",)),
|
(activities.get_activity_details, ("1",)),
|
||||||
(activities.get_activity_intervals, ("1",)),
|
(activities.get_activity_intervals, ("1",)),
|
||||||
(activities.get_activity_streams, ("1",)),
|
(activities.get_activity_streams, ("1",)),
|
||||||
(activities.get_activity_messages, ("1",)),
|
(activities.get_activity_messages, ("1",)),
|
||||||
(activities.add_activity_message, ("1", "hi")),
|
(activities.add_activity_message, ("1", "hi")),
|
||||||
|
(activities.search_activities, ("ride",)),
|
||||||
|
(activities.get_activity_best_efforts, ("1",)),
|
||||||
|
(activities.get_activity_interval_stats, ("1", 0, 100)),
|
||||||
(events.get_events, ()),
|
(events.get_events, ()),
|
||||||
(events.get_event_by_id, ("e1",)),
|
(events.get_event_by_id, ("e1",)),
|
||||||
(events.delete_event, ("e1",)),
|
(events.delete_event, ("e1",)),
|
||||||
@@ -28,6 +40,15 @@ TOOL_CALLS = [
|
|||||||
(events.add_or_update_event, ("Ride", "Name")),
|
(events.add_or_update_event, ("Ride", "Name")),
|
||||||
(events.add_or_update_note, ("Name", "desc")),
|
(events.add_or_update_note, ("Name", "desc")),
|
||||||
(wellness.get_wellness_data, ()),
|
(wellness.get_wellness_data, ()),
|
||||||
|
(wellness.update_wellness, ()),
|
||||||
|
(wellness.update_wellness_bulk, ([],)),
|
||||||
|
(wellness.get_training_readiness, ()),
|
||||||
|
(athlete.get_athlete_profile, ()),
|
||||||
|
(athlete.get_sport_settings, ()),
|
||||||
|
(athlete.get_athlete_summary, ()),
|
||||||
|
(athlete.update_sport_settings, (1,)),
|
||||||
|
(workouts.get_workouts, ()),
|
||||||
|
(workouts.get_workout, (1,)),
|
||||||
(power_curves.get_athlete_power_curves, ()),
|
(power_curves.get_athlete_power_curves, ()),
|
||||||
(gear.get_gear_list, ()),
|
(gear.get_gear_list, ()),
|
||||||
(custom_items.get_custom_items, ()),
|
(custom_items.get_custom_items, ()),
|
||||||
@@ -50,6 +71,17 @@ def test_tool_returns_message_when_unauthorized(monkeypatch, func, args):
|
|||||||
assert result == "ACCOUNT NOT APPROVED"
|
assert result == "ACCOUNT NOT APPROVED"
|
||||||
|
|
||||||
|
|
||||||
def test_all_20_tools_covered():
|
def test_all_tools_covered():
|
||||||
"""Guard: if a tool is added, add it here so its auth gate is tested."""
|
"""Guard: every registered MCP tool must appear in TOOL_CALLS.
|
||||||
assert len(TOOL_CALLS) == 20
|
|
||||||
|
Compares against the live tool registry instead of a hand-maintained count,
|
||||||
|
so adding a tool without adding its auth-gate test fails loudly here.
|
||||||
|
"""
|
||||||
|
from intervals_mcp_server.mcp_instance import mcp
|
||||||
|
|
||||||
|
registered = {t.name for t in asyncio.run(mcp.list_tools())}
|
||||||
|
covered = {f.__name__ for f, _ in TOOL_CALLS}
|
||||||
|
assert covered == registered, (
|
||||||
|
f"auth-gate matrix out of sync: missing={sorted(registered - covered)} "
|
||||||
|
f"extra={sorted(covered - registered)}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Tests for intervals_mcp_server.tools.wellness.
|
||||||
|
|
||||||
|
Covers the wellness write tool (``update_wellness``): field mapping to the
|
||||||
|
Intervals.icu camelCase schema, the sleep hours->seconds conversion and the
|
||||||
|
``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and
|
||||||
|
the error / credential branches. The default caller credentials come from the
|
||||||
|
autouse fixture in conftest (athlete ``i1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from intervals_mcp_server import credentials
|
||||||
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
|
from intervals_mcp_server.tools import wellness
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_request(monkeypatch, result):
|
||||||
|
"""Patch make_intervals_request; capture the call kwargs, return ``result``."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(wellness, "make_intervals_request", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_maps_fields_and_puts(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78})
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness(
|
||||||
|
date="2025-05-24",
|
||||||
|
weight=78,
|
||||||
|
resting_hr=50,
|
||||||
|
hrv=65.5,
|
||||||
|
sleep_hours=8,
|
||||||
|
calories_consumed=2200,
|
||||||
|
carbohydrates=300,
|
||||||
|
protein=140,
|
||||||
|
fat=70,
|
||||||
|
hydration_volume=2.5,
|
||||||
|
comments="felt good",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
assert call["method"] == "PUT"
|
||||||
|
assert call["url"] == "/athlete/i1/wellness/2025-05-24"
|
||||||
|
assert call["api_key"] == "testkey"
|
||||||
|
|
||||||
|
payload = call["data"]
|
||||||
|
assert payload["weight"] == 78
|
||||||
|
assert payload["restingHR"] == 50
|
||||||
|
assert payload["hrv"] == 65.5
|
||||||
|
assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds
|
||||||
|
assert payload["kcalConsumed"] == 2200
|
||||||
|
assert payload["carbohydrates"] == 300
|
||||||
|
assert payload["protein"] == 140
|
||||||
|
assert payload["fatTotal"] == 70
|
||||||
|
assert payload["hydrationVolume"] == 2.5
|
||||||
|
assert payload["comments"] == "felt good"
|
||||||
|
# Omitted fields must not be sent.
|
||||||
|
assert "mood" not in payload
|
||||||
|
assert "locked" not in payload
|
||||||
|
|
||||||
|
assert "Updated wellness for 2025-05-24" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_defaults_date_to_today(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {"id": "today"})
|
||||||
|
asyncio.run(wellness.update_wellness(weight=80))
|
||||||
|
# URL date segment defaults to today's date (YYYY-MM-DD, 10 chars).
|
||||||
|
date_seg = calls[0]["url"].rsplit("/", 1)[1]
|
||||||
|
assert len(date_seg) == 10 and date_seg.count("-") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_no_fields_returns_message(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {})
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24"))
|
||||||
|
assert "No wellness fields provided" in out
|
||||||
|
assert calls == [] # no request made
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_clear_with_negative_one(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
||||||
|
asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1))
|
||||||
|
payload = calls[0]["data"]
|
||||||
|
assert payload["weight"] == -1
|
||||||
|
# -1 is the clear sentinel and must NOT be scaled to -3600.
|
||||||
|
assert payload["sleepSecs"] == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_locked_false_is_sent(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
|
||||||
|
asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False))
|
||||||
|
assert calls[0]["data"]["locked"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_error_path(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||||
|
assert "Error updating wellness data: boom" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_invalid_date(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, {})
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80))
|
||||||
|
assert out.startswith("Error:")
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_credential_error(monkeypatch):
|
||||||
|
async def _deny():
|
||||||
|
raise CredentialError("not approved")
|
||||||
|
|
||||||
|
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||||
|
assert "not approved" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_non_dict_result(monkeypatch):
|
||||||
|
# If the API returns a list (unexpected), we still confirm the write.
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||||
|
assert out == "Updated wellness for 2025-05-24."
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
|
||||||
|
# If the API echo omits id/date, the confirmation body must not read "Date: N/A".
|
||||||
|
_patch_request(monkeypatch, {"weight": 80})
|
||||||
|
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
|
||||||
|
assert "Date: 2025-05-24" in out
|
||||||
|
assert "Date: N/A" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# update_wellness_bulk
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_update_wellness_bulk_success(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{"id": "2026-07-18"}, {"id": "2026-07-19"}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk(
|
||||||
|
[
|
||||||
|
{"date": "2026-07-18", "weight": 80, "carbohydrates": 300},
|
||||||
|
{"date": "2026-07-19", "sleep_hours": 8},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
call = calls[0]
|
||||||
|
assert call["method"] == "PUT"
|
||||||
|
assert call["url"] == "/athlete/i1/wellness-bulk"
|
||||||
|
body = call["data"]
|
||||||
|
assert isinstance(body, list) and len(body) == 2
|
||||||
|
assert body[0]["id"] == "2026-07-18"
|
||||||
|
assert body[0]["weight"] == 80
|
||||||
|
assert body[0]["carbohydrates"] == 300 # camelCase mapping shared with update_wellness
|
||||||
|
assert body[1]["sleepSecs"] == 8 * 3600
|
||||||
|
assert "Updated 2 day(s)" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_mapping_matches_single(monkeypatch):
|
||||||
|
# The bulk payload for a day must equal the single-day payload for the same fields
|
||||||
|
# (plus the id) — proving the shared _wellness_payload helper prevents drift.
|
||||||
|
fields = {"weight": 78, "resting_hr": 50, "sleep_hours": 7.5, "fat": 60, "locked": True}
|
||||||
|
from intervals_mcp_server.tools.wellness import _wellness_payload
|
||||||
|
|
||||||
|
single = _wellness_payload(fields)
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", **fields}]))
|
||||||
|
bulk_entry = {k: v for k, v in calls[0]["data"][0].items() if k != "id"}
|
||||||
|
assert bulk_entry == single
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_invalid_date_rejects_whole_batch(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk(
|
||||||
|
[{"date": "2026-07-18", "weight": 80}, {"date": "not-a-date", "weight": 81}]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "Error in entry 1" in out
|
||||||
|
assert calls == [] # no partial write
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_missing_date(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"weight": 80}]))
|
||||||
|
assert "entry 0 is missing a 'date'" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_entry_no_fields(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18"}]))
|
||||||
|
assert "has no wellness fields" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_empty(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([]))
|
||||||
|
assert "No entries provided" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_too_many(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk([{"date": "2026-01-01", "weight": 80}] * 93)
|
||||||
|
)
|
||||||
|
assert "Too many entries" in out
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_rejects_unknown_keys(monkeypatch):
|
||||||
|
# camelCase/API-style names must be rejected, not silently dropped: the value
|
||||||
|
# the caller asked to record would otherwise be lost behind a success message.
|
||||||
|
calls = _patch_request(monkeypatch, [{}])
|
||||||
|
out = asyncio.run(
|
||||||
|
wellness.update_wellness_bulk(
|
||||||
|
[{"date": "2026-07-18", "weight": 80, "restingHR": 50}]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "unrecognized field(s): restingHR" in out
|
||||||
|
assert "resting_hr" in out # the error names the valid fields
|
||||||
|
assert calls == [] # whole batch rejected, nothing written
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_wellness_bulk_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
out = asyncio.run(wellness.update_wellness_bulk([{"date": "2026-07-18", "weight": 80}]))
|
||||||
|
assert "Error updating wellness data: boom" in out
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Tests for intervals_mcp_server.tools.workouts (0.3.0 workout library).
|
||||||
|
|
||||||
|
Covers get_workouts (list + client-side filters) and get_workout (full detail
|
||||||
|
with a nested workout_doc), plus empty / error / credential branches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from intervals_mcp_server import credentials
|
||||||
|
from intervals_mcp_server.credentials import CredentialError
|
||||||
|
from intervals_mcp_server.tools import workouts
|
||||||
|
|
||||||
|
LIBRARY = [
|
||||||
|
{"id": 10, "name": "VO2 5x5", "type": "Ride", "icu_training_load": 95, "moving_time": 3600, "folder_id": 1},
|
||||||
|
{"id": 11, "name": "Easy run", "type": "Run", "moving_time": 2400, "folder_id": 2},
|
||||||
|
]
|
||||||
|
|
||||||
|
WORKOUT_DETAIL = {
|
||||||
|
"id": 10,
|
||||||
|
"name": "VO2 5x5",
|
||||||
|
"type": "Ride",
|
||||||
|
"indoor": True,
|
||||||
|
"moving_time": 3600,
|
||||||
|
"icu_training_load": 95,
|
||||||
|
"description": "VO2max builder",
|
||||||
|
"tags": ["vo2", "key"],
|
||||||
|
"workout_doc": {
|
||||||
|
"steps": [
|
||||||
|
{"duration": 900, "power": {"value": 60, "units": "%ftp"}, "warmup": True},
|
||||||
|
{
|
||||||
|
"reps": 5,
|
||||||
|
"steps": [
|
||||||
|
{"duration": 300, "power": {"value": 115, "units": "%ftp"}, "text": "hard"},
|
||||||
|
{"duration": 300, "power": {"value": 50, "units": "%ftp"}, "text": "easy"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"duration": 600, "power": {"start": 60, "end": 40, "units": "%ftp"}, "cooldown": True},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_request(monkeypatch, result):
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(workouts, "make_intervals_request", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workouts_all(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, LIBRARY)
|
||||||
|
out = asyncio.run(workouts.get_workouts())
|
||||||
|
assert calls[0]["url"] == "/athlete/i1/workouts"
|
||||||
|
assert "Workout Library (2)" in out
|
||||||
|
assert "VO2 5x5 | Ride (load 95, 3600s, folder 1) [id: 10]" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workouts_filter_folder(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, LIBRARY)
|
||||||
|
out = asyncio.run(workouts.get_workouts(folder_id=2))
|
||||||
|
assert "Easy run" in out
|
||||||
|
assert "VO2 5x5" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workouts_filter_sport(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, LIBRARY)
|
||||||
|
out = asyncio.run(workouts.get_workouts(sport_type="ride"))
|
||||||
|
assert "VO2 5x5" in out
|
||||||
|
assert "Easy run" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workouts_empty(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, [])
|
||||||
|
assert "No workouts found" in asyncio.run(workouts.get_workouts())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workouts_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "boom"})
|
||||||
|
assert "Error fetching workouts: boom" in asyncio.run(workouts.get_workouts())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workout_detail_with_nested_doc(monkeypatch):
|
||||||
|
calls = _patch_request(monkeypatch, WORKOUT_DETAIL)
|
||||||
|
out = asyncio.run(workouts.get_workout(10))
|
||||||
|
assert calls[0]["url"] == "/athlete/i1/workouts/10"
|
||||||
|
assert "Workout: VO2 5x5" in out
|
||||||
|
assert "Training Load: 95" in out
|
||||||
|
assert "Tags: vo2, key" in out
|
||||||
|
assert "Steps:" in out
|
||||||
|
assert "15m @ 60%ftp (warmup)" in out
|
||||||
|
assert "5x:" in out # repeat block rendered
|
||||||
|
assert "5m @ 115%ftp — hard" in out # nested step
|
||||||
|
assert "10m @ 60-40%ftp (cooldown)" in out # power range (no ramp flag set)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workout_ramp_step(monkeypatch):
|
||||||
|
_patch_request(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Ramp test",
|
||||||
|
"workout_doc": {
|
||||||
|
"steps": [{"ramp": True, "power": {"start": 100, "end": 300, "units": "w"}}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
out = asyncio.run(workouts.get_workout(12))
|
||||||
|
assert "ramp 100-300w" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workout_not_found(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {})
|
||||||
|
assert "No workout found with ID 99" in asyncio.run(workouts.get_workout(99))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workout_error(monkeypatch):
|
||||||
|
_patch_request(monkeypatch, {"error": True, "message": "nope"})
|
||||||
|
assert "Error fetching workout: nope" in asyncio.run(workouts.get_workout(10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workout_credential_error(monkeypatch):
|
||||||
|
async def _deny():
|
||||||
|
raise CredentialError("not approved")
|
||||||
|
|
||||||
|
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
|
||||||
|
assert "not approved" in asyncio.run(workouts.get_workout(10))
|
||||||
@@ -538,7 +538,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "intervalsicu-mcp"
|
name = "intervalsicu-mcp"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
@@ -1346,8 +1346,8 @@ name = "secretstorage"
|
|||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography", marker = "sys_platform != 'win32'" },
|
{ name = "cryptography" },
|
||||||
{ name = "jeepney", marker = "sys_platform != 'win32'" },
|
{ name = "jeepney" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
Reference in New Issue
Block a user