├── util ├── .gitignore ├── README.md ├── package.json ├── yarn.lock └── index.js ├── tests ├── .gitignore ├── elm-verify-examples.json ├── TestTimeZone.elm ├── elm-package.json ├── TestTimeZonePieces.elm ├── TestTimeZones.elm ├── TestDateTimeDelta.elm ├── TestZonedDateTime.elm ├── TestDate.elm ├── TestDateTime.elm └── TestIso8601.elm ├── .gitignore ├── examples ├── with-timezone │ ├── .gitignore │ ├── README.md │ ├── Makefile │ ├── index.html │ ├── elm-package.json │ └── Main.elm ├── without-timezone │ ├── .gitignore │ ├── README.md │ ├── Makefile │ ├── index.html │ ├── elm-package.json │ └── Main.elm └── with-parser-error-renderer │ ├── .gitignore │ ├── Makefile │ ├── index.html │ ├── elm-package.json │ ├── README.md │ └── Main.elm ├── .gitmodules ├── NOTES.md ├── elm-package.json ├── .travis.yml ├── src └── Time │ ├── Internal.elm │ ├── Iso8601ErrorMsg.elm │ ├── ZonedDateTime.elm │ ├── Iso8601.elm │ ├── TimeZone.elm │ ├── Date.elm │ └── DateTime.elm ├── LICENSE └── README.md /util/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff/ 2 | ./tests/VerifyExamples 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /notes 3 | documentation.json 4 | /tmp 5 | -------------------------------------------------------------------------------- /examples/with-timezone/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /index.js 3 | /index.min.js 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /examples/without-timezone/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /index.js 3 | /index.min.js 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /util/README.md: -------------------------------------------------------------------------------- 1 | # timezone-importer 2 | 3 | This module converts momentjs timezone data into `TimeZoneData.elm`. 4 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff 2 | /index.js 3 | /index.min.js 4 | npm-debug.log 5 | elm.js 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "elm-time.wiki"] 2 | path = elm-time.wiki 3 | url = https://github.com/elm-community/elm-time.wiki.git 4 | -------------------------------------------------------------------------------- /examples/with-timezone/README.md: -------------------------------------------------------------------------------- 1 | # Show Example Web Page With Timezones 2 | 3 | See **[The Examples](https://github.com/elm-community/elm-time/wiki/The-Examples)** 4 | -------------------------------------------------------------------------------- /examples/without-timezone/README.md: -------------------------------------------------------------------------------- 1 | # Show Example Web Page Without Timezones 2 | 3 | See **[The Examples](https://github.com/elm-community/elm-time/wiki/The-Examples)** 4 | -------------------------------------------------------------------------------- /tests/elm-verify-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "../src", 3 | "tests": [ 4 | "Time.Date", 5 | "Time.DateTime", 6 | "Time.Iso8601", 7 | "Time.Iso8601ErrorMsg" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /util/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timezone-importer", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "BSD3", 6 | "dependencies": { 7 | "moment-timezone": "^0.5.10" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/with-timezone/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = $(shell find ../../src -type f -name "*.elm" -print) 2 | 3 | index.js: Main.elm $(SOURCES) 4 | elm make --output=index.js Main.elm 5 | uglifyjs -c dead_code,properties,conditionals,evaluate,unused index.js > index.min.js 6 | -------------------------------------------------------------------------------- /examples/without-timezone/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = $(shell find ../../src -type f -name "*.elm" -print) 2 | 3 | index.js: Main.elm $(SOURCES) 4 | elm make --output=index.js Main.elm 5 | uglifyjs -c dead_code,properties,conditionals,evaluate,unused index.js > index.min.js 6 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = $(shell find ../../src -type f -name "*.elm" -print) 2 | 3 | index.js: Main.elm $(SOURCES) 4 | elm make --output=index.js Main.elm 5 | uglifyjs -c dead_code,properties,conditionals,evaluate,unused index.js > index.min.js 6 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/with-timezone/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | elm-utc Example 5 | 6 | 7 |
8 |
9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/without-timezone/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | elm-utc Example 5 | 6 | 7 |
8 |
9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | This is just a temporary internal document where I track what I'm doing. 4 | 5 | ## Needed TimeZone Tests 6 | 7 | | **Moment Name** | **Old Function Name** | **New Function Name** | **Implemented?** | 8 | |---|---|---|---| 9 | | unpack | TimeZone.unpack | unpack | Needs parsing | 10 | | unpackBase60 | base60 base60String 11 | 12 | ## Packed Timezone Format Change 13 | 14 | Between 2015 and 2016 the format was changed. 15 | -------------------------------------------------------------------------------- /util/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | moment-timezone: 4 | version "0.5.9" 5 | resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.9.tgz#e0ea82036d67d21d793544a91b5057f480eda2dd" 6 | dependencies: 7 | moment ">= 2.6.0" 8 | 9 | "moment@>= 2.6.0": 10 | version "2.16.0" 11 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.16.0.tgz#f38f2c97c9889b0ee18fc6cc392e1e443ad2da8e" 12 | 13 | -------------------------------------------------------------------------------- /examples/with-timezone/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0", 13 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 14 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 15 | "elm-lang/lazy": "2.0.0 <= v < 3.0.0", 16 | "elm-tools/parser": "2.0.1 <= v < 3.0.0" 17 | }, 18 | "elm-version": "0.18.0 <= v < 0.19.0" 19 | } 20 | -------------------------------------------------------------------------------- /examples/without-timezone/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0", 13 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 14 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 15 | "elm-lang/lazy": "2.0.0 <= v < 3.0.0", 16 | "elm-tools/parser": "2.0.1 <= v < 3.0.0" 17 | }, 18 | "elm-version": "0.18.0 <= v < 0.19.0" 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "elm-time example with rendering", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-lang/core": "5.1.1 <= v < 6.0.0", 13 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 14 | "elm-lang/keyboard": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 16 | "elm-tools/parser": "2.0.1 <= v < 3.0.0", 17 | "mdgriffith/style-elements": "4.3.0 <= v < 5.0.0" 18 | }, 19 | "elm-version": "0.18.0 <= v < 0.19.0" 20 | } 21 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.0.5", 3 | "summary": "Deprecated: A pure Elm date and time library.", 4 | "repository": "https://github.com/elm-community/elm-time.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [ 10 | "Time.Date", 11 | "Time.DateTime", 12 | "Time.Iso8601", 13 | "Time.Iso8601ErrorMsg", 14 | "Time.TimeZone", 15 | "Time.TimeZones", 16 | "Time.ZonedDateTime" 17 | ], 18 | "dependencies": { 19 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 20 | "elm-lang/lazy": "2.0.0 <= v < 3.0.0", 21 | "elm-tools/parser": "2.0.1 <= v < 3.0.0" 22 | }, 23 | "elm-version": "0.18.0 <= v < 0.19.0" 24 | } 25 | -------------------------------------------------------------------------------- /tests/TestTimeZone.elm: -------------------------------------------------------------------------------- 1 | module TestTimeZone exposing (..) 2 | 3 | import Debug 4 | import Lazy exposing (force) 5 | import Expect exposing (Expectation) 6 | import Test exposing (..) 7 | import Time.TimeZones exposing (europe_bucharest) 8 | import Time.TimeZoneData exposing (unpack, africa_bissau_l) 9 | import Time.ZonedDateTime exposing (..) 10 | 11 | 12 | unpackTimeZoneData : Test 13 | unpackTimeZoneData = 14 | describe "Time.TimeZone.unpack" 15 | [ test "unpack converts a packed string into a TimeZone" <| 16 | \() -> 17 | let 18 | tz = 19 | force (unpack "Africa/Bissau|LMT -01 GMT|12.k 10 0|012|-2ldWV.E 2xonV.E|39e4") 20 | in 21 | Expect.equal (force africa_bissau_l) tz 22 | ] 23 | -------------------------------------------------------------------------------- /tests/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Test Suites", 4 | "repository": "https://github.com/elm-community/elm-time.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "../src", 8 | "." 9 | ], 10 | "exposed-modules": [ 11 | "Time.Date", 12 | "Time.DateTime", 13 | "Time.Iso8601", 14 | "Time.ISO8601Error", 15 | "Time.TimeZone", 16 | "Time.TimeZones", 17 | "Time.ZonedDateTime" 18 | ], 19 | "dependencies": { 20 | "elm-community/elm-test": "4.0.0 <= v < 5.0.0", 21 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0", 22 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 23 | "elm-lang/lazy": "2.0.0 <= v < 3.0.0", 24 | "elm-tools/parser": "2.0.1 <= v < 3.0.0" 25 | }, 26 | "elm-version": "0.18.0 <= v < 0.19.0" 27 | } 28 | -------------------------------------------------------------------------------- /tests/TestTimeZonePieces.elm: -------------------------------------------------------------------------------- 1 | module TestTimeZonePieces exposing (..) 2 | 3 | --import Lazy exposing (force) 4 | 5 | import Debug 6 | import Expect exposing (Expectation, equal, fail) 7 | import Test exposing (..) 8 | import Time.TimeZone exposing (TimeZone) 9 | import Parser exposing (run) 10 | 11 | 12 | source = 13 | "Africa/Bissau|LMT -01 GMT|12.k 10 0|012|-2ldWV.E 2xonV.E|39e4" 14 | 15 | 16 | unpackTest : Test 17 | unpackTest = 18 | describe "Time.TimeZone.unpack" 19 | [ test "New" <| 20 | \() -> 21 | case Time.TimeZone.unpack source of 22 | Ok value -> 23 | let 24 | v = 25 | value 26 | in 27 | Expect.pass 28 | 29 | Err msg -> 30 | fail (toString msg) 31 | ] 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | 4 | cache: 5 | directories: 6 | - elm-stuff/build-artifacts 7 | - tests/elm-stuff/build-artifacts 8 | - sysconfcpus 9 | 10 | before_install: 11 | - | # epic build time improvement - see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142 12 | if [ ! -d sysconfcpus/bin ]; 13 | then 14 | git clone https://github.com/obmarg/libsysconfcpus.git; 15 | cd libsysconfcpus; 16 | ./configure --prefix=$TRAVIS_BUILD_DIR/sysconfcpus; 17 | make && make install; 18 | cd ..; 19 | fi 20 | 21 | install: 22 | - npm install -g elm@0.18.0 elm-test@0.18.12 elm-verify-examples@2.3.1 elm-format@0.6.1-alpha 23 | - mv $(npm config get prefix)/bin/elm-make $(npm config get prefix)/bin/elm-make-old 24 | - printf '%s\n\n' '#!/bin/bash' 'echo "Running elm-make with sysconfcpus -n 2"' '$TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make-old "$@"' > $(npm config get prefix)/bin/elm-make 25 | - chmod +x $(npm config get prefix)/bin/elm-make 26 | - elm-package install --yes 27 | 28 | script: 29 | - elm-format --validate src tests examples 30 | - elm-test 31 | - elm-verify-examples 32 | -------------------------------------------------------------------------------- /tests/TestTimeZones.elm: -------------------------------------------------------------------------------- 1 | module TestTimeZones exposing (..) 2 | 3 | import Dict 4 | import Expect exposing (Expectation) 5 | import List 6 | import Test exposing (..) 7 | import Time.TimeZone 8 | import Time.TimeZones 9 | 10 | 11 | linkedTimeZoneTest : Test 12 | linkedTimeZoneTest = 13 | describe "Time.TimeZones" 14 | [ test "Europe/Zagreb is an alias for Europe/Belgrade" <| 15 | \() -> 16 | Time.TimeZones.fromName "Europe/Zagreb" 17 | -- Rename the zone data returned so we can compare just 18 | -- the `spans`, i.e. the actual time zone data. 19 | |> Maybe.map (Time.TimeZone.setName "Europe/Belgrade") 20 | |> Expect.equal (Just (Time.TimeZones.europe_belgrade ())) 21 | ] 22 | 23 | 24 | namedTimeZoneTest : Test 25 | namedTimeZoneTest = 26 | describe "Time.TimeZones naming" <| 27 | let 28 | check ( timeZoneName, loadTimeZone ) = 29 | test (timeZoneName ++ "'s TimeZone is named " ++ timeZoneName) <| 30 | \() -> loadTimeZone () |> Time.TimeZone.name |> Expect.equal timeZoneName 31 | in 32 | Dict.toList Time.TimeZones.all |> List.map check 33 | -------------------------------------------------------------------------------- /examples/without-timezone/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Html as H exposing (Html) 4 | import Time exposing (Time) 5 | import Time.DateTime as DateTime exposing (DateTime) 6 | 7 | 8 | type alias Flags = 9 | { now : Time } 10 | 11 | 12 | type alias Model = 13 | { now : DateTime 14 | } 15 | 16 | 17 | type Msg 18 | = Tick Time 19 | 20 | 21 | main : Program Flags Model Msg 22 | main = 23 | H.programWithFlags 24 | { init = init 25 | , update = update 26 | , view = view 27 | , subscriptions = subscriptions 28 | } 29 | 30 | 31 | init : Flags -> ( Model, Cmd Msg ) 32 | init { now } = 33 | { now = DateTime.fromTimestamp now 34 | } 35 | ! [] 36 | 37 | 38 | update : Msg -> Model -> ( Model, Cmd Msg ) 39 | update msg model = 40 | case msg of 41 | Tick now -> 42 | ( { model | now = DateTime.fromTimestamp now } 43 | , Cmd.none 44 | ) 45 | 46 | 47 | view : Model -> Html Msg 48 | view { now } = 49 | H.div [] 50 | [ DateTime.toISO8601 now 51 | |> H.text 52 | ] 53 | 54 | 55 | subscriptions : Model -> Sub Msg 56 | subscriptions model = 57 | Sub.batch [ Time.every Time.second Tick ] 58 | -------------------------------------------------------------------------------- /src/Time/Internal.elm: -------------------------------------------------------------------------------- 1 | module Time.Internal exposing (..) 2 | 3 | 4 | type alias DateTimeData = 5 | { year : Int 6 | , month : Int 7 | , day : Int 8 | , hour : Int 9 | , minute : Int 10 | , second : Int 11 | , millisecond : Int 12 | } 13 | 14 | 15 | type alias TimeData d = 16 | { d 17 | | hour : Int 18 | , minute : Int 19 | , second : Int 20 | , millisecond : Int 21 | } 22 | 23 | 24 | offsetFromTimeData : TimeData d -> Int 25 | offsetFromTimeData { hour, minute, second, millisecond } = 26 | clamp 0 23 hour * hourMs + clamp 0 59 minute * minuteMs + clamp 0 59 second * secondMs + clamp 0 999 millisecond 27 | 28 | 29 | zero : DateTimeData 30 | zero = 31 | { year = 0 32 | , month = 1 33 | , day = 1 34 | , hour = 0 35 | , minute = 0 36 | , second = 0 37 | , millisecond = 0 38 | } 39 | 40 | 41 | padded : Int -> String 42 | padded n = 43 | if n < 10 then 44 | "0" ++ toString n 45 | else 46 | toString n 47 | 48 | 49 | padded3 : Int -> String 50 | padded3 n = 51 | String.padLeft 3 '0' (toString n) 52 | 53 | 54 | dayMs : number 55 | dayMs = 56 | 86400000 57 | 58 | 59 | hourMs : number 60 | hourMs = 61 | 3600000 62 | 63 | 64 | minuteMs : number 65 | minuteMs = 66 | 60000 67 | 68 | 69 | secondMs : number 70 | secondMs = 71 | 1000 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Bogdan Paul Popa 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /tests/TestDateTimeDelta.elm: -------------------------------------------------------------------------------- 1 | module TestDateTimeDelta exposing (..) 2 | 3 | import Time.DateTime as DateTime exposing (..) 4 | import Expect exposing (Expectation) 5 | 6 | 7 | -- import Fuzz exposing (Fuzzer, constant, int, intRange, oneOf) 8 | 9 | import Test exposing (..) 10 | 11 | 12 | date1 : DateTime 13 | 14 | 15 | 16 | -- hmm 17 | 18 | 19 | date1 = 20 | DateTime.fromTuple 21 | ( 1970 22 | , 1 23 | , 1 24 | , 0 25 | , 0 26 | , 0 27 | , 0 28 | ) 29 | 30 | 31 | date2 : DateTime 32 | date2 = 33 | DateTime.fromTuple 34 | ( 1969 35 | , 12 36 | , 31 37 | , 23 38 | , 59 39 | , 59 40 | , 999 41 | ) 42 | 43 | 44 | expectedDelta : DateTimeDelta 45 | expectedDelta = 46 | { years = 1 -- force one if across a year boundary (1969 -> 1970) 47 | , months = 1 -- force one if across a month boundary (Dec -> Jan) 48 | , days = 1 -- force one if across a day boundary (31 -> 1) 49 | , hours = 0 -- round (ignore hour boundary) 50 | , minutes = 0 -- round (ignore minute boundary) 51 | , seconds = 0 -- round (ignore second boundary) 52 | , milliseconds = 1 -- whatever it is 53 | } 54 | 55 | 56 | suite : Test 57 | suite = 58 | describe "DateTime delta" 59 | [ test "Exploratory: two dates one millisecond apart at midnight New Years" <| 60 | \_ -> 61 | DateTime.delta date1 date2 62 | -- |> Debug.log "date2 - date1" 63 | |> Expect.equal expectedDelta 64 | ] 65 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/README.md: -------------------------------------------------------------------------------- 1 | # Example Page Demonstrating Error Rendering 2 | 3 | In this demonstration, you modify an **ISO8601** string in a text input 4 | and see the parsed result. If the string has no parsing or validation 5 | errors, you see a grid of the parsed result. Otherwise, an "Elm-style" 6 | error message is shown, followed by the **parser**'s error structure 7 | that the error renderer needed in order to interpret to generate 8 | the error message. 9 | 10 | ## Installation 11 | 12 | ### Prerequisites 13 | 14 | | **Name** | **Version** | **Installation** | 15 | |---|---|---| 16 | | elm | \>= 0.18.0 | **[Install](https://guide.elm-lang.org/install.html)** | 17 | | node | v8.9.3 | **[node dist](https://nodejs.org/dist/v8.9.3/)** | 18 | | npm | 5.8.0 | Included as part of **node** install above | 19 | | elm-live | \>= v2.7.5 | `npm install -g elm-live` | 20 | 21 | ### Install and Run 22 | 23 | For this you need to clone `elm-community/elm-time` from GitHub 24 | into your directory of other Elm projects as follows: 25 | 26 | 1. `cd` into your Elm projects' folder into which you want to clone 27 | `elm-time`. 28 | 29 | 1. Perform the following commands: 30 | ```bash 31 | git clone git@github.com:elm-community/elm-time.git 32 | cd elm-time 33 | elm-make --warn 34 | cd examples/with-parser-error-renderer 35 | elm-live --output=elm.js Main.elm --open --warn 36 | ``` 37 | 1. Browse http://localhost:8000 and observe that the initial **ISO8601** 38 | string is parsed successfully into a row of labeled boxes. 39 | 1. In the input text edit control with the light-gray background, 40 | change the **ISO8601** string to make the parsing fail. 41 | 1. Hit the `Enter` key and observe the error message and parsing 42 | error structure in red. 43 | 44 | Try different **ISO8601** string values and see what this page does 45 | with them. 46 | -------------------------------------------------------------------------------- /tests/TestZonedDateTime.elm: -------------------------------------------------------------------------------- 1 | module TestZonedDateTime exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Test exposing (..) 5 | import Time.DateTime as DT 6 | import Time.TimeZones exposing (europe_bucharest) 7 | import Time.ZonedDateTime exposing (..) 8 | 9 | 10 | convertingToDateTime : Test 11 | convertingToDateTime = 12 | describe "Time.DateTime.toDateTime" 13 | [ test "DateTime to Zoned and back produces correct result" <| 14 | \() -> 15 | let 16 | dt = 17 | DT.dateTime { zero | year = 2016, month = 10, day = 30, hour = 1, minute = 30 } 18 | in 19 | dt 20 | |> fromDateTime (europe_bucharest ()) 21 | |> toDateTime 22 | |> Expect.equal dt 23 | , test "DateTime from naive Zoned produces correct result" <| 24 | \() -> 25 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 1, minute = 30 } 26 | |> toDateTime 27 | |> Expect.equal (DT.dateTime { zero | year = 2016, month = 10, day = 29, hour = 22, minute = 30 }) 28 | , test "DateTime from naive Zoned produces correct result during the end of summer time" <| 29 | \() -> 30 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 3 } 31 | |> toDateTime 32 | |> Expect.equal (DT.dateTime { zero | year = 2016, month = 10, day = 30, hour = 1 }) 33 | , test "DateTime from naive Zoned produces correct result after the end of summer time" <| 34 | \() -> 35 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 4 } 36 | |> toDateTime 37 | |> Expect.equal (DT.dateTime { zero | year = 2016, month = 10, day = 30, hour = 2 }) 38 | ] 39 | -------------------------------------------------------------------------------- /examples/with-timezone/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Html as H exposing (Html) 4 | import Html.Attributes as A 5 | import Html.Events as E 6 | import Json.Decode as JD 7 | import Time exposing (Time) 8 | import Time.DateTime as DT exposing (DateTime) 9 | import Time.TimeZone exposing (TimeZone) 10 | import Time.TimeZones as TimeZones 11 | import Time.Iso8601 12 | import Time.ZonedDateTime 13 | 14 | 15 | type alias Flags = 16 | { now : Time } 17 | 18 | 19 | type alias Model = 20 | { now : DateTime 21 | , zone : TimeZone 22 | } 23 | 24 | 25 | type Msg 26 | = Tick Time 27 | | ChangeZone String 28 | 29 | 30 | main : Program Flags Model Msg 31 | main = 32 | H.programWithFlags 33 | { init = init 34 | , update = update 35 | , view = view 36 | , subscriptions = subscriptions 37 | } 38 | 39 | 40 | init : Flags -> ( Model, Cmd Msg ) 41 | init { now } = 42 | { now = DT.fromTimestamp now 43 | , zone = TimeZones.utc () 44 | } 45 | ! [] 46 | 47 | 48 | update : Msg -> Model -> ( Model, Cmd Msg ) 49 | update msg model = 50 | case msg of 51 | Tick now -> 52 | { model | now = DT.fromTimestamp now } ! [] 53 | 54 | ChangeZone zoneString -> 55 | let 56 | zone = 57 | case zoneString of 58 | "Europe/Bucharest" -> 59 | TimeZones.europe_bucharest () 60 | 61 | "US/Central" -> 62 | TimeZones.us_central () 63 | 64 | _ -> 65 | TimeZones.utc () 66 | in 67 | ( { model | zone = zone } 68 | , Cmd.none 69 | ) 70 | 71 | 72 | view : Model -> Html Msg 73 | view { now, zone } = 74 | H.div [] 75 | [ H.select [ E.on "change" (JD.map ChangeZone E.targetValue) ] 76 | [ H.option [ A.value "UTC" ] [ H.text "UTC" ] 77 | , H.option [ A.value "Europe/Bucharest" ] [ H.text "Europe/Bucharest" ] 78 | , H.option [ A.value "US/Central" ] [ H.text "US/Central" ] 79 | ] 80 | , H.br [] [] 81 | , Time.ZonedDateTime.fromDateTime zone now 82 | |> Time.Iso8601.fromZonedDateTime 83 | |> H.text 84 | ] 85 | 86 | 87 | subscriptions : Model -> Sub Msg 88 | subscriptions model = 89 | Sub.batch [ Time.every Time.second Tick ] 90 | -------------------------------------------------------------------------------- /util/index.js: -------------------------------------------------------------------------------- 1 | let fs = require("fs"); 2 | let moment = require("moment-timezone"); 3 | 4 | // `names` contains a mapping from "flat" zone names to their 5 | // corresponding IANA/Olsen name, e.g. europe_london -> 'Europe/London'. 6 | // There are entries for canonical zone names as well as aliases and 7 | // deprecated names. 8 | let names = moment.tz._names; 9 | // `links` contains a mapping from flat zone aliases and deprecated 10 | // names to their canonical sanitized name, e.g. europe_belfast -> 11 | // 'europe_london'. HOWEVER, there are also mappings from SOME canonical 12 | // sanitized names to other names, like europe_london -> 'gb-eire'. It's 13 | // not clear why these latter entries exist. 14 | let links = moment.tz._links; 15 | // `zones` contains a mapping from flat zone names to actual zone data, 16 | // e.g. europe_london -> 'Europe/London|GMT BST BDST|0 -1 ...'. It 17 | // appears to only contain mappings for canonical zone names. 18 | let zones = moment.tz._zones; 19 | 20 | let sanitize = (name) => { 21 | return name. 22 | toLowerCase(). 23 | replace(/gmt\+/, "gmt_plus_"). 24 | replace(/gmt\-/, "gmt_minus_"). 25 | replace(/gmt0/, "gmt_0"). 26 | replace(/-/g, "_"); 27 | }; 28 | 29 | // Data file 30 | let timeZones = [], timeZoneData = []; 31 | 32 | // Date file: Populate canonical zone data. 33 | for (let name in zones) { 34 | let zone = zones[name]; 35 | let link = links[name]; 36 | let fullName = names[name]; 37 | 38 | name = sanitize(name); 39 | timeZoneData.push(`${name}_l = unpack "${zone}"`); 40 | timeZones.push({name, fullName, link: false}); 41 | } 42 | 43 | // Data file: Populate aliases. 44 | for (let name in links) { 45 | let link = links[name]; 46 | // Only add an alias when it refers to a canonical zone. 47 | if (zones[link]) { 48 | timeZones.push({ 49 | name: sanitize(name), 50 | fullName: names[name], 51 | timeZone: sanitize(link), 52 | link: true 53 | }); 54 | } 55 | } 56 | 57 | timeZones.sort((a, b) => { 58 | return a.name > b.name ? 1 : -1; 59 | }); 60 | timeZoneData.sort(); 61 | 62 | // TimeZones file 63 | let docs = [], fns = [], all = []; 64 | for (let i = 0; i < timeZones.length; i++) { 65 | let timeZone = timeZones[i]; 66 | 67 | docs.push(timeZone.name); 68 | all.push(`("${timeZone.fullName}", ${timeZone.name})`); 69 | 70 | if (timeZone.link) { 71 | fns.push(` 72 | {-| ${timeZone.fullName} -} 73 | ${timeZone.name} : () -> TimeZone 74 | ${timeZone.name} () = force (link "${timeZone.fullName}" ${timeZone.timeZone}_l)`); 75 | } else { 76 | fns.push(` 77 | {-| ${timeZone.fullName} -} 78 | ${timeZone.name} : () -> TimeZone 79 | ${timeZone.name} () = force ${timeZone.name}_l`); 80 | } 81 | } 82 | 83 | fs.open("../src/Time/TimeZoneData.elm", "w", (err, fd) => { 84 | let content = ` 85 | module Time.TimeZoneData exposing (..) 86 | 87 | import Lazy exposing (Lazy, force, lazy) 88 | import String 89 | import Time.TimeZone exposing (TimeZone, setName) 90 | 91 | 92 | unpack : String -> Lazy TimeZone 93 | unpack data = 94 | let 95 | helper () = 96 | case Time.TimeZone.unpack data of 97 | Err errors -> 98 | let 99 | messages = 100 | String.join " or " errors 101 | in 102 | Debug.crash ("failed to parse zone '" ++ data ++ "': " ++ messages) 103 | 104 | Ok zone -> 105 | zone 106 | in 107 | lazy helper 108 | 109 | 110 | link : String -> Lazy TimeZone -> Lazy TimeZone 111 | link link lz = 112 | Lazy.map (setName link) lz 113 | 114 | 115 | -- Data 116 | -- ---- 117 | ${timeZoneData.join("\n")}`.trim(); 118 | 119 | fs.write(fd, content, (err) => { 120 | if (err) throw new err; 121 | }); 122 | }); 123 | 124 | // Chunk `all` to prevent stack overflow in Elm 0.18 with large list literals. 125 | let all_chunks = [], all_chunk_size = 50; 126 | for (let i = 0; i < all.length; i += all_chunk_size) { 127 | let slice = all.slice(i, i + all_chunk_size); 128 | all_chunks.push("[ " + slice.join("\n , ") + "\n ]"); 129 | } 130 | 131 | fs.open("../src/Time/TimeZones.elm", "w", (err, fd) => { 132 | let content = ` 133 | module Time.TimeZones exposing (..) 134 | 135 | {-| This module contains TimeZone definitions for all Timezones as they 136 | are defined in the IANA zoneinfo database. 137 | 138 | TimeZone data is parsed lazily so, in order to retrieve a zone's value you 139 | must apply \`()\` to it. For example: 140 | 141 | import Time.DateTime exposing (epoch, toTimestamp) 142 | import Time.TimeZone exposing (abbreviation) 143 | import Time.TimeZones exposing (europe_bucharest) 144 | 145 | let 146 | timezone = europe_bucharest () 147 | in 148 | abbreviation (toTimestamp epoch) timezone 149 | 150 | @docs all, fromName, ${docs.join(", ")} 151 | -} 152 | 153 | import Dict exposing (Dict) 154 | import Lazy exposing (Lazy, force) 155 | import Time.TimeZone exposing (TimeZone) 156 | import Time.TimeZoneData exposing (..) 157 | 158 | 159 | -- TimeZones 160 | -- --------- 161 | ${fns.join("\n")} 162 | 163 | 164 | -- Utils 165 | -- ----- 166 | {-| A mapping from TimeZone names to their respective functions. Use 167 | this to look up TimeZones by name. -} 168 | all : Dict String (() -> TimeZone) 169 | all = 170 | Dict.fromList <| 171 | List.concat 172 | [ ${all_chunks.join("\n , ")} 173 | ] 174 | 175 | 176 | {-| Look up a TimeZone by name. -} 177 | fromName : String -> Maybe TimeZone 178 | fromName name = 179 | case Dict.get name all of 180 | Nothing -> 181 | Nothing 182 | 183 | Just f -> 184 | Just (f ()) 185 | `.trim(); 186 | 187 | fs.write(fd, content, (err) => { 188 | if (err) throw new err; 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /examples/with-parser-error-renderer/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | {-| An Elm web client application to exercise 4 | the `elm-time` library. 5 | 6 | 7 | # Main entry 8 | 9 | @docs main 10 | 11 | -} 12 | 13 | import Color exposing (..) 14 | import Element exposing (..) 15 | import Element.Attributes exposing (..) 16 | import Element.Input as Input 17 | import Html exposing (Html) 18 | import Keyboard exposing (KeyCode) 19 | import Parser exposing (Error) 20 | import Style exposing (..) 21 | import Style.Border as Border 22 | import Style.Color 23 | import Style.Font as Font 24 | import Task 25 | import Time.DateTime 26 | exposing 27 | ( DateTime 28 | , dateTime 29 | , fromTimestamp 30 | , fromTuple 31 | , year 32 | , month 33 | , day 34 | , hour 35 | , minute 36 | , second 37 | , millisecond 38 | ) 39 | import Time.Iso8601ErrorMsg exposing (reflow, renderText) 40 | import Time.Iso8601 exposing (toDateTime) 41 | import Window 42 | 43 | 44 | type Msg 45 | = ChangeText String 46 | | Resize Window.Size 47 | | KeyDown Keyboard.KeyCode 48 | 49 | 50 | type Styles 51 | = None 52 | | InputContainer 53 | | Page 54 | | DateTimeGrid 55 | | DateTimeRow 56 | | Box 57 | | Error 58 | 59 | 60 | stylesheet : StyleSheet Styles variation 61 | stylesheet = 62 | Style.styleSheet 63 | [ style Page 64 | [] 65 | , style InputContainer 66 | [ Style.Color.text black 67 | , Style.Color.background lightGray 68 | , Border.all 2 69 | ] 70 | , style Error 71 | [ Style.Color.text red 72 | , Font.typeface [ Font.monospace ] 73 | ] 74 | , style DateTimeGrid 75 | [ Border.top 2 76 | , Border.bottom 2 77 | , Border.left 1 78 | , Border.right 1 79 | ] 80 | , style Box 81 | [ Border.left 1 82 | , Border.right 1 83 | , Font.center 84 | ] 85 | ] 86 | 87 | 88 | {-| The "entry" 89 | -} 90 | main : Program Never Model Msg 91 | main = 92 | Html.program 93 | { init = init 94 | , update = update 95 | , view = view 96 | , subscriptions = subscriptions 97 | } 98 | 99 | 100 | type alias Model = 101 | { iso8601input : String 102 | , dateTime : Result Error DateTime 103 | , device : Device 104 | } 105 | 106 | 107 | init : ( Model, Cmd Msg ) 108 | init = 109 | let 110 | initInput = 111 | "1991-02-28T12:25:12.0Z" 112 | 113 | initDateTime = 114 | dateTime 115 | { year = 1991 116 | , month = 2 117 | , day = 28 118 | , hour = 12 119 | , minute = 25 120 | , second = 12 121 | , millisecond = 0 122 | } 123 | in 124 | ( { iso8601input = initInput 125 | , dateTime = Ok initDateTime 126 | , device = classifyDevice (Window.Size 0 0) 127 | } 128 | , Task.perform Resize Window.size 129 | ) 130 | 131 | 132 | update : Msg -> Model -> ( Model, Cmd Msg ) 133 | update msg model = 134 | case msg of 135 | ChangeText text -> 136 | ( { model 137 | | iso8601input = text 138 | } 139 | , Cmd.none 140 | ) 141 | 142 | Resize size -> 143 | ( { model 144 | | device = classifyDevice size 145 | } 146 | , Cmd.none 147 | ) 148 | 149 | KeyDown keyCode -> 150 | if enterKeyCode == keyCode then 151 | runParse model 152 | else 153 | ( model, Cmd.none ) 154 | 155 | 156 | view : Model -> Html Msg 157 | view model = 158 | Element.layout stylesheet <| 159 | el None [ center, width (px 800) ] <| 160 | column Page 161 | [ spacing 20 ] 162 | [ Input.text InputContainer 163 | [ padding 10 ] 164 | { onChange = ChangeText 165 | , value = model.iso8601input 166 | , label = 167 | Input.placeholder 168 | { label = Input.labelLeft (el None [ verticalCenter ] (text "Change ISO8601 string, then hit Enter:")) 169 | , text = "Test ISO8601 here" 170 | } 171 | , options = [] 172 | } 173 | , case model.dateTime of 174 | Ok v -> 175 | renderDateTimeSuccess v 176 | 177 | Err err -> 178 | renderDateTimeFail err 179 | ] 180 | 181 | 182 | subscriptions : Model -> Sub Msg 183 | subscriptions model = 184 | Sub.batch 185 | [ Window.resizes Resize 186 | , Keyboard.downs KeyDown 187 | ] 188 | 189 | 190 | runParse : Model -> ( Model, Cmd Msg ) 191 | runParse model = 192 | case toDateTime model.iso8601input of 193 | Ok v -> 194 | ( { model 195 | | dateTime = Ok v 196 | } 197 | , Cmd.none 198 | ) 199 | 200 | Err msg -> 201 | ( { model 202 | | dateTime = Err msg 203 | } 204 | , Cmd.none 205 | ) 206 | 207 | 208 | renderDateTimeSuccess : DateTime -> Element Styles a b 209 | renderDateTimeSuccess dt = 210 | let 211 | renderCell x y value = 212 | cell 213 | { start = ( x, y ) 214 | , width = 1 215 | , height = 1 216 | , content = el Box [] (text <| value) 217 | } 218 | in 219 | grid DateTimeGrid 220 | [] 221 | { columns = [ px 100, px 100, px 100 ] 222 | , rows = [ px 20, px 20 ] 223 | , cells = 224 | [ renderCell 0 0 "Year" 225 | , renderCell 1 0 "Month" 226 | , renderCell 2 0 "Day" 227 | , renderCell 3 0 "Hour" 228 | , renderCell 4 0 "Minute" 229 | , renderCell 5 0 "Second" 230 | , renderCell 6 0 "Millisecond" 231 | , renderCell 0 1 (toString <| year dt) 232 | , renderCell 1 1 (toString <| month dt) 233 | , renderCell 2 1 (toString <| day dt) 234 | , renderCell 3 1 (toString <| hour dt) 235 | , renderCell 4 1 (toString <| minute dt) 236 | , renderCell 5 1 (toString <| second dt) 237 | , renderCell 6 1 (toString <| millisecond dt) 238 | ] 239 | } 240 | 241 | 242 | 243 | -- el Success [] (text <| toString dateTime) 244 | 245 | 246 | renderDateTimeFail : Error -> Element Styles a b 247 | renderDateTimeFail err = 248 | el Error [] (text <| renderText err ++ "\n\n---\n\n" ++ reflow "The Elm error returned by parser:\n\n" ++ reflow (toString err)) 249 | 250 | 251 | enterKeyCode : KeyCode 252 | enterKeyCode = 253 | 13 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-time [![Build Status](https://travis-ci.org/elm-community/elm-time.svg)](https://travis-ci.org/elm-community/elm-time) 2 | 3 | ``` shell 4 | elm package install elm-community/elm-time 5 | ``` 6 | 7 | ## This Package is Released to Elm 0.19 -- Also Archived! 8 | 9 | Isaac Seymour has ported 10 | **[elm-community/elm-time](https://package.elm-lang.org/packages/elm-community/elm-time/3.0.4/)** to 11 | **[isaacseymour/deprecated-time](https://package.elm-lang.org/packages/isaacseymour/deprecated-time/latest)**. 12 | With the change in **elm/time** to use Posix, and a consensus to break this package up so that you don't have to 13 | "include everything" to use it, we decided that this was a good time to deprecate. *On October 6, 2021, this package was archived.* 14 | 15 | That being said, we will continue to support `isaacseymour/deprecated-time` with bug fixes for the foreseeable future. 16 | 17 | Thanks to Isaac for performing this upgrade! 18 | 19 | ## Major Changes! 20 | 21 | This release prepares **elm-time** to be upgraded to Elm `0.19` by changing 22 | out the **ISO8601** and **Timezone Name** parsing from the 23 | **[parser-combinators](http://package.elm-lang.org/packages/elm-community/parser-combinators/latest)** parser to 24 | Evan's **[parser](http://package.elm-lang.org/packages/elm-tools/parser/latest)**. 25 | 26 | > NOTE: this release is probably the last Elm `0.18` release. 27 | 28 | Hence, the changes are extensive and some API's have changed. Here's a summary 29 | of them: 30 | 31 | * **ISO8601** processing has been broken out into its own module: `Time.Iso8601`. 32 | * An "Elm-style" error renderer for **ISO8601** parsing errors is provided: `Time.Iso8601ErrorMsg`. 33 | * An example Elm client-application showing the error handling is provided in `/examples/with-parser-error-renderer`. 34 | * Each of the public APIs in `Time.Date`, `Time.DateTime`, `Time.ZonedDateTime`, `Iso8601`, and `Iso8601ErrorMsg` 35 | now has extensive 36 | **["verify examples"](https://github.com/stoeffel/elm-verify-examples)** documentation. 37 | 38 | ## Examples 39 | 40 | * **[Error Rendering Example](https://github.com/elm-community/elm-time/blob/daa0e1b60a912519af5b699cc26c0b17a6e06257/examples/with-parser-error-renderer/README.md)** 41 | * **[Running and Understanding Timezone Examples](https://github.com/elm-community/elm-time/wiki/The-Examples)** 42 | 43 | ## Dates 44 | 45 | `Date`s may represent any date in the [proleptic Gregorian calendar][cal]. 46 | 47 | ``` elm 48 | import Time.Date as Date exposing (Date, date) 49 | ``` 50 | 51 | ### Constructing Dates 52 | 53 | Use `date` to construct `Date` values. If given invalid values for 54 | the month and day, they are both clamped and the nearest valid date is 55 | returned. 56 | 57 | ``` elm 58 | > date 1992 2 28 59 | Date { year = 1992, month = 2, day = 28 } : Date 60 | 61 | > date 1992 2 31 62 | Date { year = 1992, month = 2, day = 29 } : Date 63 | 64 | > date 1992 2 128 65 | Date { year = 1992, month = 2, day = 29 } : Date 66 | ``` 67 | 68 | Use `year`, `month`, and `day` to inspect `Date`s. 69 | 70 | ``` elm 71 | > d = date 1992 5 29 72 | Date { year = 1992, month = 5, day = 29 } : Date 73 | 74 | > Date.year d 75 | 1992 : Int 76 | 77 | > Date.month d 78 | 5 : Int 79 | 80 | > Date.day d 81 | 29 : Int 82 | ``` 83 | 84 | ### Manipulating Dates 85 | 86 | `setYear`, `setMonth` and `setDay` can be used to create new `Dates` 87 | containing updated values for each respective field. Like `date`, 88 | these functions clamp their parameters and return the nearest valid 89 | date. 90 | 91 | `addDays` can be used to add an exact number of days to a `Date`. 92 | 93 | `addYears` and `addMonths` add a relative number of years and months 94 | to a date. If the target date is invalid, these functions continually 95 | subtract one day until a valid date is found. 96 | 97 | ``` elm 98 | import Time.Date as Date exposing (Date, date, addYears) 99 | import Time.Iso8601 100 | 101 | > date 1992 1 31 102 | | |> addYears 1 103 | | |> Time.Iso8601.fromDate 104 | "1993-01-31" : String 105 | 106 | > date 1992 2 29 107 | | |> addYears 1 108 | | |> Time.Iso8601.fromDate 109 | "1993-02-28" : String 110 | 111 | > date 1992 1 31 112 | | |> Date.addMonths 1 113 | | |> Time.Iso8601.fromDate 114 | "1992-02-28" : String 115 | ``` 116 | 117 | ## DateTimes 118 | 119 | `DateTimes` represent a `Date` together with the time information starting on midnight for the `Date`. 120 | 121 | ``` elm 122 | import Time.DateTime as DateTime exposing (DateTime, dateTime, year, month, day, hour, minute, second, millisecond) 123 | ``` 124 | 125 | ### Constructing DateTimes 126 | 127 | `DateTime`s can be constructed from a record using the `dateTime` 128 | function or from a UTC timestamp in milliseconds using `fromTimestamp`. 129 | To construct a `DateTime` using `dateTime`, pass it a record 130 | containing fields for `year`, `month`, `day`, `hour`, `minute`, 131 | `second` and `millisecond`: 132 | 133 | ``` elm 134 | dt : DateTime 135 | dt = 136 | dateTime { year = 1992, month = 5, day = 29, hour = 0, minute = 0, second = 0, millisecond = 0 } 137 | 138 | year dt --> 1992 139 | month dt --> 5 140 | day dt --> 29 141 | hour dt --> 0 142 | minute dt --> 0 143 | second --> 0 144 | millisecond --> 0 145 | 146 | dt : DateTime 147 | dt = 148 | dateTime { year = 1992, month = 2, day = 31, hour = 0, minute = 0, second = 0, millisecond = 0 } 149 | 150 | year dt --> 1992 151 | month dt --> 2 152 | day dt --> 29 - Note clamped. 153 | hour dt --> 0 154 | minute dt --> 0 155 | second --> 0 156 | millisecond --> 0 157 | 158 | dt : DateTime 159 | dt = 160 | dateTime { year = 1993, month = 2, day = 31, hour = 0, minute = 0, second = 0, millisecond = 0 } 161 | 162 | year dt --> 1993 163 | month dt --> 2 164 | day dt --> 28 - Note clamped. 165 | hour dt --> 0 166 | minute dt --> 0 167 | second --> 0 168 | millisecond --> 0 169 | ``` 170 | 171 | To make constructing `DateTimes` less tedious, the library provides 172 | `Time.DateTime.zero`: 173 | 174 | ``` elm 175 | > import Time.DateTime as DateTime exposing (DateTime, dateTime, zero) 176 | 177 | > dateTime { zero | year = 1992 } 178 | | |> DateTime.toISO8601 179 | "1992-01-01T00:00:00.000Z" : String 180 | 181 | > dateTime { zero | year = 1992, month = 2, day = 28, hour = 5 } 182 | | |> DateTime.toISO8601 183 | "1992-02-28T05:00:00.000Z" : String 184 | ``` 185 | 186 | Use `fromTimestamp` to construct a `DateTime` from a UTC timestamp in 187 | milliseconds: 188 | 189 | ``` elm 190 | > fromTimestamp 0 191 | | |> DateTime.toISO8601 192 | "1970-01-01T00:00:00.000Z" : String 193 | ``` 194 | 195 | See `examples/without-timezone` for an example of how to construct 196 | `DateTime`s from local time. 197 | 198 | ### Manipulating DateTimes 199 | 200 | Like `Time.Date`, the `DateTime` module exposes functions for adding 201 | to and updating a `DateTime`'s fields. The functions `addYears` and 202 | `addMonths` have the same behaviour as their `Time.Date` counterparts. 203 | 204 | ## ZonedDateTimes 205 | 206 | `ZonedDateTimes` represent a `DateTime` in a specific `TimeZone`. See 207 | `examples/with-timezone` for an example of how to use `ZonedDateTime`s. 208 | 209 | ``` elm 210 | import Time.TimeZones as TimeZones 211 | import Time.ZonedDateTime as ZonedDateTime exposing (ZonedDateTime) 212 | ``` 213 | 214 | 215 | [cal]: https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar 216 | -------------------------------------------------------------------------------- /src/Time/Iso8601ErrorMsg.elm: -------------------------------------------------------------------------------- 1 | module Time.Iso8601ErrorMsg 2 | exposing 3 | ( reflow 4 | , renderText 5 | ) 6 | 7 | {-| A renderer to format error messages resulting from 8 | ISO8601 parsing errors. 9 | 10 | At this time, there is a renderer, **renderText**, to render 11 | a fixed-font message to, say, a terminal screen. This renderer 12 | strives to output the friendly useful error message that elm 13 | is famous for. 14 | 15 | 16 | # Main entry 17 | 18 | @docs renderText 19 | 20 | 21 | # Utilities 22 | 23 | @docs reflow 24 | 25 | -} 26 | 27 | import Parser exposing (Parser, Problem(Fail)) 28 | 29 | 30 | {-| Invoking the renderer. This returns an 'elm compiler-style formatted' error string 31 | 32 | import Parser 33 | 34 | -- The \n in the middle of the string is to provide for the 72-char right margin 35 | failString : String 36 | failString = 37 | "Expecting the value 29 to be in the range 1 to 28 for the specified\nyear, 1991, and month, 2." 38 | 39 | renderedString : String 40 | renderedString = 41 | "The 'day-of-month' segment is invalid:\n\n" ++ 42 | " 1991-02-29T12:25:12.0Z\n" ++ 43 | " ^\n\n" ++ 44 | failString 45 | 46 | parserError : Parser.Error 47 | parserError = 48 | { row = 1 49 | , col = 11 50 | , source = "1991-02-29T12:25:12.0Z" 51 | , problem = Parser.Fail failString 52 | , context = [{ row = 1, col = 11, description = "leap-year" }] 53 | } 54 | 55 | renderText parserError 56 | --> renderedString 57 | 58 | -} 59 | renderText : Parser.Error -> String 60 | renderText error = 61 | let 62 | -- Hack: special handling of the leap year. 63 | -- This sets the marker under the beginning of the 64 | -- day-of-month segment. 65 | tweakCol ctx = 66 | if ctx.description == "leap-year" then 67 | ctx.col - 2 68 | else 69 | ctx.col 70 | 71 | ( source, diagnosis, col ) = 72 | case List.head error.context of 73 | Nothing -> 74 | ( Nothing, noContext, error.col ) 75 | 76 | Just ctx -> 77 | ( Just ctx.description 78 | , forContext ctx error.problem 79 | , tweakCol ctx 80 | ) 81 | in 82 | diagnosis 83 | ++ "\n\n " 84 | ++ relevantSource error 85 | ++ "\n " 86 | ++ marker col 87 | ++ "\n\n" 88 | ++ (reflow <| describeProblem source error.problem) 89 | 90 | 91 | {-| A convenience function to auto-wrap long strings 92 | 93 | Use this method to appropriately wrap the error string returned 94 | from `renderText` above. 95 | 96 | -- \n inserted at 72nt position coz is right margin. 97 | reflow "Expecting the value 29 to be in the range 1 to 28 for the specified year, 1991, and month, 2." 98 | --> "Expecting the value 29 to be in the range 1 to 28 for the specified" ++ "\n" ++ "year, 1991, and month, 2." 99 | 100 | -} 101 | reflow : String -> String 102 | reflow s = 103 | let 104 | flowLine : String -> String 105 | flowLine s = 106 | String.words s 107 | |> makeSentences 108 | |> String.join "\n" 109 | 110 | makeSentences : List String -> List String 111 | makeSentences words = 112 | List.foldl 113 | (\word ( sentence, acc ) -> 114 | let 115 | combined = 116 | case sentence of 117 | Nothing -> 118 | word 119 | 120 | Just s -> 121 | s ++ " " ++ word 122 | in 123 | if String.length combined > 72 then 124 | ( Just word, sentence :: acc ) 125 | else 126 | ( Just combined, acc ) 127 | ) 128 | ( Nothing, [] ) 129 | words 130 | |> uncurry (::) 131 | |> reverseFilterMap identity 132 | in 133 | s 134 | |> String.lines 135 | |> List.map flowLine 136 | |> String.join "\n" 137 | 138 | 139 | reverseFilterMap : (a -> Maybe b) -> List a -> List b 140 | reverseFilterMap toMaybe list = 141 | List.foldl 142 | (\x acc -> 143 | case toMaybe x of 144 | Just y -> 145 | y :: acc 146 | 147 | Nothing -> 148 | acc 149 | ) 150 | [] 151 | list 152 | 153 | 154 | relevantSource : Parser.Error -> String 155 | relevantSource { row, source } = 156 | String.lines source 157 | |> List.drop (row - 1) 158 | |> List.head 159 | |> Maybe.withDefault "" 160 | 161 | 162 | describeProblem : Maybe String -> Parser.Problem -> String 163 | describeProblem probableCause problem = 164 | case problem of 165 | Parser.BadInt -> 166 | "Unable to read an integer here." 167 | 168 | Parser.BadFloat -> 169 | "Unable to read a float here" 170 | 171 | Parser.BadRepeat -> 172 | case probableCause of 173 | Just cause -> 174 | "Can't find a " ++ cause ++ "." 175 | 176 | Nothing -> 177 | "I got stuck here. I'm probably looking for something specific and not making any progress here." 178 | 179 | Parser.ExpectingEnd -> 180 | "String should have stopped here, but it goes on." 181 | 182 | -- Usurped for bad day in month due to leap year: 183 | Parser.ExpectingSymbol s -> 184 | "Expecting a `" ++ s ++ "` here." 185 | 186 | Parser.ExpectingKeyword s -> 187 | "Expecting a keyword `" ++ s ++ "` here." 188 | 189 | Parser.ExpectingVariable -> 190 | "Expecting a variable here." 191 | 192 | Parser.ExpectingClosing s -> 193 | "Expecting a closing `" ++ s ++ "` here." 194 | 195 | Parser.Fail s -> 196 | s 197 | 198 | Parser.BadOneOf problems -> 199 | "Encountering multiple problems:\n\n" 200 | ++ (List.map (describeProblem probableCause) problems |> String.join "\n\n") 201 | 202 | 203 | adjustMarker : Parser.Error -> Parser.Context -> Int 204 | adjustMarker error context = 205 | case error.problem of 206 | Fail msg -> 207 | context.col 208 | 209 | _ -> 210 | error.col 211 | 212 | 213 | marker : Int -> String 214 | marker col = 215 | String.repeat (col - 1) " " ++ "^" 216 | 217 | 218 | forContext : Parser.Context -> Parser.Problem -> String 219 | forContext { description } problem = 220 | let 221 | -- hack; can't find any better way to do this. 222 | segment = 223 | if description == "leap-year" then 224 | "day-of-month" 225 | else 226 | description 227 | in 228 | case problem of 229 | Fail msg -> 230 | "The '" ++ segment ++ "' segment is invalid:" 231 | 232 | _ -> 233 | "Failed to parse the '" ++ segment ++ "' segment:" 234 | 235 | 236 | noContext : String 237 | noContext = 238 | "I ran into a problem parsing this:" 239 | -------------------------------------------------------------------------------- /tests/TestDate.elm: -------------------------------------------------------------------------------- 1 | module TestDate exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (int, intRange) 5 | import Test exposing (..) 6 | import Time.Date exposing (..) 7 | 8 | 9 | someDate : Date 10 | someDate = 11 | date 1992 5 29 12 | 13 | 14 | datesEqual : Date -> ( Int, Int, Int ) -> Expect.Expectation 15 | datesEqual date dateTuple = 16 | Expect.equal (toTuple date) dateTuple 17 | 18 | 19 | validLeapYears : List Int 20 | validLeapYears = 21 | List.concat [ [ -400, -396, -392, -388, -384, -380, -376, -372, -368, -364, -360, -356, -352, -348, -344, -340, -336, -332, -328, -324, -320, -316, -312, -308, -304, -296, -292, -288, -284, -280 ], [ -276, -272, -268, -264, -260, -256, -252, -248, -244, -240, -236, -232, -228, -224, -220, -216, -212, -208, -204, -196, -192, -188, -184, -180, -176, -172, -168, -164, -160, -156 ], [ -152, -148, -144, -140, -136, -132, -128, -124, -120, -116, -112, -108, -104, -96, -92, -88, -84, -80, -76, -72, -68, -64, -60, -56, -52, -48, -44, -40, -36, -32 ], [ -28, -24, -20, -16, -12, -8, -4, 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88 ], [ 92, 96, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 148, 152, 156, 160, 164, 168, 172, 176, 180, 184, 188, 192, 196, 204, 208, 212, 216 ], [ 220, 224, 228, 232, 236, 240, 244, 248, 252, 256, 260, 264, 268, 272, 276, 280, 284, 288, 292, 296, 304, 308, 312, 316, 320, 324, 328, 332, 336, 340 ], [ 344, 348, 352, 356, 360, 364, 368, 372, 376, 380, 384, 388, 392, 396, 400, 404, 408, 412, 416, 420, 424, 428, 432, 436, 440, 444, 448, 452, 456, 460 ], [ 464, 468, 472, 476, 480, 484, 488, 492, 496, 504, 508, 512, 516, 520, 524, 528, 532, 536, 540, 544, 548, 552, 556, 560, 564, 568, 572, 576, 580, 584 ], [ 588, 592, 596, 604, 608, 612, 616, 620, 624, 628, 632, 636, 640, 644, 648, 652, 656, 660, 664, 668, 672, 676, 680, 684, 688, 692, 696, 704, 708, 712 ], [ 716, 720, 724, 728, 732, 736, 740, 744, 748, 752, 756, 760, 764, 768, 772, 776, 780, 784, 788, 792, 796, 800, 804, 808, 812, 816, 820, 824, 828, 832 ], [ 836, 840, 844, 848, 852, 856, 860, 864, 868, 872, 876, 880, 884, 888, 892, 896, 904, 908, 912, 916, 920, 924, 928, 932, 936, 940, 944, 948, 952, 956 ], [ 960, 964, 968, 972, 976, 980, 984, 988, 992, 996, 1004, 1008, 1012, 1016, 1020, 1024, 1028, 1032, 1036, 1040, 1044, 1048, 1052, 1056, 1060, 1064, 1068, 1072, 1076, 1080 ], [ 1084, 1088, 1092, 1096, 1104, 1108, 1112, 1116, 1120, 1124, 1128, 1132, 1136, 1140, 1144, 1148, 1152, 1156, 1160, 1164, 1168, 1172, 1176, 1180, 1184, 1188, 1192, 1196, 1200, 1204 ], [ 1208, 1212, 1216, 1220, 1224, 1228, 1232, 1236, 1240, 1244, 1248, 1252, 1256, 1260, 1264, 1268, 1272, 1276, 1280, 1284, 1288, 1292, 1296, 1304, 1308, 1312, 1316, 1320, 1324, 1328 ], [ 1332, 1336, 1340, 1344, 1348, 1352, 1356, 1360, 1364, 1368, 1372, 1376, 1380, 1384, 1388, 1392, 1396, 1404, 1408, 1412, 1416, 1420, 1424, 1428, 1432, 1436, 1440, 1444, 1448, 1452 ], [ 1456, 1460, 1464, 1468, 1472, 1476, 1480, 1484, 1488, 1492, 1496, 1504, 1508, 1512, 1516, 1520, 1524, 1528, 1532, 1536, 1540, 1544, 1548, 1552, 1556, 1560, 1564, 1568, 1572, 1576 ], [ 1580, 1584, 1588, 1592, 1596, 1600, 1604, 1608, 1612, 1616, 1620, 1624, 1628, 1632, 1636, 1640, 1644, 1648, 1652, 1656, 1660, 1664, 1668, 1672, 1676, 1680, 1684, 1688, 1692, 1696 ], [ 1704, 1708, 1712, 1716, 1720, 1724, 1728, 1732, 1736, 1740, 1744, 1748, 1752, 1756, 1760, 1764, 1768, 1772, 1776, 1780, 1784, 1788, 1792, 1796, 1804, 1808, 1812, 1816, 1820, 1824 ], [ 1828, 1832, 1836, 1840, 1844, 1848, 1852, 1856, 1860, 1864, 1868, 1872, 1876, 1880, 1884, 1888, 1892, 1896, 1904, 1908, 1912, 1916, 1920, 1924, 1928, 1932, 1936, 1940, 1944, 1948 ], [ 1952, 1956, 1960, 1964, 1968, 1972, 1976, 1980, 1984, 1988, 1992, 1996, 2000, 2004, 2008, 2012, 2016, 2020 ] ] 22 | 23 | 24 | standardYearMonths : List Int 25 | standardYearMonths = 26 | [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ] 27 | 28 | 29 | leapYearMonths : List Int 30 | leapYearMonths = 31 | [ 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ] 32 | 33 | 34 | monthDays : Int -> Int -> Int 35 | monthDays year month = 36 | let 37 | monthDays = 38 | if isLeapYear year then 39 | leapYearMonths 40 | else 41 | standardYearMonths 42 | in 43 | monthDays 44 | |> List.drop (month - 1) 45 | |> List.head 46 | |> Maybe.withDefault 0 47 | 48 | 49 | fuzzDate : String -> (Int -> Int -> Int -> Expectation) -> Test 50 | fuzzDate = 51 | fuzz3 int (intRange 1 12) (intRange 1 31) 52 | 53 | 54 | constructing : Test 55 | constructing = 56 | describe "Time.Date.date" 57 | [ fuzzDate "constructs dates" <| 58 | \year month day -> 59 | let 60 | day_ = 61 | clamp 1 day (monthDays year month) 62 | in 63 | datesEqual (date year month day_) ( year, month, day_ ) 64 | , test "constructs valid dates" <| 65 | always <| 66 | datesEqual (date 1992 5 29) ( 1992, 5, 29 ) 67 | , test "accounts for leap years" <| 68 | always <| 69 | datesEqual (date 1992 2 29) ( 1992, 2, 29 ) 70 | , test "clamps invalid dates" <| 71 | always <| 72 | datesEqual (date 1993 2 29) ( 1993, 2, 28 ) 73 | ] 74 | 75 | 76 | leapYears : Test 77 | leapYears = 78 | describe "Time.Date.isLeapYear" 79 | [ describe "isLeapYear" 80 | [ fuzz (intRange -400 2020) "is correct given any year" <| 81 | \year -> 82 | if List.member year validLeapYears then 83 | Expect.true ("Expected " ++ toString year ++ " to be a leap year") (isLeapYear year) 84 | else 85 | Expect.false (toString year ++ " is not a leap year") (isLeapYear year) 86 | ] 87 | , describe "daysInMonth" 88 | [ fuzz2 (intRange -400 2020) (intRange 1 12) "is correct given any year, month pair" <| 89 | \year month -> 90 | daysInMonth year month 91 | |> Expect.equal (Just <| monthDays year month) 92 | ] 93 | ] 94 | 95 | 96 | adders : Test 97 | adders = 98 | describe "Time.Date.add{Years,Months,Days}" 99 | [ test "addYears is relative" <| 100 | \() -> 101 | let 102 | date1 = 103 | date 1992 2 29 104 | |> addYears 1 105 | 106 | date2 = 107 | date 1993 2 28 108 | in 109 | Expect.equal date1 date2 110 | , test "addMonths is relative" <| 111 | \() -> 112 | let 113 | date1 = 114 | date 1992 1 31 115 | |> addMonths 1 116 | 117 | date2 = 118 | date 1992 2 29 119 | in 120 | Expect.equal date1 date2 121 | , fuzz3 (intRange -400 3000) (intRange 1 12) (intRange -100 100) "addMonths is revertable" <| 122 | \year month addage -> 123 | let 124 | date1 = 125 | date year month 1 126 | 127 | date2 = 128 | addMonths addage date1 129 | in 130 | Expect.equal date1 (addMonths -addage date2) 131 | , fuzz int "addDays is absolute" <| 132 | \days -> 133 | let 134 | date1 = 135 | someDate 136 | 137 | date2 = 138 | addDays days date1 139 | 140 | dt = 141 | delta date2 date1 142 | in 143 | Expect.equal days dt.days 144 | ] 145 | 146 | 147 | toFromTuple : Test 148 | toFromTuple = 149 | describe "Time.Date.{to,from}Tuple" 150 | [ fuzzDate "they have an inverse relationship" <| 151 | \year month day -> 152 | let 153 | date1 = 154 | date year month day 155 | 156 | date2 = 157 | date1 158 | |> toTuple 159 | |> fromTuple 160 | in 161 | Expect.equal date1 date2 162 | ] 163 | -------------------------------------------------------------------------------- /tests/TestDateTime.elm: -------------------------------------------------------------------------------- 1 | module TestDateTime exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (Fuzzer, constant, int, intRange, oneOf) 5 | import Parser exposing (Context, Error, Parser, Problem) 6 | import Test exposing (..) 7 | import Time.Date as Date 8 | import Time.DateTime as DateTime 9 | exposing 10 | ( DateTime 11 | , addHours 12 | , addMilliseconds 13 | , addMinutes 14 | , addSeconds 15 | , date 16 | , dateTime 17 | , day 18 | , epoch 19 | , hour 20 | , millisecond 21 | , minute 22 | , month 23 | , second 24 | , setDay 25 | , setHour 26 | , setMillisecond 27 | , setMinute 28 | , setMonth 29 | , setSecond 30 | , setYear 31 | , toTuple 32 | , year 33 | , zero 34 | ) 35 | import Time.Iso8601 as Iso8601 36 | 37 | 38 | posInt : Fuzzer Int 39 | posInt = 40 | Fuzz.map abs int 41 | 42 | 43 | dateTimesEqual : DateTime -> ( Int, Int, Int, Int, Int, Int, Int ) -> Expect.Expectation 44 | dateTimesEqual dateTime dateTimeTuple = 45 | Expect.equal (toTuple dateTime) dateTimeTuple 46 | 47 | 48 | dateTimes : Test 49 | dateTimes = 50 | describe "Time.DateTime.{dateTime,epoch}" 51 | [ test "epoch is the epoch" <| 52 | \() -> 53 | Expect.equal 54 | epoch 55 | (dateTime { zero | year = 1970 }) 56 | , test "can construt valid dates" <| 57 | \() -> 58 | let 59 | (( year, month, day, hour, minute, second, millisecond ) as dateTimeTuple) = 60 | ( 1992, 5, 29, 23, 29, 50, 920 ) 61 | in 62 | dateTimesEqual 63 | (dateTime { year = year, month = month, day = day, hour = hour, minute = minute, second = second, millisecond = millisecond }) 64 | dateTimeTuple 65 | , test "clamps invalid dates" <| 66 | \() -> 67 | dateTimesEqual 68 | (dateTime { zero | year = 1993, month = 2, day = 29 }) 69 | ( 1993, 2, 28, 0, 0, 0, 0 ) 70 | ] 71 | 72 | 73 | setters : Test 74 | setters = 75 | describe "Time.DateTime.set{Year,Month,Day,Hour,Minute,Second,Millisecond}" 76 | [ test "can set year" <| 77 | \() -> 78 | dateTime zero 79 | |> setYear 1992 80 | |> year 81 | |> Expect.equal 1992 82 | , test "can set month" <| 83 | \() -> 84 | dateTime zero 85 | |> setMonth 5 86 | |> month 87 | |> Expect.equal 5 88 | , test "can set month given the current year and day" <| 89 | \() -> 90 | dateTime { zero | year = 1992, month = 1, day = 29 } 91 | |> setMonth 2 92 | |> month 93 | |> Expect.equal 2 94 | , test "can set day" <| 95 | \() -> 96 | dateTime zero 97 | |> setDay 15 98 | |> day 99 | |> Expect.equal 15 100 | , test "can set day given the current year and month" <| 101 | \() -> 102 | dateTime { zero | year = 1992, month = 2 } 103 | |> setDay 29 104 | |> day 105 | |> Expect.equal 29 106 | , test "can set hour" <| 107 | \() -> 108 | dateTime zero 109 | |> setHour 23 110 | |> hour 111 | |> Expect.equal 23 112 | , test "can set minute" <| 113 | \() -> 114 | dateTime zero 115 | |> setMinute 23 116 | |> minute 117 | |> Expect.equal 23 118 | , test "can set second" <| 119 | \() -> 120 | dateTime zero 121 | |> setSecond 23 122 | |> second 123 | |> Expect.equal 23 124 | , test "can set millisecond" <| 125 | \() -> 126 | dateTime zero 127 | |> setMillisecond 230 128 | |> millisecond 129 | |> Expect.equal 230 130 | , test "invalid months are clamped" <| 131 | \() -> 132 | dateTime zero 133 | |> setMonth 31 134 | |> month 135 | |> Expect.equal 12 136 | , test "invalid days are clamped" <| 137 | \() -> 138 | dateTime zero 139 | |> setDay 105 140 | |> day 141 | |> Expect.equal 31 142 | , test "invalid months given the current year and day are clamped to the nearest valid date" <| 143 | \() -> 144 | dateTime { zero | year = 1993, month = 1, day = 31 } 145 | |> setMonth 2 146 | |> date 147 | |> Expect.equal (Date.date 1993 2 28) 148 | , test "invalid days given the current year and month are clamped to the nearest valid date" <| 149 | \() -> 150 | dateTime { zero | year = 1991, month = 2 } 151 | |> setDay 29 152 | |> date 153 | |> Expect.equal (Date.date 1991 2 28) 154 | , fuzz4 int int int int "invalid times are clamped" <| 155 | \hour minute second millisecond -> 156 | dateTime zero 157 | |> setHour hour 158 | |> setMinute minute 159 | |> setSecond second 160 | |> setMillisecond millisecond 161 | |> (\d -> ( DateTime.hour d, DateTime.minute d, DateTime.second d, DateTime.millisecond d )) 162 | |> Expect.equal ( clamp 0 23 hour, clamp 0 59 minute, clamp 0 59 second, clamp 0 999 millisecond ) 163 | ] 164 | 165 | 166 | addition : Test 167 | addition = 168 | describe "Time.DateTime.add{Hours,Minutes,Seconds,Milliseconds}" 169 | [ test "can add a positive number of hours" <| 170 | \() -> 171 | epoch 172 | |> addHours 26 173 | |> toTuple 174 | |> Expect.equal ( 1970, 1, 2, 2, 0, 0, 0 ) 175 | , test "can add a negative number of hours" <| 176 | \() -> 177 | epoch 178 | |> addHours -24 179 | |> toTuple 180 | |> Expect.equal ( 1969, 12, 31, 0, 0, 0, 0 ) 181 | , test "can add a positive number of minutes" <| 182 | \() -> 183 | epoch 184 | |> addMinutes 5 185 | |> toTuple 186 | |> Expect.equal ( 1970, 1, 1, 0, 5, 0, 0 ) 187 | , test "can add a negative number of minutes" <| 188 | \() -> 189 | epoch 190 | |> addMinutes -1 191 | |> toTuple 192 | |> Expect.equal ( 1969, 12, 31, 23, 59, 0, 0 ) 193 | , test "can add a positive number of seconds" <| 194 | \() -> 195 | epoch 196 | |> addSeconds 30 197 | |> toTuple 198 | |> Expect.equal ( 1970, 1, 1, 0, 0, 30, 0 ) 199 | , test "can add a positive number of seconds as an absolute quantity" <| 200 | \() -> 201 | epoch 202 | |> addSeconds 3600 203 | |> toTuple 204 | |> Expect.equal ( 1970, 1, 1, 1, 0, 0, 0 ) 205 | , test "can add a negative number of seconds" <| 206 | \() -> 207 | epoch 208 | |> addSeconds -1 209 | |> toTuple 210 | |> Expect.equal ( 1969, 12, 31, 23, 59, 59, 0 ) 211 | , test "can add a positive number of milliseconds" <| 212 | \() -> 213 | epoch 214 | |> addMilliseconds 30 215 | |> toTuple 216 | |> Expect.equal ( 1970, 1, 1, 0, 0, 0, 30 ) 217 | , test "can add a positive number of milliseconds as an absolute quantity" <| 218 | \() -> 219 | epoch 220 | |> addMilliseconds 86400000 221 | |> toTuple 222 | |> Expect.equal ( 1970, 1, 2, 0, 0, 0, 0 ) 223 | , test "can add a negative number of milliseconds" <| 224 | \() -> 225 | epoch 226 | |> addMilliseconds -1 227 | |> toTuple 228 | |> Expect.equal ( 1969, 12, 31, 23, 59, 59, 999 ) 229 | ] 230 | -------------------------------------------------------------------------------- /src/Time/ZonedDateTime.elm: -------------------------------------------------------------------------------- 1 | module Time.ZonedDateTime 2 | exposing 3 | ( ZonedDateTime 4 | , abbreviation 5 | , addDays 6 | , addHours 7 | , addMilliseconds 8 | , addMinutes 9 | , addMonths 10 | , addSeconds 11 | , addYears 12 | , asTimeZone 13 | , day 14 | , fromDateTime 15 | , fromTimestamp 16 | , hour 17 | , millisecond 18 | , minute 19 | , month 20 | , second 21 | , setDate 22 | , setDay 23 | , setHour 24 | , setMillisecond 25 | , setMinute 26 | , setMonth 27 | , setSecond 28 | , setYear 29 | , timeZone 30 | , toDateTime 31 | , toTimestamp 32 | , utcOffset 33 | , utcOffsetString 34 | , weekday 35 | , year 36 | , zero 37 | , zonedDateTime 38 | ) 39 | 40 | {-| This module defines a time representation based on a Date, the 41 | time of day and a time zone. 42 | 43 | ZonedDateTimes should only be used when reasoning about or displaying 44 | `DateTime`s in a user's local time zone. For this reason, the API 45 | surface of `ZonedDateTimes` is extremely limited. 46 | 47 | 48 | # ZonedDateTimes 49 | 50 | @docs ZonedDateTime 51 | 52 | 53 | # Constructing ZonedDateTimes 54 | 55 | @docs zero, zonedDateTime, fromDateTime, toDateTime, fromTimestamp, toTimestamp 56 | 57 | 58 | # Inspecting ZonedDateTimes 59 | 60 | @docs timeZone, year, month, day, weekday, hour, minute, second, millisecond, abbreviation, utcOffset, utcOffsetString 61 | 62 | 63 | # Manipulating ZonedDateTimes 64 | 65 | @docs asTimeZone, setDate, setYear, setMonth, setDay, setHour, setMinute, setSecond, setMillisecond, addYears, addMonths, addDays, addHours, addMinutes, addSeconds, addMilliseconds 66 | 67 | -} 68 | 69 | import Time exposing (Time) 70 | import Time.Date exposing (Date, Weekday) 71 | import Time.DateTime as DateTime exposing (DateTime) 72 | import Time.Internal exposing (..) 73 | import Time.TimeZone as TimeZone exposing (TimeZone) 74 | 75 | 76 | {-| ZoneDateTime is the opaque type for all ZonedDateTime values. 77 | Values of this type represent a `(TimeZone, DateTime)` pair. 78 | -} 79 | type ZonedDateTime 80 | = ZonedDateTime 81 | { timeZone : TimeZone 82 | , dateTime : DateTime 83 | } 84 | 85 | 86 | {-| zero represents the first millisecond of the first day of the 87 | current era. Use it to build `ZonedDateTime` values: 88 | 89 | -- 0-01-01T00:00:00+02:00 90 | zonedDateTime (europe_bucharest ()) zero 91 | 92 | -- 2016-01-01T00:00:00+02:00 93 | zonedDateTime (europe_bucharest ()) { zero | year = 2016 } 94 | 95 | -- 2016-05-29T13:00:00+02:00 96 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 5, day = 29, hour = 13 } 97 | 98 | -} 99 | zero : DateTimeData 100 | zero = 101 | Time.Internal.zero 102 | 103 | 104 | {-| zonedDateTime constructs a ZonedDateTime value given a TimeZone, a 105 | date and a time. Invalid values are clamped to the nearest valid date 106 | and time. 107 | -} 108 | zonedDateTime : TimeZone -> DateTimeData -> ZonedDateTime 109 | zonedDateTime timeZone dateTimeData = 110 | ZonedDateTime 111 | { timeZone = timeZone 112 | , dateTime = DateTime.dateTime dateTimeData 113 | } 114 | 115 | 116 | {-| fromDateTime constructs a ZonedDateTime value from a TimeZone and 117 | a DateTime. 118 | -} 119 | fromDateTime : TimeZone -> DateTime -> ZonedDateTime 120 | fromDateTime timeZone dateTime = 121 | let 122 | timestamp = 123 | DateTime.toTimestamp dateTime 124 | 125 | offset = 126 | TimeZone.offset timestamp timeZone 127 | in 128 | ZonedDateTime 129 | { timeZone = timeZone 130 | , dateTime = DateTime.addMilliseconds -offset dateTime 131 | } 132 | 133 | 134 | {-| toDateTime converts a ZonedDateTime to a UTC DateTime value. 135 | -} 136 | toDateTime : ZonedDateTime -> DateTime 137 | toDateTime ((ZonedDateTime { dateTime }) as zonedDateTime) = 138 | utcOffset zonedDateTime 139 | |> flip DateTime.addMilliseconds dateTime 140 | 141 | 142 | {-| fromTimestamp converts the millisecond representation of a UNIX 143 | timestamp into a ZonedDateTime value. This is equivalent to calling 144 | `DateTime.fromTimestamp` and then converting the resulting `DateTime` 145 | value to a `ZonedDateTime`. 146 | -} 147 | fromTimestamp : TimeZone -> Time -> ZonedDateTime 148 | fromTimestamp timeZone timestamp = 149 | DateTime.fromTimestamp timestamp 150 | |> fromDateTime timeZone 151 | 152 | 153 | {-| toTimestamp converts a ZonedDateTime to its UNIX timestamp 154 | representation in milliseconds. 155 | -} 156 | toTimestamp : ZonedDateTime -> Time 157 | toTimestamp (ZonedDateTime { timeZone, dateTime }) = 158 | DateTime.toTimestamp dateTime 159 | |> flip TimeZone.offset timeZone 160 | |> flip DateTime.addMilliseconds dateTime 161 | |> DateTime.toTimestamp 162 | 163 | 164 | {-| timeZone returns a ZonedDatetime's TimeZone. 165 | -} 166 | timeZone : ZonedDateTime -> TimeZone 167 | timeZone (ZonedDateTime { timeZone }) = 168 | timeZone 169 | 170 | 171 | {-| asTimeZone converts a ZonedDateTime to another TimeZone. 172 | -} 173 | asTimeZone : TimeZone -> ZonedDateTime -> ZonedDateTime 174 | asTimeZone timeZone = 175 | fromDateTime timeZone << toDateTime 176 | 177 | 178 | {-| year returns a ZonedDateTime's year. 179 | -} 180 | year : ZonedDateTime -> Int 181 | year (ZonedDateTime { dateTime }) = 182 | DateTime.year dateTime 183 | 184 | 185 | {-| month returns a ZonedDateTime's month. 186 | -} 187 | month : ZonedDateTime -> Int 188 | month (ZonedDateTime { dateTime }) = 189 | DateTime.month dateTime 190 | 191 | 192 | {-| day returns a ZonedDateTime's day. 193 | -} 194 | day : ZonedDateTime -> Int 195 | day (ZonedDateTime { dateTime }) = 196 | DateTime.day dateTime 197 | 198 | 199 | {-| weekday returns a ZonedDateTime's day of the week. 200 | -} 201 | weekday : ZonedDateTime -> Weekday 202 | weekday (ZonedDateTime { dateTime }) = 203 | DateTime.weekday dateTime 204 | 205 | 206 | {-| hour returns a ZonedDateTime's hour. 207 | -} 208 | hour : ZonedDateTime -> Int 209 | hour (ZonedDateTime { dateTime }) = 210 | DateTime.hour dateTime 211 | 212 | 213 | {-| minute returns a ZonedDateTime's minute. 214 | -} 215 | minute : ZonedDateTime -> Int 216 | minute (ZonedDateTime { dateTime }) = 217 | DateTime.minute dateTime 218 | 219 | 220 | {-| second returns a ZonedDateTime's second. 221 | -} 222 | second : ZonedDateTime -> Int 223 | second (ZonedDateTime { dateTime }) = 224 | DateTime.second dateTime 225 | 226 | 227 | {-| millisecond returns a ZonedDateTime's millisecond. 228 | -} 229 | millisecond : ZonedDateTime -> Int 230 | millisecond (ZonedDateTime { dateTime }) = 231 | DateTime.millisecond dateTime 232 | 233 | 234 | mapInner : (a -> DateTime -> DateTime) -> a -> ZonedDateTime -> ZonedDateTime 235 | mapInner f x (ZonedDateTime ({ dateTime } as t)) = 236 | ZonedDateTime { t | dateTime = f x dateTime } 237 | 238 | 239 | {-| setDate sets a ZonedDateTime's date. 240 | -} 241 | setDate : Date -> ZonedDateTime -> ZonedDateTime 242 | setDate = 243 | mapInner DateTime.setDate 244 | 245 | 246 | {-| setYear sets a ZonedDateTime's year. 247 | -} 248 | setYear : Int -> ZonedDateTime -> ZonedDateTime 249 | setYear = 250 | mapInner DateTime.setYear 251 | 252 | 253 | {-| setMonth sets a ZonedDateTime's month. 254 | -} 255 | setMonth : Int -> ZonedDateTime -> ZonedDateTime 256 | setMonth = 257 | mapInner DateTime.setMonth 258 | 259 | 260 | {-| setDay sets a ZonedDateTime's day. 261 | -} 262 | setDay : Int -> ZonedDateTime -> ZonedDateTime 263 | setDay = 264 | mapInner DateTime.setDay 265 | 266 | 267 | {-| setHour sets a ZonedDateTime's hour. 268 | -} 269 | setHour : Int -> ZonedDateTime -> ZonedDateTime 270 | setHour = 271 | mapInner DateTime.setHour 272 | 273 | 274 | {-| setMinute sets a ZonedDateTime's minute. 275 | -} 276 | setMinute : Int -> ZonedDateTime -> ZonedDateTime 277 | setMinute = 278 | mapInner DateTime.setMinute 279 | 280 | 281 | {-| setSecond sets a ZonedDateTime's second. 282 | -} 283 | setSecond : Int -> ZonedDateTime -> ZonedDateTime 284 | setSecond = 285 | mapInner DateTime.setSecond 286 | 287 | 288 | {-| setMillisecond sets a ZonedDateTime's millisecond. 289 | -} 290 | setMillisecond : Int -> ZonedDateTime -> ZonedDateTime 291 | setMillisecond = 292 | mapInner DateTime.setMillisecond 293 | 294 | 295 | {-| addYears adds a relative number of years to a ZonedDateTime value. 296 | -} 297 | addYears : Int -> ZonedDateTime -> ZonedDateTime 298 | addYears = 299 | mapInner DateTime.addYears 300 | 301 | 302 | {-| addMonths adds a relative number of months to a ZonedDateTime value. 303 | -} 304 | addMonths : Int -> ZonedDateTime -> ZonedDateTime 305 | addMonths = 306 | mapInner DateTime.addMonths 307 | 308 | 309 | {-| addDays adds an absolute number of days to a ZonedDateTime value. 310 | -} 311 | addDays : Int -> ZonedDateTime -> ZonedDateTime 312 | addDays = 313 | mapInner DateTime.addDays 314 | 315 | 316 | {-| addHours adds a relative number of hours to a ZonedDateTime value. 317 | -} 318 | addHours : Int -> ZonedDateTime -> ZonedDateTime 319 | addHours = 320 | mapInner DateTime.addHours 321 | 322 | 323 | {-| addMinutes adds a relative number of minutes to a ZonedDateTime value. 324 | -} 325 | addMinutes : Int -> ZonedDateTime -> ZonedDateTime 326 | addMinutes = 327 | mapInner DateTime.addMinutes 328 | 329 | 330 | {-| addSeconds adds a relative number of seconds to a ZonedDateTime value. 331 | -} 332 | addSeconds : Int -> ZonedDateTime -> ZonedDateTime 333 | addSeconds = 334 | mapInner DateTime.addSeconds 335 | 336 | 337 | {-| addMilliseconds adds an absolute number of milliseconds to a 338 | ZonedDateTime value. 339 | -} 340 | addMilliseconds : Int -> ZonedDateTime -> ZonedDateTime 341 | addMilliseconds = 342 | mapInner DateTime.addMilliseconds 343 | 344 | 345 | {-| abbreviation returns a ZonedDateTime's abbreviation at that time. 346 | -} 347 | abbreviation : ZonedDateTime -> String 348 | abbreviation ((ZonedDateTime { timeZone }) as zonedDateTime) = 349 | toTimestamp zonedDateTime 350 | |> flip TimeZone.abbreviation timeZone 351 | 352 | 353 | {-| utcOffset returns a ZonedDateTime's offset from UTC in 354 | milliseconds at that time. 355 | -} 356 | utcOffset : ZonedDateTime -> Int 357 | utcOffset ((ZonedDateTime { timeZone }) as zonedDateTime) = 358 | toTimestamp zonedDateTime 359 | |> flip TimeZone.offset timeZone 360 | 361 | 362 | {-| utcOffsetString returns a ZonedDateTime's UTC offset at that time 363 | as a string. 364 | -} 365 | utcOffsetString : ZonedDateTime -> String 366 | utcOffsetString ((ZonedDateTime { timeZone }) as zonedDateTime) = 367 | toTimestamp zonedDateTime 368 | |> flip TimeZone.offsetString timeZone 369 | -------------------------------------------------------------------------------- /src/Time/Iso8601.elm: -------------------------------------------------------------------------------- 1 | module Time.Iso8601 2 | exposing 3 | ( fromDate 4 | , fromDateTime 5 | , fromZonedDateTime 6 | , toDate 7 | , toDateTime 8 | , toZonedDateTime 9 | ) 10 | 11 | {-| 12 | 13 | 14 | # Render ISO8601 strings 15 | 16 | @docs fromDate, fromDateTime, fromZonedDateTime 17 | 18 | 19 | # Parse ISO8601 strings 20 | 21 | @docs toDate, toDateTime, toZonedDateTime 22 | 23 | -} 24 | 25 | import Char 26 | import Parser 27 | exposing 28 | ( (|.) 29 | , (|=) 30 | , Count(..) 31 | , Error 32 | , Parser 33 | , andThen 34 | , fail 35 | , ignore 36 | , inContext 37 | , keep 38 | , oneOf 39 | , oneOrMore 40 | , run 41 | , succeed 42 | , zeroOrMore 43 | ) 44 | import Time.Date as Date 45 | exposing 46 | ( Date 47 | , daysInMonth 48 | , isValidDate 49 | ) 50 | import Time.DateTime as DateTime 51 | exposing 52 | ( DateTime 53 | , addMilliseconds 54 | , dateTime 55 | , day 56 | , hour 57 | , makeDateTime 58 | , millisecond 59 | , minute 60 | , month 61 | , second 62 | , year 63 | ) 64 | import Time.Internal 65 | exposing 66 | ( hourMs 67 | , minuteMs 68 | , offsetFromTimeData 69 | , padded 70 | , padded3 71 | ) 72 | import Time.TimeZone exposing (TimeZone) 73 | import Time.ZonedDateTime as ZDT exposing (ZonedDateTime) 74 | 75 | 76 | -- Shared parsers 77 | -- -------------- 78 | 79 | 80 | {-| Offset is expressed in +/- milliseconds 81 | -} 82 | type alias Milliseconds = 83 | Int 84 | 85 | 86 | {-| fromDate renders a Date in ISO8601 format. 87 | 88 | import Time.Date exposing (..) 89 | 90 | date 2018 5 27 91 | |> fromDate 92 | --> "2018-05-27" 93 | 94 | -} 95 | fromDate : Date -> String 96 | fromDate date = 97 | (toString (Date.year date) |> String.padLeft 4 '0') 98 | ++ "-" 99 | ++ padded (Date.month date) 100 | ++ "-" 101 | ++ padded (Date.day date) 102 | 103 | 104 | {-| fromDateTime renders a DateTime in ISO8601 format. 105 | 106 | import Time.DateTime exposing (..) 107 | 108 | epoch 109 | |> addMilliseconds 61000 110 | |> fromDateTime 111 | --> "1970-01-01T00:01:01.000Z" 112 | 113 | -} 114 | fromDateTime : DateTime -> String 115 | fromDateTime time = 116 | toString (year time) 117 | ++ "-" 118 | ++ padded (month time) 119 | ++ "-" 120 | ++ padded (day time) 121 | ++ "T" 122 | ++ padded (hour time) 123 | ++ ":" 124 | ++ padded (minute time) 125 | ++ ":" 126 | ++ padded (second time) 127 | ++ "." 128 | ++ padded3 (millisecond time) 129 | ++ "Z" 130 | 131 | 132 | {-| fromZonedDateTime renders a ZonedDateTime in ISO8601 format. 133 | 134 | import Time.ZonedDateTime 135 | import Time.TimeZones exposing (america_new_york) 136 | import Time.DateTime exposing (epoch) 137 | 138 | Time.ZonedDateTime.fromDateTime (america_new_york ()) epoch 139 | |> fromZonedDateTime 140 | --> "1969-12-31T19:00:00.000-05:00" 141 | 142 | -} 143 | fromZonedDateTime : ZonedDateTime -> String 144 | fromZonedDateTime dateTime = 145 | toString (ZDT.year dateTime) 146 | ++ "-" 147 | ++ padded (ZDT.month dateTime) 148 | ++ "-" 149 | ++ padded (ZDT.day dateTime) 150 | ++ "T" 151 | ++ padded (ZDT.hour dateTime) 152 | ++ ":" 153 | ++ padded (ZDT.minute dateTime) 154 | ++ ":" 155 | ++ padded (ZDT.second dateTime) 156 | ++ "." 157 | ++ padded3 (ZDT.millisecond dateTime) 158 | ++ ZDT.utcOffsetString dateTime 159 | 160 | 161 | {-| toZonedDateTime parses an ISO8601-formatted string into a 162 | ZonedDateTime object, adjusting for its offset. 163 | 164 | import Time.ZonedDateTime 165 | import Time.TimeZones exposing (america_new_york) 166 | import Time.DateTime exposing (epoch) 167 | 168 | toZonedDateTime (america_new_york ()) "1970-01-01T00:00:00.000Z" 169 | --> Ok (Time.ZonedDateTime.fromDateTime (america_new_york ()) epoch) 170 | 171 | -} 172 | toZonedDateTime : TimeZone -> String -> Result Parser.Error ZonedDateTime 173 | toZonedDateTime timeZone input = 174 | toDateTime input 175 | |> Result.map (ZDT.fromDateTime timeZone) 176 | 177 | 178 | {-| toDate parses an ISO8601-formatted date string into a Date. 179 | 180 | import Time.Date 181 | 182 | toDate "1970-12-01" 183 | --> Ok (Time.Date.date 1970 12 1) 184 | 185 | toDate "19701201" 186 | --> Ok (Time.Date.date 1970 12 1) 187 | 188 | -} 189 | toDate : String -> Result Parser.Error Date 190 | toDate input = 191 | run parseDate input 192 | 193 | 194 | {-| -} 195 | parseDate : Parser Date 196 | parseDate = 197 | (succeed (,,) 198 | |= parseYear 199 | |. optional '-' 200 | |= parseMonth 201 | |. optional '-' 202 | |= parseDay 203 | ) 204 | |> andThen convertDate 205 | 206 | 207 | parseYear : Parser Int 208 | parseYear = 209 | digits "year" 4 210 | 211 | 212 | parseMonth : Parser Int 213 | parseMonth = 214 | digitsInRange "month" 2 1 12 215 | 216 | 217 | parseDay : Parser Int 218 | parseDay = 219 | digitsInRange "day-in-month" 2 1 31 220 | 221 | 222 | digits : String -> Int -> Parser Int 223 | digits name digitsCount = 224 | inContext name <| 225 | (keep (Exactly digitsCount) Char.isDigit 226 | |> andThen (fromResult << String.toInt) 227 | ) 228 | 229 | 230 | convertDate : ( Int, Int, Int ) -> Parser Date 231 | convertDate ( year, month, day ) = 232 | if isValidDate year month day then 233 | succeed (Date.date year month day) 234 | else 235 | complainInvalid ( year, month, day ) 236 | 237 | 238 | complainInvalid : ( Int, Int, Int ) -> Parser Date 239 | complainInvalid ( year, month, day ) = 240 | inContext "leap-year" <| 241 | let 242 | maxDays = 243 | Maybe.withDefault 31 (daysInMonth year month) 244 | 245 | msg = 246 | "Expecting the value " 247 | ++ toString day 248 | ++ " to be in the range 1 to " 249 | ++ toString maxDays 250 | ++ " for the specified year, " 251 | ++ toString year 252 | ++ ", and month, " 253 | ++ toString month 254 | ++ "." 255 | in 256 | fail msg 257 | 258 | 259 | digitsInRange : String -> Int -> Int -> Int -> Parser Int 260 | digitsInRange name digitsCount lo hi = 261 | inContext name <| 262 | (keep (Exactly digitsCount) (\c -> Char.isDigit c) 263 | |> andThen (intRange lo hi << String.toInt) 264 | ) 265 | 266 | 267 | intRange : Int -> Int -> Result String Int -> Parser Int 268 | intRange lo hi result = 269 | case result of 270 | Ok n -> 271 | if n >= lo && n <= hi then 272 | succeed n 273 | else 274 | fail 275 | ("Expecting the value " 276 | ++ toString n 277 | ++ " to be in the range " 278 | ++ toString lo 279 | ++ " to " 280 | ++ toString hi 281 | ++ "." 282 | ) 283 | 284 | Err msg -> 285 | Parser.fail msg 286 | 287 | 288 | fromResult : Result String Int -> Parser Int 289 | fromResult result = 290 | case result of 291 | Ok i -> 292 | succeed i 293 | 294 | Err msg -> 295 | fail msg 296 | 297 | 298 | optional : Char -> Parser () 299 | optional char = 300 | ignore zeroOrMore (\c -> c == char) 301 | 302 | 303 | {-| toDateTime parses an ISO8601-formatted date time string into a 304 | DateTime object, adjusting for its timezone offset. 305 | -} 306 | toDateTime : String -> Result Parser.Error DateTime 307 | toDateTime input = 308 | run parseDateTime input 309 | 310 | 311 | parseDateTime : Parser DateTime 312 | parseDateTime = 313 | (succeed (,,) 314 | |= parseDate 315 | |. optional 'T' 316 | |= parseOffset 317 | |= tZOffset 318 | ) 319 | |> andThen convertDateTime 320 | 321 | 322 | parseOffset : Parser Milliseconds 323 | parseOffset = 324 | (succeed (,,,) 325 | |= digitsInRange "hours" 2 0 23 326 | |. optional ':' 327 | |= digitsInRange "minutes" 2 0 59 328 | |. optional ':' 329 | |= digitsInRange "seconds" 2 0 59 330 | |= fraction 331 | ) 332 | |> andThen convertTime 333 | 334 | 335 | convertDateTime : ( Date, Milliseconds, Milliseconds ) -> Parser DateTime 336 | convertDateTime ( date, offset, tZOffset ) = 337 | succeed 338 | (makeDateTime 339 | date 340 | offset 341 | |> addMilliseconds tZOffset 342 | ) 343 | 344 | 345 | convertTime : ( Int, Int, Int, Int ) -> Parser Milliseconds 346 | convertTime ( hours, minutes, seconds, milliseconds ) = 347 | succeed 348 | (offsetFromTimeData 349 | { hour = hours 350 | , minute = minutes 351 | , second = seconds 352 | , millisecond = milliseconds 353 | } 354 | ) 355 | 356 | 357 | fraction : Parser Milliseconds 358 | fraction = 359 | oneOf 360 | [ optionalFraction 361 | , succeed 0 362 | ] 363 | 364 | 365 | optionalFraction : Parser Milliseconds 366 | optionalFraction = 367 | inContext "fraction" <| 368 | ((succeed identity 369 | |. keep (Exactly 1) ((==) '.') 370 | |= keep oneOrMore Char.isDigit 371 | ) 372 | |> andThen (fromResult << getFraction) 373 | ) 374 | 375 | 376 | getFraction : String -> Result String Milliseconds 377 | getFraction fractionString = 378 | let 379 | numerator = 380 | Result.withDefault 0 (String.toInt fractionString) 381 | 382 | denominator = 383 | 10 ^ String.length fractionString 384 | in 385 | Ok (round (Time.Internal.secondMs * toFloat numerator / toFloat denominator)) 386 | 387 | 388 | tZOffset : Parser Milliseconds 389 | tZOffset = 390 | oneOf 391 | [ utc 392 | , optionalTZOffset 393 | , succeed 0 394 | ] 395 | 396 | 397 | utc : Parser Milliseconds 398 | utc = 399 | (succeed identity 400 | |. keep (Exactly 1) ((==) 'Z') 401 | ) 402 | |> andThen (fromResult << (\_ -> Ok 0)) 403 | 404 | 405 | optionalTZOffset : Parser Milliseconds 406 | optionalTZOffset = 407 | inContext "offset" <| 408 | ((succeed (,,) 409 | |= polarity 410 | |= digitsInRange "timezone hours" 2 0 23 411 | |. optional ':' 412 | |= digitsInRange "timezone minutes" 2 0 59 413 | ) 414 | |> andThen (fromResult << getTZOffset) 415 | ) 416 | 417 | 418 | polarity : Parser Int 419 | polarity = 420 | inContext "timezone polarity" <| 421 | (keep (Exactly 1) 422 | (\c -> 423 | c 424 | == '+' 425 | || c 426 | == '-' 427 | || c 428 | == '−' 429 | --U+2212 430 | ) 431 | |> andThen 432 | (fromResult 433 | << -- Code has to do opposite of sign char 434 | (\sign -> 435 | if sign == "+" then 436 | Ok -1 437 | else 438 | Ok 1 439 | ) 440 | ) 441 | ) 442 | 443 | 444 | getTZOffset : ( Int, Int, Int ) -> Result String Milliseconds 445 | getTZOffset ( polarity, hrs, min ) = 446 | Ok 447 | (polarity 448 | * hrs 449 | * hourMs 450 | + polarity 451 | * min 452 | * minuteMs 453 | ) 454 | -------------------------------------------------------------------------------- /src/Time/TimeZone.elm: -------------------------------------------------------------------------------- 1 | module Time.TimeZone 2 | exposing 3 | ( TimeZone 4 | , name 5 | , abbreviation 6 | , offset 7 | , offsetString 8 | , setName 9 | , unpack 10 | ) 11 | 12 | {-| This module defines a representations for Timezone information. 13 | 14 | 15 | # TimeZone values 16 | 17 | @docs TimeZone, name, abbreviation, offset, offsetString 18 | 19 | 20 | # Manipulating TimeZones 21 | 22 | @docs setName 23 | 24 | 25 | # Constructing TimeZones 26 | 27 | @docs unpack 28 | 29 | -} 30 | 31 | import Char 32 | import Debug 33 | exposing 34 | ( log 35 | ) 36 | import Parser 37 | exposing 38 | ( (|.) 39 | , (|=) 40 | , Count(..) 41 | , Error 42 | , Parser 43 | , andThen 44 | , delayedCommit 45 | , end 46 | , fail 47 | , ignore 48 | , inContext 49 | , keep 50 | , map 51 | , oneOf 52 | , oneOrMore 53 | , run 54 | , succeed 55 | , zeroOrMore 56 | ) 57 | import Time exposing (Time) 58 | import Time.Internal exposing (..) 59 | 60 | 61 | {-| TimeZone represents the opaque type of timezone values. These are 62 | generally loaded from an external source via `unpack`. 63 | 64 | See also . 65 | 66 | -} 67 | type TimeZone 68 | = TimeZone 69 | { name : String 70 | , spans : List Span 71 | } 72 | 73 | 74 | {-| Spans represent variations within a TimeZone. A Time has an 75 | associated Span if `.from <= t < .until`. 76 | 77 | `offset` is the Span's UTC offset in milliseconds. 78 | 79 | -} 80 | type alias Span = 81 | { from : Time 82 | , until : Time 83 | , abbreviation : String 84 | , offset : Int 85 | } 86 | 87 | 88 | {-| Given an arbitrary Time and a TimeZone, abbreviation returns the 89 | TimeZone's abbreviation at that Time. 90 | -} 91 | abbreviation : Time -> TimeZone -> String 92 | abbreviation time (TimeZone { spans }) = 93 | find time spans |> .abbreviation 94 | 95 | 96 | {-| Given an arbitrary Time and a TimeZone, offset returns the 97 | TimeZone's UTC offset in milliseconds at that Time. 98 | -} 99 | offset : Time -> TimeZone -> Int 100 | offset time (TimeZone { spans }) = 101 | find time spans |> .offset 102 | 103 | 104 | {-| Given an arbitrary Time and TimeZone, offsetString returns an 105 | ISO8601-formatted UTC offset for at that Time. 106 | -} 107 | offsetString : Time -> TimeZone -> String 108 | offsetString time timeZone = 109 | let 110 | utcOffset = 111 | offset time timeZone // minuteMs 112 | 113 | hours = 114 | abs utcOffset // 60 115 | 116 | minutes = 117 | abs utcOffset % 60 118 | 119 | string = 120 | padded hours ++ ":" ++ padded minutes 121 | in 122 | if utcOffset <= 0 then 123 | "+" ++ string 124 | else 125 | "-" ++ string 126 | 127 | 128 | find : Time -> List Span -> Span 129 | find time spans = 130 | let 131 | go xs = 132 | case xs of 133 | [] -> 134 | Debug.crash "find: invalid span list" 135 | 136 | x :: xs -> 137 | if time >= x.from && time < x.until then 138 | x 139 | else 140 | go xs 141 | in 142 | go spans 143 | 144 | 145 | {-| setName updates a TimeZone's name. 146 | -} 147 | setName : String -> TimeZone -> TimeZone 148 | setName name (TimeZone tz) = 149 | TimeZone { tz | name = name } 150 | 151 | 152 | {-| name returns a TimeZone's name. 153 | -} 154 | name : TimeZone -> String 155 | name (TimeZone { name }) = 156 | name 157 | 158 | 159 | {-| unpackNew decodes a packed zone data object into a TimeZone value. 160 | 161 | See also 162 | 163 | -} 164 | unpack : String -> Result Error TimeZone 165 | unpack data = 166 | run packedTimeZone data 167 | 168 | 169 | {-| packedTimeZoneNew parses a zone data string into a TimeZone, validating that 170 | the data format invariants hold. 171 | -} 172 | packedTimeZone : Parser TimeZone 173 | packedTimeZone = 174 | let 175 | decode = 176 | (succeed PackedTimeZone 177 | |= parseName 178 | |. pipe 179 | |= parseAbbrevs 180 | |. pipe 181 | |= parseOffsets 182 | |. pipe 183 | |= parseIndices 184 | |. pipe 185 | |= parseDiffs 186 | ) 187 | 188 | validate data = 189 | let 190 | abbrevs = 191 | List.length data.abbrevs 192 | 193 | offsets = 194 | List.length data.offsets 195 | 196 | maxIndex = 197 | List.maximum data.indices 198 | |> Maybe.withDefault 0 199 | in 200 | if abbrevs /= offsets then 201 | fail "abbrevs and offsets have different lengths" 202 | else if maxIndex >= abbrevs then 203 | fail "highest index is longer than both abbrevs and offsets" 204 | else 205 | succeed data 206 | 207 | span times data i idx = 208 | { from = times !! i 209 | , until = times !! (i + 1) 210 | , abbreviation = data.abbrevs !! idx 211 | , offset = round (data.offsets !! idx * minuteMs) 212 | } 213 | 214 | convert data = 215 | let 216 | times = 217 | if not <| List.isEmpty data.diffs then 218 | List.scanl (+) (data.diffs !! 0) (List.drop 1 data.diffs) 219 | else 220 | [] 221 | 222 | -- surround times with - and +infinity 223 | paddedTimes = 224 | [ -1 / 0 ] ++ times ++ [ 1 / 0 ] 225 | in 226 | TimeZone 227 | { name = data.name 228 | , spans = List.indexedMap (span paddedTimes data) data.indices 229 | } 230 | in 231 | decode 232 | |> andThen validate 233 | |> map convert 234 | 235 | 236 | type alias PackedTimeZone = 237 | { name : String 238 | , abbrevs : List String 239 | , offsets : List Float 240 | , indices : List Int 241 | , diffs : List Float 242 | } 243 | 244 | 245 | {-| Parse the name of the timezone 246 | -} 247 | parseName : Parser String 248 | parseName = 249 | inContext "name" <| 250 | succeed identity 251 | |= keep oneOrMore ((/=) '|') 252 | 253 | 254 | {-| Parse the first abbrev and then use `abbrevsHelp` to find 255 | the remaining ones. 256 | -} 257 | parseAbbrevs : Parser (List String) 258 | parseAbbrevs = 259 | let 260 | abbrev : Parser String 261 | abbrev = 262 | keep oneOrMore (\c -> c /= ' ' && c /= '|') 263 | 264 | helper : List String -> Parser (List String) 265 | helper revTerms = 266 | oneOf 267 | [ next 268 | |> andThen (\s -> helper (s :: revTerms)) 269 | , succeed (List.reverse revTerms) 270 | ] 271 | 272 | next : Parser String 273 | next = 274 | succeed identity 275 | |. parseSpace 276 | |= abbrev 277 | in 278 | inContext "abbrevs" <| 279 | succeed identity 280 | |= andThen (\s -> helper [ s ]) abbrev 281 | 282 | 283 | parseOffsets : Parser (List Float) 284 | parseOffsets = 285 | let 286 | offset : Parser Float 287 | offset = 288 | (succeed (,,) 289 | |= parseSign 290 | |= parseWhole 291 | |= parseFrac 292 | ) 293 | |> andThen convertBase60 294 | 295 | convertBase60 : ( Int, String, String ) -> Parser Float 296 | convertBase60 ( sign, whole, frac ) = 297 | if whole == "" && frac == "" then 298 | fail "expected an alphanumeric character or ." 299 | else 300 | succeed <| unsafeBase60 sign whole frac 301 | 302 | convertFrac : String -> Parser String 303 | convertFrac frac = 304 | succeed frac 305 | 306 | helper : List Float -> Parser (List Float) 307 | helper revTerms = 308 | oneOf 309 | [ next 310 | |> andThen (\f -> helper (f :: revTerms)) 311 | , succeed (List.reverse revTerms) 312 | ] 313 | 314 | next : Parser Float 315 | next = 316 | succeed identity 317 | |. parseSpace 318 | |= offset 319 | in 320 | inContext "offsets" <| 321 | succeed identity 322 | |= andThen (\f -> helper [ f ]) offset 323 | 324 | 325 | parseIndices : Parser (List Int) 326 | parseIndices = 327 | let 328 | helper : List Int -> Parser (List Int) 329 | helper revTerms = 330 | oneOf 331 | [ next 332 | |> andThen (\i -> helper (i :: revTerms)) 333 | , succeed (List.reverse revTerms) 334 | ] 335 | 336 | next : Parser Int 337 | next = 338 | succeed identity 339 | |= index 340 | 341 | index : Parser Int 342 | index = 343 | keep (Exactly 1) (\c -> Char.isDigit c) 344 | |> andThen convertDecimal 345 | 346 | convertDecimal : String -> Parser Int 347 | convertDecimal digit = 348 | case String.toInt digit of 349 | Err msg -> 350 | fail msg 351 | 352 | Ok value -> 353 | succeed value 354 | in 355 | inContext "indices" <| 356 | succeed identity 357 | |= andThen (\i -> helper [ i ]) index 358 | 359 | 360 | parseDiffs : Parser (List Float) 361 | parseDiffs = 362 | let 363 | emptyDiffs : Parser (List Float) 364 | emptyDiffs = 365 | (succeed identity 366 | |. pipe 367 | ) 368 | |> andThen (\_ -> succeed []) 369 | 370 | diffsEnd : Parser (List Float) 371 | diffsEnd = 372 | (succeed identity 373 | |. end 374 | ) 375 | |> andThen (\_ -> succeed []) 376 | 377 | helper : List Float -> Parser (List Float) 378 | helper revTerms = 379 | oneOf 380 | [ next 381 | |> andThen (\f -> helper (f :: revTerms)) 382 | , succeed (List.reverse revTerms) 383 | ] 384 | 385 | next : Parser Float 386 | next = 387 | succeed identity 388 | |. parseSpace 389 | |= diff 390 | 391 | diff : Parser Float 392 | diff = 393 | (succeed (,,) 394 | |= parseSign 395 | |= parseWhole 396 | |= parseFrac 397 | ) 398 | |> andThen convertBase60Times60000 399 | 400 | convertBase60Times60000 : ( Int, String, String ) -> Parser Float 401 | convertBase60Times60000 ( sign, whole, frac ) = 402 | if whole == "" && frac == "" then 403 | fail "expected an alphanumeric character or ." 404 | else 405 | succeed <| (*) 60000 (unsafeBase60 sign whole frac) 406 | in 407 | inContext "diffs" <| 408 | oneOf 409 | [ emptyDiffs 410 | , diffsEnd 411 | , andThen (\f -> helper [ f ]) diff 412 | ] 413 | 414 | 415 | pipe : Parser () 416 | pipe = 417 | ignore (Exactly 1) ((==) '|') 418 | 419 | 420 | parseSpace : Parser () 421 | parseSpace = 422 | ignore (Exactly 1) ((==) ' ') 423 | 424 | 425 | parseSign : Parser Int 426 | parseSign = 427 | let 428 | minusOne : String -> Parser Int 429 | minusOne hyphen = 430 | succeed -1 431 | in 432 | oneOf 433 | [ keep (Exactly 1) (\c -> c == '-') 434 | |> andThen minusOne 435 | , succeed 1 436 | ] 437 | 438 | 439 | parseWhole : Parser String 440 | parseWhole = 441 | keep zeroOrMore (\c -> unsafeBase60Digit c) 442 | 443 | 444 | parseFrac : Parser String 445 | parseFrac = 446 | oneOf 447 | [ parseSuccessfulFrac 448 | , succeed "" 449 | ] 450 | 451 | 452 | unsafeBase60Digit : Char -> Bool 453 | unsafeBase60Digit c = 454 | Char.isDigit c || Char.isUpper c || Char.isLower c 455 | 456 | 457 | parseSuccessfulFrac : Parser String 458 | parseSuccessfulFrac = 459 | (succeed identity 460 | |. ignore (Exactly 1) (\c -> c == '.') 461 | |= keep oneOrMore (\c -> unsafeBase60Digit c) 462 | ) 463 | 464 | 465 | unsafeBase60 : Int -> String -> String -> Float 466 | unsafeBase60 sign whole frac = 467 | let 468 | toNum c = 469 | let 470 | n = 471 | Char.toCode c |> toFloat 472 | in 473 | if n > 96 then 474 | n - 87 475 | else if n > 64 then 476 | n - 29 477 | else 478 | n - 48 479 | 480 | toWhole cs acc = 481 | case cs of 482 | [] -> 483 | acc 484 | 485 | c :: cs -> 486 | toWhole cs (60 * acc + toNum c) 487 | 488 | toFrac cs mul acc = 489 | let 490 | mul_ = 491 | mul / 60 492 | in 493 | case cs of 494 | [] -> 495 | acc 496 | 497 | c :: cs -> 498 | toFrac cs mul_ (acc + mul_ * toNum c) 499 | in 500 | toWhole (String.toList whole) 0 501 | |> toFrac (String.toList frac) 1 502 | |> ((*) (toFloat sign)) 503 | 504 | 505 | (!!) : List a -> Int -> a 506 | (!!) xs i = 507 | case List.head (List.drop i xs) of 508 | Nothing -> 509 | Debug.crash ("index too large: xs=" ++ toString xs ++ " i=" ++ toString i) 510 | 511 | Just x -> 512 | x 513 | -------------------------------------------------------------------------------- /tests/TestIso8601.elm: -------------------------------------------------------------------------------- 1 | module TestIso8601 exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (Fuzzer, constant, int, intRange, oneOf) 5 | import Test exposing (..) 6 | import TestDate exposing (someDate) 7 | import Time.Date exposing (Date, date) 8 | import Time.DateTime as DT 9 | exposing 10 | ( addHours 11 | , addMinutes 12 | , dateTime 13 | , epoch 14 | , millisecond 15 | , setMillisecond 16 | , zero 17 | ) 18 | import Time.Iso8601 as Iso8601 19 | exposing 20 | ( fromDate 21 | , fromDateTime 22 | , fromZonedDateTime 23 | , toDate 24 | , toDateTime 25 | ) 26 | import Time.TimeZones exposing (europe_bucharest) 27 | import Time.ZonedDateTime as ZonedDateTime 28 | exposing 29 | ( zonedDateTime 30 | ) 31 | 32 | 33 | {-| -} 34 | toFromISO8601 : Test 35 | toFromISO8601 = 36 | let 37 | parseEq input expectedDateTime = 38 | case toDateTime input of 39 | Err error -> 40 | toString error 41 | |> Expect.fail 42 | 43 | Ok actualDateTime -> 44 | if actualDateTime == expectedDateTime then 45 | Expect.pass 46 | else 47 | Expect.fail ("expected '" ++ Iso8601.fromDateTime actualDateTime ++ "' to equal '" ++ Iso8601.fromDateTime expectedDateTime ++ "' from input '" ++ input ++ "'") 48 | 49 | parseEqDate input expectedDate = 50 | case toDate input of 51 | Err error -> 52 | toString error 53 | |> Expect.fail 54 | 55 | Ok actualDate -> 56 | if actualDate == expectedDate then 57 | Expect.pass 58 | else 59 | Expect.fail ("expected '" ++ Iso8601.fromDate actualDate ++ "' to equal '" ++ Iso8601.fromDate expectedDate ++ "' from input '" ++ input ++ "'") 60 | 61 | parseFails input = 62 | case toDateTime input of 63 | Err _ -> 64 | Expect.pass 65 | 66 | Ok _ -> 67 | Expect.fail ("parsing '" ++ input ++ "' should have failed") 68 | 69 | parseMs input ms = 70 | case toDateTime input of 71 | Err _ -> 72 | Expect.fail "parse failed" 73 | 74 | Ok dt -> 75 | Expect.equal (millisecond dt) ms 76 | in 77 | describe "Time.DateTime.{to,from}ISO8601" 78 | [ test "toISO8601 of epoch is correct" <| 79 | \() -> 80 | Iso8601.fromDateTime epoch 81 | |> Expect.equal "1970-01-01T00:00:00.000Z" 82 | , test "fromISO8601 is the inverse of toISO8601" <| 83 | \() -> 84 | Iso8601.fromDateTime epoch 85 | |> flip parseEq epoch 86 | , test "fromISO8601 fails to parse invalid strings" <| 87 | \() -> parseFails "foo" 88 | , test "fromISO8601 fails to parse invalid UTC datetimes" <| 89 | \() -> parseFails "1993-02-29T00:00:00Z" 90 | , test "fromISO8601 can parse valid UTC datetime strings" <| 91 | \() -> parseEq "1992-05-29T12:25:12Z" (dateTime { zero | year = 1992, month = 5, day = 29, hour = 12, minute = 25, second = 12 }) 92 | , test "fromISO8601 can parse valid UTC datetime strings including milliseconds" <| 93 | \() -> parseEq "1992-05-29T12:25:12.001Z" (dateTime { zero | year = 1992, month = 5, day = 29, hour = 12, minute = 25, second = 12, millisecond = 1 }) 94 | , test "fromISO8601 can parse valid UTC datetime strings including zero milliseconds" <| 95 | \() -> parseEq "1992-05-29T12:25:12.000Z" (dateTime { zero | year = 1992, month = 5, day = 29, hour = 12, minute = 25, second = 12, millisecond = 0 }) 96 | , test "fromISO8601 can parse valid UTC datetime strings including milliseconds and offset" <| 97 | \() -> parseEq "1992-05-29T12:25:12.001-04:00" (dateTime { zero | year = 1992, month = 5, day = 29, hour = 16, minute = 25, second = 12, millisecond = 1 }) 98 | , test "fromISO8601 can parse valid UTC datetime strings including zero milliseconds and offset" <| 99 | \() -> parseEq "1992-05-29T12:25:12.000-04:00" (dateTime { zero | year = 1992, month = 5, day = 29, hour = 16, minute = 25, second = 12, millisecond = 0 }) 100 | , test "fromISO8601 can parse valid fractions in the hundreds" <| 101 | \() -> parseMs "2016-11-14T03:56:12.123Z" 123 102 | , test "fromISO8601 can parse valid fractions in the tens" <| 103 | \() -> parseMs "2016-11-14T03:56:12.12Z" 120 104 | , test "fromISO8601 can parse valid fractions in the ones" <| 105 | \() -> parseMs "2016-11-14T03:56:12.1Z" 100 106 | , test "fromISO8601 can parse valid padded fractions in the tens" <| 107 | \() -> parseMs "2016-11-14T03:56:12.01Z" 10 108 | , test "fromISO8601 can parse valid padded fractions in the ones" <| 109 | \() -> parseMs "2016-11-14T03:56:12.001Z" 1 110 | , test "fromISO8601 can parse valid padded fractions in the zeros" <| 111 | \() -> parseMs "2016-11-14T03:56:12.000Z" 0 112 | , test "fromISO8601 fractions are capped at millisecond precision" <| 113 | \() -> parseMs "2016-11-14T03:56:12.12345Z" 123 114 | , test "fromISO8601 fractions are capped at millisecond precision with padding" <| 115 | \() -> parseMs "2016-11-14T03:56:12.0012345Z" 1 116 | , test "fromISO8601 fractions are capped at millisecond precision with padding 2" <| 117 | \() -> parseMs "2016-11-14T03:56:12.0001234Z" 0 118 | , test "fromISO8601 fractions can be all zeros" <| 119 | \() -> parseMs "2016-11-14T03:56:12.000Z" 0 120 | , test "fromISO8601 zero fractions with offsets" <| 121 | \() -> parseMs "2017-07-03T11:27:11.000-0400" 0 122 | , test "fromISO8601 fraction 001 with offsets" <| 123 | \() -> parseMs "2017-07-03T11:27:11.001-0400" 1 124 | , test "fromISO8601 of a valid year before 1000 (with dashes) is correct" <| 125 | \() -> parseEqDate "0301-10-02" (date 301 10 2) 126 | , test "fromISO8601 of a valid year before 1000 (without dashes) is correct" <| 127 | \() -> parseEqDate "00011002" (date 1 10 2) 128 | , test "toISO8601 of a valid year before 1000 is correct" <| 129 | \() -> 130 | Iso8601.fromDate (date 301 10 2) 131 | |> Expect.equal "0301-10-02" 132 | , test "toISO8601 should format 3-digit milliseconds" <| 133 | \() -> 134 | epoch 135 | |> setMillisecond 396 136 | |> Iso8601.fromDateTime 137 | |> Expect.equal "1970-01-01T00:00:00.396Z" 138 | , test "toISO8601 should format 2-digit milliseconds" <| 139 | \() -> 140 | epoch 141 | |> setMillisecond 96 142 | |> Iso8601.fromDateTime 143 | |> Expect.equal "1970-01-01T00:00:00.096Z" 144 | , test "toISO8601 should format 1-digit milliseconds" <| 145 | \() -> 146 | epoch 147 | |> setMillisecond 6 148 | |> Iso8601.fromDateTime 149 | |> Expect.equal "1970-01-01T00:00:00.006Z" 150 | , fuzz4 (intRange -23 23) (intRange 0 59) (oneOf [ constant "-", constant "−" ]) (oneOf [ constant ":", constant "" ]) "fromISO8601 parses offsets correctly" <| 151 | \hour minute negStr separator -> 152 | let 153 | ( sign, signStr ) = 154 | if hour <= 0 then 155 | ( -1, "+" ) 156 | else 157 | ( 1, negStr ) 158 | 159 | padded n = 160 | if abs n < 10 then 161 | "0" ++ toString (abs n) 162 | else 163 | toString (abs n) 164 | 165 | input = 166 | "1992-05-29T12:25:12" ++ signStr ++ padded hour ++ separator ++ padded minute 167 | 168 | output = 169 | dateTime { zero | year = 1992, month = 5, day = 29, hour = 12, minute = 25, second = 12 } 170 | |> addHours hour 171 | |> addMinutes (sign * minute) 172 | in 173 | parseEq input output 174 | , fuzz2 (oneOf [ constant "-", constant "" ]) (oneOf [ constant ":", constant "" ]) "fromISO8601 handles basic and extended time/date formats correctly" <| 175 | \dateSep timeSep -> 176 | let 177 | input = 178 | String.join dateSep [ "1992", "05", "29" ] ++ "T" ++ String.join timeSep [ "12", "25", "12" ] ++ "Z" 179 | 180 | output = 181 | dateTime { zero | year = 1992, month = 5, day = 29, hour = 12, minute = 25, second = 12 } 182 | in 183 | parseEq input output 184 | ] 185 | 186 | 187 | toFromDateISO8601 : Test 188 | toFromDateISO8601 = 189 | let 190 | renderEq : Date -> String -> () -> Expectation 191 | renderEq date output () = 192 | fromDate date 193 | |> Expect.equal output 194 | 195 | parseEq input date () = 196 | case ( toDate input, date ) of 197 | ( Err message, _ ) -> 198 | Expect.fail (toString message ++ " in input '" ++ input ++ "'") 199 | 200 | ( Ok date1, date2 ) -> 201 | if date1 == date2 then 202 | Expect.pass 203 | else 204 | Expect.fail ("expected '" ++ fromDate date1 ++ "' to equal '" ++ fromDate date2 ++ "' from input '" ++ input ++ "'") 205 | 206 | parseFails input () = 207 | case toDateTime input of 208 | Err _ -> 209 | Expect.pass 210 | 211 | Ok _ -> 212 | Expect.fail ("parsing '" ++ input ++ "' should have failed") 213 | in 214 | describe "Time.Date.{to,from}ISO8601" 215 | [ test "toISO8601 of epoch is correct" <| 216 | renderEq (date 1970 1 1) "1970-01-01" 217 | , test "toISO8601 of some date is correct" <| 218 | renderEq someDate "1992-05-29" 219 | , test "fromISO8601 of a valid date is correct" <| 220 | parseEq "1992-12-29" (date 1992 12 29) 221 | , test "fromISO8601 of a valid padded date is correct" <| 222 | parseEq "1992-05-29" (date 1992 5 29) 223 | , test "fromISO8601 of a valid padded date with no delimeters is correct" <| 224 | parseEq "19920529" (date 1992 5 29) 225 | , test "fromISO8601 of a badly-formatted date fails" <| 226 | parseFails "" 227 | , test "fromISO8601 of a badly-formatted date fails 2" <| 228 | parseFails "1992-05" 229 | , test "fromISO8601 of an invalid date fails" <| 230 | parseFails "1991-02-31" 231 | ] 232 | 233 | 234 | summerTimes : Test 235 | summerTimes = 236 | describe "Time.DateTime.{zonedDateTime,fromDateTime}" 237 | [ test "Zoned from DateTime applies correct offset before the end of summer time" <| 238 | \() -> 239 | DT.dateTime { zero | year = 2016, month = 10, day = 29, hour = 23, minute = 59 } 240 | |> ZonedDateTime.fromDateTime (europe_bucharest ()) 241 | |> fromZonedDateTime 242 | |> Expect.equal "2016-10-30T02:59:00.000+03:00" 243 | , test "Zoned from DateTime applies correct offset during the end of summer time" <| 244 | \() -> 245 | DT.dateTime { zero | year = 2016, month = 10, day = 30, hour = 0 } 246 | |> ZonedDateTime.fromDateTime (europe_bucharest ()) 247 | |> fromZonedDateTime 248 | |> Expect.equal "2016-10-30T03:00:00.000+02:00" 249 | , test "Zoned from DateTime applies correct offset after the end of summer time" <| 250 | \() -> 251 | DT.dateTime { zero | year = 2016, month = 10, day = 30, hour = 1 } 252 | |> ZonedDateTime.fromDateTime (europe_bucharest ()) 253 | |> fromZonedDateTime 254 | |> Expect.equal "2016-10-30T03:00:00.000+02:00" 255 | , test "Zoned constructor applies correct offset before the end of summer time" <| 256 | \() -> 257 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 2 } 258 | |> fromZonedDateTime 259 | |> Expect.equal "2016-10-30T02:00:00.000+03:00" 260 | , test "Zoned constructor applies correct offset during the end of summer time" <| 261 | \() -> 262 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 3 } 263 | |> fromZonedDateTime 264 | |> Expect.equal "2016-10-30T03:00:00.000+02:00" 265 | , test "Zoned constructor applies correct offset after the end of summer time" <| 266 | \() -> 267 | zonedDateTime (europe_bucharest ()) { zero | year = 2016, month = 10, day = 30, hour = 4 } 268 | |> fromZonedDateTime 269 | |> Expect.equal "2016-10-30T04:00:00.000+02:00" 270 | , test "toISO8601 should format 3-digit milliseconds" <| 271 | \() -> 272 | zonedDateTime (europe_bucharest ()) { zero | year = 2017, millisecond = 396 } 273 | |> fromZonedDateTime 274 | |> Expect.equal "2017-01-01T00:00:00.396+02:00" 275 | , test "toISO8601 should format 2-digit milliseconds" <| 276 | \() -> 277 | zonedDateTime (europe_bucharest ()) { zero | year = 2017, millisecond = 96 } 278 | |> fromZonedDateTime 279 | |> Expect.equal "2017-01-01T00:00:00.096+02:00" 280 | , test "toISO8601 should format 1-digit milliseconds" <| 281 | \() -> 282 | zonedDateTime (europe_bucharest ()) { zero | year = 2017, millisecond = 6 } 283 | |> fromZonedDateTime 284 | |> Expect.equal "2017-01-01T00:00:00.006+02:00" 285 | ] 286 | -------------------------------------------------------------------------------- /src/Time/Date.elm: -------------------------------------------------------------------------------- 1 | module Time.Date 2 | exposing 3 | ( Date 4 | , DateDelta 5 | , Weekday(..) 6 | , addDays 7 | , addMonths 8 | , addYears 9 | , compare 10 | , date 11 | , day 12 | , daysInMonth 13 | , delta 14 | , fromTuple 15 | , isLeapYear 16 | , isValidDate 17 | , month 18 | , setDay 19 | , setMonth 20 | , setYear 21 | , toTuple 22 | , weekday 23 | , year 24 | ) 25 | 26 | {-| This module defines a timezone-independent Date type which can 27 | represent any date of the proleptic Gregorian calendar. 28 | 29 | 30 | # Dates 31 | 32 | @docs Date 33 | 34 | 35 | # Constructing Dates 36 | 37 | @docs date, fromTuple, toTuple 38 | 39 | 40 | # Inspecting Dates 41 | 42 | @docs year, month, day, Weekday, weekday 43 | 44 | 45 | # Manipulating Dates 46 | 47 | @docs setYear, setMonth, setDay, addYears, addMonths, addDays 48 | 49 | 50 | # Comparing Dates 51 | 52 | @docs compare, DateDelta, delta 53 | 54 | 55 | # Helper functions 56 | 57 | @docs isValidDate, isLeapYear, daysInMonth 58 | 59 | -} 60 | 61 | 62 | {-| Date is the opaque type for all Date values. Values of this type 63 | are guaranteed to represent valid proleptic Gregorian calendar dates. 64 | -} 65 | type Date 66 | = Date 67 | { year : Int 68 | , month : Int 69 | , day : Int 70 | } 71 | 72 | 73 | {-| Data type used to represent the days of the week. 74 | -} 75 | type Weekday 76 | = Mon 77 | | Tue 78 | | Wed 79 | | Thu 80 | | Fri 81 | | Sat 82 | | Sun 83 | 84 | 85 | {-| DateDelta represents a delta between two dates. 86 | -} 87 | type alias DateDelta = 88 | { years : Int 89 | , months : Int 90 | , days : Int 91 | } 92 | 93 | 94 | {-| date constructs a Date value given a year, a month and a day. 95 | Invalid values are clamped to the nearest valid date. 96 | 97 | d : Date 98 | d = 99 | date 2018 5 29 100 | 101 | year d --> 2018 102 | month d --> 5 103 | day d --> 29 104 | 105 | -} 106 | date : Int -> Int -> Int -> Date 107 | date year month day = 108 | firstValid year (clampMonth month) (clampDay day) 109 | 110 | 111 | {-| year returns a Date's year as an Int. 112 | 113 | year (date 2018 5 26) 114 | --> 2018 115 | 116 | -} 117 | year : Date -> Int 118 | year (Date { year }) = 119 | year 120 | 121 | 122 | {-| month returns a Date's month as an Int. Guaranteed to be in the 123 | range [1, 12]. 124 | 125 | month (date 2018 13 26) -- Note month will be clamped 126 | --> 12 127 | 128 | -} 129 | month : Date -> Int 130 | month (Date { month }) = 131 | month 132 | 133 | 134 | {-| day returns a Date's year as an Int. Guaranteed to be valid for 135 | the Date's (year, month) pair and in the range [1, 31]. 136 | 137 | day (date 2018 2 28) 138 | --> 28 139 | 140 | day (date 2018 2 29) 141 | --> 28 -- observed clamped 142 | 143 | day (date 2000 2 29) 144 | --> 29 -- leap year 145 | 146 | -} 147 | day : Date -> Int 148 | day (Date { day }) = 149 | day 150 | 151 | 152 | {-| weekday returns the day of week for a given Date. 153 | 154 | This uses Sakamoto's method to determine the day of week. 155 | 156 | weekday (date 2018 5 26) 157 | --> Sat 158 | 159 | -} 160 | weekday : Date -> Weekday 161 | weekday (Date { year, month, day }) = 162 | let 163 | m = 164 | if month == 1 then 165 | 0 166 | else if month == 2 then 167 | 3 168 | else if month == 3 then 169 | 2 170 | else if month == 4 then 171 | 5 172 | else if month == 5 then 173 | 0 174 | else if month == 6 then 175 | 3 176 | else if month == 7 then 177 | 5 178 | else if month == 8 then 179 | 1 180 | else if month == 9 then 181 | 4 182 | else if month == 10 then 183 | 6 184 | else if month == 11 then 185 | 2 186 | else 187 | 4 188 | 189 | y = 190 | if month < 3 then 191 | year - 1 192 | else 193 | year 194 | 195 | d = 196 | (y + y // 4 - y // 100 + y // 400 + m + day) % 7 197 | in 198 | if d == 0 then 199 | Sun 200 | else if d == 1 then 201 | Mon 202 | else if d == 2 then 203 | Tue 204 | else if d == 3 then 205 | Wed 206 | else if d == 4 then 207 | Thu 208 | else if d == 5 then 209 | Fri 210 | else 211 | Sat 212 | 213 | 214 | {-| setYear updates a Date's year. Invalid values are clamped to the 215 | nearest valid date. 216 | 217 | date 2000 5 26 218 | |> setYear 2016 219 | |> year 220 | --> 2016 221 | 222 | -} 223 | setYear : Int -> Date -> Date 224 | setYear year (Date ({ month, day } as date)) = 225 | firstValid year month day 226 | 227 | 228 | {-| setMonth updates a Date's month. Invalid values are clamped to the 229 | nearest valid date. 230 | 231 | date 2016 5 26 232 | |> setMonth 6 233 | |> month 234 | --> 6 235 | 236 | date 2016 5 26 237 | |> setMonth 13 -- will be clamped 238 | |> month 239 | --> 12 240 | 241 | -} 242 | setMonth : Int -> Date -> Date 243 | setMonth month (Date ({ year, day } as date)) = 244 | firstValid year (clampMonth month) day 245 | 246 | 247 | {-| setDay updates a Date's day. Invalid values are clamped to the 248 | nearest valid date. 249 | 250 | date 2016 2 26 251 | |> setDay 28 252 | |> day 253 | --> 28 254 | 255 | date 2016 2 28 256 | |> setDay 29 -- leap year 257 | |> day 258 | --> 29 259 | 260 | date 2015 2 28 261 | |> setDay 29 -- clamped 262 | |> day 263 | --> 28 264 | 265 | -} 266 | setDay : Int -> Date -> Date 267 | setDay day (Date ({ year, month } as date)) = 268 | firstValid year month (clampDay day) 269 | 270 | 271 | {-| addYears adds a relative number (positive or negative) of years to 272 | a Date, ensuring that the return value represents a valid Date. If 273 | the new date is not valid, days are subtracted from it until a valid 274 | Date can be produced. 275 | 276 | date 2000 2 29 277 | |> addYears -1 -- will no longer be leap year 278 | |> day 279 | --> 28 280 | 281 | -} 282 | addYears : Int -> Date -> Date 283 | addYears years (Date ({ year, month, day } as date)) = 284 | firstValid (year + years) month day 285 | 286 | 287 | {-| addMonths adds a relative number (positive or negative) of months to 288 | a Date, ensuring that the return value represents a valid Date. Its 289 | semantics are the same as `addYears`. 290 | 291 | date 2018 3 31 292 | |> addMonths -1 -- Switch to Feb 293 | |> day 294 | --> 28 295 | 296 | -} 297 | addMonths : Int -> Date -> Date 298 | addMonths months (Date { year, month, day }) = 299 | let 300 | ms = 301 | year * 12 + month - 1 + months 302 | 303 | yo = 304 | if ms < 0 then 305 | -1 306 | else 307 | 0 308 | in 309 | date (((ms - yo) // 12) + yo) ((ms % 12) + 1) day 310 | 311 | 312 | {-| days adds an exact number (positive or negative) of days to a 313 | Date. Adding or subtracting days always produces a valid Date so 314 | there is no fuzzing logic here like there is in `add{Months,Years}`. 315 | 316 | date 2018 2 28 317 | |> addDays 1 318 | |> month 319 | --> 3 -- March 320 | 321 | -} 322 | addDays : Int -> Date -> Date 323 | addDays days (Date ({ year, month, day } as date)) = 324 | daysFromYearMonthDay year month day 325 | |> (+) days 326 | |> dateFromDays 327 | 328 | 329 | {-| compare two Dates. 330 | 331 | Note: since this conflicts with **Basics.compare**, have to 332 | preface with **Time.Date.**; see this example: 333 | 334 | date 2018 1 28 335 | |> addYears -1 336 | |> addMonths 1 337 | |> Time.Date.compare (date 2017 2 29) 338 | --> EQ 339 | 340 | -} 341 | compare : Date -> Date -> Order 342 | compare d1 d2 = 343 | Basics.compare (toTuple d1) (toTuple d2) 344 | 345 | 346 | {-| delta returns the relative number of years, months and days between two Dates. 347 | 348 | Each field is accumulative by itself. That is, `days` not only shows the 349 | difference caused by the two `day` entries in the two `Date` arguments, but 350 | also the added days caused by differences in `months` and `years`. For `months` 351 | and `years`, is the count across month and year change boundaries respectively; illustrated 352 | by last example below. 353 | 354 | -- 3 examples showing that, if the `year` and `month` 355 | -- are the same in the two `Date` values, then the 356 | -- `years` and `months` result values remain constant 357 | -- in spite of large differences in the two inputs' 358 | -- `day` setting: 359 | 360 | delta (date 2019 1 1) (date 2018 1 1) 361 | --> { years = 1 362 | --> , months = 12 363 | --> , days = 365 364 | --> } 365 | 366 | delta (date 2019 1 31) (date 2018 1 1) 367 | --> { years = 1 368 | --> , months = 12 369 | --> , days = 395 370 | --> } 371 | 372 | delta (date 2019 1 1) (date 2018 1 31) 373 | --> { years = 1 374 | --> , months = 12 375 | --> , days = 335 376 | --> } 377 | 378 | -- 1 day apart but from last day of year to first 379 | -- day of next year: 380 | 381 | delta (date 2019 1 1) (date 2018 12 31) 382 | --> { years = 1 383 | --> , months = 1 384 | --> , days = 1 385 | --> } 386 | 387 | -} 388 | delta : Date -> Date -> DateDelta 389 | delta (Date d1) (Date d2) = 390 | { years = d1.year - d2.year 391 | , months = (abs d1.year * 12 + d1.month) - (abs d2.year * 12 + d2.month) 392 | , days = 393 | daysFromYearMonthDay d1.year d1.month d1.day 394 | - daysFromYearMonthDay d2.year d2.month d2.day 395 | } 396 | 397 | 398 | {-| toTuple converts a Date value into a (year, month, day) tuple. 399 | This is useful if you want to use Dates as Dict keys. 400 | 401 | date 2018 5 26 402 | |> toTuple 403 | --> (2018, 5, 26) 404 | 405 | -} 406 | toTuple : Date -> ( Int, Int, Int ) 407 | toTuple (Date { year, month, day }) = 408 | ( year, month, day ) 409 | 410 | 411 | {-| fromTuple converts a (year, month, day) tuple into a Date value. 412 | 413 | (2018, 5, 26) 414 | |> fromTuple 415 | --> date 2018 5 26 416 | 417 | -} 418 | fromTuple : ( Int, Int, Int ) -> Date 419 | fromTuple ( year, month, day ) = 420 | date year month day 421 | 422 | 423 | {-| isValidDate returns True if the given year, month and day 424 | represent a valid date. 425 | 426 | NOTE: when you create a Date using `date`, it does not validate 427 | the `year`, `month`, or `day` used; rather it just clamps out-of-range 428 | values to "legal" values without notifying you. If you are worried 429 | about complete validation, pass the 3 values to this 430 | method first and it will validate it. This gives you a chance to 431 | abort creating a "bad" `Date`. 432 | 433 | isValidDate 2016 12 31 434 | --> True 435 | 436 | isValidDate 2016 12 32 437 | --> False 438 | 439 | isValidDate 2016 2 29 -- leap year 440 | --> True 441 | 442 | isValidDate 2018 2 29 -- not leap year 443 | --> False 444 | 445 | -} 446 | isValidDate : Int -> Int -> Int -> Bool 447 | isValidDate year month day = 448 | daysInMonth year month 449 | |> Maybe.map (\days -> day >= 1 && day <= days) 450 | |> Maybe.withDefault False 451 | 452 | 453 | {-| isLeapYear returns True if the given year is a leap year. The 454 | rules for leap years are as follows: 455 | 456 | - A year that is a multiple of 400 is a leap year. 457 | 458 | - A year that is a multiple of 100 but not of 400 is not a leap year. 459 | 460 | - A year that is a multiple of 4 but not of 100 is a leap year. 461 | 462 | isLeapYear 2016 463 | --> True 464 | 465 | isLeapYear 2018 466 | --> False 467 | 468 | isLeapYear 400 469 | --> True 470 | 471 | isLeapYear 500 472 | --> False 473 | 474 | isLeapYear (500 + 4) 475 | --> True 476 | 477 | -} 478 | isLeapYear : Int -> Bool 479 | isLeapYear y = 480 | y % 400 == 0 || y % 100 /= 0 && y % 4 == 0 481 | 482 | 483 | {-| daysInMonth returns the number of days in a month given a specific 484 | year, taking leap years into account. 485 | 486 | - A regular year has 365 days and the corresponding February has 28 days. 487 | 488 | - A leap year has 366 days and the corresponding February has 29 days. 489 | 490 | daysInMonth 2016 2 491 | --> Just 29 492 | 493 | daysInMonth 2018 2 494 | --> Just 28 495 | 496 | daysInMonth 2018 13 -- month out of range 497 | --> Nothing 498 | 499 | -} 500 | daysInMonth : Int -> Int -> Maybe Int 501 | daysInMonth y m = 502 | if m >= 1 && m <= 12 then 503 | Just <| unsafeDaysInMonth y m 504 | else 505 | Nothing 506 | 507 | 508 | unsafeDaysInMonth : Int -> Int -> Int 509 | unsafeDaysInMonth y m = 510 | if m == 1 then 511 | 31 512 | else if m == 2 && isLeapYear y then 513 | 29 514 | else if m == 2 then 515 | 28 516 | else if m == 3 then 517 | 31 518 | else if m == 4 then 519 | 30 520 | else if m == 5 then 521 | 31 522 | else if m == 6 then 523 | 30 524 | else if m == 7 then 525 | 31 526 | else if m == 8 then 527 | 31 528 | else if m == 9 then 529 | 30 530 | else if m == 10 then 531 | 31 532 | else if m == 11 then 533 | 30 534 | else if m == 12 then 535 | 31 536 | else 537 | Debug.crash <| "invalid call to unsafeDaysInMonth: year=" ++ toString y ++ " month=" ++ toString m 538 | 539 | 540 | firstValid : Int -> Int -> Int -> Date 541 | firstValid year month day = 542 | let 543 | ( y, m, d ) = 544 | if isValidDate year month day then 545 | ( year, month, day ) 546 | else if isValidDate year month (day - 1) then 547 | ( year, month, day - 1 ) 548 | else if isValidDate year month (day - 2) then 549 | ( year, month, day - 2 ) 550 | else 551 | ( year, month, day - 3 ) 552 | in 553 | Date { year = y, month = m, day = d } 554 | 555 | 556 | daysFromYearMonthDay : Int -> Int -> Int -> Int 557 | daysFromYearMonthDay year month day = 558 | let 559 | yds = 560 | daysFromYear year 561 | 562 | mds = 563 | daysFromYearMonth year month 564 | 565 | dds = 566 | day - 1 567 | in 568 | yds + mds + dds 569 | 570 | 571 | daysFromYearMonth : Int -> Int -> Int 572 | daysFromYearMonth year month = 573 | let 574 | go year month acc = 575 | if month == 0 then 576 | acc 577 | else 578 | go year (month - 1) (acc + unsafeDaysInMonth year month) 579 | in 580 | go year (month - 1) 0 581 | 582 | 583 | daysFromYear : Int -> Int 584 | daysFromYear y = 585 | if y > 0 then 586 | 366 587 | + ((y - 1) * 365) 588 | + ((y - 1) // 4) 589 | - ((y - 1) // 100) 590 | + ((y - 1) // 400) 591 | else if y < 0 then 592 | (y * 365) 593 | + (y // 4) 594 | - (y // 100) 595 | + (y // 400) 596 | else 597 | 0 598 | 599 | 600 | yearFromDays : Int -> Int 601 | yearFromDays ds = 602 | let 603 | y = 604 | ds // 365 605 | 606 | d = 607 | daysFromYear y 608 | in 609 | if ds <= d then 610 | y - 1 611 | else 612 | y 613 | 614 | 615 | dateFromDays : Int -> Date 616 | dateFromDays ds = 617 | let 618 | d400 = 619 | daysFromYear 400 620 | 621 | y400 = 622 | ds // d400 623 | 624 | d = 625 | rem ds d400 626 | 627 | year = 628 | yearFromDays (d + 1) 629 | 630 | leap = 631 | if isLeapYear year then 632 | (+) 1 633 | else 634 | identity 635 | 636 | doy = 637 | d - daysFromYear year 638 | 639 | ( month, day ) = 640 | if doy < 31 then 641 | ( 1, doy + 1 ) 642 | else if doy < leap 59 then 643 | ( 2, doy - 31 + 1 ) 644 | else if doy < leap 90 then 645 | ( 3, doy - leap 59 + 1 ) 646 | else if doy < leap 120 then 647 | ( 4, doy - leap 90 + 1 ) 648 | else if doy < leap 151 then 649 | ( 5, doy - leap 120 + 1 ) 650 | else if doy < leap 181 then 651 | ( 6, doy - leap 151 + 1 ) 652 | else if doy < leap 212 then 653 | ( 7, doy - leap 181 + 1 ) 654 | else if doy < leap 243 then 655 | ( 8, doy - leap 212 + 1 ) 656 | else if doy < leap 273 then 657 | ( 9, doy - leap 243 + 1 ) 658 | else if doy < leap 304 then 659 | ( 10, doy - leap 273 + 1 ) 660 | else if doy < leap 334 then 661 | ( 11, doy - leap 304 + 1 ) 662 | else 663 | ( 12, doy - leap 334 + 1 ) 664 | in 665 | Date 666 | { year = year + y400 * 400 667 | , month = month 668 | , day = day 669 | } 670 | 671 | 672 | clampMonth : Int -> Int 673 | clampMonth month = 674 | clamp 1 12 month 675 | 676 | 677 | clampDay : Int -> Int 678 | clampDay day = 679 | clamp 1 31 day 680 | -------------------------------------------------------------------------------- /src/Time/DateTime.elm: -------------------------------------------------------------------------------- 1 | module Time.DateTime 2 | exposing 3 | ( DateTime 4 | , DateTimeDelta 5 | , addDays 6 | , addHours 7 | , addMilliseconds 8 | , addMinutes 9 | , addMonths 10 | , addSeconds 11 | , addYears 12 | , compare 13 | , date 14 | , dateTime 15 | , day 16 | , delta 17 | , epoch 18 | , fromTimestamp 19 | , fromTuple 20 | , hour 21 | , isValidTime 22 | , millisecond 23 | , minute 24 | , makeDateTime 25 | , month 26 | , second 27 | , setDate 28 | , setDay 29 | , setHour 30 | , setMillisecond 31 | , setMinute 32 | , setMonth 33 | , setSecond 34 | , setYear 35 | , toISO8601 36 | , toTimestamp 37 | , toTuple 38 | , weekday 39 | , year 40 | , zero 41 | ) 42 | 43 | {-| This module defines a time representation based on a Date and the 44 | time of day. 45 | 46 | 47 | # DateTimes 48 | 49 | @docs DateTime 50 | 51 | 52 | # Constructing DateTimes 53 | 54 | @docs zero, epoch, dateTime, makeDateTime, fromTimestamp, toTimestamp, fromTuple, toTuple 55 | 56 | 57 | # Inspecting DateTimes 58 | 59 | @docs date, year, month, day, weekday, hour, minute, second, millisecond 60 | 61 | 62 | # Manipulating DateTimes 63 | 64 | @docs setDate, setYear, setMonth, setDay, setHour, setMinute, setSecond, setMillisecond, addYears, addMonths, addDays, addHours, addMinutes, addSeconds, addMilliseconds 65 | 66 | 67 | # Comparing DateTimes 68 | 69 | @docs compare, DateTimeDelta, delta 70 | 71 | 72 | # Helper functions 73 | 74 | @docs isValidTime 75 | 76 | 77 | # Deprecated 78 | 79 | @docs toISO8601 80 | 81 | -} 82 | 83 | import Time exposing (Time) 84 | import Time.Date exposing (Date, Weekday, isValidDate) 85 | import Time.Internal exposing (..) 86 | 87 | 88 | {-| DateTime is the opaque type for all DateTime values. Values of this 89 | type represent a valid Date and a time offset from midnight. 90 | -} 91 | type DateTime 92 | = DateTime 93 | { date : Date 94 | , offset : Int 95 | } 96 | 97 | 98 | {-| DateTimeDelta represents the difference between two 99 | DateTime values in terms of each of the different "units". 100 | 101 | See `Time.DateTime.delta` for an "aha!" example. 102 | 103 | -} 104 | type alias DateTimeDelta = 105 | { years : Int 106 | , months : Int 107 | , days : Int 108 | , hours : Int 109 | , minutes : Int 110 | , seconds : Int 111 | , milliseconds : Int 112 | } 113 | 114 | 115 | {-| zero represents the first millisecond of the first day of the 116 | current era. Use it to construct `DateTime` values: 117 | 118 | dateTime zero 119 | |> toISO8601 120 | --> "0-01-01T00:00:00.000Z" 121 | 122 | dateTime { zero | year = 2016 } 123 | |> toISO8601 124 | --> "2016-01-01T00:00:00.000Z" 125 | 126 | dateTime { zero | year = 2016, month = 5, day = 29, hour = 13 } 127 | |> toISO8601 128 | --> "2016-05-29T13:00:00.000Z" 129 | 130 | -} 131 | zero : DateTimeData 132 | zero = 133 | Time.Internal.zero 134 | 135 | 136 | {-| epoch is the instant in time that represents the first millisecond 137 | of the UNIX Epoch. 138 | 139 | epoch 140 | |> toISO8601 141 | --> "1970-01-01T00:00:00.000Z" 142 | 143 | -} 144 | epoch : DateTime 145 | epoch = 146 | dateTime { zero | year = 1970 } 147 | 148 | 149 | {-| dateTime constructs a DateTime value given a date and a time. 150 | Invalid values are clamped to the nearest valid date and time. 151 | 152 | import Time.Date 153 | 154 | dateTime { year = 2018 155 | , month = 13 -- will be clamped 156 | , day = 25 157 | , hour = 0 158 | , minute = 0 159 | , second = 0 160 | , millisecond = 47 161 | } 162 | |> date 163 | --> Time.Date.date 2018 12 25 164 | 165 | dateTime { year = 2018 166 | , month = 13 167 | , day = 25 168 | , hour = 0 169 | , minute = 0 170 | , second = 0 171 | , millisecond = 47 172 | } 173 | |> millisecond 174 | --> 47 175 | 176 | -} 177 | dateTime : DateTimeData -> DateTime 178 | dateTime ({ year, month, day } as data) = 179 | DateTime 180 | { date = Time.Date.date year month day 181 | , offset = offsetFromTimeData data 182 | } 183 | 184 | 185 | mkDateTime : Date -> TimeData d -> DateTime 186 | mkDateTime date time = 187 | DateTime { date = date, offset = offsetFromTimeData time } 188 | 189 | 190 | {-| Create a DateTime given its date and millisecond offset 191 | 192 | import Time.Date 193 | 194 | makeDateTime (Time.Date.date 2018 13 26) 1047 195 | |> date 196 | --> Time.Date.date 2018 13 26 197 | 198 | makeDateTime (Time.Date.date 2018 13 26) 1047 199 | |> millisecond 200 | --> 47 201 | 202 | makeDateTime (Time.Date.date 2018 13 26) 1047 203 | |> second 204 | --> 1 205 | 206 | -} 207 | makeDateTime : Date -> Int -> DateTime 208 | makeDateTime date offset = 209 | DateTime { date = date, offset = offset } 210 | 211 | 212 | {-| date returns a DateTime's Date. 213 | 214 | import Time.Date 215 | 216 | dateTime { year = 2018 217 | , month = 0 -- will be clamped 218 | , day = 25 219 | , hour = 0 220 | , minute = 0 221 | , second = 0 222 | , millisecond = 47 223 | } 224 | |> date 225 | --> Time.Date.date 2018 1 25 226 | 227 | -} 228 | date : DateTime -> Date 229 | date (DateTime { date }) = 230 | date 231 | 232 | 233 | {-| year returns a DateTime's year. 234 | 235 | dateTime { zero | year = 2015 } 236 | |> year 237 | --> 2015 238 | 239 | -} 240 | year : DateTime -> Int 241 | year (DateTime { date }) = 242 | Time.Date.year date 243 | 244 | 245 | {-| month returns a DateTime's month. 246 | 247 | dateTime { zero | month = 7 } 248 | |> month 249 | --> 7 250 | 251 | dateTime { zero | month = 0 } -- will be clamped 252 | |> month 253 | --> 1 254 | 255 | -} 256 | month : DateTime -> Int 257 | month (DateTime { date }) = 258 | Time.Date.month date 259 | 260 | 261 | {-| day returns a DateTime's day. 262 | 263 | dateTime { zero | day = 31 } 264 | |> day 265 | --> 31 266 | 267 | dateTime { zero | day = 32 } -- will be clamped 268 | |> day 269 | --> 31 270 | 271 | -} 272 | day : DateTime -> Int 273 | day (DateTime { date }) = 274 | Time.Date.day date 275 | 276 | 277 | {-| weekday returns a DateTime's day of the week. 278 | 279 | import Time.Date 280 | 281 | dateTime { zero | year = 2018, month = 5, day = 27 } 282 | |> weekday 283 | --> Time.Date.Sun 284 | 285 | -} 286 | weekday : DateTime -> Weekday 287 | weekday (DateTime { date }) = 288 | Time.Date.weekday date 289 | 290 | 291 | {-| hour returns a DateTime's hour. 292 | 293 | dateTime { zero | hour = 23 } 294 | |> hour 295 | --> 23 296 | 297 | dateTime { zero | hour = 24 } -- will be clamped 298 | |> hour 299 | --> 23 300 | 301 | -} 302 | hour : DateTime -> Int 303 | hour (DateTime { offset }) = 304 | offset // hourMs 305 | 306 | 307 | {-| minute returns a DateTime's minute. 308 | 309 | dateTime { zero | minute = 59 } 310 | |> minute 311 | --> 59 312 | 313 | dateTime { zero | minute = 60 } -- will be clamped 314 | |> minute 315 | --> 59 316 | 317 | -} 318 | minute : DateTime -> Int 319 | minute (DateTime { offset }) = 320 | (offset % hourMs) // minuteMs 321 | 322 | 323 | {-| second returns a DateTime's second. 324 | 325 | dateTime { zero | second = 59 } 326 | |> second 327 | --> 59 328 | 329 | dateTime { zero | second = 60 } -- will be clamped 330 | |> second 331 | --> 59 332 | 333 | -} 334 | second : DateTime -> Int 335 | second (DateTime { offset }) = 336 | (offset % hourMs % minuteMs) // secondMs 337 | 338 | 339 | {-| millisecond returns a DateTime's millisecond. 340 | 341 | dateTime { zero | millisecond = 999 } 342 | |> millisecond 343 | --> 999 344 | 345 | dateTime { zero | millisecond = 1000 } -- will be clamped 346 | |> millisecond 347 | --> 999 348 | 349 | -} 350 | millisecond : DateTime -> Int 351 | millisecond (DateTime { offset }) = 352 | offset % hourMs % minuteMs % secondMs 353 | 354 | 355 | {-| setDate sets a DateTime's Date. 356 | 357 | import Time.Date as TD 358 | 359 | dateTime zero 360 | |> setDate (TD.date 2018 5 27) 361 | |> date 362 | --> TD.date 2018 5 27 363 | 364 | -} 365 | setDate : Date -> DateTime -> DateTime 366 | setDate date (DateTime { offset }) = 367 | DateTime 368 | { date = date 369 | , offset = offset 370 | } 371 | 372 | 373 | {-| setYear sets a DateTime's year. 374 | 375 | See also `Time.Date.setYear`. 376 | 377 | dateTime zero 378 | |> setYear 2018 379 | |> year 380 | --> 2018 381 | 382 | -} 383 | setYear : Int -> DateTime -> DateTime 384 | setYear year (DateTime { date, offset }) = 385 | DateTime 386 | { date = Time.Date.setYear year date 387 | , offset = offset 388 | } 389 | 390 | 391 | {-| setMonth sets a DateTime's month. 392 | 393 | See also `Time.Date.setMonth`. 394 | 395 | dateTime zero 396 | |> setMonth 12 397 | |> month 398 | --> 12 399 | 400 | -} 401 | setMonth : Int -> DateTime -> DateTime 402 | setMonth month (DateTime { date, offset }) = 403 | DateTime 404 | { date = Time.Date.setMonth month date 405 | , offset = offset 406 | } 407 | 408 | 409 | {-| setDay sets a DateTime's day. 410 | 411 | See also `Time.Date.setDay`. 412 | 413 | dateTime zero 414 | |> setDay 31 415 | |> day 416 | --> 31 417 | 418 | -} 419 | setDay : Int -> DateTime -> DateTime 420 | setDay day (DateTime { date, offset }) = 421 | DateTime 422 | { date = Time.Date.setDay day date 423 | , offset = offset 424 | } 425 | 426 | 427 | {-| setHour sets a DateTime's hour. 428 | 429 | dateTime zero 430 | |> setHour 23 431 | |> hour 432 | --> 23 433 | 434 | -} 435 | setHour : Int -> DateTime -> DateTime 436 | setHour hour ((DateTime { date }) as t) = 437 | mkDateTime date 438 | { hour = hour 439 | , minute = minute t 440 | , second = second t 441 | , millisecond = millisecond t 442 | } 443 | 444 | 445 | {-| setMinute sets a DateTime's minute. 446 | 447 | dateTime zero 448 | |> setMinute 59 449 | |> minute 450 | --> 59 451 | 452 | -} 453 | setMinute : Int -> DateTime -> DateTime 454 | setMinute minute ((DateTime { date }) as t) = 455 | mkDateTime date 456 | { hour = hour t 457 | , minute = minute 458 | , second = second t 459 | , millisecond = millisecond t 460 | } 461 | 462 | 463 | {-| setSecond sets a DateTime's second. 464 | dateTime zero 465 | |> setSecond 59 466 | |> second 467 | --> 59 468 | -} 469 | setSecond : Int -> DateTime -> DateTime 470 | setSecond second ((DateTime { date }) as t) = 471 | mkDateTime date 472 | { hour = hour t 473 | , minute = minute t 474 | , second = second 475 | , millisecond = millisecond t 476 | } 477 | 478 | 479 | {-| setMillisecond sets a DateTime's millisecond. 480 | 481 | dateTime zero 482 | |> setMillisecond 999 483 | |> millisecond 484 | --> 999 485 | 486 | -} 487 | setMillisecond : Int -> DateTime -> DateTime 488 | setMillisecond millisecond ((DateTime { date }) as t) = 489 | mkDateTime date 490 | { hour = hour t 491 | , minute = minute t 492 | , second = second t 493 | , millisecond = millisecond 494 | } 495 | 496 | 497 | {-| addYears adds a relative number of years to a DateTime value. 498 | 499 | See also `Time.Date.addYears`. 500 | 501 | dateTime { zero | year = 2016 } 502 | |> addYears 2 503 | |> year 504 | --> 2018 505 | 506 | -} 507 | addYears : Int -> DateTime -> DateTime 508 | addYears years (DateTime { date, offset }) = 509 | DateTime 510 | { date = Time.Date.addYears years date 511 | , offset = offset 512 | } 513 | 514 | 515 | {-| addMonths adds a relative number of months to a DateTime value. 516 | 517 | See also `Time.Date.addMonths`. 518 | 519 | dateTime { zero | month = 1 } 520 | |> addMonths 1 521 | |> month 522 | --> 2 523 | 524 | -} 525 | addMonths : Int -> DateTime -> DateTime 526 | addMonths months (DateTime { date, offset }) = 527 | DateTime 528 | { date = Time.Date.addMonths months date 529 | , offset = offset 530 | } 531 | 532 | 533 | {-| addDays adds a relative number of days to a DateTime value. 534 | 535 | See also `Time.Date.addDays`. 536 | 537 | dateTime { zero | day = 20 } 538 | |> addDays -11 539 | |> day 540 | --> 9 541 | 542 | -} 543 | addDays : Int -> DateTime -> DateTime 544 | addDays days (DateTime { date, offset }) = 545 | DateTime 546 | { date = Time.Date.addDays days date 547 | , offset = offset 548 | } 549 | 550 | 551 | {-| addHours adds a relative number of hours to a DateTime value. 552 | 553 | dateTime { zero | hour = 23 } 554 | |> addHours 1 555 | |> hour 556 | --> 0 557 | 558 | -} 559 | addHours : Int -> DateTime -> DateTime 560 | addHours hours time = 561 | addMilliseconds (hours * hourMs) time 562 | 563 | 564 | {-| addMinutes adds a relative number of minutes to a DateTime value. 565 | 566 | dateTime { zero | minute = 30 } 567 | |> addMinutes 30 568 | |> minute 569 | --> 0 570 | 571 | -} 572 | addMinutes : Int -> DateTime -> DateTime 573 | addMinutes minutes time = 574 | addMilliseconds (minutes * minuteMs) time 575 | 576 | 577 | {-| addSeconds adds a relative number of seconds to a DateTime value. 578 | 579 | dateTime { zero | second = 59 } 580 | |> addSeconds 1 581 | |> second 582 | --> 0 583 | 584 | -} 585 | addSeconds : Int -> DateTime -> DateTime 586 | addSeconds seconds time = 587 | addMilliseconds (seconds * secondMs) time 588 | 589 | 590 | {-| addMilliseconds adds an absolute number of milliseconds to a 591 | DateTime value. 592 | 593 | dateTime { zero | second = 10, millisecond = 1 } 594 | |> addMilliseconds 999 595 | |> second 596 | --> 11 597 | 598 | -} 599 | addMilliseconds : Int -> DateTime -> DateTime 600 | addMilliseconds ms (DateTime { date, offset }) = 601 | let 602 | total = 603 | ms + offset 604 | 605 | ( days, newOffset ) = 606 | if total < 0 then 607 | let 608 | days = 609 | -(abs total // dayMs + 1) 610 | 611 | offset = 612 | rem total dayMs 613 | in 614 | if offset == 0 then 615 | ( days + 1, 0 ) 616 | else 617 | ( days, dayMs + rem offset dayMs ) 618 | else 619 | ( total // dayMs, rem total dayMs ) 620 | in 621 | DateTime 622 | { date = Time.Date.addDays days date 623 | , offset = newOffset 624 | } 625 | 626 | 627 | {-| compare two DateTimes. 628 | 629 | import Basics exposing (Order(..)) 630 | 631 | epoch 632 | |> addMilliseconds -1 633 | |> Time.DateTime.compare (dateTime 634 | { year = 1969 635 | , month = 12 636 | , day = 31 637 | , hour = 23 638 | , minute = 59 639 | , second = 59 640 | , millisecond = 999 641 | } 642 | ) 643 | --> EQ 644 | 645 | -} 646 | compare : DateTime -> DateTime -> Order 647 | compare dt1 dt2 = 648 | -- comparison of 7-tuples is not supported so we use toISO8601 instead 649 | Basics.compare (toISO8601 dt1) (toISO8601 dt2) 650 | 651 | 652 | {-| delta computes the relative difference between two DateTime values. 653 | See also `Time.Date.delta`. 654 | 655 | upper : DateTime 656 | upper = dateTime 657 | { year = 1970 658 | , month = 1 659 | , day = 1 660 | , hour = 0 661 | , minute = 0 662 | , second = 0 663 | , millisecond = 0 664 | } 665 | 666 | upper 667 | |> addYears -1 668 | |> delta upper 669 | --> { years = 1 670 | --> , months = 12 671 | --> , days = 365 672 | --> , hours = 8760 673 | --> , minutes = 525600 674 | --> , seconds = 31536000 675 | --> , milliseconds = 31536000000 676 | --> } 677 | 678 | -- Note what is counted is the number of transitions 679 | -- to get from one unit to another. Hence 680 | -- the following shows that the difference of 681 | -- 1 day makes a big difference when it separates 682 | -- Dec 31 and Jan 1 as shown here. Observe 683 | -- that years, months, and days are the same because 684 | -- each only requires one transition to get from 685 | -- one date to the other. 686 | 687 | upper 688 | |> addDays -1 689 | |> delta upper 690 | --> { years = 1 691 | --> , months = 1 692 | --> , days = 1 693 | --> , hours = 24 694 | --> , minutes = 1440 695 | --> , seconds = 86400 696 | --> , milliseconds = 86400000 697 | --> } 698 | 699 | -} 700 | delta : DateTime -> DateTime -> DateTimeDelta 701 | delta (DateTime t1) (DateTime t2) = 702 | let 703 | { years, months, days } = 704 | Time.Date.delta t1.date t2.date 705 | 706 | milliseconds = 707 | days * dayMs + (t1.offset - t2.offset) 708 | 709 | hours = 710 | milliseconds // hourMs 711 | 712 | minutes = 713 | milliseconds // minuteMs 714 | 715 | seconds = 716 | milliseconds // secondMs 717 | in 718 | { years = years 719 | , months = months 720 | , days = days 721 | , hours = hours 722 | , minutes = minutes 723 | , seconds = seconds 724 | , milliseconds = milliseconds 725 | } 726 | 727 | 728 | {-| isValidTime returns True if the given hour, minute, second and 729 | millisecond represent a valid time of day. 730 | 731 | isValidTime 24 0 0 0 732 | --> False 733 | 734 | isValidTime 23 59 59 999 735 | --> True 736 | 737 | -} 738 | isValidTime : Int -> Int -> Int -> Int -> Bool 739 | isValidTime hour minute second millisecond = 740 | hour >= 0 && hour < 24 && minute >= 0 && minute < 60 && second >= 0 && second < 60 && millisecond >= 0 && millisecond < 1000 741 | 742 | 743 | {-| toTimestamp converts a DateTime value to its UNIX timestamp 744 | representation as milliseconds. 745 | 746 | epoch 747 | |> toTimestamp 748 | --> 0.0 749 | 750 | -} 751 | toTimestamp : DateTime -> Time 752 | toTimestamp time = 753 | delta time epoch 754 | |> .milliseconds 755 | |> toFloat 756 | 757 | 758 | {-| fromTimestamp converts the millisecond representation of a 759 | UNIX timestamp into a DateTime value. 760 | 761 | fromTimestamp 0.0 762 | --> epoch 763 | 764 | -} 765 | fromTimestamp : Time -> DateTime 766 | fromTimestamp timestamp = 767 | addMilliseconds (round timestamp) epoch 768 | 769 | 770 | {-| toTuple converts a DateTime into a (year, month, day, hour, miunte, 771 | second, millisecond) tuple. 772 | 773 | DEPRECATED - unavailable in Elm 0.19 774 | 775 | toTuple epoch 776 | --> (1970, 1, 1, 0, 0, 0, 0) 777 | 778 | -} 779 | toTuple : DateTime -> ( Int, Int, Int, Int, Int, Int, Int ) 780 | toTuple ((DateTime { date }) as t) = 781 | let 782 | ( year, month, day ) = 783 | Time.Date.toTuple date 784 | in 785 | ( year, month, day, hour t, minute t, second t, millisecond t ) 786 | 787 | 788 | {-| fromTuple converts a (year, month, day, hour, minute, second, 789 | millisecond) tuple into a DateTime. 790 | 791 | DEPRECATED - unavailable in Elm 0.19 792 | 793 | fromTuple (1970, 1, 1, 0, 0, 0, 0) 794 | --> epoch 795 | 796 | -} 797 | fromTuple : ( Int, Int, Int, Int, Int, Int, Int ) -> DateTime 798 | fromTuple ( year, month, day, hour, minute, second, millisecond ) = 799 | dateTime 800 | { year = year 801 | , month = month 802 | , day = day 803 | , hour = hour 804 | , minute = minute 805 | , second = second 806 | , millisecond = millisecond 807 | } 808 | 809 | 810 | {-| toISO8601 renders a DateTime in ISO8601 format. 811 | 812 | DEPRECATED: this is used as a hack for the compare function above; it is not exposed -- 813 | use the functionality in Iso8601 instead. 814 | 815 | -} 816 | toISO8601 : DateTime -> String 817 | toISO8601 time = 818 | toString (year time) 819 | ++ "-" 820 | ++ padded (month time) 821 | ++ "-" 822 | ++ padded (day time) 823 | ++ "T" 824 | ++ padded (hour time) 825 | ++ ":" 826 | ++ padded (minute time) 827 | ++ ":" 828 | ++ padded (second time) 829 | ++ "." 830 | ++ padded3 (millisecond time) 831 | ++ "Z" 832 | --------------------------------------------------------------------------------