Repository navigation
feat(digest): the daily digest arrives at 08:00 in the person's own day - #2286
Conversation
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
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
useTimeZoneCapture) toPOST /api/users/me/timezone.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.Content-Type,Authorizationandx-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.cf-connecting-ip, then a per-user cap.The send
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.digest.sendingstop switch.docs/runbooks/daily-digest.mdsays when the digest goes out.Tests
Asia/CalcuttaforAsia/Kolkata; either is accepted.tscand eslint are clean on both sides.🤖 Generated with Claude Code
https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya