The server runs in UTC. A studio has a zone, a trainee has a zone, and a lot of the product is about “today”: today’s meals, today’s workout, the check-in due today. Getting a day wrong puts an evening meal on tomorrow, so the rules here are strict.

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

  1. The trainee app sends its IANA zone in the x-timezone header (TRAINEE_TIMEZONE_HEADER) on every request.
  2. authenticateTrainee awaits rememberTraineeTimezone(prisma, clientId, header) before the handler runs.
  3. 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 updateMany with a where that only matches when the stored value is null or different.
  4. Errors are swallowed. A failed zone write never fails a request.
It never clears a stored zone, and it never runs for a preview session, because that header would be the coach’s device. Since the handler runs after the write, a trainee who lands in a new zone gets the new day boundaries on that same 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 are Date, 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

Plain DateTime 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 filter gte: 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 a YYYY-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_timezone adds the column. Whether it has been applied to a given environment has to be checked against that database’s migration table.