feat: implement format_duration_markdown utility and update tool outputs to use it for readable duration formatting
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"} |"""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user