Where zones are stored
The trainee’s effective zone is always resolved the same way:
resolveTimeZone(...candidates) returns the first valid IANA zone and falls back to DEFAULT_TIMEZONE, Asia/Jerusalem. In the trainee lane, repo.traineeTimezone(clientId) does this and always returns a string.
How the device zone arrives
- The trainee app sends its IANA zone in the
x-timezoneheader (TRAINEE_TIMEZONE_HEADER) on every request. authenticateTraineeawaitsrememberTraineeTimezone(prisma, clientId, header)before the handler runs.rememberTraineeTimezone(lib/trainee-timezone.ts):- Ignores a missing or invalid zone name (
isValidTimeZone). - Skips the write when a per process cache already holds the same zone for this client (12 hour TTL, 5,000 entries).
- Otherwise runs
updateManywith awherethat only matches when the stored value is null or different.
- Ignores a missing or invalid zone name (
- Errors are swallowed. A failed zone write never fails a request.
Which zone governs what
This is the part that is easy to get wrong.The helpers
apps/core-api/src/lib/timezone.ts is the only time zone implementation. It replaced several drifted copies. Do not write date math inline, and do not add a date library for this.
Everything is built on
Intl.DateTimeFormat, so daylight saving changes are handled by the runtime’s zone data. Formatters are memoized per zone because zones vary per trainee.
tzDayWindow computes the offset at UTC midnight of the date and applies it to the whole day. On a day when clocks change, the window’s end is off by the size of the shift. The check-in dispatcher allows for that with MAX_DST_SHIFT_HOURS.
Two day conventions
Two kinds of column hold a “day”. They look alike in TypeScript, both areDate, and they need opposite handling.
Civil day columns (@db.Date)
Postgres date columns. Prisma returns them as a Date at UTC midnight. Examples: DailyMetric.date, NutritionDayLog.date, CardioLog.performedOn, Client.birthDate, Client.updateNextOn, Client.updateSkipOn.
- Key them with
dateOnlyKey(date). - Build one with
isoToDateOnly(iso). - Never pass one to
isoDateInTz. For a zone west of UTC that shifts it back a day.
Instant columns used as a day
PlainDateTime columns that record when something happened. Examples: MealLog.consumedAt, WorkoutLog.performedOn, WeightEntry.recordedAt, Client.lastCheckInAt.
- The day is
isoDateInTz(instant, zone). - To query a day, use
tzDayWindow(iso, zone)and filtergte: from, lte: to. - Never slice
toISOString(). That gives the UTC day, which is wrong for part of every day in every zone that is not UTC.
The exception
Client.startedOn and Client.endsOn are plain DateTime columns, but they are always written from z.coerce.date() on a date only string, so they sit at UTC midnight. Treat them as civil days and use dateOnlyKey.
Quick reference
Schedulers
A scheduler cannot use one SQL day boundary for all trainees, because their days start at different instants. Check-in form dispatcher (modules/update-forms/update-forms.dispatcher.ts). Forms go out at SEND_HOUR, 8, in the trainee’s zone. The SQL query uses a loose bound that is provably safe: lastUpdateFormSentAt is null or earlier than now - (SEND_HOUR - 2h). A client who can be sent to has a local day that started at least SEND_HOUR hours ago, and 2 hours covers a daylight saving shift. The exact per client check (is it past 08:00 locally, is today a due day, was one already sent today) is then done in JavaScript with tzDate.
Form reminders (update-forms.reminder.ts). A reminder’s time can be 00:00, so no floor can be proven. The SQL bound is simply “before now” and the whole decision is made in JavaScript, every 5 minutes.
Demo activity builds its day contexts with the demo studio’s zone (dayContexts(now, studio.timeZone, backfillDays)).
Validating input
When a client sends a day, accept it as aYYYY-MM-DD string and convert it with the helpers. When it sends an instant, accept an ISO timestamp with an offset. Do not accept a bare local time without a date or zone.
The health ingestion endpoints accept days up to one day in the future (HEALTH_INGEST_FUTURE_MS), which covers a device whose zone is ahead of the server’s view.
Known gaps
- Existing rows are never moved to another day when a trainee’s zone changes. The zone applies from the next read.
- Project notes record that the coach web app computes a few relative dates in the browser, with the coach’s clock or a hardcoded studio zone, because the client payload did not expose
Client.timezone. That was not re-verified for this page. It is a web app concern, noted here because it would explain small disagreements between the two clients. - A migration named
client_timezoneadds the column. Whether it has been applied to a given environment has to be checked against that database’s migration table.