Pulls Google Calendar events and Google Tasks for a date window and emits normalized JSON. Local-first Deno script, structured so the pipeline drops into a Val Town cron val unchanged.
No external dependencies.
- Create (or pick) a project at https://console.cloud.google.com.
- APIs & Services → Library — enable Google Calendar API and Google Tasks API. Both must be enabled explicitly; the token will 403 otherwise.
- APIs & Services → OAuth consent screen — External, fill in the required fields. Add yourself as a test user.
- Add the two scopes:
.../auth/calendar.readonlyand.../auth/tasks.readonly. - Set publishing status to "In production." While the app sits in Testing, Google expires refresh tokens after 7 days. Using only these read-only scopes, going to production needs no verification review — it's one button.
- Credentials → Create credentials → OAuth client ID → Application type: Desktop app. Desktop
app is what permits the loopback (
http://127.0.0.1:…) redirect the auth script uses. Copy the client ID and secret.
cp .env.example .env # paste in the client ID and secret deno task auth # opens consent, prints GOOGLE_REFRESH_TOKEN
deno task auth prints a URL, waits on 127.0.0.1:8976 for Google to redirect back, and exchanges
the code for a refresh token. Paste that into .env and you're done — the consent flow never runs
again.
deno task start # next 7 days as JSON on stdout deno task start --days=14 deno task start --start=2026-08-01 --days=3 deno task start --no-tasks deno task start --summary # just counts, to stderr deno task start > out.json
Warnings go to stderr so stdout stays valid JSON.
| File | |
|---|---|
src/digest.ts | buildDigest() — the whole pipeline, the thing a val imports |
src/auth.ts | refresh token → access token |
src/google.ts | authed fetch, retry/backoff, pagination, bounded concurrency |
src/calendar.ts | calendar list + event fetch/normalize |
src/tasks.ts | task list + task fetch/normalize |
src/types.ts | normalized shapes |
src/main.ts | CLI wrapper — the only file a val does not need |
scripts/authorize.ts | one-time consent flow (setup only, never runs in prod) |
Things that bite, encoded in the source but worth knowing:
singleEvents=trueexpands recurring series into real instances. Without it you get RRULEs and have to expand them yourself.timeMin/timeMaxmust be RFC3339 with an offset. A bare2026-07-27is rejected.- Tasks'
maxResultsdefaults to 20 and truncates silently. - Completed tasks are also flagged hidden, so
showCompleted=truedoes nothing withoutshowHidden=true. - Tasks come back in manual drag
positionorder and there is noorderBy— sorting is ours. - Task
dueis date-only. It's formatted as RFC3339, so it looks like it has a time, but the time component isn't meaningful. - Task recurrence isn't exposed at all; repeating tasks appear as independent items.
- Events carry
start.date(all-day) orstart.dateTime(timed), never both.
src/val.ts is the cron entrypoint: it builds the digest, renders it, and sends it via std/email
to the account's own address.
deno install -gAf jsr:@valtown/vt # one-time, if vt isn't installed vt create calendar-digest # or `vt clone` into an existing project vt push
Then in the val's settings set the environment variables:
| var | |
|---|---|
GOOGLE_CLIENT_ID | same values as .env |
GOOGLE_CLIENT_SECRET | |
GOOGLE_REFRESH_TOKEN | |
DIGEST_TZ | e.g. America/Los_Angeles |
SEND_HOUR | delivery hour, local, default 6 |
DIGEST_PRIMARY_ONLY | 0 to include secondary calendars (default: primary only) |
FORCE | set to 1 to send on every run while testing |
The refresh token is a static credential that doesn't rotate — env vars are the right home for it, not blob or sqlite. The hourly access token is minted per run; for a daily job there's nothing worth caching.
Val Town evaluates cron expressions in UTC, so a fixed daily expression drifts by an hour across
each DST boundary — a "6am" digest would silently become 5am or 7am twice a year. Instead the val is
scheduled hourly (0 * * * *) and returns immediately unless the local hour in DIGEST_TZ matches
SEND_HOUR. The 23 no-op runs cost one comparison each and make no API calls.
A cron val has no public endpoint. An HTTP val would expose your calendar contents to anyone holding the URL, which is a much worse leak than the source being public. Nothing personal is baked into the source — calendar names, IDs, and colours all arrive from the API at runtime, and credentials live only in env vars.