diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.markdown
@@ -0,0 +1,4 @@
+# Change log
+
+Saturn follows the [Package Versioning Policy](https://pvp.haskell.org). You
+can find release notes [on GitHub](https://github.com/tfausak/saturn/releases).
diff --git a/LICENSE.markdown b/LICENSE.markdown
new file mode 100644
--- /dev/null
+++ b/LICENSE.markdown
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Taylor Fausak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.markdown b/README.markdown
new file mode 100644
--- /dev/null
+++ b/README.markdown
@@ -0,0 +1,9 @@
+# Saturn
+
+[![Workflow](https://github.com/tfausak/saturn/actions/workflows/workflow.yaml/badge.svg)](https://github.com/tfausak/saturn/actions/workflows/workflow.yaml)
+[![Hackage](https://img.shields.io/hackage/v/saturn)](https://hackage.haskell.org/package/saturn)
+[![Stackage](https://www.stackage.org/package/saturn/badge/nightly?label=stackage)](https://www.stackage.org/package/saturn)
+
+:ringed_planet: Handle POSIX cron schedules.
+
+See the documentation on Hackage: <https://hackage.haskell.org/package/saturn>.
diff --git a/saturn.cabal b/saturn.cabal
new file mode 100644
--- /dev/null
+++ b/saturn.cabal
@@ -0,0 +1,93 @@
+cabal-version:   2.2
+name:            saturn
+version:         0.1.0.0
+synopsis:        Handle POSIX cron schedules.
+description:     Saturn handles POSIX cron schedules.
+build-type:      Simple
+category:        Cron
+extra-doc-files:
+  CHANGELOG.markdown
+  README.markdown
+
+maintainer:      Taylor Fausak
+license:         MIT
+license-file:    LICENSE.markdown
+
+source-repository head
+  location: https://github.com/tfausak/saturn
+  type:     git
+
+flag pedantic
+  default: False
+  manual:  True
+
+common library
+  build-depends:
+    , base ^>=4.16.4 || ^>=4.17.0 || ^>=4.18.0
+    , parsec ^>=3.1.15
+    , time ^>=1.11.1 || ^>=1.12.2
+
+  default-extensions: FlexibleContexts
+  default-language:   Haskell2010
+  ghc-options:
+    -Weverything -Wno-all-missed-specialisations -Wno-implicit-prelude
+    -Wno-missed-specialisations -Wno-missing-deriving-strategies
+    -Wno-missing-export-lists -Wno-missing-kind-signatures
+    -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module
+    -Wno-safe -Wno-unsafe
+
+  if flag(pedantic)
+    ghc-options: -Werror
+
+common executable
+  import:        library
+  build-depends: saturn
+  ghc-options:   -rtsopts -threaded
+
+library
+  import:          library
+  build-depends:
+    , containers ^>=0.6.5
+    , text ^>=1.2.5 || ^>=2.0.2
+
+  exposed-modules:
+    Saturn
+    Saturn.Unstable.Extra.Int
+    Saturn.Unstable.Extra.Ord
+    Saturn.Unstable.Extra.Parsec
+    Saturn.Unstable.Extra.Time
+    Saturn.Unstable.Extra.Tuple
+    Saturn.Unstable.Match
+    Saturn.Unstable.Parse
+    Saturn.Unstable.Render
+    Saturn.Unstable.Type.Day
+    Saturn.Unstable.Type.Element
+    Saturn.Unstable.Type.Field
+    Saturn.Unstable.Type.Hour
+    Saturn.Unstable.Type.Minute
+    Saturn.Unstable.Type.Month
+    Saturn.Unstable.Type.Number
+    Saturn.Unstable.Type.Range
+    Saturn.Unstable.Type.Schedule
+    Saturn.Unstable.Type.Weekday
+    Saturn.Unstable.Type.Wildcard
+
+  hs-source-dirs:  source/library
+
+test-suite saturn-test-suite
+  import:         executable
+  build-depends:
+    , hspec ^>=2.11.2
+    , QuickCheck ^>=2.14.3
+
+  hs-source-dirs: source/test-suite
+  main-is:        Main.hs
+  other-modules:
+    Saturn.Unstable.Extra.IntSpec
+    Saturn.Unstable.Extra.OrdSpec
+    Saturn.Unstable.Extra.ParsecSpec
+    Saturn.Unstable.Extra.TimeSpec
+    Saturn.Unstable.Extra.TupleSpec
+    SaturnSpec
+
+  type:           exitcode-stdio-1.0
diff --git a/source/library/Saturn.hs b/source/library/Saturn.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn.hs
@@ -0,0 +1,64 @@
+-- | Saturn handles POSIX cron schedules, which are defined here:
+-- <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07>.
+--
+-- A cron schedule is specified with five fields, each separated with at least
+-- one space. Each field can either be a wildcard (represented by an asterisk),
+-- or it can be one or more elements separated by commas. Each element can
+-- either be a number or a range, which is two numbers separated by a hyphen.
+--
+-- In order, the fields represent:
+--
+-- 1. Minute, between 0 and 59.
+-- 2. Hour, between 0 and 23.
+-- 3. Day, between 1 and 31.
+-- 4. Month, between 1 and 12.
+-- 5. Weekday, between 0 and 6 where 0 represents Sunday.
+--
+-- Here is a more graphical representation of the fields:
+--
+-- >  +--------- Minute
+-- >  | +------- Hour
+-- >  | | +----- Day
+-- >  | | | +--- Month
+-- >  | | | | +- Weekday
+-- >  | | | | |
+-- > "* * * * *"
+--
+-- To get started, use 'Parse.fromText' to parse a 'Schedule.Schedule'. Then
+-- use 'Render.toText' to render it again. To see if the 'Schedule.Schedule'
+-- matches a certain time, use 'Match.isMatch'. To get the next time that
+-- matches a schedule, use 'Match.nextMatch'.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> let Right schedule = fromText "* * * * *"
+-- >>> toText schedule
+-- "* * * * *"
+-- >>> let utcTime = Data.Time.UTCTime (Data.Time.fromGregorian 1970 1 1) 0
+-- >>> isMatch utcTime schedule
+-- True
+-- >>> nextMatch utcTime schedule
+-- Just 1970-01-01 00:01:00 UTC
+module Saturn
+  ( -- * Types
+    Schedule.Schedule,
+
+    -- * Parsing
+    Parse.fromText,
+    Parse.fromLazyText,
+    Parse.fromString,
+
+    -- * Rendering
+    Render.toText,
+    Render.toLazyText,
+    Render.toString,
+
+    -- * Matching
+    Match.isMatch,
+    Match.nextMatch,
+  )
+where
+
+import qualified Saturn.Unstable.Match as Match
+import qualified Saturn.Unstable.Parse as Parse
+import qualified Saturn.Unstable.Render as Render
+import qualified Saturn.Unstable.Type.Schedule as Schedule
diff --git a/source/library/Saturn/Unstable/Extra/Int.hs b/source/library/Saturn/Unstable/Extra/Int.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Extra/Int.hs
@@ -0,0 +1,10 @@
+module Saturn.Unstable.Extra.Int where
+
+import qualified Data.Bits as Bits
+import qualified Data.Word as Word
+
+fromWord8 :: Word.Word8 -> Int
+fromWord8 = fromIntegral
+
+toWord8 :: Int -> Maybe Word.Word8
+toWord8 = Bits.toIntegralSized
diff --git a/source/library/Saturn/Unstable/Extra/Ord.hs b/source/library/Saturn/Unstable/Extra/Ord.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Extra/Ord.hs
@@ -0,0 +1,4 @@
+module Saturn.Unstable.Extra.Ord where
+
+within :: (Ord a) => (a, a) -> a -> Bool
+within (lo, hi) x = lo <= x && x <= hi
diff --git a/source/library/Saturn/Unstable/Extra/Parsec.hs b/source/library/Saturn/Unstable/Extra/Parsec.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Extra/Parsec.hs
@@ -0,0 +1,16 @@
+module Saturn.Unstable.Extra.Parsec where
+
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Text.Parsec as Parsec
+
+either ::
+  Parsec.ParsecT s u m a ->
+  Parsec.ParsecT s u m b ->
+  Parsec.ParsecT s u m (Either a b)
+either l r = fmap Left l Parsec.<|> fmap Right r
+
+sepByNE ::
+  Parsec.ParsecT s u m a ->
+  Parsec.ParsecT s u m sep ->
+  Parsec.ParsecT s u m (NonEmpty.NonEmpty a)
+sepByNE p s = (NonEmpty.:|) <$> p <*> Parsec.many (s *> p)
diff --git a/source/library/Saturn/Unstable/Extra/Time.hs b/source/library/Saturn/Unstable/Extra/Time.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Extra/Time.hs
@@ -0,0 +1,14 @@
+module Saturn.Unstable.Extra.Time where
+
+import qualified Data.Time as Time
+import qualified Data.Word as Word
+
+dayOfWeekToWord8 :: Time.DayOfWeek -> Word.Word8
+dayOfWeekToWord8 dayOfWeek = case dayOfWeek of
+  Time.Sunday -> 0
+  Time.Monday -> 1
+  Time.Tuesday -> 2
+  Time.Wednesday -> 3
+  Time.Thursday -> 4
+  Time.Friday -> 5
+  Time.Saturday -> 6
diff --git a/source/library/Saturn/Unstable/Extra/Tuple.hs b/source/library/Saturn/Unstable/Extra/Tuple.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Extra/Tuple.hs
@@ -0,0 +1,9 @@
+module Saturn.Unstable.Extra.Tuple where
+
+import qualified Data.Bifunctor as Bifunctor
+
+mapBoth :: (a -> b) -> (a, a) -> (b, b)
+mapBoth f = Bifunctor.bimap f f
+
+toSequence :: (Enum a) => (a, a) -> [a]
+toSequence = uncurry enumFromTo
diff --git a/source/library/Saturn/Unstable/Match.hs b/source/library/Saturn/Unstable/Match.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Match.hs
@@ -0,0 +1,89 @@
+module Saturn.Unstable.Match where
+
+import qualified Control.Monad as Monad
+import qualified Data.List as List
+import qualified Data.Maybe as Maybe
+import qualified Data.Set as Set
+import qualified Data.Time as Time
+import qualified Saturn.Unstable.Extra.Int as Int
+import qualified Saturn.Unstable.Extra.Time as Time
+import qualified Saturn.Unstable.Type.Day as Day
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Saturn.Unstable.Type.Hour as Hour
+import qualified Saturn.Unstable.Type.Minute as Minute
+import qualified Saturn.Unstable.Type.Month as Month
+import qualified Saturn.Unstable.Type.Schedule as Schedule
+import qualified Saturn.Unstable.Type.Weekday as Weekday
+import qualified Saturn.Unstable.Type.Wildcard as Wildcard
+
+-- | Returns 'True' if the given 'Time.UTCTime' matches the given
+-- 'Schedule.Schedule', otherwise returns 'False'.
+isMatch :: Time.UTCTime -> Schedule.Schedule -> Bool
+isMatch utcTime schedule = Maybe.fromMaybe False $ do
+  let time = Time.pastMidnight $ Time.utctDayTime utcTime
+  minute <- Int.toWord8 $ Time.todMin time
+  Monad.guard . Minute.isMatch minute $ Schedule.minute schedule
+
+  hour <- Int.toWord8 $ Time.todHour time
+  Monad.guard . Hour.isMatch hour $ Schedule.hour schedule
+
+  let date = Time.utctDay utcTime
+  let (_, monthOfYear, dayOfMonth) = Time.toGregorian date
+  month <- Int.toWord8 monthOfYear
+  Monad.guard . Month.isMatch month $ Schedule.month schedule
+
+  day <- Int.toWord8 dayOfMonth
+  let dayMatches = Day.isMatch day $ Schedule.day schedule
+  let weekday = Time.dayOfWeekToWord8 $ Time.dayOfWeek date
+  let weekdayMatches = Weekday.isMatch weekday $ Schedule.weekday schedule
+  Monad.guard $
+    if dayIsWildcard schedule || weekdayIsWildcard schedule
+      then dayMatches && weekdayMatches
+      else dayMatches || weekdayMatches
+
+  pure True
+
+dayIsWildcard :: Schedule.Schedule -> Bool
+dayIsWildcard = Field.isWildcard . Day.toField . Schedule.day
+
+weekdayIsWildcard :: Schedule.Schedule -> Bool
+weekdayIsWildcard = Field.isWildcard . Weekday.toField . Schedule.weekday
+
+-- | Looks for the first time after the given 'Time.UTCTime' that matches the
+-- given 'Schedule.Schedule'. Returns 'Nothing' if the 'Schedule.Schedule' only
+-- matches dates that cannot happen, like February 30th.
+nextMatch :: Time.UTCTime -> Schedule.Schedule -> Maybe Time.UTCTime
+nextMatch utcTime schedule =
+  if dayIsWildcard schedule || weekdayIsWildcard schedule
+    then incompleteNextMatch utcTime schedule
+    else do
+      let wildcard = Field.fromEither . Left $ Wildcard.fromUnit ()
+      day <- Day.fromField wildcard
+      weekday <- Weekday.fromField wildcard
+      Maybe.listToMaybe . List.sort $
+        Maybe.catMaybes
+          [ incompleteNextMatch utcTime schedule {Schedule.day = day},
+            incompleteNextMatch utcTime schedule {Schedule.weekday = weekday}
+          ]
+
+incompleteNextMatch :: Time.UTCTime -> Schedule.Schedule -> Maybe Time.UTCTime
+incompleteNextMatch utcTime schedule = Maybe.listToMaybe $ do
+  let oldDate = Time.utctDay utcTime
+  let (oldYear, _, _) = Time.toGregorian oldDate
+  year <- [oldYear .. oldYear + 8]
+  month <- fmap Int.fromWord8 . Set.toAscList . Month.expand $ Schedule.month schedule
+  day <- fmap Int.fromWord8 . Set.toAscList . Day.expand $ Schedule.day schedule
+  date <- Maybe.maybeToList $ Time.fromGregorianValid year month day
+  Monad.guard $ date >= oldDate
+  Monad.guard
+    . Set.member (Time.dayOfWeekToWord8 $ Time.dayOfWeek date)
+    . Weekday.expand
+    $ Schedule.weekday schedule
+  hour <- fmap Int.fromWord8 . Set.toAscList . Hour.expand $ Schedule.hour schedule
+  minute <- fmap Int.fromWord8 . Set.toAscList . Minute.expand $ Schedule.minute schedule
+  time <-
+    fmap Time.sinceMidnight
+      . Maybe.maybeToList
+      $ Time.makeTimeOfDayValid hour minute 0
+  Monad.when (date == oldDate) . Monad.guard $ time > Time.utctDayTime utcTime
+  pure Time.UTCTime {Time.utctDay = date, Time.utctDayTime = time}
diff --git a/source/library/Saturn/Unstable/Parse.hs b/source/library/Saturn/Unstable/Parse.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Parse.hs
@@ -0,0 +1,28 @@
+module Saturn.Unstable.Parse where
+
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LazyText
+import qualified Saturn.Unstable.Type.Schedule as Schedule
+import qualified Text.Parsec as Parsec
+
+-- | Parses a lazy 'LazyText.Text' value into a 'Schedule.Schedule'. See
+-- 'fromText' for details.
+fromLazyText :: LazyText.Text -> Either Parsec.ParseError Schedule.Schedule
+fromLazyText = Parsec.parse parsec ""
+
+-- | Parses a 'String' into a 'Schedule.Schedule'. See 'fromText' for details.
+fromString :: String -> Either Parsec.ParseError Schedule.Schedule
+fromString = Parsec.parse parsec ""
+
+-- | Parses a strict 'Text.Text' value into a 'Schedule.Schedule'. The input
+-- should have five fields, each separated by at least one space. Leading and
+-- trailing spaces are allowed.
+fromText :: Text.Text -> Either Parsec.ParseError Schedule.Schedule
+fromText = Parsec.parse parsec ""
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Schedule.Schedule
+parsec =
+  Parsec.skipMany (Parsec.char ' ')
+    *> Schedule.parsec
+    <* Parsec.skipMany (Parsec.char ' ')
+    <* Parsec.eof
diff --git a/source/library/Saturn/Unstable/Render.hs b/source/library/Saturn/Unstable/Render.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Render.hs
@@ -0,0 +1,21 @@
+module Saturn.Unstable.Render where
+
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LazyText
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Saturn.Unstable.Type.Schedule as Schedule
+
+-- | Renders a 'Schedule.Schedule' into a lazy 'LazyText.Text' value. See
+-- 'toText' for details.
+toLazyText :: Schedule.Schedule -> LazyText.Text
+toLazyText = Builder.toLazyText . Schedule.toBuilder
+
+-- | Renders a 'Schedule.Schedule' into a 'String' value. See 'toText' for
+-- details.
+toString :: Schedule.Schedule -> String
+toString = LazyText.unpack . toLazyText
+
+-- | Renders a 'Schedule.Schedule' into a strict 'Text.Text' value. The output
+-- will have five fields, each separated by a single space.
+toText :: Schedule.Schedule -> Text.Text
+toText = LazyText.toStrict . toLazyText
diff --git a/source/library/Saturn/Unstable/Type/Day.hs b/source/library/Saturn/Unstable/Type/Day.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Day.hs
@@ -0,0 +1,36 @@
+module Saturn.Unstable.Type.Day where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Text.Parsec as Parsec
+
+newtype Day
+  = Day Field.Field
+  deriving (Eq, Show)
+
+bounds :: (Word.Word8, Word.Word8)
+bounds = (1, 31)
+
+fromField :: Field.Field -> Maybe Day
+fromField field =
+  if Field.isValid bounds field then Just $ Day field else Nothing
+
+toField :: Day -> Field.Field
+toField = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Day
+parsec = do
+  field <- Field.parsec
+  maybe (fail "invalid Day") pure $ fromField field
+
+toBuilder :: Day -> Builder.Builder
+toBuilder = Field.toBuilder . toField
+
+expand :: Day -> Set.Set Word.Word8
+expand = Field.expand bounds . toField
+
+isMatch :: Word.Word8 -> Day -> Bool
+isMatch word8 = Set.member word8 . expand
diff --git a/source/library/Saturn/Unstable/Type/Element.hs b/source/library/Saturn/Unstable/Type/Element.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Element.hs
@@ -0,0 +1,32 @@
+module Saturn.Unstable.Type.Element where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Extra.Parsec as Parsec
+import qualified Saturn.Unstable.Type.Number as Number
+import qualified Saturn.Unstable.Type.Range as Range
+import qualified Text.Parsec as Parsec
+
+newtype Element
+  = Element (Either Range.Range Number.Number)
+  deriving (Eq, Show)
+
+fromEither :: Either Range.Range Number.Number -> Element
+fromEither = Coerce.coerce
+
+toEither :: Element -> Either Range.Range Number.Number
+toEither = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Element
+parsec = fromEither <$> Parsec.either (Parsec.try Range.parsec) Number.parsec
+
+toBuilder :: Element -> Builder.Builder
+toBuilder = either Range.toBuilder Number.toBuilder . toEither
+
+isValid :: (Word.Word8, Word.Word8) -> Element -> Bool
+isValid tuple = either (Range.isValid tuple) (Number.isValid tuple) . toEither
+
+expand :: Element -> Set.Set Word.Word8
+expand = either Range.expand (Set.singleton . Number.toWord8) . toEither
diff --git a/source/library/Saturn/Unstable/Type/Field.hs b/source/library/Saturn/Unstable/Type/Field.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Field.hs
@@ -0,0 +1,56 @@
+module Saturn.Unstable.Type.Field where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Either as Either
+import qualified Data.Foldable as Foldable
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Extra.Parsec as Parsec
+import qualified Saturn.Unstable.Extra.Tuple as Tuple
+import qualified Saturn.Unstable.Type.Element as Element
+import qualified Saturn.Unstable.Type.Wildcard as Wildcard
+import qualified Text.Parsec as Parsec
+
+newtype Field
+  = Field (Either Wildcard.Wildcard (NonEmpty.NonEmpty Element.Element))
+  deriving (Eq, Show)
+
+fromEither ::
+  Either Wildcard.Wildcard (NonEmpty.NonEmpty Element.Element) -> Field
+fromEither = Coerce.coerce
+
+toEither ::
+  Field -> Either Wildcard.Wildcard (NonEmpty.NonEmpty Element.Element)
+toEither = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Field
+parsec =
+  fromEither
+    <$> Parsec.either
+      Wildcard.parsec
+      (Parsec.sepByNE Element.parsec $ Parsec.char ',')
+
+toBuilder :: Field -> Builder.Builder
+toBuilder =
+  either
+    Wildcard.toBuilder
+    ( Foldable.fold
+        . NonEmpty.intersperse (Builder.singleton ',')
+        . fmap Element.toBuilder
+    )
+    . toEither
+
+isValid :: (Word.Word8, Word.Word8) -> Field -> Bool
+isValid tuple = either (const True) (all $ Element.isValid tuple) . toEither
+
+expand :: (Word.Word8, Word.Word8) -> Field -> Set.Set Word.Word8
+expand tuple =
+  either
+    (const . Set.fromList $ Tuple.toSequence tuple)
+    (Set.unions . fmap Element.expand)
+    . toEither
+
+isWildcard :: Field -> Bool
+isWildcard = Either.isLeft . toEither
diff --git a/source/library/Saturn/Unstable/Type/Hour.hs b/source/library/Saturn/Unstable/Type/Hour.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Hour.hs
@@ -0,0 +1,36 @@
+module Saturn.Unstable.Type.Hour where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Text.Parsec as Parsec
+
+newtype Hour
+  = Hour Field.Field
+  deriving (Eq, Show)
+
+bounds :: (Word.Word8, Word.Word8)
+bounds = (0, 23)
+
+fromField :: Field.Field -> Maybe Hour
+fromField field =
+  if Field.isValid bounds field then Just $ Hour field else Nothing
+
+toField :: Hour -> Field.Field
+toField = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Hour
+parsec = do
+  field <- Field.parsec
+  maybe (fail "invalid Hour") pure $ fromField field
+
+toBuilder :: Hour -> Builder.Builder
+toBuilder = Field.toBuilder . toField
+
+expand :: Hour -> Set.Set Word.Word8
+expand = Field.expand bounds . toField
+
+isMatch :: Word.Word8 -> Hour -> Bool
+isMatch word8 = Set.member word8 . expand
diff --git a/source/library/Saturn/Unstable/Type/Minute.hs b/source/library/Saturn/Unstable/Type/Minute.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Minute.hs
@@ -0,0 +1,36 @@
+module Saturn.Unstable.Type.Minute where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Text.Parsec as Parsec
+
+newtype Minute
+  = Minute Field.Field
+  deriving (Eq, Show)
+
+bounds :: (Word.Word8, Word.Word8)
+bounds = (0, 59)
+
+fromField :: Field.Field -> Maybe Minute
+fromField field =
+  if Field.isValid bounds field then Just $ Minute field else Nothing
+
+toField :: Minute -> Field.Field
+toField = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Minute
+parsec = do
+  field <- Field.parsec
+  maybe (fail "invalid Minute") pure $ fromField field
+
+toBuilder :: Minute -> Builder.Builder
+toBuilder = Field.toBuilder . toField
+
+expand :: Minute -> Set.Set Word.Word8
+expand = Field.expand bounds . toField
+
+isMatch :: Word.Word8 -> Minute -> Bool
+isMatch word8 = Set.member word8 . expand
diff --git a/source/library/Saturn/Unstable/Type/Month.hs b/source/library/Saturn/Unstable/Type/Month.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Month.hs
@@ -0,0 +1,36 @@
+module Saturn.Unstable.Type.Month where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Text.Parsec as Parsec
+
+newtype Month
+  = Month Field.Field
+  deriving (Eq, Show)
+
+bounds :: (Word.Word8, Word.Word8)
+bounds = (1, 12)
+
+fromField :: Field.Field -> Maybe Month
+fromField field =
+  if Field.isValid bounds field then Just $ Month field else Nothing
+
+toField :: Month -> Field.Field
+toField = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Month
+parsec = do
+  field <- Field.parsec
+  maybe (fail "invalid Month") pure $ fromField field
+
+toBuilder :: Month -> Builder.Builder
+toBuilder = Field.toBuilder . toField
+
+expand :: Month -> Set.Set Word.Word8
+expand = Field.expand bounds . toField
+
+isMatch :: Word.Word8 -> Month -> Bool
+isMatch word8 = Set.member word8 . expand
diff --git a/source/library/Saturn/Unstable/Type/Number.hs b/source/library/Saturn/Unstable/Type/Number.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Number.hs
@@ -0,0 +1,33 @@
+module Saturn.Unstable.Type.Number where
+
+import qualified Data.Bits as Bits
+import qualified Data.Coerce as Coerce
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Text.Lazy.Builder.Int as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Extra.Ord as Ord
+import qualified Text.Parsec as Parsec
+import qualified Text.Read as Read
+
+newtype Number
+  = Number Word.Word8
+  deriving (Eq, Show)
+
+fromWord8 :: Word.Word8 -> Number
+fromWord8 = Coerce.coerce
+
+toWord8 :: Number -> Word.Word8
+toWord8 = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Number
+parsec = do
+  string <- Parsec.many1 Parsec.digit
+  integer <- maybe (fail "invalidNumber") pure $ Read.readMaybe string
+  maybe (fail "invalidNumber") (pure . fromWord8) $
+    Bits.toIntegralSized (integer :: Integer)
+
+toBuilder :: Number -> Builder.Builder
+toBuilder = Builder.decimal . toWord8
+
+isValid :: (Word.Word8, Word.Word8) -> Number -> Bool
+isValid tuple = Ord.within tuple . toWord8
diff --git a/source/library/Saturn/Unstable/Type/Range.hs b/source/library/Saturn/Unstable/Type/Range.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Range.hs
@@ -0,0 +1,45 @@
+module Saturn.Unstable.Type.Range where
+
+import qualified Control.Monad as Monad
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Extra.Tuple as Tuple
+import qualified Saturn.Unstable.Type.Number as Number
+import qualified Text.Parsec as Parsec
+
+newtype Range
+  = Range (Number.Number, Number.Number)
+  deriving (Eq, Show)
+
+fromTuple :: (Number.Number, Number.Number) -> Maybe Range
+fromTuple (lo, hi) =
+  if Number.toWord8 lo > Number.toWord8 hi
+    then Nothing
+    else Just $ Range (lo, hi)
+
+toTuple :: Range -> (Number.Number, Number.Number)
+toTuple = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Range
+parsec = do
+  lo <- Number.parsec
+  Monad.void $ Parsec.char '-'
+  hi <- Number.parsec
+  maybe (fail "invalid Range") pure $ fromTuple (lo, hi)
+
+toBuilder :: Range -> Builder.Builder
+toBuilder range =
+  let (lo, hi) = toTuple range
+   in Number.toBuilder lo <> Builder.singleton '-' <> Number.toBuilder hi
+
+isValid :: (Word.Word8, Word.Word8) -> Range -> Bool
+isValid tuple = uncurry (&&) . Tuple.mapBoth (Number.isValid tuple) . toTuple
+
+expand :: Range -> Set.Set Word.Word8
+expand =
+  Set.fromList
+    . Tuple.toSequence
+    . Tuple.mapBoth Number.toWord8
+    . toTuple
diff --git a/source/library/Saturn/Unstable/Type/Schedule.hs b/source/library/Saturn/Unstable/Type/Schedule.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Schedule.hs
@@ -0,0 +1,43 @@
+module Saturn.Unstable.Type.Schedule where
+
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Saturn.Unstable.Type.Day as Day
+import qualified Saturn.Unstable.Type.Hour as Hour
+import qualified Saturn.Unstable.Type.Minute as Minute
+import qualified Saturn.Unstable.Type.Month as Month
+import qualified Saturn.Unstable.Type.Weekday as Weekday
+import qualified Text.Parsec as Parsec
+
+data Schedule = Schedule
+  { minute :: Minute.Minute,
+    hour :: Hour.Hour,
+    day :: Day.Day,
+    month :: Month.Month,
+    weekday :: Weekday.Weekday
+  }
+  deriving (Eq, Show)
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Schedule
+parsec =
+  Schedule
+    <$> Minute.parsec
+    <* Parsec.skipMany1 (Parsec.char ' ')
+    <*> Hour.parsec
+    <* Parsec.skipMany1 (Parsec.char ' ')
+    <*> Day.parsec
+    <* Parsec.skipMany1 (Parsec.char ' ')
+    <*> Month.parsec
+    <* Parsec.skipMany1 (Parsec.char ' ')
+    <*> Weekday.parsec
+
+toBuilder :: Schedule -> Builder.Builder
+toBuilder schedule =
+  Minute.toBuilder (minute schedule)
+    <> Builder.singleton ' '
+    <> Hour.toBuilder (hour schedule)
+    <> Builder.singleton ' '
+    <> Day.toBuilder (day schedule)
+    <> Builder.singleton ' '
+    <> Month.toBuilder (month schedule)
+    <> Builder.singleton ' '
+    <> Weekday.toBuilder (weekday schedule)
diff --git a/source/library/Saturn/Unstable/Type/Weekday.hs b/source/library/Saturn/Unstable/Type/Weekday.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Weekday.hs
@@ -0,0 +1,36 @@
+module Saturn.Unstable.Type.Weekday where
+
+import qualified Data.Coerce as Coerce
+import qualified Data.Set as Set
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Data.Word as Word
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Text.Parsec as Parsec
+
+newtype Weekday
+  = Weekday Field.Field
+  deriving (Eq, Show)
+
+bounds :: (Word.Word8, Word.Word8)
+bounds = (0, 6)
+
+fromField :: Field.Field -> Maybe Weekday
+fromField field =
+  if Field.isValid bounds field then Just $ Weekday field else Nothing
+
+toField :: Weekday -> Field.Field
+toField = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Weekday
+parsec = do
+  field <- Field.parsec
+  maybe (fail "invalid Weekday") pure $ fromField field
+
+toBuilder :: Weekday -> Builder.Builder
+toBuilder = Field.toBuilder . toField
+
+expand :: Weekday -> Set.Set Word.Word8
+expand = Field.expand bounds . toField
+
+isMatch :: Word.Word8 -> Weekday -> Bool
+isMatch word8 = Set.member word8 . expand
diff --git a/source/library/Saturn/Unstable/Type/Wildcard.hs b/source/library/Saturn/Unstable/Type/Wildcard.hs
new file mode 100644
--- /dev/null
+++ b/source/library/Saturn/Unstable/Type/Wildcard.hs
@@ -0,0 +1,22 @@
+module Saturn.Unstable.Type.Wildcard where
+
+import qualified Control.Monad as Monad
+import qualified Data.Coerce as Coerce
+import qualified Data.Text.Lazy.Builder as Builder
+import qualified Text.Parsec as Parsec
+
+newtype Wildcard
+  = Wildcard ()
+  deriving (Eq, Show)
+
+fromUnit :: () -> Wildcard
+fromUnit = Coerce.coerce
+
+toUnit :: Wildcard -> ()
+toUnit = Coerce.coerce
+
+parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m Wildcard
+parsec = fmap fromUnit . Monad.void $ Parsec.char '*'
+
+toBuilder :: Wildcard -> Builder.Builder
+toBuilder = const $ Builder.singleton '*'
diff --git a/source/test-suite/Main.hs b/source/test-suite/Main.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Main.hs
@@ -0,0 +1,19 @@
+import qualified Saturn.Unstable.Extra.IntSpec
+import qualified Saturn.Unstable.Extra.OrdSpec
+import qualified Saturn.Unstable.Extra.ParsecSpec
+import qualified Saturn.Unstable.Extra.TimeSpec
+import qualified Saturn.Unstable.Extra.TupleSpec
+import qualified SaturnSpec
+import qualified Test.Hspec as Hspec
+
+main :: IO ()
+main = Hspec.hspec $ Hspec.parallel spec
+
+spec :: Hspec.Spec
+spec = do
+  Saturn.Unstable.Extra.IntSpec.spec
+  Saturn.Unstable.Extra.OrdSpec.spec
+  Saturn.Unstable.Extra.ParsecSpec.spec
+  Saturn.Unstable.Extra.TimeSpec.spec
+  Saturn.Unstable.Extra.TupleSpec.spec
+  SaturnSpec.spec
diff --git a/source/test-suite/Saturn/Unstable/Extra/IntSpec.hs b/source/test-suite/Saturn/Unstable/Extra/IntSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Saturn/Unstable/Extra/IntSpec.hs
@@ -0,0 +1,23 @@
+module Saturn.Unstable.Extra.IntSpec where
+
+import qualified Saturn.Unstable.Extra.Int as Int
+import qualified Test.Hspec as Hspec
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn.Unstable.Extra.Int" $ do
+  Hspec.describe "fromWord8" $ do
+    Hspec.it "works" $ do
+      Int.fromWord8 0 `Hspec.shouldBe` 0
+
+  Hspec.describe "toWord8" $ do
+    Hspec.it "succeeds with minimum" $ do
+      Int.toWord8 0 `Hspec.shouldBe` Just 0
+
+    Hspec.it "succeeds with maximum" $ do
+      Int.toWord8 255 `Hspec.shouldBe` Just 255
+
+    Hspec.it "fails below minimum" $ do
+      Int.toWord8 (-1) `Hspec.shouldBe` Nothing
+
+    Hspec.it "fails above maximum" $ do
+      Int.toWord8 256 `Hspec.shouldBe` Nothing
diff --git a/source/test-suite/Saturn/Unstable/Extra/OrdSpec.hs b/source/test-suite/Saturn/Unstable/Extra/OrdSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Saturn/Unstable/Extra/OrdSpec.hs
@@ -0,0 +1,22 @@
+module Saturn.Unstable.Extra.OrdSpec where
+
+import qualified Saturn.Unstable.Extra.Ord as Ord
+import qualified Test.Hspec as Hspec
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn.Unstable.Extra.Ord" $ do
+  Hspec.describe "within" $ do
+    Hspec.it "fails below lower bound" $ do
+      'a' `Hspec.shouldNotSatisfy` Ord.within ('b', 'd')
+
+    Hspec.it "succeeds at lower bound" $ do
+      'b' `Hspec.shouldSatisfy` Ord.within ('b', 'd')
+
+    Hspec.it "succeeds within bounds" $ do
+      'c' `Hspec.shouldSatisfy` Ord.within ('b', 'd')
+
+    Hspec.it "succeeds at upper bound" $ do
+      'd' `Hspec.shouldSatisfy` Ord.within ('b', 'd')
+
+    Hspec.it "fails above upper bound" $ do
+      'e' `Hspec.shouldNotSatisfy` Ord.within ('b', 'd')
diff --git a/source/test-suite/Saturn/Unstable/Extra/ParsecSpec.hs b/source/test-suite/Saturn/Unstable/Extra/ParsecSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Saturn/Unstable/Extra/ParsecSpec.hs
@@ -0,0 +1,35 @@
+module Saturn.Unstable.Extra.ParsecSpec where
+
+import qualified Data.Either as Either
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Saturn.Unstable.Extra.Parsec as Parsec
+import qualified Test.Hspec as Hspec
+import qualified Text.Parsec as Parsec
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn.Unstable.Extra.Parsec" $ do
+  Hspec.describe "either" $ do
+    let parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m (Either Char Char)
+        parsec = Parsec.either (Parsec.char 'a') (Parsec.char 'b')
+
+    Hspec.it "succeeds with left" $ do
+      Parsec.parse parsec "" "a" `Hspec.shouldBe` Right (Left 'a')
+
+    Hspec.it "succeeds with right" $ do
+      Parsec.parse parsec "" "b" `Hspec.shouldBe` Right (Right 'b')
+
+    Hspec.it "fails with neither" $ do
+      Parsec.parse parsec "" "c" `Hspec.shouldSatisfy` Either.isLeft
+
+  Hspec.describe "sepByNE" $ do
+    let parsec :: (Parsec.Stream s m Char) => Parsec.ParsecT s u m (NonEmpty.NonEmpty Char)
+        parsec = Parsec.sepByNE (Parsec.char 'a') (Parsec.char ' ')
+
+    Hspec.it "succeeds with one" $ do
+      Parsec.parse parsec "" "a" `Hspec.shouldBe` Right ('a' NonEmpty.:| [])
+
+    Hspec.it "succeeds with many" $ do
+      Parsec.parse parsec "" "a a" `Hspec.shouldBe` Right ('a' NonEmpty.:| "a")
+
+    Hspec.it "fails with none" $ do
+      Parsec.parse parsec "" "" `Hspec.shouldSatisfy` Either.isLeft
diff --git a/source/test-suite/Saturn/Unstable/Extra/TimeSpec.hs b/source/test-suite/Saturn/Unstable/Extra/TimeSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Saturn/Unstable/Extra/TimeSpec.hs
@@ -0,0 +1,29 @@
+module Saturn.Unstable.Extra.TimeSpec where
+
+import qualified Data.Time as Time
+import qualified Saturn.Unstable.Extra.Time as Time
+import qualified Test.Hspec as Hspec
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn.Unstable.Extra.Time" $ do
+  Hspec.describe "dayOfWeekToWord8" $ do
+    Hspec.it "works with Sunday" $ do
+      Time.dayOfWeekToWord8 Time.Sunday `Hspec.shouldBe` 0
+
+    Hspec.it "works with Monday" $ do
+      Time.dayOfWeekToWord8 Time.Monday `Hspec.shouldBe` 1
+
+    Hspec.it "works with Tuesday" $ do
+      Time.dayOfWeekToWord8 Time.Tuesday `Hspec.shouldBe` 2
+
+    Hspec.it "works with Wednesday" $ do
+      Time.dayOfWeekToWord8 Time.Wednesday `Hspec.shouldBe` 3
+
+    Hspec.it "works with Thursday" $ do
+      Time.dayOfWeekToWord8 Time.Thursday `Hspec.shouldBe` 4
+
+    Hspec.it "works with Friday" $ do
+      Time.dayOfWeekToWord8 Time.Friday `Hspec.shouldBe` 5
+
+    Hspec.it "works with Saturday" $ do
+      Time.dayOfWeekToWord8 Time.Saturday `Hspec.shouldBe` 6
diff --git a/source/test-suite/Saturn/Unstable/Extra/TupleSpec.hs b/source/test-suite/Saturn/Unstable/Extra/TupleSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/Saturn/Unstable/Extra/TupleSpec.hs
@@ -0,0 +1,20 @@
+module Saturn.Unstable.Extra.TupleSpec where
+
+import qualified Saturn.Unstable.Extra.Tuple as Tuple
+import qualified Test.Hspec as Hspec
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn.Unstable.Extra.Tuple" $ do
+  Hspec.describe "mapBoth" $ do
+    Hspec.it "works" $ do
+      Tuple.mapBoth succ ('A', 'a') `Hspec.shouldBe` ('B', 'b')
+
+  Hspec.describe "toSequence" $ do
+    Hspec.it "works" $ do
+      Tuple.toSequence ('b', 'd') `Hspec.shouldBe` "bcd"
+
+    Hspec.it "works with singleton" $ do
+      Tuple.toSequence ('a', 'a') `Hspec.shouldBe` "a"
+
+    Hspec.it "works with empty" $ do
+      Tuple.toSequence ('b', 'a') `Hspec.shouldBe` ""
diff --git a/source/test-suite/SaturnSpec.hs b/source/test-suite/SaturnSpec.hs
new file mode 100644
--- /dev/null
+++ b/source/test-suite/SaturnSpec.hs
@@ -0,0 +1,929 @@
+module SaturnSpec where
+
+import qualified Data.Either as Either
+import qualified Data.Fixed as Fixed
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Data.Maybe as Maybe
+import qualified Data.Time as Time
+import qualified Data.Time.Calendar.WeekDate as Time
+import qualified Data.Word as Word
+import qualified Saturn
+import qualified Saturn.Unstable.Extra.Tuple as Tuple
+import qualified Saturn.Unstable.Type.Day as Day
+import qualified Saturn.Unstable.Type.Element as Element
+import qualified Saturn.Unstable.Type.Field as Field
+import qualified Saturn.Unstable.Type.Hour as Hour
+import qualified Saturn.Unstable.Type.Minute as Minute
+import qualified Saturn.Unstable.Type.Month as Month
+import qualified Saturn.Unstable.Type.Number as Number
+import qualified Saturn.Unstable.Type.Range as Range
+import qualified Saturn.Unstable.Type.Schedule as Schedule
+import qualified Saturn.Unstable.Type.Weekday as Weekday
+import qualified Saturn.Unstable.Type.Wildcard as Wildcard
+import qualified Test.Hspec as Hspec
+import qualified Test.QuickCheck as QuickCheck
+
+spec :: Hspec.Spec
+spec = Hspec.describe "Saturn" $ do
+  Hspec.describe "round trips" $ do
+    Hspec.it "through string"
+      . QuickCheck.forAllShrink arbitrarySchedule shrinkSchedule
+      $ \schedule ->
+        Saturn.fromString (Saturn.toString schedule) `Hspec.shouldBe` Right schedule
+
+    Hspec.it "through strict text"
+      . QuickCheck.forAllShrink arbitrarySchedule shrinkSchedule
+      $ \schedule ->
+        Saturn.fromText (Saturn.toText schedule) `Hspec.shouldBe` Right schedule
+
+    Hspec.it "through lazy text"
+      . QuickCheck.forAllShrink arbitrarySchedule shrinkSchedule
+      $ \schedule ->
+        Saturn.fromLazyText (Saturn.toLazyText schedule) `Hspec.shouldBe` Right schedule
+
+  Hspec.describe "fromString" $ do
+    Hspec.it "accepts wildcards" $ do
+      schedule <- newSchedule [] [] [] [] []
+      Saturn.fromString "* * * * *" `Hspec.shouldBe` Right schedule
+
+    Hspec.it "accepts extra spaces" $ do
+      schedule <- newSchedule [] [] [] [] []
+      Saturn.fromString "  *  *  *  *  *  " `Hspec.shouldBe` Right schedule
+
+    Hspec.it "accepts numbers" $ do
+      schedule <- newSchedule [[4]] [[3]] [[2]] [[1]] [[0]]
+      Saturn.fromString "4 3 2 1 0" `Hspec.shouldBe` Right schedule
+
+    Hspec.it "accepts ranges" $ do
+      schedule <- newSchedule [[8, 9]] [[6, 7]] [[4, 5]] [[2, 3]] [[0, 1]]
+      Saturn.fromString "8-9 6-7 4-5 2-3 0-1" `Hspec.shouldBe` Right schedule
+
+    Hspec.it "accepts choices" $ do
+      schedule <- newSchedule [[8], [9]] [[6], [7]] [[4], [5]] [[2], [3]] [[0], [1]]
+      Saturn.fromString "8,9 6,7 4,5 2,3 0,1" `Hspec.shouldBe` Right schedule
+
+    Hspec.describe "minute" $ do
+      Hspec.it "accepts a number" $ do
+        schedule <- newSchedule [[0]] [] [] [] []
+        Saturn.fromString "0 * * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a range" $ do
+        schedule <- newSchedule [[0, 1]] [] [] [] []
+        Saturn.fromString "0-1 * * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a choice" $ do
+        schedule <- newSchedule [[0], [1]] [] [] [] []
+        Saturn.fromString "0,1 * * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "rejects two wildcards" $ do
+        Saturn.fromString "*,* * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a number" $ do
+        Saturn.fromString "*,0 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a range" $ do
+        Saturn.fromString "*,0-0 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number" $ do
+        Saturn.fromString "60 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number as part of a choice" $ do
+        Saturn.fromString "0,60 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds range" $ do
+        Saturn.fromString "60-61 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a half out of bounds range" $ do
+        Saturn.fromString "0-60 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a backwards range" $ do
+        Saturn.fromString "1-0 * * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+    Hspec.describe "hour" $ do
+      Hspec.it "accepts a number" $ do
+        schedule <- newSchedule [] [[0]] [] [] []
+        Saturn.fromString "* 0 * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a range" $ do
+        schedule <- newSchedule [] [[0, 1]] [] [] []
+        Saturn.fromString "* 0-1 * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a choice" $ do
+        schedule <- newSchedule [] [[0], [1]] [] [] []
+        Saturn.fromString "* 0,1 * * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "rejects two wildcards" $ do
+        Saturn.fromString "* *,* * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a number" $ do
+        Saturn.fromString "* *,0 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a range" $ do
+        Saturn.fromString "* *,0-0 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number" $ do
+        Saturn.fromString "* 24 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number as part of a choice" $ do
+        Saturn.fromString "* 0,24 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds range" $ do
+        Saturn.fromString "* 24-25 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a half out of bounds range" $ do
+        Saturn.fromString "* 0-24 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a backwards range" $ do
+        Saturn.fromString "* 1-0 * * *" `Hspec.shouldSatisfy` Either.isLeft
+
+    Hspec.describe "day" $ do
+      Hspec.it "accepts a number" $ do
+        schedule <- newSchedule [] [] [[1]] [] []
+        Saturn.fromString "* * 1 * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a range" $ do
+        schedule <- newSchedule [] [] [[1, 2]] [] []
+        Saturn.fromString "* * 1-2 * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a choice" $ do
+        schedule <- newSchedule [] [] [[1], [2]] [] []
+        Saturn.fromString "* * 1,2 * *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "rejects two wildcards" $ do
+        Saturn.fromString "* * *,* * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a number" $ do
+        Saturn.fromString "* * *,1 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a range" $ do
+        Saturn.fromString "* * *,1-1 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number" $ do
+        Saturn.fromString "* * 32 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number as part of a choice" $ do
+        Saturn.fromString "* * 1,32 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds range" $ do
+        Saturn.fromString "* * 32-33 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a half out of bounds range" $ do
+        Saturn.fromString "* * 1-32 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a backwards range" $ do
+        Saturn.fromString "* * 2-1 * *" `Hspec.shouldSatisfy` Either.isLeft
+
+    Hspec.describe "month" $ do
+      Hspec.it "accepts a number" $ do
+        schedule <- newSchedule [] [] [] [[1]] []
+        Saturn.fromString "* * * 1 *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a range" $ do
+        schedule <- newSchedule [] [] [] [[1, 2]] []
+        Saturn.fromString "* * * 1-2 *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a choice" $ do
+        schedule <- newSchedule [] [] [] [[1], [2]] []
+        Saturn.fromString "* * * 1,2 *" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "rejects two wildcards" $ do
+        Saturn.fromString "* * * *,* *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a number" $ do
+        Saturn.fromString "* * * *,1 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a range" $ do
+        Saturn.fromString "* * * *,1-1 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number" $ do
+        Saturn.fromString "* * * 13 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number as part of a choice" $ do
+        Saturn.fromString "* * * 1,13 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds range" $ do
+        Saturn.fromString "* * * 13-14 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a half out of bounds range" $ do
+        Saturn.fromString "* * * 1-13 *" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a backwards range" $ do
+        Saturn.fromString "* * * 2-1 *" `Hspec.shouldSatisfy` Either.isLeft
+
+    Hspec.describe "weekday" $ do
+      Hspec.it "accepts a number" $ do
+        schedule <- newSchedule [] [] [] [] [[0]]
+        Saturn.fromString "* * * * 0" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a range" $ do
+        schedule <- newSchedule [] [] [] [] [[0, 1]]
+        Saturn.fromString "* * * * 0-1" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "accepts a choice" $ do
+        schedule <- newSchedule [] [] [] [] [[0], [1]]
+        Saturn.fromString "* * * * 0,1" `Hspec.shouldBe` Right schedule
+
+      Hspec.it "rejects two wildcards" $ do
+        Saturn.fromString "* * * * *,*" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a number" $ do
+        Saturn.fromString "* * * * *,0" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a wildcard and a range" $ do
+        Saturn.fromString "* * * * *,0-0" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number" $ do
+        Saturn.fromString "* * * * 7" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds number as part of a choice" $ do
+        Saturn.fromString "* * * * 0,7" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects an out of bounds range" $ do
+        Saturn.fromString "* * * * 7-8" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a half out of bounds range" $ do
+        Saturn.fromString "* * * * 0-7" `Hspec.shouldSatisfy` Either.isLeft
+
+      Hspec.it "rejects a backwards range" $ do
+        Saturn.fromString "* * * * 1-0" `Hspec.shouldSatisfy` Either.isLeft
+
+  Hspec.describe "toString" $ do
+    Hspec.it "works with wildcards" $ do
+      schedule <- newSchedule [] [] [] [] []
+      Saturn.toString schedule `Hspec.shouldBe` "* * * * *"
+
+    Hspec.it "works with numbers" $ do
+      schedule <- newSchedule [[4]] [[3]] [[2]] [[1]] [[0]]
+      Saturn.toString schedule `Hspec.shouldBe` "4 3 2 1 0"
+
+    Hspec.it "works with ranges" $ do
+      schedule <- newSchedule [[8, 9]] [[6, 7]] [[4, 5]] [[2, 3]] [[0, 1]]
+      Saturn.toString schedule `Hspec.shouldBe` "8-9 6-7 4-5 2-3 0-1"
+
+    Hspec.it "works with choices" $ do
+      schedule <- newSchedule [[8], [9]] [[6], [7]] [[4], [5]] [[2], [3]] [[0], [1]]
+      Saturn.toString schedule `Hspec.shouldBe` "8,9 6,7 4,5 2,3 0,1"
+
+  Hspec.describe "isMatch" $ do
+    Hspec.it "is always true with all wildcards"
+      . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+      $ \utcTime -> do
+        schedule <- newSchedule [] [] [] [] []
+        schedule `Hspec.shouldSatisfy` Saturn.isMatch utcTime
+
+    Hspec.it "is true when day or weekday matches" $ do
+      s <- newSchedule [] [] [[5]] [] [[5]]
+      t1 <- newUtcTime 1970 1 5 0 0 0
+      s `Hspec.shouldSatisfy` Saturn.isMatch t1
+      t2 <- newUtcTime 1970 1 2 0 0 0
+      s `Hspec.shouldSatisfy` Saturn.isMatch t2
+
+    Hspec.describe "minute" $ do
+      Hspec.it "is always true when a number matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [[5]] [] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMinute 5 utcTime)
+
+      Hspec.it "is always true when a range matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [[4, 5]] [] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMinute 5 utcTime)
+
+      Hspec.it "is always true when a choice matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [[4], [5]] [] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMinute 5 utcTime)
+
+      Hspec.it "is true when a number matches" $ do
+        t <- newUtcTime 1970 1 1 0 5 0
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a number does not match" $ do
+        t <- newUtcTime 1970 1 1 0 6 0
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a range matches" $ do
+        t <- newUtcTime 1970 1 1 0 5 0
+        s <- newSchedule [[4, 5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a range does not match" $ do
+        t <- newUtcTime 1970 1 1 0 6 0
+        s <- newSchedule [[4, 5]] [] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a choice matches" $ do
+        t <- newUtcTime 1970 1 1 0 5 0
+        s <- newSchedule [[4], [5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a choice does not match" $ do
+        t <- newUtcTime 1970 1 1 0 6 0
+        s <- newSchedule [[4], [5]] [] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any second" $ do
+        t <- newUtcTime 1970 1 1 0 5 6
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any hour" $ do
+        t <- newUtcTime 1970 1 1 6 5 0
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any day" $ do
+        t <- newUtcTime 1970 1 6 0 5 0
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any month" $ do
+        t <- newUtcTime 1970 6 1 0 5 0
+        s <- newSchedule [[5]] [] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+    Hspec.describe "hour" $ do
+      Hspec.it "is always true when a number matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [[5]] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withHour 5 utcTime)
+
+      Hspec.it "is always true when a range matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [[4, 5]] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withHour 5 utcTime)
+
+      Hspec.it "is always true when a choice matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [[4], [5]] [] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withHour 5 utcTime)
+
+      Hspec.it "is true when a number matches" $ do
+        t <- newUtcTime 1970 1 1 5 0 0
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a number does not match" $ do
+        t <- newUtcTime 1970 1 1 6 0 0
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a range matches" $ do
+        t <- newUtcTime 1970 1 1 5 0 0
+        s <- newSchedule [] [[4, 5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a range does not match" $ do
+        t <- newUtcTime 1970 1 1 6 0 0
+        s <- newSchedule [] [[4, 5]] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a choice matches" $ do
+        t <- newUtcTime 1970 1 1 5 0 0
+        s <- newSchedule [] [[4], [5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a choice does not match" $ do
+        t <- newUtcTime 1970 1 1 6 0 0
+        s <- newSchedule [] [[4], [5]] [] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any second" $ do
+        t <- newUtcTime 1970 1 1 5 0 6
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any minute" $ do
+        t <- newUtcTime 1970 1 1 5 6 0
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any day" $ do
+        t <- newUtcTime 1970 1 6 5 0 0
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any month" $ do
+        t <- newUtcTime 1970 6 1 5 0 0
+        s <- newSchedule [] [[5]] [] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+    Hspec.describe "day" $ do
+      Hspec.it "is always true when a number matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [[5]] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfMonth 5 utcTime)
+
+      Hspec.it "is always true when a range matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [[4, 5]] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfMonth 5 utcTime)
+
+      Hspec.it "is always true when a choice matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [[4], [5]] [] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfMonth 5 utcTime)
+
+      Hspec.it "is true when a number matches" $ do
+        t <- newUtcTime 1970 1 5 0 0 0
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a number does not match" $ do
+        t <- newUtcTime 1970 1 6 0 0 0
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a range matches" $ do
+        t <- newUtcTime 1970 1 5 0 0 0
+        s <- newSchedule [] [] [[4, 5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a range does not match" $ do
+        t <- newUtcTime 1970 1 6 0 0 0
+        s <- newSchedule [] [] [[4, 5]] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a choice matches" $ do
+        t <- newUtcTime 1970 1 5 0 0 0
+        s <- newSchedule [] [] [[4], [5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a choice does not match" $ do
+        t <- newUtcTime 1970 1 6 0 0 0
+        s <- newSchedule [] [] [[4], [5]] [] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any second" $ do
+        t <- newUtcTime 1970 1 5 0 0 6
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any minute" $ do
+        t <- newUtcTime 1970 1 5 0 6 0
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any hour" $ do
+        t <- newUtcTime 1970 1 5 6 0 0
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any month" $ do
+        t <- newUtcTime 1970 6 5 0 0 0
+        s <- newSchedule [] [] [[5]] [] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+    Hspec.describe "month" $ do
+      Hspec.it "is always true when a number matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [[5]] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMonthOfYear 5 utcTime)
+
+      Hspec.it "is always true when a range matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [[4, 5]] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMonthOfYear 5 utcTime)
+
+      Hspec.it "is always true when a choice matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [[4], [5]] []
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withMonthOfYear 5 utcTime)
+
+      Hspec.it "is true when a number matches" $ do
+        t <- newUtcTime 1970 5 1 0 0 0
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a number does not match" $ do
+        t <- newUtcTime 1970 6 1 0 0 0
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a range matches" $ do
+        t <- newUtcTime 1970 5 1 0 0 0
+        s <- newSchedule [] [] [] [[4, 5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a range does not match" $ do
+        t <- newUtcTime 1970 6 1 0 0 0
+        s <- newSchedule [] [] [] [[4, 5]] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a choice matches" $ do
+        t <- newUtcTime 1970 5 1 0 0 0
+        s <- newSchedule [] [] [] [[4], [5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a choice does not match" $ do
+        t <- newUtcTime 1970 6 1 0 0 0
+        s <- newSchedule [] [] [] [[4], [5]] []
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any second" $ do
+        t <- newUtcTime 1970 5 1 0 0 6
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any minute" $ do
+        t <- newUtcTime 1970 5 1 0 6 0
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any hour" $ do
+        t <- newUtcTime 1970 5 1 6 0 0
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any day" $ do
+        t <- newUtcTime 1970 5 6 0 0 0
+        s <- newSchedule [] [] [] [[5]] []
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+    Hspec.describe "weekday" $ do
+      Hspec.it "is always true when a number matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [] [[5]]
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfWeek Time.Friday utcTime)
+
+      Hspec.it "is always true when a range matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [] [[4, 5]]
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfWeek Time.Friday utcTime)
+
+      Hspec.it "is always true when a choice matches"
+        . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+        $ \utcTime -> do
+          schedule <- newSchedule [] [] [] [] [[4], [5]]
+          schedule `Hspec.shouldSatisfy` Saturn.isMatch (withDayOfWeek Time.Friday utcTime)
+
+      Hspec.it "is true when a number matches" $ do
+        t <- newUtcTime 1970 1 2 0 0 0
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a number does not match" $ do
+        t <- newUtcTime 1970 1 3 0 0 0
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a range matches" $ do
+        t <- newUtcTime 1970 1 2 0 0 0
+        s <- newSchedule [] [] [] [] [[4, 5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a range does not match" $ do
+        t <- newUtcTime 1970 1 3 0 0 0
+        s <- newSchedule [] [] [] [] [[4, 5]]
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "is true when a choice matches" $ do
+        t <- newUtcTime 1970 1 2 0 0 0
+        s <- newSchedule [] [] [] [] [[4], [5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "is false when a choice does not match" $ do
+        t <- newUtcTime 1970 1 3 0 0 0
+        s <- newSchedule [] [] [] [] [[4], [5]]
+        s `Hspec.shouldNotSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any second" $ do
+        t <- newUtcTime 1970 1 2 0 0 6
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any minute" $ do
+        t <- newUtcTime 1970 1 2 0 6 0
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any hour" $ do
+        t <- newUtcTime 1970 1 2 6 0 0
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+      Hspec.it "accepts any day" $ do
+        t <- newUtcTime 1970 1 9 0 0 0
+        s <- newSchedule [] [] [] [] [[5]]
+        s `Hspec.shouldSatisfy` Saturn.isMatch t
+
+  Hspec.describe "nextMatch" $ do
+    Hspec.it "succeeds with a leap day" $ do
+      s <- newSchedule [[0]] [[0]] [[29]] [[2]] []
+      t1 <- newUtcTime 1970 1 1 0 0 0
+      t2 <- newUtcTime 1972 2 29 0 0 0
+      Saturn.nextMatch t1 s `Hspec.shouldBe` Just t2
+
+    Hspec.it "succeeds with the next leap day" $ do
+      s <- newSchedule [[0]] [[0]] [[29]] [[2]] []
+      t1 <- newUtcTime 1972 2 29 0 0 0
+      t2 <- newUtcTime 1976 2 29 0 0 0
+      Saturn.nextMatch t1 s `Hspec.shouldBe` Just t2
+
+    Hspec.it "succeeds with the furthest leap day" $ do
+      s <- newSchedule [[0]] [[0]] [[29]] [[2]] []
+      t1 <- newUtcTime 1896 2 29 0 0 0
+      t2 <- newUtcTime 1904 2 29 0 0 0
+      Saturn.nextMatch t1 s `Hspec.shouldBe` Just t2
+
+    Hspec.it "fails with an impossible date" $ do
+      s <- newSchedule [[0]] [[0]] [[30]] [[2]] []
+      t <- newUtcTime 1970 1 1 0 0 0
+      Saturn.nextMatch t s `Hspec.shouldBe` Nothing
+
+    Hspec.it "is always in the future"
+      . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+      $ \t1 -> do
+        schedule <- newSchedule [] [] [] [] []
+        t2 <- maybe (fail "impossible") pure $ Saturn.nextMatch t1 schedule
+        t2 `Hspec.shouldSatisfy` (>= t1)
+
+    Hspec.it "always matches"
+      . QuickCheck.forAllShrink arbitraryUtcTime shrinkUtcTime
+      $ \t1 -> do
+        schedule <- newSchedule [] [] [] [] []
+        t2 <- maybe (fail "impossible") pure $ Saturn.nextMatch t1 schedule
+        schedule `Hspec.shouldSatisfy` Saturn.isMatch t2
+
+withMinute :: Int -> Time.UTCTime -> Time.UTCTime
+withMinute minute = overTimeOfDay $ \timeOfDay -> timeOfDay {Time.todMin = minute}
+
+withHour :: Int -> Time.UTCTime -> Time.UTCTime
+withHour hour = overTimeOfDay $ \timeOfDay -> timeOfDay {Time.todHour = hour}
+
+overTimeOfDay :: (Time.TimeOfDay -> Time.TimeOfDay) -> Time.UTCTime -> Time.UTCTime
+overTimeOfDay f utcTime =
+  utcTime
+    { Time.utctDayTime =
+        Time.sinceMidnight . f . Time.pastMidnight $ Time.utctDayTime utcTime
+    }
+
+withDayOfMonth :: Time.DayOfMonth -> Time.UTCTime -> Time.UTCTime
+withDayOfMonth dayOfMonth = overDay $ \day ->
+  let (year, monthOfYear, _) = Time.toGregorian day
+   in Time.fromGregorian year monthOfYear dayOfMonth
+
+withMonthOfYear :: Time.MonthOfYear -> Time.UTCTime -> Time.UTCTime
+withMonthOfYear monthOfYear = overDay $ \day ->
+  let (year, _, dayOfMonth) = Time.toGregorian day
+   in Time.fromGregorian year monthOfYear dayOfMonth
+
+withDayOfWeek :: Time.DayOfWeek -> Time.UTCTime -> Time.UTCTime
+withDayOfWeek dayOfWeek = overDay $ \day ->
+  let fwt = Time.FirstWholeWeek
+      dow = Time.Sunday
+      (year, weekOfYear, _) = Time.toWeekCalendar fwt dow day
+   in Time.fromWeekCalendar fwt dow year weekOfYear dayOfWeek
+
+overDay :: (Time.Day -> Time.Day) -> Time.UTCTime -> Time.UTCTime
+overDay f utcTime = utcTime {Time.utctDay = f $ Time.utctDay utcTime}
+
+newUtcTime ::
+  (MonadFail m) =>
+  Time.Year ->
+  Time.MonthOfYear ->
+  Time.DayOfMonth ->
+  Int ->
+  Int ->
+  Fixed.Pico ->
+  m Time.UTCTime
+newUtcTime year monthOfYear dayOfMonth hour minute second = do
+  day <-
+    maybe (fail "invalid Day") pure $
+      Time.fromGregorianValid year monthOfYear dayOfMonth
+  timeOfDay <-
+    maybe (fail "invalid TimeOfDay") pure $
+      Time.makeTimeOfDayValid hour minute second
+  pure
+    Time.UTCTime
+      { Time.utctDay = day,
+        Time.utctDayTime = Time.sinceMidnight timeOfDay
+      }
+
+arbitraryUtcTime :: QuickCheck.Gen Time.UTCTime
+arbitraryUtcTime =
+  Time.UTCTime
+    <$> fmap Time.ModifiedJulianDay QuickCheck.arbitrary
+    <*> fmap Time.picosecondsToDiffTime (QuickCheck.chooseInteger (0, 86400000000000000 - 1))
+
+shrinkUtcTime :: Time.UTCTime -> [Time.UTCTime]
+shrinkUtcTime =
+  QuickCheck.shrinkMap
+    ( \(d, t) ->
+        Time.UTCTime
+          { Time.utctDay = Time.ModifiedJulianDay d,
+            Time.utctDayTime = Time.picosecondsToDiffTime t
+          }
+    )
+    ( \x ->
+        ( Time.toModifiedJulianDay $ Time.utctDay x,
+          Time.diffTimeToPicoseconds $ Time.utctDayTime x
+        )
+    )
+
+newSchedule ::
+  (MonadFail m) =>
+  [[Word.Word8]] ->
+  [[Word.Word8]] ->
+  [[Word.Word8]] ->
+  [[Word.Word8]] ->
+  [[Word.Word8]] ->
+  m Schedule.Schedule
+newSchedule minutes hours days months weekdays =
+  Schedule.Schedule
+    <$> newFieldWith "Minute" Minute.fromField minutes
+    <*> newFieldWith "Hour" Hour.fromField hours
+    <*> newFieldWith "Day" Day.fromField days
+    <*> newFieldWith "Month" Month.fromField months
+    <*> newFieldWith "Weekday" Weekday.fromField weekdays
+
+newFieldWith ::
+  (MonadFail m) =>
+  String ->
+  (Field.Field -> Maybe a) ->
+  [[Word.Word8]] ->
+  m a
+newFieldWith name fromField xs = do
+  field <- newField xs
+  maybe (fail $ "invalid " <> name <> ": " <> show xs) pure $ fromField field
+
+newField :: (MonadFail m) => [[Word.Word8]] -> m Field.Field
+newField =
+  fmap
+    ( Field.fromEither
+        . maybe (Left $ Wildcard.fromUnit ()) Right
+        . NonEmpty.nonEmpty
+    )
+    . mapM newElement
+
+newElement :: (MonadFail m) => [Word.Word8] -> m Element.Element
+newElement xs = case xs of
+  [x] -> pure . Element.fromEither . Right $ Number.fromWord8 x
+  [x, y] -> Element.fromEither . Left <$> newRange (x, y)
+  _ -> fail $ "invalid Element: " <> show xs
+
+newRange :: (MonadFail m) => (Word.Word8, Word.Word8) -> m Range.Range
+newRange tuple =
+  maybe (fail $ "invalid Range: " <> show tuple) pure
+    . Range.fromTuple
+    $ Tuple.mapBoth Number.fromWord8 tuple
+
+arbitrarySchedule :: QuickCheck.Gen Schedule.Schedule
+arbitrarySchedule =
+  Schedule.Schedule
+    <$> arbitraryMinute
+    <*> arbitraryHour
+    <*> arbitraryDay
+    <*> arbitraryMonth
+    <*> arbitraryWeekday
+
+arbitraryMinute :: QuickCheck.Gen Minute.Minute
+arbitraryMinute = QuickCheck.suchThatMap arbitraryField Minute.fromField
+
+arbitraryHour :: QuickCheck.Gen Hour.Hour
+arbitraryHour = QuickCheck.suchThatMap arbitraryField Hour.fromField
+
+arbitraryDay :: QuickCheck.Gen Day.Day
+arbitraryDay = QuickCheck.suchThatMap arbitraryField Day.fromField
+
+arbitraryMonth :: QuickCheck.Gen Month.Month
+arbitraryMonth = QuickCheck.suchThatMap arbitraryField Month.fromField
+
+arbitraryWeekday :: QuickCheck.Gen Weekday.Weekday
+arbitraryWeekday = QuickCheck.suchThatMap arbitraryField Weekday.fromField
+
+arbitraryField :: QuickCheck.Gen Field.Field
+arbitraryField =
+  Field.fromEither
+    <$> QuickCheck.liftArbitrary2
+      arbitraryWildcard
+      (arbitraryNonEmpty arbitraryElement)
+
+arbitraryWildcard :: QuickCheck.Gen Wildcard.Wildcard
+arbitraryWildcard = pure $ Wildcard.fromUnit ()
+
+arbitraryNonEmpty :: QuickCheck.Gen a -> QuickCheck.Gen (NonEmpty.NonEmpty a)
+arbitraryNonEmpty g = (NonEmpty.:|) <$> g <*> QuickCheck.listOf g
+
+arbitraryElement :: QuickCheck.Gen Element.Element
+arbitraryElement =
+  Element.fromEither
+    <$> QuickCheck.liftArbitrary2
+      arbitraryRange
+      arbitraryNumber
+
+arbitraryRange :: QuickCheck.Gen Range.Range
+arbitraryRange =
+  QuickCheck.suchThatMap
+    (QuickCheck.liftArbitrary2 arbitraryNumber arbitraryNumber)
+    Range.fromTuple
+
+arbitraryNumber :: QuickCheck.Gen Number.Number
+arbitraryNumber = Number.fromWord8 <$> QuickCheck.arbitrary
+
+shrinkSchedule :: Schedule.Schedule -> [Schedule.Schedule]
+shrinkSchedule schedule =
+  ( \(minute, hour, day, month, weekday) ->
+      Schedule.Schedule
+        { Schedule.minute = minute,
+          Schedule.hour = hour,
+          Schedule.day = day,
+          Schedule.month = month,
+          Schedule.weekday = weekday
+        }
+  )
+    <$> liftShrink5
+      shrinkMinute
+      shrinkHour
+      shrinkDay
+      shrinkMonth
+      shrinkWeekday
+      ( Schedule.minute schedule,
+        Schedule.hour schedule,
+        Schedule.day schedule,
+        Schedule.month schedule,
+        Schedule.weekday schedule
+      )
+
+liftShrink5 ::
+  (t1 -> [t1]) ->
+  (t2 -> [t2]) ->
+  (t3 -> [t3]) ->
+  (t4 -> [t4]) ->
+  (t5 -> [t5]) ->
+  (t1, t2, t3, t4, t5) ->
+  [(t1, t2, t3, t4, t5)]
+liftShrink5 f1 f2 f3 f4 f5 (x1, x2, x3, x4, x5) =
+  (\((y1, y2), (y3, (y4, y5))) -> (y1, y2, y3, y4, y5))
+    <$> QuickCheck.liftShrink2
+      (QuickCheck.liftShrink2 f1 f2)
+      (QuickCheck.liftShrink2 f3 (QuickCheck.liftShrink2 f4 f5))
+      ((x1, x2), (x3, (x4, x5)))
+
+shrinkMinute :: Minute.Minute -> [Minute.Minute]
+shrinkMinute = Maybe.mapMaybe Minute.fromField . shrinkField . Minute.toField
+
+shrinkHour :: Hour.Hour -> [Hour.Hour]
+shrinkHour = Maybe.mapMaybe Hour.fromField . shrinkField . Hour.toField
+
+shrinkDay :: Day.Day -> [Day.Day]
+shrinkDay = Maybe.mapMaybe Day.fromField . shrinkField . Day.toField
+
+shrinkMonth :: Month.Month -> [Month.Month]
+shrinkMonth = Maybe.mapMaybe Month.fromField . shrinkField . Month.toField
+
+shrinkWeekday :: Weekday.Weekday -> [Weekday.Weekday]
+shrinkWeekday = Maybe.mapMaybe Weekday.fromField . shrinkField . Weekday.toField
+
+shrinkField :: Field.Field -> [Field.Field]
+shrinkField field =
+  let xs = case Field.toEither field of
+        Left _ -> []
+        Right _ -> [Field.fromEither . Left $ Wildcard.fromUnit ()]
+   in mappend xs
+        . fmap Field.fromEither
+        . QuickCheck.liftShrink2 shrinkWildcard (shrinkNonEmpty shrinkElement)
+        $ Field.toEither field
+
+shrinkWildcard :: Wildcard.Wildcard -> [Wildcard.Wildcard]
+shrinkWildcard = fmap Wildcard.fromUnit . QuickCheck.shrink . Wildcard.toUnit
+
+shrinkNonEmpty :: (a -> [a]) -> NonEmpty.NonEmpty a -> [NonEmpty.NonEmpty a]
+shrinkNonEmpty f = Maybe.mapMaybe (NonEmpty.nonEmpty . f) . NonEmpty.toList
+
+shrinkElement :: Element.Element -> [Element.Element]
+shrinkElement element =
+  let xs = case Element.toEither element of
+        Left range ->
+          let (lo, hi) = Range.toTuple range
+           in fmap (Element.fromEither . Right) [lo, hi]
+        Right _ -> []
+   in mappend xs
+        . fmap Element.fromEither
+        . QuickCheck.liftShrink2 shrinkRange shrinkNumber
+        $ Element.toEither element
+
+shrinkRange :: Range.Range -> [Range.Range]
+shrinkRange =
+  Maybe.mapMaybe Range.fromTuple
+    . QuickCheck.liftShrink2 shrinkNumber shrinkNumber
+    . Range.toTuple
+
+shrinkNumber :: Number.Number -> [Number.Number]
+shrinkNumber = fmap Number.fromWord8 . QuickCheck.shrink . Number.toWord8
