diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,355 @@
+Mozilla Public License Version 2.0
+==================================
+
+### 1. Definitions
+
+**1.1. “Contributor”**
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+**1.2. “Contributor Version”**
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+**1.3. “Contribution”**
+    means Covered Software of a particular Contributor.
+
+**1.4. “Covered Software”**
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+**1.5. “Incompatible With Secondary Licenses”**
+    means
+
+* **(a)** that the initial Contributor has attached the notice described
+    in Exhibit B to the Covered Software; or
+* **(b)** that the Covered Software was made available under the terms of
+    version 1.1 or earlier of the License, but not also under the
+    terms of a Secondary License.
+
+**1.6. “Executable Form”**
+    means any form of the work other than Source Code Form.
+
+**1.7. “Larger Work”**
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+**1.8. “License”**
+    means this document.
+
+**1.9. “Licensable”**
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+**1.10. “Modifications”**
+    means any of the following:
+
+* **(a)** any file in Source Code Form that results from an addition to,
+    deletion from, or modification of the contents of Covered
+    Software; or
+* **(b)** any new file in Source Code Form that contains any Covered
+    Software.
+
+**1.11. “Patent Claims” of a Contributor**
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+**1.12. “Secondary License”**
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+**1.13. “Source Code Form”**
+    means the form of the work preferred for making modifications.
+
+**1.14. “You” (or “Your”)**
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, “You” includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, “control” means **(a)** the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or **(b)** ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+
+### 2. License Grants and Conditions
+
+#### 2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+* **(a)** under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+* **(b)** under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+#### 2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+#### 2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+* **(a)** for any code that a Contributor has removed from Covered Software;
+    or
+* **(b)** for infringements caused by: **(i)** Your and any other third party's
+    modifications of Covered Software, or **(ii)** the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+* **(c)** under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+#### 2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+#### 2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+#### 2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+#### 2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+
+### 3. Responsibilities
+
+#### 3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+#### 3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+* **(a)** such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+* **(b)** You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+#### 3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+#### 3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+#### 3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+
+### 4. Inability to Comply Due to Statute or Regulation
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: **(a)** comply with
+the terms of this License to the maximum extent possible; and **(b)**
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+
+### 5. Termination
+
+**5.1.** The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated **(a)** provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and **(b)** on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+**5.2.** If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+**5.3.** In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+
+### 6. Disclaimer of Warranty
+
+> Covered Software is provided under this License on an “as is”
+> basis, without warranty of any kind, either expressed, implied, or
+> statutory, including, without limitation, warranties that the
+> Covered Software is free of defects, merchantable, fit for a
+> particular purpose or non-infringing. The entire risk as to the
+> quality and performance of the Covered Software is with You.
+> Should any Covered Software prove defective in any respect, You
+> (not any Contributor) assume the cost of any necessary servicing,
+> repair, or correction. This disclaimer of warranty constitutes an
+> essential part of this License. No use of any Covered Software is
+> authorized under this License except under this disclaimer.
+
+### 7. Limitation of Liability
+
+> Under no circumstances and under no legal theory, whether tort
+> (including negligence), contract, or otherwise, shall any
+> Contributor, or anyone who distributes Covered Software as
+> permitted above, be liable to You for any direct, indirect,
+> special, incidental, or consequential damages of any character
+> including, without limitation, damages for lost profits, loss of
+> goodwill, work stoppage, computer failure or malfunction, or any
+> and all other commercial damages or losses, even if such party
+> shall have been informed of the possibility of such damages. This
+> limitation of liability shall not apply to liability for death or
+> personal injury resulting from such party's negligence to the
+> extent applicable law prohibits such limitation. Some
+> jurisdictions do not allow the exclusion or limitation of
+> incidental or consequential damages, so this exclusion and
+> limitation may not apply to You.
+
+
+### 8. Litigation
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+
+### 9. Miscellaneous
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+
+### 10. Versions of the License
+
+#### 10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+#### 10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+#### 10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+#### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+## Exhibit A - Source Code Form License Notice
+
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+## Exhibit B - “Incompatible With Secondary Licenses” Notice
+
+    This Source Code Form is "Incompatible With Secondary Licenses", as
+    defined by the Mozilla Public License, v. 2.0.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# `flight-kml`
+
+Provides parsing of dumped tracklogs. In hang gliding and paragliding
+competitions when [FS](http://fs.fai.org/) and [GpsDump](http://www.gpsdump.no)
+are paired in competition mode a pilot's tracklog is dumped as
+[KML](https://developers.google.com/kml/). This is exlained in detail on the
+[FS wiki](http://fs.fai.org/trac/wiki/GpsDump/).
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,7 @@
+-- This script is used to build and install your package. Typically you don't
+-- need to change it. The Cabal documentation has more information about this
+-- file: <https://www.haskell.org/cabal/users-guide/installing-packages.html>.
+import qualified Distribution.Simple
+
+main :: IO ()
+main = Distribution.Simple.defaultMain
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,8 @@
+# 1.0.0
+
+Initial version with;
+
+* Data types for a single fix. This being made up of a time stamp, latitude,
+  longitude and GPS altitude with an optional barometric pressure altitude.
+* Data types for tracklogs made up of a series of fixes.
+* A `parse` function.
diff --git a/flight-kml.cabal b/flight-kml.cabal
new file mode 100644
--- /dev/null
+++ b/flight-kml.cabal
@@ -0,0 +1,140 @@
+-- This file has been generated from package.dhall by hpack version 0.27.0.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: 4169a64eda387613528861a1240648a12345b7b32bd35566a2ae3f719750caa5
+
+name:           flight-kml
+version:        1.0.0
+synopsis:       Parsing of pilot tracklogs dumped as KML.
+description:    Provides parsing of dumped tracklogs. In hang gliding and paragliding competitions when FS and GpsDump are paired in competition mode a pilot's tracklog is dumped as KML. This is exlained in detail on the FS wiki.
+category:       Data, Parsing, Geography, Gps, Flight, XML, KML
+homepage:       https://github.com/blockscope/flare-timing/tree/master/kml#readme
+bug-reports:    https://github.com/blockscope/flare-timing/issues
+author:         Phil de Joux
+maintainer:     phil.dejoux@blockscope.com
+copyright:      © 2017-2018 Phil de Joux, © 2017-2018 Block Scope Limited
+license:        MPL-2.0
+license-file:   LICENSE.md
+tested-with:    GHC == 8.2.2
+build-type:     Simple
+cabal-version:  >= 1.10
+
+extra-source-files:
+    changelog.md
+    package.dhall
+    README.md
+
+source-repository head
+  type: git
+  location: https://github.com/blockscope/flare-timing
+  subdir: kml
+
+library
+  exposed-modules:
+      Flight.Kml
+      Flight.Kml.Internal
+  other-modules:
+      Flight.Types
+      Paths_flight_kml
+  hs-source-dirs:
+      library
+  ghc-options: -Wall
+  build-depends:
+      aeson
+    , base >=4.5 && <5
+    , detour-via-sci
+    , hxt
+    , hxt-xpath
+    , parsec
+    , path
+    , siggy-chardust
+    , split
+    , time
+  default-language: Haskell2010
+
+test-suite doctest
+  type: exitcode-stdio-1.0
+  main-is: DocTest.hs
+  other-modules:
+      Flight.Kml
+      Flight.Kml.Internal
+      Flight.Types
+      Paths_flight_kml
+  hs-source-dirs:
+      library
+      test-suite-doctest
+  ghc-options: -Wall -rtsopts -threaded -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base
+    , detour-via-sci
+    , doctest
+    , hlint
+    , hxt
+    , hxt-xpath
+    , parsec
+    , path
+    , siggy-chardust
+    , split
+    , template-haskell
+    , time
+  default-language: Haskell2010
+
+test-suite hlint
+  type: exitcode-stdio-1.0
+  main-is: HLint.hs
+  other-modules:
+      Flight.Kml
+      Flight.Kml.Internal
+      Flight.Types
+      Paths_flight_kml
+  hs-source-dirs:
+      library
+      test-suite-hlint
+  ghc-options: -Wall -rtsopts -threaded -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base
+    , detour-via-sci
+    , hlint
+    , hxt
+    , hxt-xpath
+    , parsec
+    , path
+    , siggy-chardust
+    , split
+    , time
+  default-language: Haskell2010
+
+test-suite parse
+  type: exitcode-stdio-1.0
+  main-is: Parse.hs
+  other-modules:
+      Flight.Kml
+      Flight.Kml.Internal
+      Flight.Types
+      TestNewtypes
+      Paths_flight_kml
+  hs-source-dirs:
+      library
+      test-suite-parse
+  ghc-options: -Wall -rtsopts -threaded -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base
+    , detour-via-sci
+    , hxt
+    , hxt-xpath
+    , parsec
+    , path
+    , raw-strings-qq
+    , siggy-chardust
+    , smallcheck
+    , split
+    , tasty
+    , tasty-hunit
+    , tasty-quickcheck
+    , tasty-smallcheck
+    , time
+  default-language: Haskell2010
diff --git a/library/Flight/Kml.hs b/library/Flight/Kml.hs
new file mode 100644
--- /dev/null
+++ b/library/Flight/Kml.hs
@@ -0,0 +1,258 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE PartialTypeSignatures #-}
+{-# LANGUAGE FlexibleInstances #-}
+
+{-|
+Module: Flight.Kml
+Copyright:
+    © 2018 Phil de Joux
+    © 2018 Block Scope Limited
+License: MPL-2.0
+Maintainer: Phil de Joux <phil.dejoux@blockscope.com>
+Stability: experimental
+
+Provides parsing of dumped tracklogs. In hang gliding and paragliding
+competitions when <http://fs.fai.org/ FS> and <http://www.gpsdump.no GpsDump>
+are paired in competition mode a pilot's tracklog is dumped as
+<https://developers.google.com/kml/ KML>. This is exlained in detail on the
+<http://fs.fai.org/trac/wiki/GpsDump/ FS wiki>.
+-}
+module Flight.Kml
+    (
+    -- * Usage
+    -- $use
+    
+    -- * The newtypes
+      Seconds(..)
+    , Latitude(..)
+    , Longitude(..)
+    , Altitude(..)
+
+    -- * Typeclasses
+    , T.LatLngAlt(..)
+    , T.FixMark(..)
+
+    -- * Tracklog as a list of marked fixes
+    , LLA(..)
+    , mkPosition
+    , Fix(..)
+    , MarkedFixes(..)
+
+    -- * Parsing
+    , parse
+
+    -- * GPSDump KML
+    -- $kml
+    ) where
+
+import Text.XML.HXT.DOM.TypeDefs (XmlTree)
+import Text.XML.HXT.Core
+    ( ArrowXml
+    , (&&&)
+    , (>>>)
+    , (/>)
+    , (>>.)
+    , runX
+    , getText
+    , getAttrValue
+    , withValidate
+    , withWarnings
+    , readString
+    , no
+    , hasName
+    , getChildren
+    , hasAttrValue
+    , filterA
+    , listA
+    , arr
+    , orElse
+    , constA
+    )
+import Data.List (concatMap)
+import qualified Flight.Types as T (LatLngAlt(..), FixMark(..))
+import Flight.Types
+    ( LLA(..)
+    , Fix(..)
+    , MarkedFixes(..)
+    , Seconds(..)
+    , Latitude(..)
+    , Longitude(..)
+    , Altitude(..)
+    , mkPosition
+    )
+import Flight.Kml.Internal
+
+zipFixes :: [Seconds] -> [LLA] -> [Maybe Altitude] -> [Fix]
+zipFixes = zipWith3 Fix
+
+-- | Get the fixes. Some KML files don't have PressureAltitude.
+getFix :: ArrowXml a => a XmlTree (Maybe MarkedFixes)
+getFix =
+    getTrack
+    >>> (getFsInfo >>> getFirstTime)
+    &&& listA getCoord
+    &&& (getFsInfo >>> listA getTime)
+    &&& (getFsInfo >>> (listA getBaro `orElse` constA []))
+    >>> arr (\(t0, (cs, (ts, bs))) -> do
+        t0' <- t0
+        let bs' = if null bs then repeat Nothing else Just <$> bs
+        let fs = zipFixes ts cs bs'
+        return $ MarkedFixes t0' fs)
+    where
+        isMetadata =
+            getChildren
+            >>> hasName "Metadata"
+            >>> hasAttrValue "type" (== "track")
+
+        getTrack =
+            getChildren
+            >>> hasName "Document"
+            /> hasName "Folder"
+            /> hasName "Placemark"
+            >>> filterA isMetadata
+            >>. take 1
+
+        getFirstTime =
+            getAttrValue "time_of_first_point"
+            >>> arr parseUtcTime
+
+        getFsInfo =
+            getChildren
+            >>> hasName "Metadata"
+            /> hasName "FsInfo"
+            >>. take 1
+
+        getTime =
+            getChildren
+            >>> hasName "SecondsFromTimeOfFirstPoint"
+            /> getText
+            >>. concatMap parseTimeOffsets
+
+        getBaro =
+            getChildren
+            >>> hasName "PressureAltitude"
+            /> getText
+            >>. concatMap parseBaroMarks
+
+        getCoord =
+            getChildren
+            >>> hasName "LineString"
+            /> hasName "coordinates"
+            /> getText
+            >>. concatMap parseLngLatAlt
+
+-- | Parse the tracklog from the KML string as 'MarkedFixes'.
+-- 
+-- >>> Right MarkedFixes{mark0, fixes} <- parse kml
+-- >>> mark0
+-- 2012-01-14 02:12:55 UTC
+-- >>> length fixes
+-- 6547
+-- >>> head fixes
+-- Fix {fixMark = 0s, fix = LLA {llaLat = -33.36160000°, llaLng = 147.93205000°, llaAltGps = 237m}, fixAltBaro = Just 239m}
+-- >>> last fixes
+-- Fix {fixMark = 13103s, fix = LLA {llaLat = -33.65073300°, llaLng = 147.56036700°, llaAltGps = 214m}, fixAltBaro = Just 238m}
+parse :: String -> IO (Either String MarkedFixes)
+parse contents = do
+    let doc = readString [ withValidate no, withWarnings no ] contents
+    xs <- runX $ doc >>> getFix
+    return
+        $ case xs of
+            [Nothing] -> Left "Couldn't parse the marked fixes"
+            [Just x] -> Right x
+            _ -> Left $
+                    "Expected 1 set of marked fixes but got "
+                    ++ show (length xs)
+                    ++ "."
+
+-- $setup
+-- >>> :set -XTemplateHaskell
+-- >>> :set -XNamedFieldPuns
+-- >>> import Language.Haskell.TH
+-- >>> import Language.Haskell.TH.Syntax (lift)
+-- >>> import Flight.Kml
+-- >>> import Flight.Kml.Internal (showLatLngAlt, showLngLatAlt, showTimeAlt)
+-- :{
+-- embedStr :: IO String -> ExpQ
+-- embedStr readStr = lift =<< runIO readStr
+-- :}
+-- 
+-- >>> kml = $(embedStr (readFile "./test-suite-doctest/Phil de Joux.20120114-082221.21437.40.kml"))
+-- 
+
+-- $kml
+-- #kml#
+-- Here's an example of a tracklog dump from the last day of the Hang Gliding
+-- Pre-Worlds Forbes 2012. The flight instrument is
+-- a <http://www.flytec.com/6030.html Flytec 6030>. The pilot with
+-- <http://civlrankings.fai.org/FL.aspx?a=309&person_id=21437 CIVL ID 21437> is
+-- me, the author of this package, Phil de Joux.
+-- 
+-- @
+-- \<?xml version="1.0" encoding=\"UTF-8\"?\>
+-- \<Document\>
+-- \<open\>1\</open\>
+--   \<Folder\>
+--     \<Metadata src=\"GpsDump\" v="4.66" type="trip"/\>
+--     \<open\>1\</open\>
+--     \<name\>Trip\</nam\e>
+--     \<description\>\<![CDATA[Tracklog from GpsDump competition mode
+-- \<pre\>
+-- Flight statistics
+-- Date                 2012-01-14
+-- Start/finish         02:12:55 - 05:51:18
+-- Duration             3 : 38 : 23
+-- Max./min. height     2392 / 214 m
+-- Max. mean/top speed  72 km/h / 82 km/h
+-- Max/min climb rate   4.35 / -4.23 m/s over 60s
+-- Total distance       166.89 km
+-- \</pre\>]]\>
+--     \</description\>
+-- ...
+--     \<Placemark\>
+--       \<Metadata src=\"GpsDump\" v="4.66" type="track"\>
+--         \<FsInfo time_of_first_point="2012-01-14T02:12:55Z"
+--                 civl_pilot_id="21437" comp_pilot_id="40"
+--                 instrument="6030 SN06451 SW3.30"
+--                 downloaded="2012-01-14T08:22:21Z"
+--                 hash="61168B84FE0DAC55F3D65EFBA888B08F72834DDF"\>
+--           \<SecondsFromTimeOfFirstPoint\>
+-- 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48
+-- ...
+-- 13061 13063 13065 13067 13069 13071 13073 13075 13077 13079 13081 13083 13085 13087 13089 13091 13093 13095 13097 13099 13101 13103
+--           \</SecondsFromTimeOfFirstPoint\>
+--           \<PressureAltitude\>
+-- 239 240 240 239 239 239 239 239 239 240 239 240 239 239 240 239 240 240 240 240 239 239 240 240 240 
+-- ...
+-- 237 237 237 237 237 237 237 238 238 237 238 237 237 238 237 237 238 238 237 238 237 238
+--           \</PressureAltitude\>
+--         \</FsInfo\>
+--       \</Metadata\>
+--       \<name\>Tracklog\</name\>
+--       \<LineString\>
+--         \<altitudeMode\>absolute\</altitudeMode\>
+--         \<coordinates\>
+-- 147.932050,-33.361600,237 147.932050,-33.361600,238 147.932050,-33.361600,238 147.932050,-33.361600,238 147.932067,-33.361600,238 
+-- ...
+-- 147.560367,-33.650733,215 147.560367,-33.650733,215 147.560367,-33.650733,214 147.560367,-33.650733,214 147.560367,-33.650733,214 
+-- 147.560367,-33.650733,214 147.560367,-33.650733,214
+--         \</coordinates\>
+--       \</LineString\>
+--     \</Placemark\>
+--   \</Folder\>
+-- \</Document\>
+-- 
+-- @
+
+-- $use
+-- Working with the <#kml KML tracklog dump> from the tracklog file "__@Phil de Joux.20120114-082221.21437.40.kml@__".
+--
+-- >>> Right mf@(MarkedFixes{mark0, fixes}) <- parse kml
+-- >>> mark0
+-- 2012-01-14 02:12:55 UTC
+-- >>> length fixes
+-- 6547
+-- >>> head fixes
+-- Fix {fixMark = 0s, fix = LLA {llaLat = -33.36160000°, llaLng = 147.93205000°, llaAltGps = 237m}, fixAltBaro = Just 239m}
+-- >>> last fixes
+-- Fix {fixMark = 13103s, fix = LLA {llaLat = -33.65073300°, llaLng = 147.56036700°, llaAltGps = 214m}, fixAltBaro = Just 238m}
diff --git a/library/Flight/Kml/Internal.hs b/library/Flight/Kml/Internal.hs
new file mode 100644
--- /dev/null
+++ b/library/Flight/Kml/Internal.hs
@@ -0,0 +1,343 @@
+﻿{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE NamedFieldPuns #-}
+
+module Flight.Kml.Internal
+    (
+    -- * Internal Usage
+    -- $internal-use
+    
+    -- ** Display of a fix
+      showLatLngAlt
+    , showLngLatAlt
+    , showTimeAlt
+    
+    -- ** Length and range
+    , fixesLength
+    , fixesSecondsRange
+    , fixesUTCTimeRange
+
+    -- ** Display of fixes
+    , showFixesLength
+    , showFixesSecondsRange
+    , showFixesUTCTimeRange
+
+    -- * Parsing
+    , formatFloat
+    , roundTripLatLngAlt
+    , parseTimeOffsets
+    , parseBaroMarks
+    , parseLngLatAlt
+    , parseUtcTime
+
+    ) where
+
+import Data.List.Split (splitOn)
+import Numeric (showFFloat)
+import Data.Time.Clock (UTCTime, addUTCTime)
+import Data.Time.Format (parseTimeM, defaultTimeLocale)
+import Text.Parsec (string, parserZero)
+import Text.Parsec.Token as P
+import Text.Parsec.Char (spaces, digit, char)
+import Text.ParserCombinators.Parsec
+    ( GenParser
+    , (<?>)
+    , eof
+    , option
+    , sepBy
+    , count
+    , noneOf
+    , many
+    )
+import qualified Text.ParserCombinators.Parsec as P (parse)
+import Text.Parsec.Language (emptyDef)
+import Data.Functor.Identity (Identity)
+import Text.Parsec.Prim (ParsecT, parsecMap)
+import Flight.Types
+    ( Latitude(..), Longitude(..), Altitude(..), Seconds(..)
+    , LLA(..), Fix(..)
+    , MarkedFixes(..)
+    , FixMark(..)
+    )
+
+lexer :: GenTokenParser String u Identity
+lexer = P.makeTokenParser emptyDef
+
+pFloat:: ParsecT String u Identity Rational
+pFloat = parsecMap toRational $ P.float lexer 
+
+pNat :: ParsecT String u Identity Integer
+pNat = P.natural lexer 
+
+pNats :: GenParser Char st [Integer]
+pNats = do
+    _ <- spaces
+    xs <- pNat `sepBy` spaces
+    _ <- eof
+    return xs
+
+-- | Parses UTC time in the format yyyy-MM-ddThh:mm:ssZ.
+--
+-- >>> parseUtcTime "2012-01-14T08:22:21Z"
+-- Just 2012-01-14 08:22:21 UTC
+parseUtcTime :: String -> Maybe UTCTime
+parseUtcTime s =
+    case P.parse pUtcTimeZ "(stdin)" s of
+        Left _ -> Nothing
+        Right t -> Just t
+
+pUtcTimeZ :: GenParser Char st UTCTime
+pUtcTimeZ = do
+    ymd <- many $ noneOf "T"
+    _ <- char 'T'
+    hrs <- count 2 digit
+    _ <- char ':'
+    mins <- count 2 digit
+    _ <- char ':'
+    secs <- count 2 digit
+    zulu <- option "Z" (string "Z")
+
+    let s = mconcat [ymd, "T", hrs, ":", mins, ":", secs, zulu]
+    let t = parseTimeM False defaultTimeLocale "%FT%TZ" s
+
+    case t of
+        Nothing -> parserZero
+        Just t' -> return t'
+
+pFix :: GenParser Char st (Rational, Rational, Integer)
+pFix = do
+    -- NOTE: KML coordinates have a space between tuples.
+    -- lon,lat[,alt]
+    -- SEE: https://developers.google.com/kml/documentation/kmlreference#linestring
+    lngSign <- option id $ const negate <$> char '-'
+    lng <- pFloat <?> "No longitude"
+    _ <- char ','
+    latSign <- option id $ const negate <$> char '-'
+    lat <- pFloat <?> "No latitude"
+    _ <- char ','
+    altSign <- option id $ const negate <$> char '-'
+    alt <- pNat <?> "No altitude"
+    return (latSign lat, lngSign lng, altSign alt)
+
+pFixes :: GenParser Char st [ (Rational, Rational, Integer) ]
+pFixes = do
+    _ <- spaces
+    xs <- pFix `sepBy` spaces <?> "No fixes"
+    _ <- eof
+    return xs
+
+-- | Parse the list of time offsets.
+-- 
+-- >>> parseTimeOffsets "0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30"
+-- [0s,2s,4s,6s,8s,10s,12s,14s,16s,18s,20s,22s,24s,26s,28s,30s]
+parseTimeOffsets :: String -> [Seconds]
+parseTimeOffsets s =
+    case P.parse pNats "(stdin)" s of
+        Left _ -> []
+        Right xs -> Seconds <$> xs
+
+-- | Parse the list of barometric pressure altitudes.
+-- 
+-- >>> parseBaroMarks "239 240 240 239 239 239 239 239 239 240 239 240 239 239 240"
+-- [239m,240m,240m,239m,239m,239m,239m,239m,239m,240m,239m,240m,239m,239m,240m]
+parseBaroMarks :: String -> [Altitude]
+parseBaroMarks s =
+    case P.parse pNats "(stdin)" s of
+         Left _ -> []
+         Right xs -> Altitude <$> xs
+
+-- | Parse comma-separated triples of lng,lat,alt, each triple separated by
+-- spaces.
+--
+-- >>> parseLngLatAlt "147.932050,-33.361600,237 147.932050,-33.361600,238"
+-- [LLA {llaLat = -33.36160000°, llaLng = 147.93205000°, llaAltGps = 237m},LLA {llaLat = -33.36160000°, llaLng = 147.93205000°, llaAltGps = 238m}]
+parseLngLatAlt :: String -> [LLA]
+parseLngLatAlt s =
+    case P.parse pFixes "(stdin)" s of
+         Left _ -> []
+         Right xs ->
+             (\(lat, lng, alt) ->
+                 LLA
+                     (Latitude lat)
+                     (Longitude lng)
+                     (Altitude alt)) <$> xs
+
+-- | Avoids __@"0."@__ because ...
+-- 
+-- @
+-- > (read "0." :: Double)
+-- Exception: Prelude.read: no parse
+-- > (read "0.0" :: Double)
+-- 0.0
+-- @
+--
+-- >>> formatFloat "112.2334455"
+-- "112.233446"
+-- >>> formatFloat "0"
+-- "0.000000"
+-- >>> formatFloat "0."
+-- "0.000000"
+-- >>> formatFloat "0.0"
+-- "0.000000"
+formatFloat :: String -> String
+formatFloat s =
+    case splitOn "." s of
+         [ a, "" ] -> showFFloat (Just 6) (read a :: Double) ""
+         _ -> showFFloat (Just 6) (read s :: Double) ""
+
+-- | Shows relative time offset in seconds and altitude in metres.
+--
+-- >>> import Flight.Kml (mkPosition)
+-- >>> let lla = mkPosition (Latitude (-33.65073300), Longitude 147.56036700, Altitude 214)
+-- >>> showTimeAlt $ Fix (Seconds 0) lla Nothing
+-- "(0s,214m)"
+showTimeAlt :: Fix -> String
+showTimeAlt Fix{fixMark, fix} =
+    "(" ++ show s ++ "s," ++ show a ++ "m)"
+    where
+        Seconds s = fixMark
+        LLA{llaAltGps} = fix
+        Altitude a = llaAltGps
+
+-- | Shows lat,lng,alt.
+--
+-- >>> showLatLngAlt (Latitude (-33.65073300), Longitude 147.56036700, Altitude 214)
+-- "-33.650733,147.560367,214"
+showLatLngAlt :: (Latitude, Longitude, Altitude) -> String
+showLatLngAlt (Latitude lat, Longitude lng, Altitude alt) =
+    mconcat [ formatFloat $ show (fromRational lat :: Double)
+            , ","
+            , formatFloat $ show (fromRational lng :: Double)
+            , ","
+            , show alt
+            ]
+
+-- | Shows lng,lat,alt.
+--
+-- >>> showLngLatAlt (Latitude (-33.65073300), Longitude 147.56036700, Altitude 214)
+-- "147.560367,-33.650733,214"
+showLngLatAlt :: (Latitude, Longitude, Altitude) -> String
+showLngLatAlt (Latitude lat, Longitude lng, Altitude alt) =
+    mconcat [ formatFloat $ show (fromRational lng :: Double)
+            , ","
+            , formatFloat $ show (fromRational lat :: Double)
+            , ","
+            , show alt
+            ]
+
+-- | Round trip from rational to double and back to rational.
+-- 
+-- >>> roundTripLatLngAlt (Latitude (-33.65073300), Longitude 147.56036700, Altitude 214)
+-- (-33.650733,147.560367,214m)
+roundTripLatLngAlt :: (Latitude, Longitude, Altitude)
+                   -> (Double, Double, Altitude)
+roundTripLatLngAlt (Latitude lat, Longitude lng, alt) =
+    let lat' = read $ formatFloat $ show (fromRational lat :: Double)
+        lng' = read $ formatFloat $ show (fromRational lng :: Double)
+    in (lat', lng', alt)
+
+-- | The number of fixes in the track log.  There is a <#range fixesLength>
+-- example in the usage section.
+fixesLength :: MarkedFixes -> Int
+fixesLength MarkedFixes{fixes} =
+    length fixes
+
+-- | In the given list of fixes, the seconds offset of the first and last
+-- elements.  There is a <#range fixesSecondsRange> example in the usage
+-- section.
+fixesSecondsRange :: MarkedFixes -> Maybe (Seconds, Seconds)
+fixesSecondsRange MarkedFixes{fixes} =
+    case (fixes, reverse fixes) of
+        ([], _) -> Nothing
+        (_, []) -> Nothing
+        (x : _, y : _) -> Just (mark x, mark y)
+
+-- | In the given list of fixes, the UTC time of the first and last elements.
+-- There is a <#range fixesUTCTimeRange> example in the usage section.
+fixesUTCTimeRange :: MarkedFixes -> Maybe (UTCTime, UTCTime)
+fixesUTCTimeRange mf@MarkedFixes{mark0} =
+    rangeUTCTime mark0 <$> fixesSecondsRange mf
+
+-- | Shows the number of elements in the list of fixes, in the tracklog.  There
+-- is a <#showfixes showFixesLength> example in the usage section.
+showFixesLength :: MarkedFixes -> String
+showFixesLength = show . fixesLength
+
+-- | Shows the relative time range of the tracklog.  There is a
+-- <#showfixes showFixesSecondsRange> example in the usage section.
+showFixesSecondsRange :: MarkedFixes -> String
+showFixesSecondsRange mf =
+    maybe "[]" show (fixesSecondsRange mf)
+
+-- | Shows the absolute time range of the tracklog.  There is a
+-- <#showfixes showFixesUTCTimeRange> example in the usage section.
+showFixesUTCTimeRange :: MarkedFixes -> String
+showFixesUTCTimeRange mf@MarkedFixes{mark0} =
+    maybe "" (show . rangeUTCTime mark0) (fixesSecondsRange mf)
+
+-- | By providing the UTC time of the first fix, convert a relative time range
+-- of offset seconds into a time absolute time range of UTC times.
+rangeUTCTime :: UTCTime -> (Seconds, Seconds) -> (UTCTime, UTCTime)
+rangeUTCTime mark0 (Seconds s0, Seconds s1) =
+    let f secs = fromInteger secs `addUTCTime` mark0 in (f s0, f s1)
+
+-- $setup
+-- >>> :set -XTemplateHaskell
+-- >>> :set -XNamedFieldPuns
+-- >>> import Language.Haskell.TH
+-- >>> import Language.Haskell.TH.Syntax (lift)
+-- >>> import Flight.Kml
+-- >>> import Flight.Kml.Internal (showLatLngAlt, showLngLatAlt, showTimeAlt)
+-- :{
+-- embedStr :: IO String -> ExpQ
+-- embedStr readStr = lift =<< runIO readStr
+-- :}
+-- 
+-- >>> kml = $(embedStr (readFile "./test-suite-doctest/Phil de Joux.20120114-082221.21437.40.kml"))
+-- 
+
+-- $internal-use
+-- Working with the <Flight-Kml.html#kml KML tracklog dump> from the tracklog file "__@Phil de Joux.20120114-082221.21437.40.kml@__".
+--
+-- >>> Right mf@(MarkedFixes{mark0, fixes}) <- parse kml
+-- >>> mark0
+-- 2012-01-14 02:12:55 UTC
+-- >>> length fixes
+-- 6547
+-- >>> head fixes
+-- Fix {fixMark = 0s, fix = LLA {llaLat = -33.36160000°, llaLng = 147.93205000°, llaAltGps = 237m}, fixAltBaro = Just 239m}
+-- >>> last fixes
+-- Fix {fixMark = 13103s, fix = LLA {llaLat = -33.65073300°, llaLng = 147.56036700°, llaAltGps = 214m}, fixAltBaro = Just 238m}
+--
+-- #range#
+-- The length and range of the tracklog.
+--
+-- >>> fixesLength mf
+-- 6547
+-- >>> fixesSecondsRange mf
+-- Just (0s,13103s)
+-- >>> fixesUTCTimeRange mf
+-- Just (2012-01-14 02:12:55 UTC,2012-01-14 05:51:18 UTC)
+--
+-- #showfixes#
+-- Showing the fixes in the tracklog.
+--
+-- >>> showFixesLength mf
+-- "6547"
+-- >>> showFixesSecondsRange mf
+-- "(0s,13103s)"
+-- >>> showFixesUTCTimeRange mf
+-- "(2012-01-14 02:12:55 UTC,2012-01-14 05:51:18 UTC)"
+--
+-- Showing a single fix.
+--
+-- >>> let a = head fixes
+-- >>> let z = last fixes
+-- >>> let lla = (lat . fix $ a, lng . fix $ a, altGps . fix $ a)
+-- >>> showLatLngAlt lla
+-- "-33.361600,147.932050,237"
+-- >>> showLngLatAlt lla
+-- "147.932050,-33.361600,237"
+-- >>> showTimeAlt a
+-- "(0s,237m)"
+-- >>> showTimeAlt z
+-- "(13103s,214m)"
diff --git a/library/Flight/Types.hs b/library/Flight/Types.hs
new file mode 100644
--- /dev/null
+++ b/library/Flight/Types.hs
@@ -0,0 +1,141 @@
+﻿{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module Flight.Types
+    ( Fix(..)
+    , LLA(..)
+    , LatLngAlt(..)
+    , FixMark(..)
+    , Seconds(..)
+    , Latitude(..)
+    , Longitude(..)
+    , Altitude(..)
+    , MarkedFixes(..)
+    , mkPosition
+    ) where
+
+import Data.Time.Clock (UTCTime)
+import GHC.Generics (Generic)
+import Data.Aeson (ToJSON(..), FromJSON(..))
+
+import Data.Via.Scientific
+    ( DefaultDecimalPlaces(..)
+    , deriveDecimalPlaces, toSci, showSci, dpDegree
+    )
+
+-- | Latitude in degress.
+newtype Latitude = Latitude Rational
+    deriving (Eq, Generic)
+    deriving anyclass (ToJSON, FromJSON)
+
+-- | Longitude in degress.
+newtype Longitude = Longitude Rational
+    deriving (Eq, Generic)
+    deriving anyclass (ToJSON, FromJSON)
+
+-- | Altitude in metres.
+newtype Altitude = Altitude Integer
+    deriving (Eq, Ord, Generic)
+    deriving newtype Num
+    deriving anyclass (ToJSON, FromJSON)
+
+-- | The number of seconds offset from the time of the first fix.
+newtype Seconds = Seconds Integer
+    deriving (Eq, Ord, Generic)
+    deriving newtype Num
+    deriving anyclass (ToJSON, FromJSON)
+
+deriveDecimalPlaces dpDegree ''Latitude
+deriveDecimalPlaces dpDegree ''Longitude
+deriveDecimalPlaces dpDegree ''Altitude
+deriveDecimalPlaces dpDegree ''Seconds
+
+instance Show Latitude where
+    show x@(Latitude lat') =
+        showSci dp (toSci dp lat') ++ "°"
+            where
+                dp = defdp x
+
+instance Show Longitude where
+    show x@(Longitude lng') =
+        showSci dp (toSci dp lng') ++ "°"
+            where
+                dp = defdp x
+
+instance Show Altitude where
+    show (Altitude alt) = show alt ++ "m"
+
+instance Show Seconds where
+    show (Seconds sec) = show sec ++ "s"
+
+-- | Latitude, longitude and GPS altitude.  Use 'mkPosition' to construct a 'LLA'.
+data LLA =
+    LLA
+        { llaLat :: Latitude
+        , llaLng :: Longitude
+        , llaAltGps :: Altitude
+        }
+    deriving (Eq, Show, Generic)
+    deriving anyclass (ToJSON, FromJSON)
+
+-- | Constructs a 'LLA' from its parts.
+-- 
+-- >>> mkPosition (Latitude (-33.65073300), Longitude 147.56036700, Altitude 214)
+-- LLA {llaLat = -33.65073300°, llaLng = 147.56036700°, llaAltGps = 214m}
+mkPosition :: (Latitude, Longitude, Altitude) -> LLA
+mkPosition (lat', lng', alt') = LLA lat' lng' alt'
+
+-- | Latitude, longitude and GPS altitude with a relative time offset in
+-- seconds and possibly a barometric pressure altitude.
+data Fix =
+    Fix
+        { fixMark :: Seconds
+        -- ^ A mark in time, seconds offset from the first fix.
+        , fix :: LLA
+        -- ^ The coordinates of the fix, latitude, longitude and altitude.
+        , fixAltBaro :: Maybe Altitude
+        -- ^ The barometric pressure altitude of the fix.
+        }
+    deriving (Eq, Show, Generic)
+    deriving anyclass (ToJSON, FromJSON)
+
+-- | Class for a fix made up of latitude, longitude and GPS altitude.
+class LatLngAlt a where
+    lat :: a -> Latitude
+    lng :: a -> Longitude
+    altGps :: a -> Altitude
+
+instance LatLngAlt LLA where
+    lat LLA{llaLat} = llaLat
+    lng LLA{llaLng} = llaLng
+    altGps LLA{llaAltGps} = llaAltGps
+
+instance LatLngAlt Fix where
+    lat Fix{fix} = lat fix
+    lng Fix{fix} = lng fix
+    altGps Fix{fix} = altGps fix
+
+-- | Class for a tracklog relative fix, offset in seconds, with an optional
+-- barometric pressure altitude.
+class LatLngAlt a => FixMark a where
+    -- | Seconds offset from first fix.
+    mark :: a -> Seconds
+    -- | Barometric pressure altitude.
+    altBaro :: a -> Maybe Altitude
+
+instance FixMark Fix where
+    mark Fix{fixMark} = fixMark
+    altBaro Fix{fixAltBaro} = fixAltBaro
+
+-- | A tracklog is a list of fixes along with the UTC time of the first fix.
+data MarkedFixes =
+    MarkedFixes
+        { mark0 :: UTCTime -- ^ The UTC time of the first fix.
+        , fixes :: [Fix] -- ^ The fixes of the track log.
+        }
+    deriving (Eq, Show, Generic, ToJSON, FromJSON)
+
diff --git a/package.dhall b/package.dhall
new file mode 100644
--- /dev/null
+++ b/package.dhall
@@ -0,0 +1,106 @@
+  ./defaults.dhall 
+⫽ { name =
+      "flight-kml"
+  , version =
+      "1.0.0"
+  , synopsis =
+      "Parsing of pilot tracklogs dumped as KML."
+  , description =
+      "Provides parsing of dumped tracklogs. In hang gliding and paragliding competitions when FS and GpsDump are paired in competition mode a pilot's tracklog is dumped as KML. This is exlained in detail on the FS wiki."
+  , category =
+      "Data, Parsing, Geography, Gps, Flight, XML, KML"
+  , github =
+      "blockscope/flare-timing/kml"
+  , homepage =
+      "https://github.com/blockscope/flare-timing/tree/master/kml#readme"
+  , library =
+      { dependencies =
+          [ "base >=4.5 && <5"
+          , "split"
+          , "parsec"
+          , "hxt"
+          , "path"
+          , "hxt-xpath"
+          , "aeson"
+          , "time"
+          , "detour-via-sci"
+          , "siggy-chardust"
+          ]
+      , source-dirs =
+          "library"
+      , exposed-modules =
+          [ "Flight.Kml", "Flight.Kml.Internal" ]
+      }
+  , tests =
+      { doctest =
+          { dependencies =
+              [ "base"
+              , "split"
+              , "parsec"
+              , "hxt"
+              , "path"
+              , "hxt-xpath"
+              , "aeson"
+              , "time"
+              , "hlint"
+              , "detour-via-sci"
+              , "siggy-chardust"
+              , "template-haskell"
+              , "doctest"
+              ]
+          , ghc-options =
+              [ "-rtsopts", "-threaded", "-with-rtsopts=-N" ]
+          , main =
+              "DocTest.hs"
+          , source-dirs =
+              [ "library", "test-suite-doctest" ]
+          }
+      , hlint =
+          { dependencies =
+              [ "base"
+              , "split"
+              , "parsec"
+              , "hxt"
+              , "path"
+              , "hxt-xpath"
+              , "aeson"
+              , "time"
+              , "hlint"
+              , "detour-via-sci"
+              , "siggy-chardust"
+              ]
+          , ghc-options =
+              [ "-rtsopts", "-threaded", "-with-rtsopts=-N" ]
+          , main =
+              "HLint.hs"
+          , source-dirs =
+              [ "library", "test-suite-hlint" ]
+          }
+      , parse =
+          { dependencies =
+              [ "base"
+              , "split"
+              , "parsec"
+              , "hxt"
+              , "path"
+              , "hxt-xpath"
+              , "aeson"
+              , "time"
+              , "raw-strings-qq"
+              , "tasty"
+              , "tasty-hunit"
+              , "tasty-quickcheck"
+              , "tasty-smallcheck"
+              , "smallcheck"
+              , "detour-via-sci"
+              , "siggy-chardust"
+              ]
+          , ghc-options =
+              [ "-rtsopts", "-threaded", "-with-rtsopts=-N" ]
+          , main =
+              "Parse.hs"
+          , source-dirs =
+              [ "library", "test-suite-parse" ]
+          }
+      }
+  }
diff --git a/test-suite-doctest/DocTest.hs b/test-suite-doctest/DocTest.hs
new file mode 100644
--- /dev/null
+++ b/test-suite-doctest/DocTest.hs
@@ -0,0 +1,14 @@
+module Main (main) where
+
+import Test.DocTest (doctest)
+
+arguments :: [String]
+arguments =
+    [ "-isrc"
+    , "library/Flight/Kml.hs"
+    , "library/Flight/Types.hs"
+    , "library/Flight/Kml/Internal.hs"
+    ]
+
+main :: IO ()
+main = doctest arguments
diff --git a/test-suite-hlint/HLint.hs b/test-suite-hlint/HLint.hs
new file mode 100644
--- /dev/null
+++ b/test-suite-hlint/HLint.hs
@@ -0,0 +1,16 @@
+module Main (main) where
+
+import Language.Haskell.HLint (hlint)
+import System.Exit (exitFailure, exitSuccess)
+
+arguments :: [String]
+arguments =
+    [ "library"
+    , "test-suite-hlint"
+    , "test-suite-parse"
+    ]
+
+main :: IO ()
+main = do
+    hints <- hlint arguments
+    if null hints then exitSuccess else exitFailure
diff --git a/test-suite-parse/Parse.hs b/test-suite-parse/Parse.hs
new file mode 100644
--- /dev/null
+++ b/test-suite-parse/Parse.hs
@@ -0,0 +1,213 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Main (main) where
+
+import Test.Tasty (TestTree, testGroup, defaultMain)
+import Test.Tasty.SmallCheck as SC
+import Test.SmallCheck.Series as SC
+import Test.Tasty.QuickCheck as QC
+import Test.Tasty.HUnit as HU ((@?=), testCase)
+
+import Data.List.Split (split, dropBlanks, dropDelims, oneOf, chunksOf)
+import Text.RawString.QQ (r)
+import TestNewtypes ()
+
+import qualified Flight.Kml as K (LatLngAlt(lat, lng, altGps))
+import Flight.Kml
+    ( LLA
+    , Seconds(..)
+    , Latitude(..)
+    , Longitude(..)
+    , Altitude(..)
+    , mkPosition
+    )
+import Flight.Kml.Internal
+    ( parseTimeOffsets
+    , parseBaroMarks
+    , parseLngLatAlt
+    , showLngLatAlt
+    , showLatLngAlt
+    , roundTripLatLngAlt
+    )
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests = testGroup "Tests" [properties, unitTests]
+
+properties :: TestTree
+properties = testGroup "Properties" [scProps, qcProps]
+
+scProps :: TestTree
+scProps = testGroup "(checked by SmallCheck)"
+    [ SC.testProperty "Parse positive time offset from [ Int ]" $
+        \xs -> parseSeconds parseTimeOffsets $ SC.getPositive <$> xs
+
+    , SC.testProperty "Parse barometric pressure from [ Int ]" $
+        \xs -> parseAltitudes parseBaroMarks $ SC.getPositive <$> xs
+
+    , SC.testProperty "Parse lng,lat,alt triples from [ (Float, Float, Int) ]" $
+        SC.changeDepth (const 4) $
+        \xs -> parseTriples parseLngLatAlt $
+                (\(lng :: Double, lat :: Double, alt) ->
+                    let lat' = Latitude $ toRational lat
+                        lng' = Longitude $ toRational lng
+                        alt' = Altitude $ SC.getPositive alt
+                    in mkPosition (lat', lng', alt')) <$> xs
+    ]
+
+qcProps :: TestTree
+qcProps = testGroup "(checked by QuickCheck)"
+    [ QC.testProperty "Parse positive time offset from [ Int ]" $
+        \xs -> parseSeconds parseTimeOffsets $ QC.getPositive <$> xs
+
+    , QC.testProperty "Parse barometric pressure from [ Int ]" $
+        \xs -> parseAltitudes parseBaroMarks $ QC.getPositive <$> xs
+
+    -- WARNING: Failing test.
+    --   *** Failed! Falsifiable (after 2 tests):
+    --   [(0.3909011791596698,0.3011360590812839,Positive {getPositive = 1})]
+    --   Use --quickcheck-replay '1 TFGenR 00000002137B7589000000000001E848000000000000E21F000002878F6B1480 0 12 4 0' to reproduce.
+    , QC.testProperty "Parse lng,lat,alt triples from [ (Float, Float, Int) ]" $
+        \xs -> parseTriples parseLngLatAlt $
+                (\(lng :: Double, lat :: Double, alt) ->
+                    let lat' = Latitude $ toRational lat
+                        lng' = Longitude $ toRational lng
+                        alt' = Altitude $ QC.getPositive alt
+                    in mkPosition (lat', lng', alt')) <$> xs
+    ]
+
+unitTests :: TestTree
+unitTests = testGroup "Unit tests"
+    [ HU.testCase "Parse time (same length)" $
+        length parsedTimeOffsets @?= length expectedTimeOffsets
+
+    , HU.testCase "Parse baro (same length)" $
+        length parsedBaroMarks @?= length expectedBaroMarks
+
+    , HU.testCase "Parse coord (same length)" $
+        length parsedCoords @?= length expectedCoords
+
+    , HU.testCase "Parse time (as expected)" $
+        parsedTimeOffsets @?= expectedTimeOffsets
+
+    , HU.testCase "Parse baro (as expected)" $
+        parsedBaroMarks @?= expectedBaroMarks
+
+    , HU.testCase "Parse coord (as expected)" $
+        parsedCoords @?= expectedCoords
+
+    , HU.testCase "Show lat,lng,alt (0.0, 0.0, 1) (as expected)" $
+        showLngLatAlt (Latitude 0.0, Longitude 0.0, Altitude 1)
+        @?= "0.000000,0.000000,1"
+
+    , HU.testCase "Show lat,lng,alt (0.3909011791596698, 0.3011360590812839, 1) => (0.390901,0.301136,1 as expected)" $
+        showLatLngAlt (Latitude 0.3909011791596698, Longitude 0.3011360590812839, Altitude 1)
+        @?= "0.390901,0.301136,1"
+
+    , HU.testCase "Show lng,lat,alt (0.3909011791596698, 0.3011360590812839, 1) => (0.301136,0.390901,1 as expected)" $
+        showLngLatAlt (Latitude 0.3909011791596698, Longitude 0.3011360590812839, Altitude 1)
+        @?= "0.301136,0.390901,1"
+    ]
+
+parseAltitudes :: (String -> [Altitude]) -> [Altitude] -> Bool
+parseAltitudes parser xs =
+    let strings ::  [String]
+        strings = show . (\(Altitude alt) -> alt) <$> xs
+
+        parsed :: [Altitude]
+        parsed = parser $ unwords strings
+
+    in parsed == xs
+
+parseSeconds :: (String -> [Seconds]) -> [Seconds] -> Bool
+parseSeconds parser xs =
+    let strings ::  [String]
+        strings = show . (\(Seconds s) -> s) <$> xs
+
+        parsed :: [Seconds]
+        parsed = parser $ unwords strings
+
+    in parsed == xs
+
+parseTriples :: (String -> [LLA]) -> [LLA] -> Bool
+parseTriples parser xs =
+    let extractLatLngAlt :: LLA -> (Latitude, Longitude, Altitude)
+        extractLatLngAlt x = (K.lat x, K.lng x, K.altGps x)
+
+        ys :: [(Double, Double, Altitude)]
+        ys = roundTripLatLngAlt . extractLatLngAlt <$> xs
+
+        zs :: [LLA]
+        zs =
+            (\(lat, lng, alt) ->
+                let lat' = Latitude $ toRational lat
+                    lng' = Longitude $ toRational lng
+                in mkPosition (lat', lng', alt)) <$> ys
+
+        strings ::  [String]
+        strings = showLngLatAlt . extractLatLngAlt <$> xs
+
+        parsed :: [LLA]
+        parsed = parser $ unwords strings
+
+    in parsed == zs
+
+parsedTimeOffsets :: [Seconds]
+parsedTimeOffsets = parseTimeOffsets timeOffsetsToParse
+        
+expectedTimeOffsets :: [Seconds]
+expectedTimeOffsets =
+    (\x -> Seconds (read x :: Integer)) <$>
+        split (dropBlanks $ dropDelims $ oneOf " \n") timeOffsetsToParse
+
+timeOffsetsToParse :: String
+timeOffsetsToParse = [r|
+0 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95 100 105 110 115 120
+125 130 135 140 145 150 155 160 165 170 175 180 185 190 195 200 205 210 215 220 225 231 236 241 246 
+12519 12524 12529 12534 12539 12544 12549 12554 12559 12564 12569 12574 12579 12584 12589 12594 12599 12604 12609 12614 12619 12624 12629 12634 12639 
+12644 12649 12654 12659 12664 12669 12674 12679 12684 12689
+              |]
+
+parsedBaroMarks :: [Altitude]
+parsedBaroMarks = parseBaroMarks baroMarksToParse
+
+expectedBaroMarks :: [Altitude]
+expectedBaroMarks =
+    (\x -> Altitude (read x :: Integer)) <$>
+        split (dropBlanks $ dropDelims $ oneOf " \n") baroMarksToParse
+
+baroMarksToParse :: String
+baroMarksToParse = [r|
+221 221 221 221 221 221 221 221 221 221 221 221 221 221 221 222 222 222 222 222 222 222 222 222 222 
+222 222 221 225 232 246 262 268 274 279 290 305 321 340 356 372 389 401 409 418 422 422 430 428 442 
+399 397 393 384 376 368 360 350 343 340 330 319 313 313 311 311 311 311 311 311 311 312 312 312 312 
+312 312 312 312 312 312 312 312 312 312
+              |]
+
+parsedCoords :: [LLA]
+parsedCoords = parseLngLatAlt coordsToParse
+
+triple :: [String] -> [LLA]
+triple xs =
+    case xs of
+        [lng, lat, alt] ->
+            let lat' = toRational (read lat :: Double)
+                lng' = toRational (read lng :: Double)
+                alt' = read alt :: Integer
+             in [ mkPosition (Latitude lat', Longitude lng', Altitude alt') ]
+        _ -> []
+
+expectedCoords :: [LLA]
+expectedCoords = 
+    concatMap triple $ chunksOf 3 (split (dropBlanks $ dropDelims $ oneOf " ,\n") coordsToParse)
+
+coordsToParse :: String
+coordsToParse = [r|
+147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 
+147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 147.932417,-33.360950,241 
+147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277 
+147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277 147.932183,-33.708533,277
+              |]
diff --git a/test-suite-parse/TestNewtypes.hs b/test-suite-parse/TestNewtypes.hs
new file mode 100644
--- /dev/null
+++ b/test-suite-parse/TestNewtypes.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# lANGUAGE PatternSynonyms #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module TestNewtypes where
+
+-- NOTE: Avoid orphan instance warnings with these newtypes.
+
+import Test.SmallCheck.Series as SC
+import Test.Tasty.QuickCheck as QC
+
+import Flight.Types
+    ( Seconds(..)
+    , Latitude(..)
+    , Longitude(..)
+    , Altitude(..)
+    )
+
+instance Monad m => SC.Serial m Seconds where
+    series = cons1 Seconds
+
+instance QC.Arbitrary Seconds where
+    arbitrary = Seconds <$> arbitrary
+
+instance Monad m => SC.Serial m Altitude where
+    series = cons1 Altitude
+
+instance QC.Arbitrary Altitude where
+    arbitrary = Altitude <$> arbitrary
+
+instance Monad m => SC.Serial m Latitude where
+    series = cons1 Latitude
+
+instance QC.Arbitrary Latitude where
+    arbitrary = Latitude <$> arbitrary
+
+instance Monad m => SC.Serial m Longitude where
+    series = cons1 Longitude
+
+instance QC.Arbitrary Longitude where
+    arbitrary = Longitude <$> arbitrary
