DEV Community

Samya for Kordelis

Posted on

Why did my birthday move? A date-only bug across time zones

Someone enters their birthday as 12 June. After travelling, they open the app and see 11 June.

They haven't changed it. One possible cause is that the app turned a calendar date into a moment in time, then displayed that moment in a different time zone.

Here's a small example of how that can happen, and what to test in an app that stores birthdays.

Watch the short video: Why did my birthday move?

From 12 June to 11 June

Suppose a birthday is 1994-06-12. An app saves it as midnight UTC:

1994-06-12T00:00:00Z
Enter fullscreen mode Exit fullscreen mode

That timestamp identifies a moment. At that moment in Los Angeles, the local date and time are 11 June 1994, 17:00. If the app converts the timestamp to the device's time zone and takes the date, it will show 11 June.

The time-zone conversion is doing what it was asked to do. The problem is that a birthday was represented as a moment in the first place. Travelling doesn't change a correctly stored birthday.

Here's the conversion in Kotlin:

import java.time.Instant
import java.time.ZoneId

val savedMoment = Instant.parse("1994-06-12T00:00:00Z")
val displayedDate = savedMoment
    .atZone(ZoneId.of("America/Los_Angeles"))
    .toLocalDate()

println(displayedDate) // 1994-06-11
Enter fullscreen mode Exit fullscreen mode

The offset depends on the zone's rules for the date in question. Using a named zone such as America/Los_Angeles lets the time API apply those rules, including daylight saving time.

Keep a birthday as a date

For a birthday, the value we need is a year, month and day. In Java or Kotlin, LocalDate represents that without a time or time zone:

import java.time.LocalDate

val birthday = LocalDate.parse("1994-06-12")
val apiValue = birthday.toString() // "1994-06-12"
val loadedBirthday = LocalDate.parse(apiValue)

check(loadedBirthday == birthday)
Enter fullscreen mode Exit fullscreen mode

The important part is what happens between those two operations. Agree on a date-only value in the API, for example:

{ "birthday": "1994-06-12" }
Enter fullscreen mode Exit fullscreen mode

Then preserve it as a date through the server and database. For example, PostgreSQL's DATE type stores a calendar date without a time of day. On the way back, parse the value as a date and format it for the person's locale—without converting it through an instant.

On Android, java.time.LocalDate is available from API level 26. If you support older Android versions, check your project's Java API desugaring configuration and supported APIs.

Test the full round trip

A parsing test alone can't catch a conversion hidden in the API, database mapping or UI. Try a test that exercises the actual save and reload path:

  1. In UTC, enter and save 1994-06-12.
  2. Reload it and check that the API returns 1994-06-12 and the screen displays 12 June.
  3. Change the device or test environment to America/Los_Angeles, reload the same record and check both values again.
  4. Repeat with a zone east of UTC, such as Asia/Tokyo, and include dates near year boundaries and a leap day.

For an automated integration test, run the save and reload through your real serialization and persistence layers in each relevant test environment. Inspect what travels over the API as well as what appears on screen; a date-only string can still be converted incorrectly by a client.

Existing timestamp data needs care. Before migrating, establish what the stored timestamps meant and which time zone was used when the original dates were converted. A timestamp alone may not reliably reveal the intended birthday; don't apply a blanket conversion without checking the data and assumptions.

A birthday isn't the only date-only value: a due date or anniversary may be one too. Scheduled meetings and payment events, on the other hand, represent moments and need appropriate time-zone handling. Choose the representation based on what the value means.

References

Disclosure: AI assisted with drafting this article. The example is illustrative.

Top comments (0)