From 16721b8fba7fcc9269f347ee32ab98ca8be37a07 Mon Sep 17 00:00:00 2001 From: Marin Salinas Date: Thu, 7 May 2026 22:21:15 -0500 Subject: [PATCH] feat: add --since/--until date filters to workouts list Both flags accept YYYY-MM-DD and are inclusive on the date portion. Filtering is client-side (Hevy /v1/workouts has no native date params), applied in both --all and paginated branches against Workout.start_time. Updates the Claude Code skill to surface the new flags and rewrites the "this week's workouts" example to push filtering into the CLI rather than jq. --- README.md | 3 + skills/hevy/SKILL.md | 6 +- src/hevy_cli/commands/workouts.py | 48 +++++++++++++- tests/conftest.py | 14 ++++ tests/test_commands/test_workouts.py | 96 ++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 163 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index baf2491..0ddddfb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/hevy/SKILL.md b/skills/hevy/SKILL.md index 61500a0..93383d7 100644 --- a/skills/hevy/SKILL.md +++ b/skills/hevy/SKILL.md @@ -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 --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 | @@ -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. diff --git a/src/hevy_cli/commands/workouts.py b/src/hevy_cli/commands/workouts.py index d0418b4..0a925d5 100644 --- a/src/hevy_cli/commands/workouts.py +++ b/src/hevy_cli/commands/workouts.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from typing import Any import click @@ -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.""" @@ -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, diff --git a/tests/conftest.py b/tests/conftest.py index 88bce82..b5e64cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 { diff --git a/tests/test_commands/test_workouts.py b/tests/test_commands/test_workouts.py index ec74464..cd3df84 100644 --- a/tests/test_commands/test_workouts.py +++ b/tests/test_commands/test_workouts.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import respx from click.testing import CliRunner from httpx import Response @@ -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 diff --git a/uv.lock b/uv.lock index 654672b..acde685 100644 --- a/uv.lock +++ b/uv.lock @@ -196,7 +196,7 @@ wheels = [ [[package]] name = "hevy-cli" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "click" },