feat: read Toggl custom reports via the Analytics API - #6
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 separateanalyticsAPI 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.
list_custom_reports(only_pinned=False)get_custom_report(report_id)run_custom_report(report_id, chart_id=None, start_date=None, end_date=None)run_custom_reportuses the report's saved date period unless dates are passed, and defaults to the first chart.Implementation notes
toggl_track_mcp/analytics.pyholds the models and pure helpers; the HTTP methods sit onTogglAPIClientalongside the existing ones. Three behaviours of that API needed handling:resolve_rowsadds a*_secondscolumn rather than silently changing units.attributesalongsidegroupings, and for an ordination over a property that isn't grouped — both of which a saved chart can legitimately contain.build_querystrips them;sort_rowsreapplies the dropped ordering locally, after ids have been resolved to names, so aclient_namesort still works.prevMonth,lastSemester, …) rather than dates.resolve_periodmirrors 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_requesttakes abase_urloverride for the analytics host instead of a third copy of the httpx block.Testing
analytics.pyat 100% coverage), 15 for the client methods, 8 for the tools. Suite is 191 tests, total coverage 90.94%.ruff,black --check,isort --check-onlyandmypyall clean on the package.🤖 Generated with Claude Code