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
34 changes: 32 additions & 2 deletions src/ytstudio/commands/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -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):
Expand Down
60 changes: 60 additions & 0 deletions tests/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
)
Loading