Skip to content

feat(digest): the daily digest arrives at 08:00 in the person's own day - #2286

Merged
lilyshen0722 merged 2 commits into
mainfrom
growth/digest-local-0800
Oct 16, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
growth/digest-local-0800

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Stickiness Phase 2b (TASK-257): the daily digest arrives at 08:00 in the person's own day. Today it goes out at 06:00 UTC for everyone, which is 23:00 the night before in California and 11:30 in India.

The zone

  • The v2 shell sends the browser's IANA zone once per signed-in account (useTimeZoneCapture) to POST /api/users/me/timezone.
  • The server keeps the first valid zone and never overwrites it (User.timezone, write-once, bots excluded). A zone the runtime doesn't know is ignored (utils/timeZone.ts), and the reply is 204 either way.
  • Why a JSON body and not a header: the API's CORS list allows only Content-Type, Authorization and x-auth-token. A new header would fail every request from a new frontend during a rolling deploy. This way a frontend ahead of the backend just gets a harmless 404.
  • The route reuses the profile-write limiters: an ingress cap by cf-connecting-ip, then a per-user cap.

The send

  • The digest job now runs hourly at minute 1 (it was 0 6 * * *). Each run takes the people for whom it's 08:00 in their zone, or 06:00 UTC when the zone is unknown or unusable.
    • Minute 1 still lands inside 08:00 in half-hour zones, e.g. India's 08:31 at 03:01 UTC.
    • A person without a zone gets exactly what they get today.
  • Nobody gets a second digest within 20 hours. A zone captured mid-day, or a repeated run after a restart, never sends twice.
  • Unchanged: the away rule (20h) and the digest.sending stop switch.
  • docs/runbooks/daily-digest.md says when the digest goes out.

Tests

  • Zone cleaner: keeps known zones; drops unknown ones, markup and objects. ICU may answer Asia/Calcutta for Asia/Kolkata; either is accepted.
  • Local hour: read correctly, including half-hour zones.
  • Who is due when:
    • a known zone at its 08:00, and not at other hours;
    • no zone at 06:00 UTC only;
    • an unusable zone falls back to 06:00 UTC.
  • Scheduled run: takes only those due, and nobody gets a second digest within 20 hours.
  • Route: keeps the first zone, ignores bad ones, and never sets a zone on a bot.
  • Shell: sends once per account per browser; another account on the same browser still sends; nothing is sent when signed out, even with a lingering user object; a failed send is retried.
  • Mutations, each failing a test:
    • the zone ignored;
    • the repeat guard removed;
    • the route overwriting;
    • the zone not validated;
    • the hook resending;
    • the hook sending while signed out.
  • Ran locally on Node 22:
    • backend: digest, scheduler-digest, digest-email, users routes and timeZone suites;
    • frontend: the hook, plus every suite that renders V2App, 298/298, and App.
    • tsc and eslint are clean on both sides.

🤖 Generated with Claude Code

https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya

lilyshen0722 and others added 2 commits October 16, 2026 15:03
Stickiness Phase 2b (TASK-257). The digest went out at 06:00 UTC for
everyone: 23:00 the night before in California, 11:30 in India.

- The zone: the v2 shell sends the browser's IANA zone once per signed-in
  account (useTimeZoneCapture) to POST /api/users/me/timezone, which keeps
  the first valid zone and never overwrites it (User.timezone). It is a
  JSON body on an existing-shape request, not a new header: the API's CORS
  list allows only Content-Type, Authorization and x-auth-token, and a new
  header would fail every request from a new frontend during a rolling
  deploy. A frontend ahead of the backend gets a harmless 404. A zone the
  runtime does not know is ignored (utils/timeZone.ts); the reply is 204.
- The send: the digest job runs hourly at minute 1, and each run takes the
  people for whom it is 08:00 in their zone, or 06:00 UTC when it is
  unknown or unusable. Minute 1 still lands inside 08:00 in half-hour
  zones. Nobody gets a second digest within 20 hours, so a zone captured
  mid-day, or a repeated run, never sends twice. The away rule and the
  digest.sending stop switch are unchanged.
- The runbook says when the digest goes out.

Tests: the zone cleaner keeps known zones and drops unknown ones, markup
and objects; the local hour, half-hour zones included; who is due when
(a known zone at its 08:00 and not otherwise, no zone at 06:00 UTC, an
unusable zone falls back); a scheduled run takes only those due; nobody
gets a second digest within 20 hours; the route keeps the first zone,
ignores bad ones and bots; the shell sends once per account per browser,
not when signed out, and retries a failed send. Mutations: the zone
ignored, the repeat guard removed, the route overwriting, the zone not
validated, the hook resending, and the hook sending signed out each red
an arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya
…ame person twice

Cofounder on #2286: the repeat guard was tested against a seeded earlier
digest, not two actual runs. This arm runs the scheduled job at 06:00 UTC
(no zone on file: due), captures the person's zone between runs so 08:00
UTC is their hour too, runs again, and finds one digest. Mutation: the
repeat guard removed reds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Written by sprint-review, a Commonly agent — code gate at a57642598. pod thread

Not the head I was asked for, again: the request named 68e54c8f8, and a57642598 landed while I was measuring (cancelling the earlier CI run). The delta is test-only — git diff --name-only between the two heads returns one file under __tests__, so every source measurement below was taken on identical code, and I re-ran the baseline and the load-bearing mutation at the new head to confirm. Base c01b38fd0 (= origin/main), merge CLEAN. Tier 1 green at this head: run 38973537818, 54 suites / 538 tests.

Nine mutations, all red by name

BASE and RESTORED both 4 suites / 36 passed at this head:

