Introduction
The other day I was working with some old relational data, where timestamps had been stored without timezone data - and not standardized to UTC.
(By the way, read this: https://stackoverflow.com/a/32443004).
The relational data made its way into a Java module as java.util.Date
values. I knew the data was captured in the local time of the database server - and the server was in the U.S. Eastern time zone.
I wanted to display the dates with an additional piece of data showing whether the datetime represented an EST or EDT value:
- “EST” - for datetimes falling in Eastern Standard Time.
- “EDT” - for datetimes falling in Eastern Daylight Saving Time
Also, using Java 11, I wanted to make use of classes in the java.time
package.
Attempt One
After rummaging around in java.util.TimeZone
, java.time.ZoneId
, java.time.zone.ZoneRules
, and others, my first attempt was this cumbersome thing:
Java
1
2
3
4
5
6
|
public static String getTimezoneAbbrev(Date date) {
boolean isDaylightSavings = TimeZone.getTimeZone("America/New_York").toZoneId()
.getRules().isDaylightSavings(date.toInstant());
return TimeZone.getTimeZone("America/New_York")
.getDisplayName(isDaylightSavings, TimeZone.SHORT);
}
|
It passed all the unit tests (see below). But I was left feeling that java.time
hasn’t made things better - it’s only made them “better”.
Attempt Two
Then I noticed that java.util.TimeZone
(which has existed since the dawn of time - pun intended) has a inDaylightTime(java.util.Date)
method.
The newer stuff had led me down a hole. But here is my second attempt:
Java
1
2
3
4
|
public static String getTimezoneAbbrev(Date date) {
TimeZone tz = TimeZone.getTimeZone("America/New_York");
return tz.getDisplayName(tz.inDaylightTime(date), TimeZone.SHORT);
}
|
More compact - not exactly nice, but a lot better.
A Warning
From the Javadoc for
TimeZone:
Java will return “the specified TimeZone or the GMT zone if the given ID cannot be understood”.
This is dangerous because it can easily introduce silent errors into your code. It can be easy to get the time zone name wrong.
This is correct:
America/New_York
But this will cause your code to use GMT, without any warnings:
America/New-York
The difference is subtle - an underscore (_
) vs. a dash (-
).
Your units tests will catch that, though… right?
Attempt Three
If I want to avoid the dangers associated with java.util.TimeZone
then I can use the java.time
package as follows:
Java
1
2
3
4
5
6
7
8
9
10
11
|
import java.util.Date;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
...
public static String getTimezoneAbbrev(Date date) {
ZoneId zoneId = ZoneId.of("America/New_York");
return date.toInstant().atZone(zoneId)
.format(DateTimeFormatter.ofPattern("z"));
}
|
The z
in the datetime formatter is documented as follows:
If the pattern letter is ‘z’ the output is the daylight savings aware zone name. If there is insufficient information to determine whether DST applies, the name ignoring daylight savings time will be used. If the count of letters is one, two or three, then the short name is output. If the count of letters is four, then the full name is output.
But most importantly of all, the following line, which uses that non-existent time zone name from Attempt Two…
Java
1
|
ZoneId.of("America/New-York")
|
…will now throw an exception:
java.time.zone.ZoneRulesException: Unknown time-zone ID: America/New-York
Also, as a side-note: If you just need a boolean
to indicate whether or not Daylight Saving is in effect for the given date and time, then you can use this:
Java
1
|
boolean b = zoneId.getRules().isDaylightSavings(date.toInstant());
|
Anyway, Attempt Three is the best approach!
Or is it…?
Attempt Four?
The best solution would be to fix the source - in the upstream module which is providing the database data as java.util.Date
objects.
The data is stored in a timestamp field in the database, without any time zone information (so, really, that needs to be fixed in the DB schema) but let’s focus on Java.
For this situation, instead of populating a Date
field, the upstream code should be using java.time.LocalDateTime
- where “local” here actually means (perhaps counter-intuitively) “no specific locality”.
So now we can take this zoneless date and time, tell it what time zone it is actually in, and then format it for the z
timezone abbreviation:
Java
1
2
3
4
|
public static String getTimezoneAbbrev(LocalDateTime ldt) {
ZoneId zoneId = ZoneId.of("America/New-York");
return ldt.atZone(zoneId).format(DateTimeFormatter.ofPattern("z"));
}
|
My Unit Tests
Here are my tests for the methods using Date
as the parameter:
Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
@Test
public void getTimezoneAbbrevTest() throws Exception {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm");
String tzAbbrev;
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2015-01-12 12:21"));
assertThat(tzAbbrev).isEqualTo("EST");
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2012-05-12 12:21"));
assertThat(tzAbbrev).isEqualTo("EDT");
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2020-01-12 12:21"));
assertThat(tzAbbrev).isEqualTo("EST");
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2022-06-12 12:21"));
assertThat(tzAbbrev).isEqualTo("EDT");
//
// "SPRING FORWARD" BOUNDARY TESTS
//
// Spring boundary test 1:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 01:59"));
assertThat(tzAbbrev).isEqualTo("EST");
// Spring boundary test 2:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 02:00"));
assertThat(tzAbbrev).isEqualTo("EDT");
// Spring boundary test 3:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-03-10 03:00"));
assertThat(tzAbbrev).isEqualTo("EDT");
//
// PAST "FALL BACK" BOUNDARY TESTS
//
// Past fall boundary test 1:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 00:59"));
assertThat(tzAbbrev).isEqualTo("EDT");
// Past fall boundary test 2 - Java uses EDT for the duplicated hour:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 01:00"));
assertThat(tzAbbrev).isEqualTo("EST");
// Past fall boundary test 3 - Java uses EDT for the duplicated hour:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 01:59"));
assertThat(tzAbbrev).isEqualTo("EST");
// Past fall boundary test 4:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 02:00"));
assertThat(tzAbbrev).isEqualTo("EST");
// Past fall boundary test 5:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2019-11-03 03:00"));
assertThat(tzAbbrev).isEqualTo("EST");
//
// FUTURE "FALL BACK" BOUNDARY TESTS - just in case...
//
// Future fall boundary test 1:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-03 00:59"));
assertThat(tzAbbrev).isEqualTo("EDT");
// Future fall boundary test 2 - Java uses EDT for the duplicated hour:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 01:00"));
assertThat(tzAbbrev).isEqualTo("EST");
// Future fall boundary test 3 - Java uses EDT for the duplicated hour:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 01:59"));
assertThat(tzAbbrev).isEqualTo("EST");
// Future fall boundary test 4:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 02:00"));
assertThat(tzAbbrev).isEqualTo("EST");
// Future fall boundary test 5:
tzAbbrev = Main.getTimezoneAbbrev(sdf.parse("2021-11-07 03:00"));
assertThat(tzAbbrev).isEqualTo("EST");
}
|
Dumping TZ Data
This led me to wonder what all the time zones are in Java - their names and their IDs. For example:
Standard TZ short name : EST
Standard TZ long name : Eastern Standard Time
Daylight saving TZ short name : EDT
Daylight saving TZ long name : Eastern Daylight Time
Local time : 17:38:55.355959500
Local time with offset of GMT+02:30 : 01:08:55.371588
Local time in current TZ : 17:38:55.371588
Local TZ name using default TZ : America/New_York
Another way to get the same thing : America/New_York
Get the short TZ name : ET
Get the short TZ name another way : ET
Get the "narrow"(?!?) TZ name : America/New_York
Get current date & time in the other TZ : Nov 26, 2019, 2:38:55 PM
Is daylight savings in effect in that TZ?: false
Is daylight savings in effect here? : false
The following code generates a full listing, in case you are interested:
Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
|
package org.northcoder.timezones;
import java.util.TimeZone;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.io.PrintStream;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* Read this: https://stackoverflow.com/a/32443004
*/
public class Main {
public static void main(String[] args) {
// key = timezone display name
// value = list of all the timezone IDs for that display name
Map<String, List<String>> tzMap = new HashMap();
// key = raw offset from GMT (millis)
// value = list of timezone display names
// we use this to sort the final output by timezone offset
Map<Integer, List<String>> offsets = new HashMap();
for (String timeZoneID : TimeZone.getAvailableIDs()) {
TimeZone tz = TimeZone.getTimeZone(timeZoneID);
String displayName = tz.getDisplayName();
int rawOffset = tz.getRawOffset();
if (offsets.containsKey(rawOffset)) {
if (!offsets.get(rawOffset).contains(displayName)) {
offsets.get(rawOffset).add(displayName);
}
} else {
List<String> displayNames = new ArrayList();
displayNames.add(displayName);
offsets.put(rawOffset, displayNames);
}
if (tzMap.containsKey(displayName)) {
tzMap.get(displayName).add(timeZoneID);
} else {
List<String> ids = new ArrayList();
ids.add(timeZoneID);
tzMap.put(displayName, ids);
}
}
List<Integer> offsetsList = new ArrayList<>(offsets.keySet());
Collections.sort(offsetsList);
PrintStream out = new PrintStream(System.out, true, UTF_8);
out.println();
out.println("Java Time Zone Names and IDs");
out.println();
for (int offset : offsetsList) {
List<String> tzDisplayNames = offsets.get(offset);
Collections.sort(tzDisplayNames);
for (String tzDisplayName : tzDisplayNames) {
List<String> tzIDs = tzMap.get(tzDisplayName);
Collections.sort(tzIDs);
printDetails(tzIDs);
}
}
}
private static void printDetails(List<String> timeZoneIDs) {
PrintStream out = new PrintStream(System.out, true, UTF_8);
final boolean daylightSavingsName = true;
final boolean standardName = false;
TimeZone tz = TimeZone.getTimeZone(timeZoneIDs.get(0));
out.println("Time Zone Display name : " + tz.getDisplayName());
out.println();
out.println("Display name (French) : " + tz.getDisplayName(Locale.FRANCE));
out.println("Display name (Chinese) : " + tz.getDisplayName(Locale.CHINA));
out.println("Display name std short : " + tz.getDisplayName(standardName, TimeZone.SHORT));
out.println("Display name std long : " + tz.getDisplayName(standardName, TimeZone.LONG));
out.println("Display name savings short: " + tz.getDisplayName(daylightSavingsName, TimeZone.SHORT));
out.println("Display name savings long : " + tz.getDisplayName(daylightSavingsName, TimeZone.LONG));
double offsetInHours = tz.getRawOffset() / 1000.0 / 60.0 / 60.0;
out.println("Offset (hours) : " + offsetInHours);
out.println("Observes daylight saving : " + tz.observesDaylightTime());
out.println("Time Zone IDs for this timezone: ");
timeZoneIDs.forEach((id) -> {
out.println(" - " + id);
});
out.println();
}
}
|
An entry looks like the following - and the entries are sorted by offset:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
Time Zone Display name : Western European Standard Time
Display name (French) : heure normale d’Europe de l’Ouest
Display name (Chinese) : 西欧标准时间
Display name std short : WET
Display name std long : Western European Standard Time
Display name savings short: WEST
Display name savings long : Western European Summer Time
Offset (hours) : 1.0
Observes daylight saving : false
Time Zone IDs for this timezone:
- Africa/Casablanca
- Africa/El_Aaiun
- Atlantic/Canary
- Atlantic/Faeroe
- Atlantic/Faroe
- Atlantic/Madeira
- Europe/Lisbon
- Portugal
|