diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index 8e32f86..321013b 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -1,3 +1,8 @@ +## 0.3.0 (In development) + +* Add Timezone/Daylight Saving Disambiguation +* Add the `auto` timezone shortcut + ## 0.2.1 * Fix allowing timezone names to be binary diff --git a/README.markdown b/README.markdown index 6ae392c..8928871 100644 --- a/README.markdown +++ b/README.markdown @@ -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_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 omitted, will be determined as described below in "Understanding Timezone 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`. + If no timezone is specified by either of the above, `qdate` assumes "GMT" 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` @@ -543,6 +621,7 @@ not exist. ### Thanks to Additional Contributors + [Mark Allen](https://github.com/mrallen1) ++ [Christopher Phillips](http://github.com/lostcolony) ## Changelog diff --git a/src/qdate.app.src b/src/qdate.app.src index 9eda033..73000f4 100644 --- a/src/qdate.app.src +++ b/src/qdate.app.src @@ -1,7 +1,7 @@ {application, qdate, [ {description, "Simple Date and Timezone handling for Erlang"}, - {vsn, "0.2.1"}, + {vsn, "0.3.0-pre"}, {registered, []}, {applications, [ kernel, diff --git a/src/qdate.erl b/src/qdate.erl index a9ce983..8fd4104 100644 --- a/src/qdate.erl +++ b/src/qdate.erl @@ -13,8 +13,10 @@ to_string/1, to_string/2, to_string/3, + to_string/4, to_date/1, to_date/2, + to_date/3, to_now/1, to_unixtime/1, unixtime/0 @@ -67,6 +69,8 @@ end). -define(DETERMINE_TZ, determine_timezone()). +-define(DEFAULT_DISAMBIG, prefer_standard). +-define(else, true). start() -> application:start(qdate). @@ -74,22 +78,24 @@ start() -> stop() -> application:stop(qdate). - to_string(Format) -> to_string(Format, os:timestamp()). to_string(Format, 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 undefined -> throw({undefined_format_key,FormatKey}); F -> F end, - to_string(Format, ToTZ, Date); -to_string(Format, ToTZ, Date) when is_binary(Format) -> - list_to_binary(to_string(binary_to_list(Format), ToTZ, Date)); -to_string(Format, ToTZ, Date) when is_list(Format) -> + to_string(Format, ToTZ, Disambiguate, Date); +to_string(Format, ToTZ, Disambiguate, Date) when is_binary(Format) -> + list_to_binary(to_string(binary_to_list(Format), ToTZ, Disambiguate, Date)); +to_string(Format, ToTZ, Disambiguate, Date) when is_list(Format) -> %% 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 %% 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 %% to do it twice, since it's already ensured. 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) -> - ToTZ ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$I|RestFormat], ToTZ, Date) -> +to_string_worker([$e|RestFormat], ToTZ, Disamb, Date) -> + ToTZ ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$I|RestFormat], ToTZ, Disamb, Date) -> I = case localtime_dst:check(Date, ToTZ) of is_in_dst -> "1"; is_not_in_dst -> "0"; ambiguous_time -> "?" end, - I ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([H | RestFormat], ToTZ, Date) when H==$O orelse H==$P -> - Shift = get_timezone_shift(ToTZ, Date), + I ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([H | RestFormat], ToTZ, Disamb, Date) when H==$O orelse H==$P -> + Shift = get_timezone_shift(ToTZ, Disamb, Date), Separator = case H of $O -> ""; $P -> ":" end, - format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$T | RestFormat], ToTZ, Date) -> - {ShortName,_} = localtime:tz_name(Date, ToTZ), - ShortName ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$Z | RestFormat], ToTZ, Date) -> - {Sign, Hours, Mins} = get_timezone_shift(ToTZ, Date), + format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$T | RestFormat], ToTZ, Disamb, Date) -> + ShortName = tz_name(Date, Disamb, ToTZ), + ShortName ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$Z | RestFormat], ToTZ, Disamb, Date) -> + {Sign, Hours, Mins} = get_timezone_shift(ToTZ, Disamb, Date), Seconds = (Hours * 3600) + (Mins * 60), - atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$r | RestFormat], ToTZ, Date) -> + atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$r | RestFormat], ToTZ, Disamb, Date) -> NewFormat = "D, d M Y H:i:s O", - to_string_worker(NewFormat, ToTZ, Date) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$c | RestFormat], ToTZ, Date) -> + to_string_worker(NewFormat, ToTZ, Disamb, Date) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$c | RestFormat], ToTZ, Disamb, Date) -> Format1 = "Y-m-d", Format2 = "H:i:sP", - to_string_worker(Format1, ToTZ, Date) + to_string_worker(Format1, ToTZ, Disamb, Date) ++ "T" - ++ to_string_worker(Format2, ToTZ, Date) - ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([H | RestFormat], ToTZ, Date) -> - ec_date:format([H], Date) ++ to_string_worker(RestFormat, ToTZ, Date). + ++ to_string_worker(Format2, ToTZ, Disamb, Date) + ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([H | RestFormat], ToTZ, Disamb, 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) -> SignStr = atom_to_list(Sign), @@ -166,11 +199,14 @@ nparse(String) -> to_date(RawDate) -> to_date(?DETERMINE_TZ, RawDate). -to_date(ToTZ, RawDate) when is_binary(RawDate) -> - to_date(ToTZ, binary_to_list(RawDate)); -to_date(ToTZ, RawDate) when is_binary(ToTZ) -> - to_date(binary_to_list(ToTZ), RawDate); -to_date(ToTZ, RawDate) -> +to_date(ToTZ, RawDate) -> + to_date(ToTZ, ?DEFAULT_DISAMBIG, RawDate). + +to_date(ToTZ, Disambiguate, RawDate) when is_binary(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), {RawDate3, FromTZ} = case try_registered_parsers(RawDate) of undefined -> @@ -182,12 +218,12 @@ to_date(ToTZ, RawDate) -> end, try raw_to_date(RawDate3) of D={{_,_,_},{_,_,_}} -> - date_tz_to_tz(D, FromTZ, ToTZ) + date_tz_to_tz(D, Disambiguate, FromTZ, ToTZ) catch _:_ -> case raw_to_date(RawDate) of D2={{_,_,_},{_,_,_}} -> - date_tz_to_tz(D2, ?DETERMINE_TZ, ToTZ) + date_tz_to_tz(D2, Disambiguate, ?DETERMINE_TZ, ToTZ) end end. @@ -270,11 +306,12 @@ compare(A, Op, B) -> %%%%%%%%%%%%%%%%%%%%%%%%%%%%% Timezone Stuff %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -get_timezone_shift(TZ, Date) -> +get_timezone_shift(TZ, Disambiguate, Date) -> case localtime:tz_shift(Date, TZ) of unable_to_detect -> {error,unable_to_detect}; {error,T} -> {error,T}; - {Sh, _DstSh} -> Sh; + {Sh, _} when Disambiguate==prefer_standard -> Sh; + {_, Sh} when Disambiguate==prefer_daylight -> Sh; Sh -> Sh end. @@ -336,12 +373,29 @@ determine_timezone() -> %% 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 %% 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), - date_tz_to_tz(NewDate, "GMT", ToTZ); -date_tz_to_tz(Date, FromTZ, ToTZ) -> + date_tz_to_tz(NewDate, Disambiguate, "GMT", ToTZ); +date_tz_to_tz(Date, Disambiguate, FromTZ, 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(binary_to_list(TZ)); @@ -360,6 +414,8 @@ get_timezone() -> get_timezone(Key) -> qdate_srv:get_timezone(Key). +ensure_timezone(auto) -> + ?DETERMINE_TZ; ensure_timezone(Key) when is_atom(Key) orelse is_tuple(Key) -> case get_timezone(Key) of undefined -> throw({timezone_key_not_found,Key}); @@ -469,7 +525,8 @@ tz_test_() -> tz_tests(SetupData), test_process_die(SetupData), parser_format_test(SetupData), - test_deterministic_parser(SetupData) + test_deterministic_parser(SetupData), + test_disambiguation(SetupData) ]} end }. @@ -489,6 +546,21 @@ test_deterministic_parser(_) -> ?_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(_) -> {inorder,[ ?_assertEqual(ok,set_timezone(<<"Europe/Moscow">>)),