Skip to content

feat: read Toggl custom reports via the Analytics API - #6

Merged
tomstockton merged 5 commits into
mainfrom
feature/analytics-custom-reports
Aug 5, 2026
Merged

feat: read Toggl custom reports via the Analytics API#6
tomstockton merged 5 commits into
mainfrom
feature/analytics-custom-reports

Conversation

@tomstockton

Copy link
Copy Markdown
Member

What and why

The custom reports built in Toggl's My Reports section (track.toggl.com/reports/{organization_id}/custom/{report_id}) were unreachable from this server. They are not part of the v9 or Reports v3 APIs it wraps — Toggl serves them from a separate analytics API with its own resource model (organization-scoped dashboards containing charts).

That API is undocumented: there is no OpenAPI spec and nothing in the published docs. The endpoints here were read out of the Toggl web app's own API client and then verified against the live API. It authenticates with the ordinary API token, so no new configuration is needed, but it is unversioned and Toggl can change it without notice — hence keeping it behind its own module.

Tools added

All read-only. None of them modify a report.

Tool Returns
list_custom_reports(only_pinned=False) Saved reports with id, name, chart types, saved date preset, creator
get_custom_report(report_id) Each chart's groupings, aggregations and filters, plus the report-level filters
run_custom_report(report_id, chart_id=None, start_date=None, end_date=None) One chart's rows, ids resolved to names, durations in seconds and formatted, plus totals

run_custom_report uses the report's saved date period unless dates are passed, and defaults to the first chart.

Implementation notes

toggl_track_mcp/analytics.py holds the models and pure helpers; the HTTP methods sit on TogglAPIClient alongside the existing ones. Three behaviours of that API needed handling:

  • Durations are milliseconds, not seconds as in Reports v3. resolve_rows adds a *_seconds column rather than silently changing units.
  • The query engine returns a 500, not a validation error, for attributes alongside groupings, and for an ordination over a property that isn't grouped — both of which a saved chart can legitimately contain. build_query strips them; sort_rows reapplies the dropped ordering locally, after ids have been resolved to names, so a client_name sort still works.
  • Reports store a date preset (prevMonth, lastSemester, …) rather than dates. resolve_period mirrors the web app's own preset definitions, including quarter and half-year boundaries and the user's start-of-week, so a run covers the same days the UI shows.

_make_request takes a base_url override for the analytics host instead of a third copy of the httpx block.

Testing

  • 80 new tests: 57 for the helpers (analytics.py at 100% coverage), 15 for the client methods, 8 for the tools. Suite is 191 tests, total coverage 90.94%.
  • ruff, black --check, isort --check-only and mypy all clean on the package.
  • Verified end to end against a live workspace: listing, definition, a run over the saved period and a run with explicit dates all returned correct data, and the totals reconciled against the same query issued by hand.

🤖 Generated with Claude Code

tomstockton and others added 5 commits August 3, 2026 23:27
Custom reports ("My Reports" dashboards) are served from an analytics API
that is separate from the v9 and Reports v3 APIs this server already wraps,
so none of the existing tools could reach them. It is undocumented — the
endpoints were read out of the Toggl web app's own API client and verified
against the live API — but it authenticates with the ordinary API token.

Adds three read-only tools:

- list_custom_reports: the organization's saved reports, with chart types
  and the date period each one is saved with
- get_custom_report: a report's definition, chart by chart
- run_custom_report: runs one chart and returns its rows, over the report's
  saved period unless dates are given

analytics.py holds the models and the pure helpers, and absorbs three
quirks of that API: durations arrive in milliseconds; the query engine
500s on `attributes` alongside `groupings` and on an ordination over an
ungrouped property, so both are stripped and the sort applied locally; and
reports store a date preset rather than dates, so resolve_period mirrors
the web app's own preset definitions.

_make_request takes a base_url override rather than a third copy of the
httpx block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ones

The README examples and one test fixture carried live ids from a real Toggl
workspace, including two colleagues' first names next to their user ids, in a
public repo. Replaced with invented values; nothing in the repo now resolves
to a real record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found reviewing the analytics work, all of which returned
plausible-looking but wrong data rather than failing.

Local sorting compared every value as a string, so an ordination the query
engine rejected and we reapply ourselves ordered numbers as text: sorting
[900, 1000, 90] descending gave [900, 90, 1000]. Those sorts are usually on
an aggregate column, so this was the common path, not an edge case. The sort
key now orders numbers numerically and keeps mixed types comparable.

The report's saved week start was never used. run_dashboard_chart always
passed the caller's, so period_for_dashboard's saved-preference branch was
dead in production and a week-based preset resolved to a window shifted by a
day against the UI. The report's setting now wins, and the caller's is only a
fallback; the parameter is renamed to say so. The existing test passed no
argument and so asserted a path production never took.

A saved chart's pagination was forwarded verbatim, which would return one
page and report it as the whole result. The response carries no total count,
so nothing could detect the truncation. Omitting pagination returns
everything: for a 7-month ungrouped query that is 7,582 rows, matching
count(time_entry_id) for the same window.

Also: skip the /me lookup when explicit dates make it unnecessary, cache the
resolved organization id, stop retrying 4xx responses three times over (a
permission error took ~14s to surface), and total only an exact `count`
column. Report queries turn out to carry their own hourly quota
(x-toggl-quota-remaining, ~240/hour), now documented.

Verified against the live API: the same report returns the same 47 rows and
2100h 47m total as before these changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module docstring and CLAUDE.md still described stripping only the
unsendable ordinations. Since 1da17cf the whole sequence moves locally when
any one of them can't be sent, so that a secondary key cannot override the
primary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tomstockton
tomstockton merged commit 74e00ba into main Aug 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant