Public
Daily Google Calendar + Tasks digest, emailed each morning
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

calendar-val

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.

One-time setup

1. Google Cloud

  1. Create (or pick) a project at https://console.cloud.google.com.
  2. APIs & Services → Library — enable Google Calendar API and Google Tasks API. Both must be enabled explicitly; the token will 403 otherwise.
  3. APIs & Services → OAuth consent screen — External, fill in the required fields. Add yourself as a test user.
  4. Add the two scopes: .../auth/calendar.readonly and .../auth/tasks.readonly.
  5. 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.
  6. 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.

2. Local

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.

Use

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.

Layout

File
src/digest.tsbuildDigest() — the whole pipeline, the thing a val imports
src/auth.tsrefresh token → access token
src/google.tsauthed fetch, retry/backoff, pagination, bounded concurrency
src/calendar.tscalendar list + event fetch/normalize
src/tasks.tstask list + task fetch/normalize
src/types.tsnormalized shapes
src/main.tsCLI wrapper — the only file a val does not need
scripts/authorize.tsone-time consent flow (setup only, never runs in prod)

Notes on the APIs

Things that bite, encoded in the source but worth knowing:

  • singleEvents=true expands recurring series into real instances. Without it you get RRULEs and have to expand them yourself.
  • timeMin / timeMax must be RFC3339 with an offset. A bare 2026-07-27 is rejected.
  • Tasks' maxResults defaults to 20 and truncates silently.
  • Completed tasks are also flagged hidden, so showCompleted=true does nothing without showHidden=true.
  • Tasks come back in manual drag position order and there is no orderBy — sorting is ours.
  • Task due is 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) or start.dateTime (timed), never both.

Deploying to Val Town

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_IDsame values as .env
GOOGLE_CLIENT_SECRET
GOOGLE_REFRESH_TOKEN
DIGEST_TZe.g. America/Los_Angeles
SEND_HOURdelivery hour, local, default 6
DIGEST_PRIMARY_ONLY0 to include secondary calendars (default: primary only)
FORCEset 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.

Why the cron runs hourly

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.

Deliberately not an HTTP val

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.