A Ruby service sends dentist appointment reminders to patients in several time zones; how should it store and compute those times so daylight-saving changes never shift them?
answer
- wall time plus zone name
- instants in UTC for scheduling
- fixed offsets ignore DST
- Time.find_timezone or a zone object
- never swap ENV["TZ"]
basics
~20 sStore each appointment's local date, time and IANA zone name; resolve them to a UTC instant through a timezone object when scheduling, and build "day before at 18:00" with Date arithmetic in that zone. Fixed offsets and ENV["TZ"] swaps break.
solid answer
~40 sAn appointment is a **wall-clock** fact: 09:30 on 30 March in Europe/Berlin. Store the local date and time plus the zone **name**, not just a UTC instant, because zone rules can change before a future date. When scheduling, convert to an instant with a timezone object: core `Time` accepts objects that respond to `local_to_utc`/`utc_to_local`, and resolves names only if `Time.find_timezone` is defined, for example via the tzinfo gem. Plain `Time.new(..., in: "Europe/Berlin")` raises `ArgumentError`, and a fixed offset such as `"+01:00"` ignores daylight saving. "24 hours before" is instant arithmetic (`appt - 86_400`); "the day before at 18:00 local" is calendar arithmetic (`date - 1`, then build a `Time` in the zone). Queue jobs in UTC, never change `ENV["TZ"]` per patient, and test the daylight-saving dates.
code
ruby · 16 linesrequire "date"
require "tzinfo" # timezone gem, listed in the Gemfile
def Time.find_timezone(name) = TZInfo::Timezone.get(name)
Appointment = Data.define(:date, :hour, :min, :zone)
appt = Appointment.new(Date.new(2026, 3, 30), 9, 30, "Europe/Berlin")
starts = Time.new(appt.date.year, appt.date.month, appt.date.day,
appt.hour, appt.min, 0, in: appt.zone)
day_before = appt.date - 1
remind_at = Time.new(day_before.year, day_before.month, day_before.day,
18, 0, 0, in: appt.zone)
enqueue(remind_at.getutc.iso8601, starts.getutc.iso8601)go deeper
Recall the difference between a zone name and a fixed offset, and that Ruby compares Time objects by instant, whatever zone they display.
Explain why fixed offsets ignore daylight saving, how in: and Time.find_timezone resolve names, and why reminders are enqueued as UTC instants.
Design it: store wall time plus zone name for future events, choose duration or calendar arithmetic from the product rule, cover gaps and overlaps, and test daylight-saving dates.
Weigh storing wall time plus zone against UTC instants across the whole platform, including zone-rule updates, reporting and multi-region clinics, and own the policy.
## Two kinds of time in one feature A reminder system for a dental practice deals with two different things that both look like "a time": - **Wall-clock time**: the appointment is at 09:30 on 30 March in the patient's city. This is what the patient and the receptionist agreed. - **Instants**: the moment a reminder job must fire, and the moment it actually ran. These are points on the global timeline, best kept in UTC. Most zone bugs come from storing one kind and treating it as the other. ## Store the wall clock and the zone name For **future** appointments, store `local_date`, `local_time` and an **IANA zone name** such as `"Europe/Berlin"` or `"America/New_York"`. A UTC instant alone is fragile: if a government changes its daylight-saving rules after booking, the instant computed at booking time now maps to the wrong local hour, while the patient still expects 09:30. Recompute the instant from wall time and zone when scheduling. Past events, such as "reminder sent at", are instants and belong in UTC. ## What core Time can and cannot do | You pass | Core `Time` result | |---|---| | `in: "+01:00"` | fixed offset; **never** changes for daylight saving | | `in: "UTC"` or `"Z"` | UTC mode | | `in: "Europe/Berlin"` | `ArgumentError`, unless `Time.find_timezone` is defined | | a timezone object | converted through its `local_to_utc` and `utc_to_local` | Ruby's documentation shows the hook for named zones: define `Time.find_timezone(name)` to return a zone object, for instance from the tzinfo gem, and then `Time.new("2026-03-30 09:30:00", in: "Europe/Berlin")` and `Time.now(in: "America/New_York")` work. Core Ruby itself ships no zone database. **Do not** use `ENV["TZ"]` to switch zones per patient. It is process-global, so two threads building reminders for different patients overwrite each other's zone. ## Computing the reminder instant The product rule decides which arithmetic you need: 1. **"24 hours before"** is a duration. Build the appointment instant in its zone, then subtract `24 * 60 * 60`; the reminder fires exactly one day of elapsed time earlier, even across a clock change. 2. **"The day before at 18:00 local"** is a calendar rule. Take the appointment's `Date`, subtract one day (`require "date"`), then build `18:00` on that date *in the patient's zone*. Second arithmetic cannot express this: 86,400 seconds before a 09:30 appointment on the day the clocks go forward is 08:30 local the day before, which is why wall-clock rules are built from the date. 3. Convert the result with `getutc` and enqueue it as UTC, serialised with `iso8601`. ## Edge cases a senior is expected to raise - **Gaps**: on the spring-forward date, local times such as 02:30 do not exist. The zone object decides how to resolve them; test that the practice cannot book one, or that it resolves the way you intend. - **Overlaps**: on the fall-back date, 02:30 happens twice. A zone library may pick one or ask you to choose; know which. - **Travelling patients**: the reminder follows the clinic's zone, not the phone's, because the appointment happens at the clinic. - **Display**: format for the patient with `strftime` in the clinic zone, including the zone or offset, so "09:30" is never ambiguous. - **Tests**: pin the clock by injecting `now`, and cover the two daylight-saving dates of each zone you serve. ## A schema that works - `appointments`: `local_date`, `local_time` and `zone_name`, the zone name validated at booking by resolving it through the zone library. - `reminders.run_at`: a UTC instant computed when scheduling, and recomputed whenever the appointment moves or the zone data is updated. - `reminders.sent_at`: a UTC instant written when the job runs, used for auditing and retries. Rejecting an unknown zone name at booking is much cheaper than discovering it when a reminder job raises `ArgumentError` at 18:00 the day before. ## Summary - Future wall-clock facts: date, time, zone name. - Scheduling and history: UTC instants. - Named zones need a zone object or `Time.find_timezone`; fixed offsets drift across daylight saving. - Choose duration or calendar arithmetic from the product rule, then test the daylight-saving dates.
- In Ruby, why is storing only Time.now(in: "+01:00") for a Berlin appointment wrong?A fixed offset is a single number of seconds, so the `Time` never follows Berlin's switch to `+02:00` in summer. Any time computed from it, such as a reminder months later, is an hour off. Store the zone name and resolve it with a timezone object when you need the instant.
- In Ruby, why not set ENV["TZ"] to each patient's zone while building their reminder?`ENV` is shared by the whole process, and after each `TZ` assignment Ruby re-reads the zone before the next local conversion. Two threads building reminders for different patients overwrite each other's zone, producing wrong times intermittently. Pass a zone to `Time.new(..., in:)` or `getlocal` instead.
saying these in an interview costs you the question
- Storing a UTC instant alone is enough for appointments months ahead.
- Time.new(..., in: "Europe/Berlin") works in plain Ruby without any setup.
- Subtracting 86_400 always gives the same clock time the day before.
- Setting ENV["TZ"] per patient is a safe way to switch zones.
- An offset like "+01:00" is the same thing as a time zone.