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
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
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)
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" }
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:
- In UTC, enter and save
1994-06-12. - Reload it and check that the API returns
1994-06-12and the screen displays 12 June. - Change the device or test environment to
America/Los_Angeles, reload the same record and check both values again. - 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)