Add Timezone/DST Disambiguation

This commit is contained in:
Jesse Gumm 2013-10-22 17:29:16 -05:00
parent ef659da9a4
commit 7384819edb
4 changed files with 200 additions and 44 deletions

View file

@ -1,3 +1,8 @@
## 0.3.0 (In development)
* Add Timezone/Daylight Saving Disambiguation
* Add the `auto` timezone shortcut
## 0.2.1 ## 0.2.1
* Fix allowing timezone names to be binary * Fix allowing timezone names to be binary

View file

@ -65,6 +65,10 @@ T, Z, r, and c), `qdate` will handle them for us.
+ `to_now(Date)` - converts any date/time format to Erlang now format. + `to_now(Date)` - converts any date/time format to Erlang now format.
+ `to_unixtime(Date)` - converts any date/time format to a unixtime integer + `to_unixtime(Date)` - converts any date/time format to a unixtime integer
A **ToTimezone** value of the atom `auto` will automatically determine the
timezone. For example, `to_date(Date, auto)` is exactly the same as
`to_date(Date)`
**A Note About Argument Order**: In all cases, `ToTimezone` is optional and if **A Note About Argument Order**: In all cases, `ToTimezone` is optional and if
omitted, will be determined as described below in "Understanding Timezone omitted, will be determined as described below in "Understanding Timezone
Determining and Conversion". If `ToTimezone` is specified, it will always be Determining and Conversion". If `ToTimezone` is specified, it will always be
@ -93,6 +97,80 @@ will infer the timezone in the following order.
application variable `default_timezone`. application variable `default_timezone`.
+ If no timezone is specified by either of the above, `qdate` assumes "GMT" + If no timezone is specified by either of the above, `qdate` assumes "GMT"
for all dates. for all dates.
+ A timezone value of `auto` will act as if no timezone is specified.
#### Disambiguating Ambiguous Timezone Conversions
Sometimes, when youre converting a datetime from one timezone to another, there
are potentially two different results if the conversion happens to land on in a
timezone that's in the middle of a Daylight Saving conversion. For example,
converting "11-Nov-2013 1:00:am" in "America/New York" to "GMT" could be both
"5am" and "6am" in GMT, since "1am EST". This is a side effect of the
"intelligence" of `qdate` - `qdate` would notice that 1am in New York is EST,
and should be converted to "1am EST", and then do the conversion from "1am EST"
to "GMT". This can lead to confusion.
Further, since `qdate` attempts to be "smart" about mistakenly entered
timezones (ie, if you entered "2013-01-01 EDT", `qdate` knows that "EDT"
(Eastern Daylight Time) doesn't apply to January first, so it *assumes* you
meant "EST".
**THE SOLUTION** to this tangled mess that we call Daylight Saving Time is to
provide an option to disambiguate if you so desire. By default disambiguation
is disabled, and `qdate` will just guess as to it's best choice. But if you so
desire, you can make sure qdate does *both* conversions, and returns both.
You can do this by passing a `Disambiguation` argument to `to_string` or
`to_date`. `Disambiguation` can be an atom of the values:
+ `prefer_standard` *(Default Behavior)*: If an ambiguous result occurs,
qdate will return the date in standard time rather than daylight time.
+ `prefer_daylight`: If an ambiguous result occurs, qdate will return the
preferred daylight time.
+ `both`: If an ambiguous result occurs, `qdate` will return the tuple:
`{ambiguous, DateStandard, DateDaylight}`, where `DateStandard` is the date
in Standard Time, and `DateDaylight` is the date in Daylight Saving Time.
So the two more helper functions are:
+ `to_date(ToTimezone, Disambiguate, Date)`
+ `to_string(FormatString, ToTimezone, Disambiguate, Date)`
Examples:
```erlang
1> qdate:set_timezone("GMT").
ok
%% Here, converting GMT 2013-11-03 6AM to America/New York yields an ambiguous
%% result
2> qdate:to_date("America/New York", both, {{2013,11,3},{6,0,0}}).
{ambiguous,{{2013,11,3},{1,0,0}},{{2013,11,3},{2,0,0}}}
%% Let's just use daylight time
3> qdate:to_date("America/New York", prefer_daylight, {{2013,11,3},{6,0,0}}).
{{2013,11,3},{2,0,0}}
%% Let's just use standard time (the default behavior)
4> qdate:to_date("America/New York", prefer_standard, {{2013,11,3},{6,0,0}}).
{{2013,11,3},{1,0,0}}
5> qdate:set_timezone("America/New York").
ok
%% Switching from 1AM Eastern Time to GMT yields a potentially ambiguous result
6> qdate:to_date("GMT", both, {{2013,11,3},{1,0,0}}).
{ambiguous,{{2013,11,3},{6,0,0}},{{2013,11,3},{5,0,0}}}
%% Use daylight time for conversion
7> qdate:to_date("GMT", prefer_daylight, {{2013,11,3},{1,0,0}}).
{{2013,11,3},{5,0,0}}
%% Here we demonstrated that even if we ask for "both", if there is no
%% ambiguity, the plain date is returned
8> qdate:to_date("GMT", both, {{2013,11,3},{5,0,0}}).
{{2013,11,3},{10,0,0}}
```
#### Conversion Functions provided for API compatibility with `ec_date` #### Conversion Functions provided for API compatibility with `ec_date`
@ -543,6 +621,7 @@ not exist.
### Thanks to Additional Contributors ### Thanks to Additional Contributors
+ [Mark Allen](https://github.com/mrallen1) + [Mark Allen](https://github.com/mrallen1)
+ [Christopher Phillips](http://github.com/lostcolony)
## Changelog ## Changelog

View file

@ -1,7 +1,7 @@
{application, qdate, {application, qdate,
[ [
{description, "Simple Date and Timezone handling for Erlang"}, {description, "Simple Date and Timezone handling for Erlang"},
{vsn, "0.2.1"}, {vsn, "0.3.0-pre"},
{registered, []}, {registered, []},
{applications, [ {applications, [
kernel, kernel,

View file

@ -13,8 +13,10 @@
to_string/1, to_string/1,
to_string/2, to_string/2,
to_string/3, to_string/3,
to_string/4,
to_date/1, to_date/1,
to_date/2, to_date/2,
to_date/3,
to_now/1, to_now/1,
to_unixtime/1, to_unixtime/1,
unixtime/0 unixtime/0
@ -67,6 +69,8 @@
end). end).
-define(DETERMINE_TZ, determine_timezone()). -define(DETERMINE_TZ, determine_timezone()).
-define(DEFAULT_DISAMBIG, prefer_standard).
-define(else, true).
start() -> start() ->
application:start(qdate). application:start(qdate).
@ -74,22 +78,24 @@ start() ->
stop() -> stop() ->
application:stop(qdate). application:stop(qdate).
to_string(Format) -> to_string(Format) ->
to_string(Format, os:timestamp()). to_string(Format, os:timestamp()).
to_string(Format, Date) -> to_string(Format, Date) ->
to_string(Format, ?DETERMINE_TZ, Date). to_string(Format, ?DETERMINE_TZ, Date).
to_string(FormatKey, ToTZ, Date) when is_atom(FormatKey) orelse is_tuple(FormatKey) -> to_string(Format, ToTZ, Date) ->
to_string(Format, ToTZ, ?DEFAULT_DISAMBIG, Date).
to_string(FormatKey, ToTZ, Disambiguate, Date) when is_atom(FormatKey) orelse is_tuple(FormatKey) ->
Format = case qdate_srv:get_format(FormatKey) of Format = case qdate_srv:get_format(FormatKey) of
undefined -> throw({undefined_format_key,FormatKey}); undefined -> throw({undefined_format_key,FormatKey});
F -> F F -> F
end, end,
to_string(Format, ToTZ, Date); to_string(Format, ToTZ, Disambiguate, Date);
to_string(Format, ToTZ, Date) when is_binary(Format) -> to_string(Format, ToTZ, Disambiguate, Date) when is_binary(Format) ->
list_to_binary(to_string(binary_to_list(Format), ToTZ, Date)); list_to_binary(to_string(binary_to_list(Format), ToTZ, Disambiguate, Date));
to_string(Format, ToTZ, Date) when is_list(Format) -> to_string(Format, ToTZ, Disambiguate, Date) when is_list(Format) ->
%% it may seem odd that we're ensuring it here, and then again %% it may seem odd that we're ensuring it here, and then again
%% as one of the last steps of the to_date process, but we need %% as one of the last steps of the to_date process, but we need
%% the actual name for the strings for the PHP "T" and "e", so %% the actual name for the strings for the PHP "T" and "e", so
@ -97,46 +103,73 @@ to_string(Format, ToTZ, Date) when is_list(Format) ->
%% Then we can pass it on to to_date as well. That way we don't have %% Then we can pass it on to to_date as well. That way we don't have
%% to do it twice, since it's already ensured. %% to do it twice, since it's already ensured.
ActualToTZ = ensure_timezone(ToTZ), ActualToTZ = ensure_timezone(ToTZ),
to_string_worker(Format, ActualToTZ, to_date(ActualToTZ, Date)). case to_date(ActualToTZ, Disambiguate, Date) of
{ambiguous, Standard, Daylight} ->
{ambiguous,
to_string_worker(Format, ActualToTZ, prefer_standard, Standard),
to_string_worker(Format, ActualToTZ, prefer_daylight, Daylight)};
ActualDate ->
case tz_name(ActualDate,Disambiguate, ActualToTZ) of
{ambiguous,_,_} ->
{ambiguous,
to_string_worker(Format, ActualToTZ, prefer_standard, ActualDate),
to_string_worker(Format, ActualToTZ, prefer_daylight, ActualDate)};
_ ->
to_string_worker(Format, ActualToTZ, Disambiguate, ActualDate)
end
end.
to_string_worker([], _, _) ->
to_string_worker([], _, _, _) ->
""; "";
to_string_worker([$e|RestFormat], ToTZ, Date) -> to_string_worker([$e|RestFormat], ToTZ, Disamb, Date) ->
ToTZ ++ to_string_worker(RestFormat, ToTZ, Date); ToTZ ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([$I|RestFormat], ToTZ, Date) -> to_string_worker([$I|RestFormat], ToTZ, Disamb, Date) ->
I = case localtime_dst:check(Date, ToTZ) of I = case localtime_dst:check(Date, ToTZ) of
is_in_dst -> "1"; is_in_dst -> "1";
is_not_in_dst -> "0"; is_not_in_dst -> "0";
ambiguous_time -> "?" ambiguous_time -> "?"
end, end,
I ++ to_string_worker(RestFormat, ToTZ, Date); I ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([H | RestFormat], ToTZ, Date) when H==$O orelse H==$P -> to_string_worker([H | RestFormat], ToTZ, Disamb, Date) when H==$O orelse H==$P ->
Shift = get_timezone_shift(ToTZ, Date), Shift = get_timezone_shift(ToTZ, Disamb, Date),
Separator = case H of Separator = case H of
$O -> ""; $O -> "";
$P -> ":" $P -> ":"
end, end,
format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Date); format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([$T | RestFormat], ToTZ, Date) -> to_string_worker([$T | RestFormat], ToTZ, Disamb, Date) ->
{ShortName,_} = localtime:tz_name(Date, ToTZ), ShortName = tz_name(Date, Disamb, ToTZ),
ShortName ++ to_string_worker(RestFormat, ToTZ, Date); ShortName ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([$Z | RestFormat], ToTZ, Date) -> to_string_worker([$Z | RestFormat], ToTZ, Disamb, Date) ->
{Sign, Hours, Mins} = get_timezone_shift(ToTZ, Date), {Sign, Hours, Mins} = get_timezone_shift(ToTZ, Disamb, Date),
Seconds = (Hours * 3600) + (Mins * 60), Seconds = (Hours * 3600) + (Mins * 60),
atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Date); atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([$r | RestFormat], ToTZ, Date) -> to_string_worker([$r | RestFormat], ToTZ, Disamb, Date) ->
NewFormat = "D, d M Y H:i:s O", NewFormat = "D, d M Y H:i:s O",
to_string_worker(NewFormat, ToTZ, Date) ++ to_string_worker(RestFormat, ToTZ, Date); to_string_worker(NewFormat, ToTZ, Disamb, Date) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([$c | RestFormat], ToTZ, Date) -> to_string_worker([$c | RestFormat], ToTZ, Disamb, Date) ->
Format1 = "Y-m-d", Format1 = "Y-m-d",
Format2 = "H:i:sP", Format2 = "H:i:sP",
to_string_worker(Format1, ToTZ, Date) to_string_worker(Format1, ToTZ, Disamb, Date)
++ "T" ++ "T"
++ to_string_worker(Format2, ToTZ, Date) ++ to_string_worker(Format2, ToTZ, Disamb, Date)
++ to_string_worker(RestFormat, ToTZ, Date); ++ to_string_worker(RestFormat, ToTZ, Disamb, Date);
to_string_worker([H | RestFormat], ToTZ, Date) -> to_string_worker([H | RestFormat], ToTZ, Disamb, Date) ->
ec_date:format([H], Date) ++ to_string_worker(RestFormat, ToTZ, Date). ec_date:format([H], Date) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date).
tz_name(Date, Disambiguate, ToTZ) ->
case localtime:tz_name(Date, ToTZ) of
{ShortName, _} when is_list(ShortName) ->
ShortName;
{{ShortStandard,_},{ShortDST,_}} ->
case Disambiguate of
prefer_standard -> ShortStandard;
prefer_daylight -> ShortDST;
both -> {ambiguous, ShortStandard, ShortDST}
end
end.
format_shift({Sign,Hours,Mins},Separator) -> format_shift({Sign,Hours,Mins},Separator) ->
SignStr = atom_to_list(Sign), SignStr = atom_to_list(Sign),
@ -166,11 +199,14 @@ nparse(String) ->
to_date(RawDate) -> to_date(RawDate) ->
to_date(?DETERMINE_TZ, RawDate). to_date(?DETERMINE_TZ, RawDate).
to_date(ToTZ, RawDate) when is_binary(RawDate) -> to_date(ToTZ, RawDate) ->
to_date(ToTZ, binary_to_list(RawDate)); to_date(ToTZ, ?DEFAULT_DISAMBIG, RawDate).
to_date(ToTZ, RawDate) when is_binary(ToTZ) ->
to_date(binary_to_list(ToTZ), RawDate); to_date(ToTZ, Disambiguate, RawDate) when is_binary(RawDate) ->
to_date(ToTZ, RawDate) -> to_date(ToTZ, Disambiguate, binary_to_list(RawDate));
to_date(ToTZ, Disambiguate, RawDate) when is_binary(ToTZ) ->
to_date(binary_to_list(ToTZ), Disambiguate, RawDate);
to_date(ToTZ, Disambiguate, RawDate) ->
{ExtractedDate, ExtractedTZ} = extract_timezone(RawDate), {ExtractedDate, ExtractedTZ} = extract_timezone(RawDate),
{RawDate3, FromTZ} = case try_registered_parsers(RawDate) of {RawDate3, FromTZ} = case try_registered_parsers(RawDate) of
undefined -> undefined ->
@ -182,12 +218,12 @@ to_date(ToTZ, RawDate) ->
end, end,
try raw_to_date(RawDate3) of try raw_to_date(RawDate3) of
D={{_,_,_},{_,_,_}} -> D={{_,_,_},{_,_,_}} ->
date_tz_to_tz(D, FromTZ, ToTZ) date_tz_to_tz(D, Disambiguate, FromTZ, ToTZ)
catch catch
_:_ -> _:_ ->
case raw_to_date(RawDate) of case raw_to_date(RawDate) of
D2={{_,_,_},{_,_,_}} -> D2={{_,_,_},{_,_,_}} ->
date_tz_to_tz(D2, ?DETERMINE_TZ, ToTZ) date_tz_to_tz(D2, Disambiguate, ?DETERMINE_TZ, ToTZ)
end end
end. end.
@ -270,11 +306,12 @@ compare(A, Op, B) ->
%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Timezone Stuff %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%% Timezone Stuff %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
get_timezone_shift(TZ, Date) -> get_timezone_shift(TZ, Disambiguate, Date) ->
case localtime:tz_shift(Date, TZ) of case localtime:tz_shift(Date, TZ) of
unable_to_detect -> {error,unable_to_detect}; unable_to_detect -> {error,unable_to_detect};
{error,T} -> {error,T}; {error,T} -> {error,T};
{Sh, _DstSh} -> Sh; {Sh, _} when Disambiguate==prefer_standard -> Sh;
{_, Sh} when Disambiguate==prefer_daylight -> Sh;
Sh -> Sh Sh -> Sh
end. end.
@ -336,12 +373,29 @@ determine_timezone() ->
%% If FromTZ is an integer, then it's an integer that represents the number of minutes %% If FromTZ is an integer, then it's an integer that represents the number of minutes
%% relative to GMT. So we convert the date to GMT based on that number, then we can %% relative to GMT. So we convert the date to GMT based on that number, then we can
%% do the other timezone conversion. %% do the other timezone conversion.
date_tz_to_tz(Date, FromTZ, ToTZ) when is_integer(FromTZ) -> date_tz_to_tz(Date, Disambiguate, FromTZ, ToTZ) when is_integer(FromTZ) ->
NewDate = localtime:adjust_datetime(Date, FromTZ), NewDate = localtime:adjust_datetime(Date, FromTZ),
date_tz_to_tz(NewDate, "GMT", ToTZ); date_tz_to_tz(NewDate, Disambiguate, "GMT", ToTZ);
date_tz_to_tz(Date, FromTZ, ToTZ) -> date_tz_to_tz(Date, Disambiguate, FromTZ, ToTZ) ->
ActualToTZ = ensure_timezone(ToTZ), ActualToTZ = ensure_timezone(ToTZ),
localtime:local_to_local(Date,FromTZ,ActualToTZ). case Disambiguate of
prefer_standard ->
localtime:local_to_local(Date, FromTZ, ActualToTZ);
prefer_daylight ->
localtime:local_to_local_dst(Date, FromTZ, ActualToTZ);
both ->
date_tz_to_tz_both(Date, FromTZ, ToTZ)
end.
date_tz_to_tz_both(Date, FromTZ, ToTZ) ->
Standard = localtime:local_to_local(Date, FromTZ, ToTZ),
Daylight = localtime:local_to_local_dst(Date, FromTZ, ToTZ),
if
Standard=:=Daylight ->
Standard;
?else ->
{ambiguous, Standard, Daylight}
end.
set_timezone(TZ) when is_binary(TZ) -> set_timezone(TZ) when is_binary(TZ) ->
set_timezone(binary_to_list(TZ)); set_timezone(binary_to_list(TZ));
@ -360,6 +414,8 @@ get_timezone() ->
get_timezone(Key) -> get_timezone(Key) ->
qdate_srv:get_timezone(Key). qdate_srv:get_timezone(Key).
ensure_timezone(auto) ->
?DETERMINE_TZ;
ensure_timezone(Key) when is_atom(Key) orelse is_tuple(Key) -> ensure_timezone(Key) when is_atom(Key) orelse is_tuple(Key) ->
case get_timezone(Key) of case get_timezone(Key) of
undefined -> throw({timezone_key_not_found,Key}); undefined -> throw({timezone_key_not_found,Key});
@ -469,7 +525,8 @@ tz_test_() ->
tz_tests(SetupData), tz_tests(SetupData),
test_process_die(SetupData), test_process_die(SetupData),
parser_format_test(SetupData), parser_format_test(SetupData),
test_deterministic_parser(SetupData) test_deterministic_parser(SetupData),
test_disambiguation(SetupData)
]} ]}
end end
}. }.
@ -489,6 +546,21 @@ test_deterministic_parser(_) ->
?_assertEqual({{2012,5,10}, {0,0,0}}, qdate:to_date("2012-5-10")) ?_assertEqual({{2012,5,10}, {0,0,0}}, qdate:to_date("2012-5-10"))
]}. ]}.
test_disambiguation(_) ->
{inorder, [
?_assertEqual(ok, set_timezone("America/New York")),
?_assertEqual({ambiguous, {{2013,11,3},{6,0,0}}, {{2013,11,3},{5,0,0}}}, qdate:to_date("GMT",both,{{2013,11,3},{1,0,0}})),
?_assertEqual({{2013,11,3},{6,0,0}}, qdate:to_date("GMT",prefer_standard,{{2013,11,3},{1,0,0}})),
?_assertEqual({{2013,11,3},{5,0,0}}, qdate:to_date("GMT",prefer_daylight,{{2013,11,3},{1,0,0}})),
?_assertEqual({ambiguous, "GMT","GMT"}, qdate:to_string("T", "GMT", both, {{2013,11,3},{1,0,0}})),
?_assertEqual({ambiguous, "EST","EDT"}, qdate:to_string("T", auto, both, {{2013,11,3},{1,0,0}})),
?_assertEqual(ok, set_timezone("GMT")),
?_assertEqual({ambiguous, {{2013,11,3},{1,0,0}}, {{2013,11,3},{2,0,0}}}, qdate:to_date("America/New York", both, {{2013,11,3},{6,0,0}})),
?_assertEqual({{2013,11,3},{2,0,0}}, qdate:to_date("America/New York", prefer_daylight, {{2013,11,3},{6,0,0}})),
?_assertEqual({{2013,11,3},{1,0,0}}, qdate:to_date("America/New York", prefer_standard, {{2013,11,3},{6,0,0}}))
]}.
tz_tests(_) -> tz_tests(_) ->
{inorder,[ {inorder,[
?_assertEqual(ok,set_timezone(<<"Europe/Moscow">>)), ?_assertEqual(ok,set_timezone(<<"Europe/Moscow">>)),