feat: implement format_duration_markdown utility and update tool outputs to use it for readable duration formatting
CI/CD Pipeline / Lint & Check (push) Successful in 11s
CI/CD Pipeline / Publish to PyPI (push) Has been skipped
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m34s

This commit is contained in:
2026-05-30 11:40:51 +02:00
parent e4f8a0c4e0
commit 90e23c26b8
6 changed files with 87 additions and 37 deletions
+15 -13
View File
@@ -6,8 +6,7 @@ from strava_mcp_server.utils import (
parse_iso_to_unix,
format_date_iso,
format_date_human,
format_duration_text,
format_duration_table,
format_duration_markdown,
)
@@ -68,7 +67,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"start_date": format_date_iso(start_date_raw),
"start_date_local": format_date_human(start_date_raw),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": format_duration_table(a.get("moving_time")),
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"average_heartrate": a.get("average_heartrate"),
"gear_id": a.get("gear_id"),
@@ -87,13 +86,14 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
markdown_summary += (
"|-------|-------|------|---------|------|------------|------|\n"
)
for a in essential_data:
for a, raw_act in zip(essential_data, activities):
hr = (
f"{a['average_heartrate']:.0f} bpm"
if a["average_heartrate"]
else "-"
)
markdown_summary += f"| {a['start_date_local']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n"
time_md = format_duration_markdown(raw_act.get("moving_time"))
markdown_summary += f"| {a['start_date_local']} | {a['sport_type']} | {a['name']} | {a['distance']} | {time_md} | {a['total_elevation_gain']} | {hr} |\n"
return [
_user_text(markdown_summary.strip()),
@@ -127,7 +127,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
sport = activity.get("sport_type") or activity.get("type", "N/A")
date = format_date_human(activity.get("start_date"))
dist = f"{activity.get('distance', 0) / 1000:.2f} km"
time = format_duration_text(activity.get("moving_time"))
time = format_duration_markdown(activity.get("moving_time"))
elev = f"{activity.get('total_elevation_gain', 0):.0f} m"
avg_hr = (
f"{activity.get('average_heartrate', 0):.0f} bpm"
@@ -256,8 +256,8 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"lap_index": lap.get("lap_index"),
"name": lap.get("name"),
"distance": f"{lap.get('distance', 0) / 1000:.3f} km",
"moving_time": format_duration_table(lap.get("moving_time")),
"elapsed_time": format_duration_table(lap.get("elapsed_time")),
"moving_time": f"{lap.get('moving_time', 0) / 60:.1f} min",
"elapsed_time": f"{lap.get('elapsed_time', 0) / 60:.1f} min",
"average_speed": f"{lap.get('average_speed', 0) * 3.6:.2f} km/h",
"max_speed": f"{lap.get('max_speed', 0) * 3.6:.2f} km/h",
"elevation_gain": f"{lap.get('total_elevation_gain', 0):.0f} m",
@@ -272,13 +272,14 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md = f"### 🔄 Runden ({len(data)})\n"
md += "| # | Distanz | Zeit | Ø Speed | Ø HR |\n"
md += "|---|---------|------|---------|------|\n"
for lap in data:
for lap, raw_lap in zip(data, laps):
hr = (
f"{lap['average_heartrate']:.0f} bpm"
if lap["average_heartrate"]
else "-"
)
md += f"| {lap['lap_index']} | {lap['distance']} | {lap['moving_time']} | {lap['average_speed']} | {hr} |\n"
time_md = format_duration_markdown(raw_lap.get("moving_time"))
md += f"| {lap['lap_index']} | {lap['distance']} | {time_md} | {lap['average_speed']} | {hr} |\n"
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/laps", data),
@@ -303,7 +304,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"zone": i + 1,
"min": b.get("min", 0),
"max": b.get("max", -1),
"time_in_zone": format_duration_text(b.get("time")),
"time_in_zone": f"{b.get('time', 0) / 60:.1f} min",
}
for i, b in enumerate(zone.get("distribution_buckets", []))
]
@@ -322,9 +323,10 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
else "Leistung (Power)"
)
md += f"#### {label}\n| Zone | Bereich | Zeit |\n|------|---------|------|\n"
for b in buckets:
for b, raw_b in zip(buckets, zone.get("distribution_buckets", [])):
max_val = "max" if b["max"] == -1 else str(b["max"])
md += f"| {b['zone']} | {b['min']} {max_val} | {b['time_in_zone']} |\n"
time_md = format_duration_markdown(raw_b.get("time"))
md += f"| {b['zone']} | {b['min']} {max_val} | {time_md} |\n"
md += "\n"
return [
_user_text(md.strip()),
+5 -4
View File
@@ -2,7 +2,7 @@ import json
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import format_duration_table
from strava_mcp_server.utils import format_duration_markdown
def _resource(uri: str, data) -> EmbeddedResource:
@@ -80,7 +80,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"name": a.get("name"),
"sport_type": a.get("sport_type") or a.get("type"),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": format_duration_table(a.get("moving_time")),
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"athlete": f"{a.get('athlete', {}).get('firstname')} {a.get('athlete', {}).get('lastname')}",
}
@@ -92,8 +92,9 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md = f"### 🚴 Club-Aktivitäten ({len(data)})\n"
md += "| Athlet | Sport | Name | Distanz | Zeit |\n"
md += "|--------|-------|------|---------|------|\n"
for a in data:
md += f"| {a['athlete']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} |\n"
for a, raw_act in zip(data, activities):
time_md = format_duration_markdown(raw_act.get("moving_time"))
md += f"| {a['athlete']} | {a['sport_type']} | {a['name']} | {a['distance']} | {time_md} |\n"
return [
_user_text(md.strip()),
_resource(f"internal://clubs/{club_id}/activities", data),
+10 -10
View File
@@ -2,7 +2,7 @@ import json
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import format_duration_text, format_duration_table
from strava_mcp_server.utils import format_duration_markdown
def _resource(uri: str, data) -> EmbeddedResource:
@@ -44,9 +44,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"sub_type": r.get("sub_type"),
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
"estimated_moving_time": format_duration_table(
r.get("estimated_moving_time")
),
"estimated_moving_time": f"{r.get('estimated_moving_time', 0) / 60:.0f} min",
"starred": r.get("starred", False),
"private": r.get("private", False),
}
@@ -58,9 +56,12 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md = f"### 🗺️ Routen ({len(data)})\n"
md += "| Name | Typ | Distanz | Höhenmeter | Dauer |\n"
md += "|------|-----|---------|------------|-------|\n"
for r in data:
for r, raw_r in zip(data, routes):
star = "" if r["starred"] else ""
md += f"| {star}{r['name']} | {r['type']} | {r['distance']} | {r['elevation_gain']} | {r['estimated_moving_time']} |\n"
time_md = format_duration_markdown(
raw_r.get("estimated_moving_time")
)
md += f"| {star}{r['name']} | {r['type']} | {r['distance']} | {r['elevation_gain']} | {time_md} |\n"
return [_user_text(md.strip()), _resource("internal://routes/list", data)]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching routes: {str(e)}")]
@@ -85,9 +86,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
else "Other",
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
"estimated_moving_time": format_duration_text(
r.get("estimated_moving_time")
),
"estimated_moving_time": f"{r.get('estimated_moving_time', 0) / 60:.0f} min",
"starred": r.get("starred", False),
"private": r.get("private", False),
"segments": [
@@ -101,13 +100,14 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
],
}
n_seg = len(data["segments"])
time_md = format_duration_markdown(r.get("estimated_moving_time"))
md = f"""### 🗺️ Route: {data["name"]}
| Feld | Wert |
|------|------|
| Typ | {data["type"]} |
| Distanz | {data["distance"]} |
| Höhenmeter | {data["elevation_gain"]} |
| Geschätzte Dauer | {data["estimated_moving_time"]} |
| Geschätzte Dauer | {time_md} |
| Segmente | {n_seg} |
| Favorit | {"⭐ Ja" if data["starred"] else "Nein"} |
| Privat | {"🔒 Ja" if data["private"] else "Nein"} |"""
+13 -10
View File
@@ -5,8 +5,7 @@ from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import (
format_date_iso,
format_date_human,
format_duration_text,
format_duration_table,
format_duration_markdown,
)
@@ -40,8 +39,8 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
data = {
"id": str(e.get("id")),
"name": e.get("name"),
"elapsed_time": format_duration_text(e.get("elapsed_time")),
"moving_time": format_duration_text(e.get("moving_time")),
"elapsed_time": f"{e.get('elapsed_time', 0) / 60:.1f} min",
"moving_time": f"{e.get('moving_time', 0) / 60:.1f} min",
"start_date": format_date_iso(e.get("start_date")),
"distance": f"{e.get('distance', 0) / 1000:.2f} km",
"average_watts": e.get("average_watts"),
@@ -58,13 +57,15 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
if data["average_heartrate"]
else "N/A"
)
time_elapsed = format_duration_markdown(e.get("elapsed_time"))
time_moving = format_duration_markdown(e.get("moving_time"))
md = f"""### 🏅 Segment-Effort: {data["name"]}
| Feld | Wert |
|------|------|
| Datum | {format_date_human(data["start_date"])} |
| Distanz | {data["distance"]} |
| Zeit (gesamt) | {data["elapsed_time"]} |
| Fahrzeit | {data["moving_time"]} |
| Zeit (gesamt) | {time_elapsed} |
| Fahrzeit | {time_moving} |
| Ø Leistung | {w} |
| Ø Herzfrequenz | {hr} |
| PR-Rang | {pr} |
@@ -104,8 +105,8 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
data = [
{
"id": str(e.get("id")),
"elapsed_time": format_duration_table(e.get("elapsed_time")),
"moving_time": format_duration_table(e.get("moving_time")),
"elapsed_time": f"{e.get('elapsed_time', 0) / 60:.1f} min",
"moving_time": f"{e.get('moving_time', 0) / 60:.1f} min",
"start_date": format_date_iso(e.get("start_date")),
"average_watts": e.get("average_watts"),
"average_heartrate": e.get("average_heartrate"),
@@ -119,9 +120,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md = f"### 🏅 Segment-Efforts ({len(data)})\n"
md += "| Datum | Zeit | Fahrzeit | PR-Rang |\n"
md += "|-------|------|----------|--------|\n"
for effort in data:
for effort, raw_eff in zip(data, efforts):
pr = f"#{effort['pr_rank']}" if effort["pr_rank"] else "-"
md += f"| {format_date_human(effort['start_date'])} | {effort['elapsed_time']} | {effort['moving_time']} | {pr} |\n"
elapsed_md = format_duration_markdown(raw_eff.get("elapsed_time"))
moving_md = format_duration_markdown(raw_eff.get("moving_time"))
md += f"| {format_date_human(effort['start_date'])} | {elapsed_md} | {moving_md} | {pr} |\n"
return [
_user_text(md.strip()),
_resource(f"internal://segments/{segment_id}/efforts", data),
+20
View File
@@ -105,3 +105,23 @@ def format_duration_table(seconds_input: Optional[float | int]) -> str:
return f"{hours}:{minutes:02d}"
except Exception:
return str(seconds_input)
def format_duration_markdown(seconds_input: Optional[float | int]) -> str:
"""
Formats a duration in seconds to a readable markdown format: '1h 23min' or '45min'.
e.g., 9240 -> '2h 34min', 2700 -> '45min'.
"""
if seconds_input is None:
return "N/A"
try:
total_minutes = int(float(seconds_input) / 60 + 0.5)
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0:
return f"{hours}h {minutes}min"
return f"{minutes}min"
except Exception:
return str(seconds_input)
+24
View File
@@ -8,6 +8,7 @@ from strava_mcp_server.utils import (
format_date_human,
format_duration_text,
format_duration_table,
format_duration_markdown,
)
@@ -164,3 +165,26 @@ class TestFormatDurationTable:
def test_fractional_input(self):
assert format_duration_table(2700.2) == "0:45"
class TestFormatDurationMarkdown:
def test_none_returns_na(self):
assert format_duration_markdown(None) == "N/A"
def test_zero_seconds(self):
assert format_duration_markdown(0) == "0min"
def test_under_one_hour(self):
# 2700 seconds = 45 minutes
assert format_duration_markdown(2700) == "45min"
def test_over_one_hour(self):
# 9240 seconds = 2h 34m
assert format_duration_markdown(9240) == "2h 34min"
def test_hours_with_zero_minutes(self):
# 7200 seconds = 2h 0m
assert format_duration_markdown(7200) == "2h 0min"
def test_fractional_input(self):
assert format_duration_markdown(2700.2) == "45min"