From e1f89d925debc3a115adc18aa07d8aa46379d4ae Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:38:20 +0200 Subject: [PATCH 1/2] feat: add period-over-period growth deltas to analytics overview Closes #31. overview now queries the previous equal-length window by default and shows the percent change per metric (green up, red down). --no-compare reproduces the single-window output. JSON gains previous and pct_change fields; previous == 0 yields a null pct_change / (new) marker instead of dividing by zero. --- docs/analytics.md | 9 ++- src/ytstudio/commands/analytics.py | 89 +++++++++++++++++++++++++++--- tests/test_analytics.py | 88 +++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index c99d8cf..e78b61f 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -10,10 +10,17 @@ scripting. ```bash ytstudio analytics overview # channel summary for the last 28 days ytstudio analytics overview --days 7 # rolling 7-day window +ytstudio analytics overview --no-compare # totals only, no growth deltas ytstudio analytics video # one video's recent performance ytstudio analytics video --days 90 # longer window for one video ``` +By default `overview` also compares each metric to the previous equal-length +window and shows the percent change (green up, red down). Pass `--no-compare` +for plain totals. In JSON output the comparison is exposed as `previous` +(the prior window's metrics) and `pct_change` (percent change per metric, +`null` when there is no prior-window baseline). + ## Custom queries `analytics query` is a thin wrapper over the YouTube Analytics @@ -55,7 +62,7 @@ Run `ytstudio analytics --help` (or open the === "JSON" ```bash - ytstudio analytics overview -o json | jq '.rows[0]' + ytstudio analytics overview -o json | jq '.pct_change' ``` ## Quota diff --git a/src/ytstudio/commands/analytics.py b/src/ytstudio/commands/analytics.py index 2fad1fd..4b075b7 100644 --- a/src/ytstudio/commands/analytics.py +++ b/src/ytstudio/commands/analytics.py @@ -79,9 +79,44 @@ def fetch_query( return api(analytics_service.reports().query(**query_params)) +# Metrics shown with a period-over-period delta in the overview. +OVERVIEW_COMPARE_METRICS = ( + "views", + "estimatedMinutesWatched", + "averageViewDuration", + "likes", + "comments", +) + + +def _pct_change(current: float, previous: float) -> float | None: + """Percent change vs the previous window, or None when there is no baseline.""" + if previous == 0: + return None + return round((current - previous) / previous * 100, 1) + + +def _delta_suffix(metrics: dict, previous: dict | None, metric: str) -> str: + """Coloured ' (+N%)' suffix comparing a metric to the previous window.""" + if previous is None: + return "" + current = float(metrics.get(metric, 0)) + pct = _pct_change(current, float(previous.get(metric, 0))) + if pct is None: + return dim(" (new)") if current else "" + colour = "green" if pct >= 0 else "red" + sign = "+" if pct >= 0 else "" + return f" [{colour}]({sign}{pct:.0f}%)[/{colour}]" + + @app.command() def overview( days: int = typer.Option(28, "--days", "-d", help="Number of days to analyze"), + compare: bool = typer.Option( + True, + "--compare/--no-compare", + help="Compare each metric to the previous equal-length window", + ), output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"), ): """Get channel overview analytics""" @@ -119,8 +154,41 @@ def overview( metrics = dict(zip(headers, rows[0], strict=False)) + # Previous equal-length window [today-2*days, today-days] for growth deltas. + previous = None + pct_change = None + if compare: + prev_start = (datetime.now() - timedelta(days=days * 2)).strftime("%Y-%m-%d") + prev_response = fetch_query( + data_service, + analytics_service, + metric_names=metric_names, + dimension_names=[], + start_date=prev_start, + end_date=start_date, + days=days, + ) + prev_rows = prev_response.get("rows", []) + if prev_rows: + prev_headers = [h["name"] for h in prev_response.get("columnHeaders", [])] + previous = dict(zip(prev_headers, prev_rows[0], strict=False)) + pct_change = { + m: _pct_change(float(metrics.get(m, 0)), float(previous.get(m, 0))) + for m in OVERVIEW_COMPARE_METRICS + } + if output == "json": - print(json.dumps({"analytics": metrics, "days": days}, indent=2)) + print( + json.dumps( + { + "analytics": metrics, + "days": days, + "previous": previous, + "pct_change": pct_change, + }, + indent=2, + ) + ) return views = int(metrics.get("views", 0)) @@ -131,16 +199,23 @@ def overview( likes = int(metrics.get("likes", 0)) comments = int(metrics.get("comments", 0)) - console.print(f"\n[bold]Channel Analytics[/bold] {dim(f'(last {days} days)')}\n") + subtitle = f"(last {days} days" + subtitle += f" vs previous {days})" if previous is not None else ")" + console.print(f"\n[bold]Channel Analytics[/bold] {dim(subtitle)}\n") table = create_kv_table() - table.add_row(dim("views"), format_number(views)) - table.add_row(dim("watch time"), f"{watch_hours} hours") - table.add_row(dim("avg duration"), f"{avg_secs // 60}:{avg_secs % 60:02d}") + def d(metric: str) -> str: + return _delta_suffix(metrics, previous, metric) + + table.add_row(dim("views"), format_number(views) + d("views")) + table.add_row(dim("watch time"), f"{watch_hours} hours" + d("estimatedMinutesWatched")) + table.add_row( + dim("avg duration"), f"{avg_secs // 60}:{avg_secs % 60:02d}" + d("averageViewDuration") + ) table.add_row(dim("subscribers gained"), f"[green]+{subs_gained}[/green]") table.add_row(dim("subscribers lost"), f"[red]-{subs_lost}[/red]") - table.add_row(dim("likes"), format_number(likes)) - table.add_row(dim("comments"), format_number(comments)) + table.add_row(dim("likes"), format_number(likes) + d("likes")) + table.add_row(dim("comments"), format_number(comments) + d("comments")) console.print(table) diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 1d1c2c6..138a018 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -97,6 +97,94 @@ def test_overview_no_data(self): assert result.exit_code == 0 assert "No analytics data" in result.output + @staticmethod + def _overview_response(row): + return { + "columnHeaders": [ + {"name": "views"}, + {"name": "estimatedMinutesWatched"}, + {"name": "averageViewDuration"}, + {"name": "subscribersGained"}, + {"name": "subscribersLost"}, + {"name": "likes"}, + {"name": "comments"}, + ], + "rows": [row], + } + + def _two_window_services(self, current_row, previous_row): + data_service = MagicMock() + analytics_service = MagicMock() + data_service.channels.return_value.list.return_value.execute.return_value = { + "items": [{"id": "UC_test"}] + } + analytics_service.reports.return_value.query.return_value.execute.side_effect = [ + self._overview_response(current_row), + self._overview_response(previous_row), + ] + return data_service, analytics_service + + def test_overview_compare_deltas_table(self): + # views 12000 vs 10000 -> +20%, avg duration 180 vs 200 -> -10% + data_svc, analytics_svc = self._two_window_services( + [12000, 6000, 180, 42, 3, 770, 25], + [10000, 6000, 200, 42, 3, 700, 25], + ) + with ( + patch("ytstudio.commands.analytics.get_data_service", return_value=data_svc), + patch("ytstudio.commands.analytics.get_analytics_service", return_value=analytics_svc), + ): + result = runner.invoke(app, ["analytics", "overview"]) + assert result.exit_code == 0 + assert "+20%" in result.output + assert "-10%" in result.output + + def test_overview_compare_json(self): + data_svc, analytics_svc = self._two_window_services( + [12000, 6000, 180, 42, 3, 770, 25], + [10000, 6000, 200, 42, 3, 700, 25], + ) + with ( + patch("ytstudio.commands.analytics.get_data_service", return_value=data_svc), + patch("ytstudio.commands.analytics.get_analytics_service", return_value=analytics_svc), + ): + result = runner.invoke(app, ["analytics", "overview", "-o", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["previous"]["views"] == 10000 + assert payload["pct_change"]["views"] == 20.0 + assert payload["pct_change"]["averageViewDuration"] == -10.0 + assert payload["pct_change"]["likes"] == 10.0 + + def test_overview_no_compare(self): + data_svc, analytics_svc = self._mock_overview_services() + with ( + patch("ytstudio.commands.analytics.get_data_service", return_value=data_svc), + patch("ytstudio.commands.analytics.get_analytics_service", return_value=analytics_svc), + ): + result = runner.invoke(app, ["analytics", "overview", "--no-compare", "-o", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["previous"] is None + assert payload["pct_change"] is None + # only the current window is queried + assert analytics_svc.reports.return_value.query.return_value.execute.call_count == 1 + + def test_overview_compare_zero_previous(self): + # previous window has no data -> guard against divide-by-zero + data_svc, analytics_svc = self._two_window_services( + [500, 6000, 180, 42, 3, 789, 25], + [0, 0, 0, 0, 0, 0, 0], + ) + with ( + patch("ytstudio.commands.analytics.get_data_service", return_value=data_svc), + patch("ytstudio.commands.analytics.get_analytics_service", return_value=analytics_svc), + ): + result = runner.invoke(app, ["analytics", "overview", "-o", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["pct_change"]["views"] is None + def test_video_not_found(self, mock_auth): mock_auth.videos.return_value.list.return_value.execute.return_value = {"items": []} with ( From dc68fbbc5453a33c553b4e5723e47d195e58825c Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:43:44 +0200 Subject: [PATCH 2/2] fix: previous overview window no longer shares a boundary day Previous window now ends the day before the current window starts, so the two equal-length windows do not double-count the shared day. --- src/ytstudio/commands/analytics.py | 8 +++++--- tests/test_analytics.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ytstudio/commands/analytics.py b/src/ytstudio/commands/analytics.py index 4b075b7..6db7962 100644 --- a/src/ytstudio/commands/analytics.py +++ b/src/ytstudio/commands/analytics.py @@ -154,18 +154,20 @@ def overview( metrics = dict(zip(headers, rows[0], strict=False)) - # Previous equal-length window [today-2*days, today-days] for growth deltas. + # Previous equal-length window ending the day before the current one starts, + # so the two windows do not share a boundary day. previous = None pct_change = None if compare: - prev_start = (datetime.now() - timedelta(days=days * 2)).strftime("%Y-%m-%d") + prev_end = (datetime.now() - timedelta(days=days + 1)).strftime("%Y-%m-%d") + prev_start = (datetime.now() - timedelta(days=days * 2 + 1)).strftime("%Y-%m-%d") prev_response = fetch_query( data_service, analytics_service, metric_names=metric_names, dimension_names=[], start_date=prev_start, - end_date=start_date, + end_date=prev_end, days=days, ) prev_rows = prev_response.get("rows", []) diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 138a018..d84fbfd 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -156,6 +156,24 @@ def test_overview_compare_json(self): assert payload["pct_change"]["averageViewDuration"] == -10.0 assert payload["pct_change"]["likes"] == 10.0 + def test_overview_compare_windows_do_not_overlap(self): + # the previous window must end before the current window starts + data_svc, analytics_svc = self._two_window_services( + [12000, 6000, 180, 42, 3, 770, 25], + [10000, 6000, 200, 42, 3, 700, 25], + ) + with ( + patch("ytstudio.commands.analytics.get_data_service", return_value=data_svc), + patch("ytstudio.commands.analytics.get_analytics_service", return_value=analytics_svc), + ): + result = runner.invoke(app, ["analytics", "overview", "--days", "7"]) + assert result.exit_code == 0 + + calls = analytics_svc.reports.return_value.query.call_args_list + current_start = calls[0].kwargs["startDate"] + previous_end = calls[1].kwargs["endDate"] + assert previous_end < current_start + def test_overview_no_compare(self): data_svc, analytics_svc = self._mock_overview_services() with (