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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ hevy workouts get abc123
# List all workouts (auto-paginate)
hevy workouts list --all

# Filter by date range (YYYY-MM-DD, both bounds inclusive)
hevy workouts list --since 2026-01-01 --until 2026-03-31 --all

# Count total workouts
hevy workouts count

Expand Down
6 changes: 4 additions & 2 deletions skills/hevy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ All commands accept `--format json` (default for piping) or `--format table|yaml

| Operation | Command | Notes |
|---|---|---|
| List recent workouts | `hevy workouts list --format json` | Paginated, default 5/page; add `--all` to fetch all pages |
| List recent workouts | `hevy workouts list --format json` | Paginated, default 5/page; add `--all` to fetch all pages. Filter by date with `--since YYYY-MM-DD` and/or `--until YYYY-MM-DD` (both bounds inclusive) |
| Get one workout | `hevy workouts get <id> --format json` | |
| Total count | `hevy workouts count` | Returns just the integer |
| Sync events | `hevy workouts events --since 2026-01-01T00:00:00Z --format json` | Updates/deletes since timestamp |
Expand Down Expand Up @@ -99,8 +99,10 @@ The CLI suppresses tracebacks by default. Pass `--debug` to see HTTP requests an

### Show this week's workouts

Prefer `--since` over jq filtering — it pushes the filter into the CLI and avoids dumping unwanted workouts into context:

```bash
hevy workouts list --all --format json | jq '[.[] | select(.start_time >= "2026-04-19T00:00:00Z")] | length'
hevy workouts list --since 2026-04-19 --all --format json | jq -r '.[] | "\(.start_time[:10]) \(.title)"'
```

Then summarise titles, dates, and total volume in plain text.
Expand Down
48 changes: 45 additions & 3 deletions src/hevy_cli/commands/workouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from datetime import datetime
from typing import Any

import click
Expand All @@ -26,6 +27,30 @@ def _enrich_workout(w: dict[str, Any]) -> dict[str, Any]:
return w


def _parse_date(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
"""Parse a date string in YYYY-MM-DD format, returning ISO string, or raise BadParameter if invalid."""

if value is None:
return None
try:
# Accept YYYY-MM-DD format, add more formats as needed
return datetime.strptime(value, "%Y-%m-%d").date().isoformat()
except ValueError as exc:
raise click.BadParameter(
f"Date '{value}' is not valid. Please use YYYY-MM-DD format."
) from exc


def _within_range(w: dict[str, Any], since: str | None, until: str | None) -> bool:
"""Return True if workout w's start_time date is within [since, until] (inclusive), or since/until are None."""
# Get date portion from ISO string "YYYY-MM-DDTHH:MM:SSZ"
workout_date = w.get("start_time", "")[:10]

return not (
(since is not None and workout_date < since) or (until is not None and workout_date > until)
)


@click.group()
def workouts() -> None:
"""Manage workouts."""
Expand All @@ -35,19 +60,36 @@ def workouts() -> None:
@click.option("--page", default=1, type=int, help="Page number")
@click.option("--page-size", default=5, type=int, help="Items per page (max 10)")
@click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages")
@click.option("--since", callback=_parse_date, help="Show workouts since this date (YYYY-MM-DD)")
@click.option("--until", callback=_parse_date, help="Show workouts until this date (YYYY-MM-DD)")
@format_option
@click.pass_context
def list_workouts(ctx: click.Context, page: int, page_size: int, fetch_all: bool) -> None:
def list_workouts(
ctx: click.Context,
page: int,
page_size: int,
fetch_all: bool,
since: str | None,
until: str | None,
) -> None:
"""List workouts."""
client = get_client(ctx)
fmt = detect_format(ctx.obj.get("output_format"))

if fetch_all:
items = [_enrich_workout(w) for w in client.iter_all_workouts(page_size=page_size)]
items = [
_enrich_workout(w)
for w in client.iter_all_workouts(page_size=page_size)
if _within_range(w, since, until)
]
output(items, fmt=fmt, columns=WORKOUT_COLUMNS, title="All Workouts")
else:
result = client.list_workouts(page=page, page_size=page_size)
items = [_enrich_workout(w.model_dump()) for w in result.workouts]
items = []
for w in result.workouts:
d = w.model_dump()
if _within_range(d, since, until):
items.append(_enrich_workout(d))
output(
items,
fmt=fmt,
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ def sample_workout() -> dict:
}


@pytest.fixture
def multi_workout_response(sample_workout: dict) -> dict:
"""Three workouts at different dates for date-range filter tests."""
return {
"page": 1,
"page_count": 1,
"workouts": [
{**sample_workout, "id": "workout-aug-10", "start_time": "2024-08-10T12:00:00Z"},
{**sample_workout, "id": "workout-aug-14", "start_time": "2024-08-14T12:00:00Z"},
{**sample_workout, "id": "workout-aug-20", "start_time": "2024-08-20T12:00:00Z"},
],
}


@pytest.fixture
def sample_routine() -> dict:
return {
Expand Down
96 changes: 96 additions & 0 deletions tests/test_commands/test_workouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import json

import respx
from click.testing import CliRunner
from httpx import Response
Expand Down Expand Up @@ -39,3 +41,97 @@ def test_workouts_list_no_api_key() -> None:
result = runner.invoke(cli, ["workouts", "list"], env={"HEVY_API_KEY": ""})
assert result.exit_code != 0
assert "API key required" in result.output


@respx.mock
def test_workouts_list_with_since(multi_workout_response: dict) -> None:
respx.get("https://api.hevy.com/v1/workouts").mock(
return_value=Response(
200,
json=multi_workout_response,
)
)
runner = CliRunner()
result = runner.invoke(
cli,
["--api-key", "test-key", "--format", "json", "workouts", "list", "--since", "2024-08-12"],
)
assert result.exit_code == 0

parsed = json.loads(result.output)
ids = [w["id"] for w in parsed]

assert len(ids) == 2
assert "workout-aug-10" not in ids
assert "workout-aug-14" in ids
assert "workout-aug-20" in ids


@respx.mock
def test_workouts_list_with_until(multi_workout_response: dict) -> None:
respx.get("https://api.hevy.com/v1/workouts").mock(
return_value=Response(
200,
json=multi_workout_response,
)
)
runner = CliRunner()
result = runner.invoke(
cli,
["--api-key", "test-key", "--format", "json", "workouts", "list", "--until", "2024-08-15"],
)
assert result.exit_code == 0

parsed = json.loads(result.output)
ids = [w["id"] for w in parsed]

assert len(ids) == 2
assert "workout-aug-20" not in ids
assert "workout-aug-14" in ids
assert "workout-aug-10" in ids


@respx.mock
def test_workouts_list_with_since_and_until(multi_workout_response: dict) -> None:
respx.get("https://api.hevy.com/v1/workouts").mock(
return_value=Response(
200,
json=multi_workout_response,
)
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--api-key",
"test-key",
"--format",
"json",
"workouts",
"list",
"--since",
"2024-08-12",
"--until",
"2024-08-18",
],
)
assert result.exit_code == 0

parsed = json.loads(result.output)
ids = [w["id"] for w in parsed]

assert len(ids) == 1
assert "workout-aug-10" not in ids
assert "workout-aug-20" not in ids
assert "workout-aug-14" in ids


def test_workouts_list_invalid_date(multi_workout_response: dict) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
["--api-key", "test-key", "--format", "json", "workouts", "list", "--until", "08-15-2024"],
)
assert result.exit_code != 0

assert "is not valid" in result.output
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.