Weekly workload digest: Todoist + Calendar → Slack
Task load meets meeting load: one ranked digest per teammate, posted to Slack every Friday.
Task counts alone don't show who's overloaded — three tasks and twelve hours of meetings is worse than ten tasks and an open calendar. This pulls each teammate's overdue-and-due-this-week count from Todoist, cross-references it with busy hours from Google Calendar, and pushes one ranked digest to Slack every Friday, before the team plans next week. Todoist's own People Tab shows per-person counts on free and paid plans, but it's a view you open, with no meeting data or push; this adds the calendar half and lands it in Slack automatically.
How it flows
- 01
Weekly trigger fires
Every Friday at 16:00 in the team's timezone, the digest job kicks off. Nothing app-specific here — wire it to whatever scheduler drives this workflow.
- 02
Team roster pulled from Todoist
Collaborators on the shared project returns every teammate's email and user_id — the one authoritative roster, with nothing to maintain separately.
- 03
Per-person task load pulled
For each person on the roster, the filter query returns their overdue-plus-due-this-week count.
- 04
Per-person busy hours pulled
The same roster's emails go into one freeBusy.query call; each person's busy[] intervals are summed into hours busy for the coming week.
- 05
Rows ranked and assembled into Block Kit
People are ordered by whatever mix of overdue count and busy hours the team cares about most — this workflow leaves the exact weighting to the team — into one header plus one section-with-fields block per person, closed with a footer noting the data cutoff.
- 06
Digest posted to Slack
chat.postMessage delivers the finished digest to the target channel once, well under its per-channel rate limit.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Todoist
Per-person task load: overdue plus due this week
- 01
Get a read-only API token
Settings → Integrations → Developer → copy your personal API token, or register an OAuth app and request only `data:read` if this should run under a service account rather than your own login. `data:read` covers both tasks and collaborators, and this workflow never writes back to Todoist. (Todoist's own People Tab — available on Beginner and Business team plans, not Business-exclusive — already shows per-person task/overdue counts, but only as something you open inside the app: no meeting data, no push. See the workflow description for the full boundary.)
- 02
Find the shared project and pull the roster
Grab the project_id of the team's shared project from its URL, or from GET /api/v1/projects. Calling collaborators on that project id returns every teammate's email and user_id in one shot — that email is the join key against Google Calendar's freebusy response later, so there is no separate roster to keep in sync by hand.
GET /api/v1/projects/{project_id}/collaboratorscurl -s "https://api.todoist.com/api/v1/projects/6X6WMMqgq2PWxjCX/collaborators" \ -H "Authorization: Bearer $TODOIST_TOKEN" - 03
Build the per-person filter query
For each collaborator, query overdue-plus-due-this-week tasks with Todoist's own filter language — no client-side date math needed. `od` covers everything already overdue, `7 days` covers what's due before next Friday; `&`/`|` combine them. Use `/api/v1/` only: REST v2 and Sync v9 both return 410 Gone now, so there is nothing to fall back to on those paths.
GET /api/v1/tasks?filter=…GET https://api.todoist.com/api/v1/tasks?filter=assigned%20to%3A%20jane%40example.com%20%26%20(od%20%7C%207%20days) Authorization: Bearer $TODOIST_TOKEN
Google Calendar
Per-person busy hours for the coming week
- 01
Request the freebusy-only scope
Create an OAuth client and request only `https://www.googleapis.com/auth/calendar.freebusy`. It returns busy/free blocks and nothing else — no event titles, no attendee lists — which keeps this workflow from needing every teammate's full calendar-read consent just to produce an hours-busy total.
- 02
Confirm internal sharing is at least free/busy
freeBusy.query only sees a calendar that is actually shared with the requester. Workspace admins check Admin console → Apps → Google Workspace → Calendar → Sharing settings and confirm internal sharing isn't set to "No sharing" — Google does not document what a newly created org defaults to, so check rather than assume. Anyone outside that org default (or not on a Workspace domain at all) has to share individually: My calendars → the calendar's ⋮ → Settings and sharing → Shared with → add the requesting account at "See only free/busy". Do this for every teammate before the first run, or their row in the digest comes back empty with no error to explain why.
- 03
Query the week's busy blocks for the whole roster
One freeBusy.query call covers up to 50 calendars (the calendarExpansionMax ceiling), so a starter-sized team never needs to paginate this. Sum each person's busy[] intervals into total hours. Note that "busy" includes anything marked opaque — Focus time and out-of-office blocks count the same as meetings — so label the digest column "busy hours," not "meeting hours," unless the team is prepared to switch to events.list with an attendee-count filter instead, which needs the broader calendar.events.readonly scope this workflow deliberately avoids.
POST /calendar/v3/freeBusy{ "timeMin": "2026-08-10T00:00:00-04:00", "timeMax": "2026-08-17T00:00:00-04:00", "timeZone": "America/New_York", "items": [ { "id": "jane@example.com" }, { "id": "bob@example.com" } ] }
Slack
Delivers the ranked digest to the team channel
- 01
Create the app and add posting scopes
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: add `chat:write` (also add `chat:write.public` if the digest needs to post to a public channel the bot hasn't been invited to yet). Install/reinstall the app to the workspace.
- 02
Invite the bot to the target channel
/invite @<bot-name> in the channel the digest should land in, e.g. #team-ops. Slack's API reference now lives at docs.slack.dev — api.slack.com/methods and api.slack.com/block-kit redirect there as of this check, so bookmark the new domain for anything you look up later.
- 03
Resolve emails to Slack user ids (optional, for @-mentions)
Add `users:read.email` only if the digest should @-mention each person by name rather than post a channel-wide summary; skip it entirely if a plain roster post is enough, which keeps setup shorter for a starter workflow.
GET users.lookupByEmailcurl -s -G "https://slack.com/api/users.lookupByEmail" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -d email=jane@example.com - 04
Send a test digest to check the layout
Post a one-person sample before wiring up the real weekly job, to confirm the format survives Block Kit's limits: 50 blocks per message, 10 fields per section, 2000 characters per field. A starter-sized team fits comfortably in one message — a header, one section-with-fields block per person, a closing divider, and a context footer citing the data cutoff.
POST /api/chat.postMessage{ "channel": "#team-ops", "text": "Weekly Capacity Digest", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Weekly Capacity Digest — week of Aug 10" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Jane Doe*" }, { "type": "mrkdwn", "text": "3 overdue · 6 due this week · 14h busy" } ] }, { "type": "divider" }, { "type": "context", "elements": [{ "type": "mrkdwn", "text": "Task counts via Todoist · busy hours via Google Calendar freebusy · generated Fri 16:00" }] } ] }