diff --git a/src/ytstudio/commands/analytics.py b/src/ytstudio/commands/analytics.py index 6db7962..0aea481 100644 --- a/src/ytstudio/commands/analytics.py +++ b/src/ytstudio/commands/analytics.py @@ -2,7 +2,7 @@ import io import json from dataclasses import asdict, dataclass -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import typer @@ -318,6 +318,35 @@ def _parse_comma_list(value: str) -> list[str]: return [v.strip() for v in value.split(",") if v.strip()] +def _align_date_range( + dimension_names: list[str], start_date: str, end_date: str +) -> tuple[str, str]: + """Snap dates to the boundaries the YouTube Analytics API requires. + + month: both ends must be the first of a month. The API rejects other days, + it does not snap. So snap both ends DOWN to the first of their month. + week: API weeks begin on Sunday. Snap both ends back to their Sunday. + See https://developers.google.com/youtube/analytics/dimensions (time periods). + Other dimensions (day, country, ...) are returned unchanged. + """ + if DimensionName.MONTH in dimension_names: + return _snap_to_month_start(start_date), _snap_to_month_start(end_date) + if "week" in dimension_names: + return _snap_to_week_start(start_date), _snap_to_week_start(end_date) + return start_date, end_date + + +def _snap_to_month_start(value: str) -> str: + d = date.fromisoformat(value) + return d.replace(day=1).isoformat() + + +def _snap_to_week_start(value: str) -> str: + # weekday(): Mon=0 .. Sun=6; days back to the preceding Sunday. + d = date.fromisoformat(value) + return (d - timedelta(days=(d.weekday() + 1) % 7)).isoformat() + + def _format_query_response(response: dict, output: str) -> None: headers = [h["name"] for h in response.get("columnHeaders", [])] rows = response.get("rows", []) @@ -449,9 +478,10 @@ def query( raise typer.Exit(1) filters_str = ";".join(filter_list) - # Build dates + # Build dates, then snap to dimension-required boundaries (month, week). end_date = end or datetime.now().strftime("%Y-%m-%d") start_date = start or (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + start_date, end_date = _align_date_range(dimension_names, start_date, end_date) # The video dimension requires sort + maxResults per YouTube API docs if DimensionName.VIDEO in dimension_names and (not sort or not limit): diff --git a/tests/test_analytics.py b/tests/test_analytics.py index d84fbfd..c0700e6 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -4,6 +4,7 @@ import typer from typer.testing import CliRunner +from ytstudio.commands.analytics import _align_date_range from ytstudio.main import app from ytstudio.ui import format_number, set_raw_output @@ -392,3 +393,62 @@ def test_json_output(self): data = json.loads(result.output) assert isinstance(data, list) assert any(d["name"] == "country" for d in data) + + +class TestAlignDateRange: + def test_month_snaps_start_down(self): + assert _align_date_range(["month"], "2026-04-17", "2026-06-01") == ( + "2026-04-01", + "2026-06-01", + ) + + def test_month_snaps_end_down(self): + assert _align_date_range(["month"], "2026-04-01", "2026-06-23") == ( + "2026-04-01", + "2026-06-01", + ) + + def test_month_days_derived_non_aligned(self): + # --days 365 from a mid-month "today" yields two arbitrary days. + assert _align_date_range(["month"], "2025-06-18", "2026-06-18") == ( + "2025-06-01", + "2026-06-01", + ) + + def test_month_already_aligned_unchanged(self): + assert _align_date_range(["month"], "2026-04-01", "2026-06-01") == ( + "2026-04-01", + "2026-06-01", + ) + + def test_month_same_month_range(self): + assert _align_date_range(["month"], "2026-04-10", "2026-04-25") == ( + "2026-04-01", + "2026-04-01", + ) + + def test_day_untouched(self): + assert _align_date_range(["day"], "2026-04-17", "2026-06-23") == ( + "2026-04-17", + "2026-06-23", + ) + + def test_no_dimension_untouched(self): + assert _align_date_range([], "2026-04-17", "2026-06-23") == ( + "2026-04-17", + "2026-06-23", + ) + + def test_week_snaps_back_to_sunday(self): + # 2026-06-18 is a Thursday; preceding Sunday is 2026-06-14. + assert _align_date_range(["week"], "2026-06-18", "2026-06-18") == ( + "2026-06-14", + "2026-06-14", + ) + + def test_week_sunday_unchanged(self): + # 2026-06-14 is a Sunday; stays put. + assert _align_date_range(["week"], "2026-06-14", "2026-06-21") == ( + "2026-06-14", + "2026-06-21", + )