packages feed

aws-eventbridge-cron-0.2.0.0: src/AWS/EventBridge/Schedule.hs

{-# LANGUAGE RecordWildCards #-}

-- | Zone-aware helpers built on top of 'AWS.EventBridge.Cron'.
--
-- == Choosing an entry point
--
-- * Parse the expression: use 'parseCronText' (UTC-only) or 'scheduleFromText'
--   when the rule already specifies an IANA timezone.
-- * Wrap the expression with 'scheduleFromExpr' or 'scheduleFromText' to carry
--   timezone metadata.
-- * Evaluate upcoming runs via the primary trio:
--
--   * 'nextRunTimesUTC'   – keep everything in UTC and compare against other
--     absolute timestamps.
--   * 'nextRunTimesLocal' – receive wall-clock values in the schedule's zone.
--   * 'nextRunTimesZoned' – like 'nextRunTimesLocal' but tagged with the
--     'TimeZone' used at each occurrence (captures DST changes).
--
-- Conversion helpers (the @*FromUTC@, @*FromLocal@, and @*FromZoned@ variants)
-- simply preprocess the base value before delegating to one of the three
-- primary functions. They are handy when your caller already has a specific
-- representation and you want to avoid manual conversions.
--
-- == Base/Input vs Output quick reference
--
-- @
-- Base input  Output      Function
-- ----------------------------------------------
-- UTCTime     UTCTime     nextRunTimesUTC
-- LocalTime   UTCTime     nextRunTimesUTCFromLocal
-- ZonedTime   UTCTime     nextRunTimesUTCFromZoned
-- UTCTime     LocalTime   nextRunTimesLocalFromUTC
-- LocalTime   LocalTime   nextRunTimesLocal
-- ZonedTime   LocalTime   nextRunTimesLocalFromZoned
-- UTCTime     ZonedTime   nextRunTimesZonedFromUTC
-- LocalTime   ZonedTime   nextRunTimesZonedFromLocal
-- ZonedTime   ZonedTime   nextRunTimesZoned
-- @
module AWS.EventBridge.Schedule
  ( -- * Schedule construction
    Schedule(..)
  , scheduleFromExpr
  , scheduleFromExprIANA
  , scheduleFromText
  , scheduleFromTextIANA
  , parseCronTextWithZone
  , parseCronTextWithIANA
    -- * Primary evaluation helpers
  , nextRunTimesUTC
  , nextRunTimesLocal
  , nextRunTimesZoned
    -- * Conversion helpers
  , nextRunTimesUTCFromLocal
  , nextRunTimesUTCFromZoned
  , nextRunTimesLocalFromUTC
  , nextRunTimesLocalFromZoned
  , nextRunTimesZonedFromUTC
  , nextRunTimesZonedFromLocal
  ) where

import AWS.EventBridge.Cron
  ( CronExprT
  , ScheduleKind(..)
  , nextRunTimes
  , parseCronText
  , scheduleKind
  )
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time
  ( LocalTime(..)
  , ZonedTime(..)
  , UTCTime(..)
  , timeOfDayToTime
  , timeToTimeOfDay
  , zonedTimeToUTC
  )
import Data.Time.Zones
  ( TZ
  , localTimeToUTCTZ
  , timeZoneForUTCTime
  , utcToLocalTimeTZ
  )
import Data.Time.Zones.All (TZLabel(..), fromTZName, tzByLabel)

-- | Scheduling expression paired with its IANA timezone.
--
-- Use 'scheduleFromText' when you need to parse the expression and bind the
-- timezone in one step, or 'scheduleFromExpr' if you already have a
-- 'CronExprT'.
data Schedule = Schedule
  { scheduleExpr :: CronExprT
  , scheduleZone :: TZ
  , scheduleZoneLabel :: TZLabel
  }

instance Show Schedule where
  show Schedule{..} =
    "Schedule {scheduleExpr = " <> show scheduleExpr <>
    ", scheduleZoneLabel = " <> show scheduleZoneLabel <>
    "}"

-- | Construct a 'Schedule' from an existing 'CronExprT' and timezone label.
--
-- @tz@ ships with bindings for the entire IANA database through 'TZLabel'. The
-- resulting schedule can be fed directly into the zone-aware 'nextRunTimes*'
-- helpers below.
scheduleFromExpr :: TZLabel -> CronExprT -> Schedule
scheduleFromExpr label expr = Schedule
  { scheduleExpr = expr
  , scheduleZone = tzByLabel label
  , scheduleZoneLabel = label
  }

-- | Construct a 'Schedule' directly from an IANA location name such as
-- @"America/New_York"@. This is a convenience wrapper over
-- 'scheduleFromExpr' for callers that already store timezone identifiers as
-- strings. Returns 'Left' when the name is unknown to the bundled tz database.
scheduleFromExprIANA :: Text -> CronExprT -> Either String Schedule
scheduleFromExprIANA tzName expr =
  scheduleFromExpr <$> resolveTZLabel tzName <*> pure expr

-- | Parse an EventBridge expression and attach a timezone in the same step.
--
-- >>> :{
-- let base = read "2025-11-16 03:30:00 UTC" :: UTCTime
-- in case scheduleFromText Asia__Kolkata "cron(0 9 ? NOV SUN 2025)" of
--      Left err -> Left err
--      Right sched -> nextRunTimesUTC base 1 sched
-- :}
-- Right [2025-11-16 03:30:00 UTC]
scheduleFromText :: TZLabel -> Text -> Either String Schedule
scheduleFromText label input =
  fmap (scheduleFromExpr label) (parseCronText input)

-- | Parse an EventBridge expression and attach a timezone via its IANA name
-- (for example @"Asia/Kolkata"@). This helper surfaces nicer ergonomics for
-- API payloads or configuration files that keep the canonical string form.
--
-- >>> let base = read "2025-11-16 03:30:00 UTC" :: UTCTime
-- >>> scheduleFromTextIANA "Asia/Kolkata" "cron(0 9 ? NOV SUN 2025)" >>= \sched -> nextRunTimesUTC base 1 sched
-- Right [2025-11-16 03:30:00 UTC]
scheduleFromTextIANA :: Text -> Text -> Either String Schedule
scheduleFromTextIANA tzName input =
  resolveTZLabel tzName >>= \label -> scheduleFromText label input

-- | Backwards-compatible alias for 'scheduleFromText'.
parseCronTextWithZone :: TZLabel -> Text -> Either String Schedule
parseCronTextWithZone = scheduleFromText

-- | Alias for 'scheduleFromTextIANA'.
--
-- >>> parseCronTextWithIANA "America/New_York" "cron(0 9 * * ? *)" >>= \sched -> nextRunTimesLocal (read "2025-11-01 08:30:00" :: LocalTime) 1 sched
-- Right [2025-11-01 09:00:00]
parseCronTextWithIANA :: Text -> Text -> Either String Schedule
parseCronTextWithIANA = scheduleFromTextIANA

-- | Local-time primary helper.
--
-- Evaluate run times in the schedule's local timezone, starting from a local
-- base time that is already expressed in the schedule's zone. Choose this when
-- you want to present results exactly as the rule owner configured them.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 08:00:00" :: LocalTime
-- >>> nextRunTimesLocal base 1 sched
-- Right [2025-11-01 09:00:00]
nextRunTimesLocal :: LocalTime -> Int -> Schedule -> Either String [LocalTime]
nextRunTimesLocal base limit schedule = scheduleLocalOccurrences schedule base limit

-- | Conversion helper.
--
-- Evaluate run times in the schedule's local timezone using a UTC base. Ideal
-- when upstream systems give you absolute timestamps (e.g. database clocks) but
-- UI clients expect the local wall clock.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 12:00:00 UTC" :: UTCTime
-- >>> nextRunTimesLocalFromUTC base 1 sched
-- Right [2025-11-01 09:00:00]
nextRunTimesLocalFromUTC :: UTCTime -> Int -> Schedule -> Either String [LocalTime]
nextRunTimesLocalFromUTC base limit schedule =
  scheduleLocalOccurrences schedule (utcToLocalTimeTZ (scheduleZone schedule) base) limit

-- | Conversion helper.
--
-- Evaluate run times in the schedule's local timezone using an arbitrary
-- 'ZonedTime' base (the offset on the input is ignored; only the instant
-- matters). Handy when you get user input such as "2025-11-01 09:00 EDT" and
-- want to keep working in local values.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 09:00:00-04:00" :: ZonedTime
-- >>> nextRunTimesLocalFromZoned base 2 sched
-- Right [2025-11-01 09:00:00,2025-11-02 09:00:00]
nextRunTimesLocalFromZoned :: ZonedTime -> Int -> Schedule -> Either String [LocalTime]
nextRunTimesLocalFromZoned base limit schedule =
  scheduleLocalOccurrences schedule (utcToLocalTimeTZ (scheduleZone schedule) (zonedTimeToUTC base)) limit

-- | UTC primary helper.
--
-- Evaluate run times in UTC while accepting a UTC base. This is the simplest
-- option when the rest of your system already speaks UTC.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 12:30:00 UTC" :: UTCTime
-- >>> nextRunTimesUTC base 1 sched
-- Right [2025-11-01 13:00:00 UTC]
nextRunTimesUTC :: UTCTime -> Int -> Schedule -> Either String [UTCTime]
nextRunTimesUTC base limit schedule =
  fmap (map (localToUTC schedule)) (nextRunTimesLocalFromUTC base limit schedule)

-- | Conversion helper.
--
-- Evaluate run times in UTC with a local base. Use this when your caller has
-- local wall time but downstream systems expect UTC instants.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 08:00:00" :: LocalTime
-- >>> nextRunTimesUTCFromLocal base 1 sched
-- Right [2025-11-01 13:00:00 UTC]
nextRunTimesUTCFromLocal :: LocalTime -> Int -> Schedule -> Either String [UTCTime]
nextRunTimesUTCFromLocal base limit schedule =
  fmap (map (localToUTC schedule)) (nextRunTimesLocal base limit schedule)

-- | Conversion helper.
--
-- Evaluate run times in UTC with a 'ZonedTime' base. Helpful when upstream APIs
-- hand you zoned timestamps and you want to compare the schedule against other
-- UTC data.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 09:00:00-04:00" :: ZonedTime
-- >>> nextRunTimesUTCFromZoned base 1 sched
-- Right [2025-11-01 13:00:00 UTC]
nextRunTimesUTCFromZoned :: ZonedTime -> Int -> Schedule -> Either String [UTCTime]
nextRunTimesUTCFromZoned base limit schedule =
  fmap (map (localToUTC schedule)) (nextRunTimesLocalFromZoned base limit schedule)

-- | Zoned-time primary helper.
--
-- Evaluate run times as 'ZonedTime' values using a zoned base. This preserves
-- the original offset used for the base and propagates DST changes into the
-- outputs, which is useful for logs or API responses that must mention the
-- effective offset explicitly.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 09:00:00-04:00" :: ZonedTime
-- >>> nextRunTimesZoned base 2 sched
-- Right [2025-11-01 09:00:00-04:00,2025-11-02 09:00:00-05:00]
nextRunTimesZoned :: ZonedTime -> Int -> Schedule -> Either String [ZonedTime]
nextRunTimesZoned base limit schedule =
  fmap (map (localToZoned schedule)) (nextRunTimesLocalFromZoned base limit schedule)

-- | Conversion helper.
--
-- Evaluate run times as 'ZonedTime' values from a UTC base.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 12:30:00 UTC" :: UTCTime
-- >>> nextRunTimesZonedFromUTC base 2 sched
-- Right [2025-11-01 09:00:00-04:00,2025-11-02 09:00:00-05:00]
nextRunTimesZonedFromUTC :: UTCTime -> Int -> Schedule -> Either String [ZonedTime]
nextRunTimesZonedFromUTC base limit schedule =
  fmap (map (localToZoned schedule)) (nextRunTimesLocalFromUTC base limit schedule)

-- | Conversion helper.
--
-- Evaluate run times as 'ZonedTime' values from a local base.
--
-- >>> let Right sched = scheduleFromText America__New_York "cron(0 9 * * ? *)"
-- >>> let base = read "2025-11-01 09:00:00" :: LocalTime
-- >>> nextRunTimesZonedFromLocal base 1 sched
-- Right [2025-11-01 09:00:00-04:00]
nextRunTimesZonedFromLocal :: LocalTime -> Int -> Schedule -> Either String [ZonedTime]
nextRunTimesZonedFromLocal base limit schedule =
  fmap (map (localToZoned schedule)) (nextRunTimesLocal base limit schedule)

scheduleLocalOccurrences :: Schedule -> LocalTime -> Int -> Either String [LocalTime]
scheduleLocalOccurrences sched@Schedule{..} base limit =
  case scheduleKind scheduleExpr of
    RateSchedule -> do
      let baseUtc = localToUTC sched base
      timesUtc <- nextRunTimes baseUtc limit scheduleExpr
      pure (map (utcToLocalTimeTZ scheduleZone) timesUtc)
    _ ->
      fmap (map utcToLocalNaive) (nextRunTimes (localToNaiveUTC base) limit scheduleExpr)

localToNaiveUTC :: LocalTime -> UTCTime
localToNaiveUTC (LocalTime day tod) = UTCTime day (timeOfDayToTime tod)

utcToLocalNaive :: UTCTime -> LocalTime
utcToLocalNaive (UTCTime day diff) = LocalTime day (timeToTimeOfDay diff)

localToUTC :: Schedule -> LocalTime -> UTCTime
localToUTC Schedule{..} = localTimeToUTCTZ scheduleZone

localToZoned :: Schedule -> LocalTime -> ZonedTime
localToZoned schedule local =
  let utcVal = localToUTC schedule local
      zone = scheduleZone schedule
      localWall = utcToLocalTimeTZ zone utcVal
      tzInfo = timeZoneForUTCTime zone utcVal
   in ZonedTime localWall tzInfo

resolveTZLabel :: Text -> Either String TZLabel
resolveTZLabel tzName =
  maybe (Left errMsg) Right (fromTZName (encodeUtf8 tzName))
  where
    errMsg = "unknown IANA timezone: " <> T.unpack tzName