mutation failures
T1 write-once dropped keeps the first zone it is sent, and never overwrites it
T2 the route stops validating 2 — ignores an unknown zone…, ignores markup…
T3 the bot exclusion dropped never sets a zone on a bot
T4 the Intl round-trip dropped, shape alone decides 3
T5 the 20h repeat guard removed 2 — nobody gets a second digest within 20 hours… and the new two scheduled runs within 20 hours never digest the same person twice
T6 the hour filter removed (everyone, every hour) a scheduled run takes only the people for whom it is the hour
T7 the unknown-zone fallback hour changed 3
T8 an unusable stored zone trusted instead of falling back an unusable zone falls back to 06:00 UTC
T9 the cron back to a single 0 6 * * * delivers only the summaries returned by the current generation run

Frontend: 4/4, and removing the once-per-account storage guard reds a signed-in account sends its zone once, then not again from this browser.

The scheduling property, checked exhaustively rather than by example

The comment justifies :01 with one case (India at 03:01 UTC). I ran the real dueAt over every zone this runtime knows:

dueAt across 418 IANA zones, plain day 2026-10-20
firing hours per zone: {"1":418}
zones not exactly once: none

and then over all 365 days of 2026 for the awkward ones — America/New_York, Europe/London, Australia/Lord_Howe (30-minute DST), America/Havana (midnight transition), Asia/Kolkata, Pacific/Chatham (+12:45), Asia/Tehran: "exactly once every day" for all seven. So no zone gets two digest hours in a day and none gets zero, including across DST — the half-hour and 45-minute offsets that the single India example only gestures at.

The fallback is equally solid: undefined, '', 'Not/AZone' and { $ne: null } all fire at UTC hour [6] and nothing else.

Validation, probed

'utc' -> 'UTC'    '  UTC  ' -> 'UTC'    'US/Pacific' -> 'America/Los_Angeles'   'GMT' -> 'UTC'
'Not/AZone', '../../etc/passwd', 65 chars, 'UTC;DROP', '$ne', ''  -> undefined
{ $ne: null }, ['UTC'], 42, null, undefined                       -> undefined

The shape regex plus the Intl round-trip means only a zone this runtime can actually format is stored, so localHour can never be handed something that throws; and Number(hour?.value) would be NaN if the parts lookup ever failed, which compares false — fail-closed, nobody gets mailed.

The CORS rationale in the PR body is true, not folklore: server.ts:173 sets allowedHeaders: ['Content-Type', 'Authorization', 'x-auth-token'], a closed whitelist, so a new custom header would have needed a CORS change. The JSON body avoids that.

Two observations, neither blocking

Canonicalisation renames some zones. Asia/Kolkata is stored as Asia/Calcutta and Asia/Kathmandu as Asia/Katmandu — ICU's older aliases. Harmless for the hour arithmetic (same offsets), but if User.timezone ever surfaces in an ops view or a profile, someone in Kolkata will see a name their browser never sent. Worth knowing before it's displayed.

The guard counts generated digests, not sent ones. alreadyHave is Summary.distinct('metadata.userId', { type: 'daily-digest', createdAt: ≥ now-20h }), so a person whose digest is generated and then not emailed — the away rule, SMTP unconfigured, the stop switch — is still guarded for 20 hours, and #2277 established that an unemailed summary is never swept later. That is the right trade for a digest (one attempt a day, no double sends) and it matches the away rule's intent; it does mean "digest generated" and "digest delivered" diverge silently, and the only place that difference is visible is the log. No change requested — stating it because the guard's name suggests it tracks delivery.

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Written by sprint-review, a Commonly agent — follow-up to the gate at a57642598, answering Cofounder's three asks explicitly. pod thread

1. Own-user only — yes, and by two independent means. The only mutating routes in routes/users.ts are PUT /profile (:177), POST /:id/follow (:180) and POST /me/timezone (:187). There is no :id timezone variant, and /:id/follow cannot shadow /me/timezone — it needs the literal second segment follow. Beyond routing, the handler takes userId from req.userId || req.user?.id || req.user?._id and reads no path parameter at all, so even if an :id route were added later it could not aim this write at another account. The update is further narrowed by isBot: { $ne: true } (mutating that filter away reds never sets a zone on a bot).

2. An invalid zone, with a test — yes, but the verb is ignored, not refused. The route answers 204 either way and simply does not store; there is no 400. The tests say so in their names — ignores an unknown zone and still answers 204 and ignores markup and still answers 204 — and both red when the route stops validating, as does drops an unknown zone at the util tier when the Intl round-trip is removed. I am flagging the wording rather than the behaviour: nothing invalid reaches the database, which is the property that matters, but "refused" implies a status code this endpoint never returns, and a future reader checking for one will not find it. If the intent was a 400 for a malformed body, that is a change, not a confirmation.

3. Two runs within 20 hours, one digest — yes. Removing the repeat guard (!alreadyHave.has(String(user._id))) reds both arms: the existing nobody gets a second digest within 20 hours, even if their hour comes round again and your new two scheduled runs within 20 hours never digest the same person twice. Baseline at this head is 4 suites / 36 passed; restored, 36/36.

One scoping note on that guard, repeated from the gate because it bears on how you will read the numbers: alreadyHave is built from Summary.distinct('metadata.userId', { type: 'daily-digest', createdAt: ≥ now − 20h }), so it counts digests generated, not sent. A person skipped by the away rule, or by the stop switch, is still guarded for 20 hours — correct for "one attempt a day", and it means a generated-but-unemailed digest is never retried later (#2277 established that summaries outside the current run are not swept).

GATE: PASS

Merged via the queue into main with commit 8101148 Oct 16, 2026
18 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