timeline

Requirements

The timeline program outputs a timeline string on stdout that starts with a date in ISO 8601 format, followed by a date-dependent string. Four timeline string variants are possible, depending on the date:

A date before the start of my partial retirement

For a date before the start of my partial retirement, that is, before 2019-02-01, the timeline string looks like the string in the following example:

2019-01-31: Days left to start of partial retirement ............... 1

The overall size of the timeline string is 70 characters.

The date-dependent string starts with the text Days left to start of partial retirement, followed by dots, and ends with the number of days left (related to the current date), up to the date 2019-02-01, in other words, the difference in days between the date 2019-02-01 and the current date.

A date during the active phase of my partial retirement

For a date in the active phase of my partial retirement, that is, in the range 2019-02-01 .. 2021-03-31, the timeline string looks like the string in the following example:

2019-11-05: 790|------------------512~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|0

The overall size of the timeline string is 70 characters.

The date-dependent string starts with 790|, followed by 70 - 18 = 52 dash (-), tilde (~), or digit (0 .. 9) characters, and ends with |0, where the first dash / tilde (nearby 790|) marks the date 2019-01-31, and the last dash / tilde (nearby |0) marks the date 2021-03-31.

The current number of days left (related to the current date), up to the date 2021-03-31, in other words, the difference in days between the date 2021-03-31 and the current date, replaces the appropriate number of dashes (three for a three-digit number) at the “true to scale” position inside the date-dependent string.

Dash characters are used to the left of the current number of days left, whereas tilde characters are used to the right of the current number of days left.

A date during the passive phase of my partial retirement

For a date in the passive phase of my partial retirement, that is, in the range 2021-04-01 .. 2023-05-31, the timeline string looks like the string in the following example:

2021-08-06: 0|~~~~~~~128------------------------------------------|791

The overall size of the timeline string is 70 characters.

The date-dependent string starts with 0|, followed by 70 - 18 = 52 tilde (~), dash (-), or digit (0 .. 9) characters, and ends with |791, where the first tilde / dash (nearby 0|) marks the date 2021-03-31, and the last tilde / dash (nearby |791) marks the date 2023-05-31.

The current number of days passed (related to the current date), starting at the date 2021-03-31, in other words, the difference in days between the current date and the date 2021-03-31, replaces the appropriate number of tilde characters (three for a three-digit number) at the “true to scale” position inside the date-dependent string.

Tilde characters are used to the left of the current number of days passed, whereas dash characters are used to the right of the current number of days passed.

A date after the start of my retirement

For a date after the start of my retirement, that is, at 2023-06-01 or later, the timeline string looks like the string in the following example:

2023-06-01: RED .................................................... 1

The overall size of the timeline string is 70 characters.

The date-dependent string starts with the text RED, followed by dots, and ends with the number of days passed (related to the current date), starting at the date 2023-05-31, in other words, the difference in days between the current date and the date 2023-05-31.

Implementation in Haskell

For some time passed, functional programming is my passion. Therefore, I wrote the following small Haskell program to create the timeline string:

-- timeline.hs


import Data.Time
import System.Environment


timeLineSize :: Int
timeLineSize = 70

startDateActive, startDatePassive, startDateRetired  :: (Integer, Int, Int)
startDateActive  = (2019, 02, 01)
startDatePassive = (2021, 04, 01)
startDateRetired = (2023, 06, 01)


startDayActive, startDayPassive, startDayRetired:: Day
startDayActive  = fromGregorian' startDateActive
startDayPassive = fromGregorian' startDatePassive
startDayRetired = fromGregorian' startDateRetired

daysActive, daysPassive :: Int
daysActive  = fromInteger $ startDayPassive `diffDays` startDayActive
daysPassive = fromInteger $ startDayRetired `diffDays` startDayPassive

prefixActive, suffixActive, prefixPassive, suffixPassive :: String
prefixActive  = show daysActive <> "|"
suffixActive  = "|0"
prefixPassive = "0|"
suffixPassive = "|" <> show daysPassive

prefixBeforeActive, prefixRetired :: String
prefixBeforeActive = "Days left to start of partial retirement "
prefixRetired      = "RED "

prefixSuffixLengthActive, prefixSuffixLengthPassive :: Int
prefixSuffixLengthActive  = length $ mconcat [currentDate startDayActive,  prefixActive,  suffixActive]
prefixSuffixLengthPassive = length $ mconcat [currentDate startDayPassive, prefixPassive, suffixPassive]

prefixLengthBeforeActive, prefixLengthRetired :: Int
prefixLengthBeforeActive = length $ currentDate startDayActive  <> prefixBeforeActive
prefixLengthRetired      = length $ currentDate startDayPassive <> prefixRetired

fromGregorian' :: (Integer, Int, Int) -> Day
fromGregorian' (year, month, day) = fromGregorian year month day

scale :: Int -> Int -> Int -> Int
scale numerator denominator value
  | denominator == 0 = 0
  | otherwise        = value * numerator `div` denominator

mapper :: Int -> Int -> Int -> String
mapper dayDiff scaled x
  | scaled < x = "-"
  | scaled > x = "~"
  | otherwise = show dayDiff


currentDate :: Day -> String
currentDate = (<> ": ") . showGregorian

beforeActive :: Day -> String
beforeActive day
  | day < startDayActive = mconcat [prefixBeforeActive, replicate numDots '.', " ", show daysToStartActive]
  | otherwise = ""
  where
    daysToStartActive = startDayActive `diffDays` day
    numDots           = timeLineSize - sum [prefixLengthBeforeActive, length (show daysToStartActive), 1]


duringActive :: Day -> String
duringActive day
  | day >= startDayActive && day < startDayPassive = mconcat [prefixActive, line >>= mapper', suffixActive]
  | otherwise = ""
  where
    daysToEndActive = fromInteger $ startDayPassive `diffDays` day - 1
    start = timeLineSize - prefixSuffixLengthActive - length (show daysToEndActive)
    line = [start,start - 1 .. 0]
    mapper' :: Int -> String
    mapper' = mapper daysToEndActive $ scale start daysActive daysToEndActive

duringPassive :: Day -> String
duringPassive day
  | day >= startDayPassive && day < startDayRetired = mconcat [prefixPassive, line >>= mapper',  suffixPassive]
  | otherwise = ""
  where
    dayInPassive = fromInteger $ day `diffDays` startDayPassive + 1
    end = timeLineSize - prefixSuffixLengthPassive - length (show dayInPassive)
    line = [0 .. end]
    mapper' :: Int -> String
    mapper' = mapper dayInPassive $ scale end daysPassive dayInPassive

duringRetired :: Day -> String
duringRetired day
  | day >= startDayRetired = mconcat [prefixRetired, replicate numDots '.', " ", show dayInRetired]
  | otherwise = ""
  where
    dayInRetired = day `diffDays` startDayRetired + 1
    numDots      = timeLineSize - sum [prefixLengthRetired, length (show dayInRetired), 1]


timeLine :: Day -> String
timeLine = mconcat [currentDate, beforeActive, duringActive, duringPassive, duringRetired]

timeLineIO :: IO String
timeLineIO = timeLine . localDay . zonedTimeToLocalTime <$> getZonedTime


main :: IO ()
main = do
  args <- getArgs
  if null args || null (head args)
    then timeLineIO >>= putStrLn
    else mapM_ (putStrLn . timeLine) [addDays (-3) startDayActive .. addDays 2 startDayRetired]


-- EOF

There is no doubt that the implementation is possible in any language. But functional programming, especially Haskell, offers some cool stuff that I want to mention here.

Timeline string size, start dates of the (partial) retirement phases

timeLineSize defines the overall size of the timeline string, mentioned above, and is of type Int:

timeLineSize :: Int
timeLineSize = 70

startDateActive, startDatePassive, and startDateRetired define the start dates of the three phases of my (partial) retirement. Each variable is of type triple of Integer, Int, Int, where the first triple element is the year, the second element is the month, and the third element is the day:

startDateActive, startDatePassive, startDateRetired  :: (Integer, Int, Int)
startDateActive  = (2019, 02, 01)
startDatePassive = (2021, 04, 01)
startDateRetired = (2023, 06, 01)

Haskell variables are variables in the math sense, i.e. they are immutable. Haskell as a statically-typed language is very nitpicking concerning types. But fortunately, due to type inference, the compiler can deduce a lot of types automatically (cf. auto in C++).

Some further useful values

The variables startDayActive, startDayPassive, and startDayRetired are all of type Day, where Day is the Modified Julian Day as a standard count of days, with zero being the day 1858-11-17:

startDayActive, startDayPassive, startDayRetired:: Day
startDayActive  = fromGregorian' startDateActive
startDayPassive = fromGregorian' startDatePassive
startDayRetired = fromGregorian' startDateRetired

These variables are created by function fromGregorian'. fromGregorian' is a function of type (Integer, Int, Int) → Day, i.e. it expects a triple of Integer, Int, Int and returns a Day:

fromGregorian' :: (Integer, Int, Int) -> Day
fromGregorian' (year, month, day) = fromGregorian year month day

The prime (') does not make the function or the function name special. fromGregorian' is just a different function than fromGregorian (with a different name).

The function fromGregorian' is called with a triple, e.g. startDateActive. Pattern matching is used to assign the first value of the triple to the variable year, the second value to month, and the third value to day. Now, fromGregorian'calls the library function fromGregorian, which expects these three parameters as input.

The variable daysActive defines the number of days during the active phase of my partial retirement (790 days), whereas the variable daysPassive defines the number of days during the passive phase (791 days):

daysActive, daysPassive :: Int
daysActive  = fromInteger $ startDayPassive `diffDays` startDayActive
daysPassive = fromInteger $ startDayRetired `diffDays` startDayPassive

The backticks make the library function diffDays an infix operator. It could also be written as

daysActive, daysPassive :: Int
daysActive  = fromInteger $ diffDays startDayPassive startDayActive
daysPassive = fromInteger $ diffDays startDayRetired startDayPassive

But written as infix operator it is clear in the first place, which value shall be subtracted from which value.

The function fromInteger converts the result of diffDays, which is of type Integer, to Int. Normally, we have to be careful with this, since the conversion might result in 0. But in our case the day difference values are always small enough to fit in the Int type.

The $ sign is a function application that is used to avoid additional parenthesizes. You could also write:

daysActive, daysPassive :: Int
daysActive  = fromInteger (startDayPassive `diffDays` startDayActive)
daysPassive = fromInteger (startDayRetired `diffDays` startDayPassive)

The infix operator $ is right associate with lowest precedence. In contrast, normal function application (via white space) is left associative with highest precedence.

The String variable prefixActive is set to 790| by concatenating the string representation of daysActive (using function show) and the character |. The String variable suffixPassive is set to |791 by concatenating the character | and the string representation of daysPassive:

prefixActive, suffixActive, prefixPassive, suffixPassive :: String
prefixActive  = show daysActive <> "|"
suffixActive  = "|0"
prefixPassive = "0|"
suffixPassive = "|" <> show daysPassive

Instead of <>, we could also use ++. But the latter is only defined for lists (String is just a type “alias” for [Char]). There is a better (that is, more performant alternative) available for String: Text (in module Data.Text and Data.Text.Lazy). ++ is not defined for Text, but <> is, because Text (as well as lists) is a semigroup. If it is necessary to switch to Text, it is easy, if we use <> in the first place.

The String variables prefixBeforeActive and prefixRetired are simple:

prefixBeforeActive, prefixRetired :: String
prefixBeforeActive = "Days left to start of partial retirement "
prefixRetired      = "RED "

The Int variables prefixSuffixLengthActive and prefixSuffixLengthPassive both have a value of 18 and are calculated as follows:

prefixSuffixLengthActive, prefixSuffixLengthPassive :: Int
prefixSuffixLengthActive  = length $ mconcat [currentDate startDayActive,  prefixActive,  suffixActive]
prefixSuffixLengthPassive = length $ mconcat [currentDate startDayPassive, prefixPassive, suffixPassive]

The function currentDate expects a Day (Modified Julian Day) value, and returns the related date in ISO 8601 format, concatenated with a colon and a space character:

currentDate :: Day -> String
currentDate = (<> ": ") . showGregorian

The function is defined in pointfree notation, that is, with no function arguments. The library function showGregorian expects a Day value and returns a string representation of this value.

<> as an infix operator can also be written like an ordinary function, using parenthesizes: (<>). This is a function that expects two (semigroup, here: String) parameters. (<> ": ") is a function with one parameter (here: ": ") already applied. This partial application leads to a function that expects one (String) parameter.

The function currentDate is a function composition of showGregorian and (<> ": ") and is realized with the dot operator: (<> ":") . showGregorian. . is read as after. The result is a function (namely currentDate) that expects a Day and returns a String. It could also be written as

currentDate :: Day -> String
currentDate day = showGregorian day <> ": "

As a consequence, the variables prefixSuffixLengthActive and prefixSuffixLengthPassive get the value 18 as follows:

2019-11-05: 790|------------------512~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|0
\__________/\__/                                                    \/
    12       4                                                      2

2021-08-06: 0|~~~~~~~128------------------------------------------|791
\__________/\/                                                    \__/
    12      2                                                      4

Since String and Text are also monoids, we can use the related concatenation function mconcat with a list of String (or Text) instead of using <> repeatedly:

mconcat [currentDate startDayActive, prefixActive, suffixActive]

is equivalent to

currentDate startDayActive <> prefixActive <> suffixActive

The Int variables prefixLengthBeforeActive and prefixLengthRetired get their valaues as follows (watch the trailing blanks):

2019-01-31: Days left to start of partial retirement
\__________/\_______________________________________/
    12                         41

2023-06-01: RED
\__________/\__/
    12       4

Helper functions

The function scale expects a numerator, a denominator, and a value, and returns value * (numerator / denominator) that is, it scales a given value proportionally:

scale :: Int -> Int -> Int -> Int
scale numerator denominator value
  | denominator == 0 = 0
  | otherwise        = value * numerator `div` denominator

Although not necessary here (because the denominator cannot be 0), it is better practice to prevent a possible exception due to division by zero in the first place. One solution could be to return Maybe Int instead of Int. Our simple solution here is just returning 0 for a denominator set to 0.

The function uses guards (|) for checking the denominator.

The function mapper expects the Integer values dayDiff, scaled, and x, and returns a String, which is either a dash, a tilde, or the string representation of dayDiff.

mapper :: Int -> Int -> Int -> String
mapper dayDiff scaled x
  | scaled < x = "-"
  | scaled > x = "~"
  | otherwise = show dayDiff

The functions beforeActive, duringActive, duringPassive, and duringRetired expect a Day value each and return a string, depending on the given Day value.

Each function checks the given Day for being in the expected range, using guards (|). If Day is in the expected range, the function outputs a non-empty (date-dependent) string, otherwise an empty string. For the function beforeActive this is rather simple:

beforeActive :: Day -> String
beforeActive day
  | day < startDayActive = mconcat [prefixBeforeActive, replicate numDots '.', " ", show daysToStartActive]
  | otherwise = ""
  where
    daysToStartActive = startDayActive `diffDays` day
    numDots           = timeLineSize - sum [prefixLengthBeforeActive, length (show daysToStartActive), 1]

The job of function duringRetired is not so difficult as well:

duringRetired :: Day -> String
duringRetired day
  | day >= startDayRetired = mconcat [prefixRetired, replicate numDots '.', " ", show dayInRetired]
  | otherwise = ""
  where
    dayInRetired = day `diffDays` startDayRetired + 1
    numDots      = timeLineSize - sum [prefixLengthRetired, length (show dayInRetired), 1]

The function duringActive has to create a date-dependent string as follows:

duringActive :: Day -> String
duringActive day
  | day >= startDayActive && day < startDayPassive = mconcat [prefixActive, line >>= mapper', suffixActive]
  | otherwise = ""
  where
    daysToEndActive = fromInteger $ startDayPassive `diffDays` day - 1
    start = timeLineSize - prefixSuffixLengthActive - length (show daysToEndActive)
    line = [start,start - 1 .. 0]
    mapper' :: Int -> String
    mapper' = mapper daysToEndActive $ scale start daysActive daysToEndActive

line is a list of Int values. mapper' is a nested function, which is mapped over the list, that is, it is called repeatedly for one list element after the other, and returns a string that is either a dash (-), a tilde (~), or the string representation of daysToEndActive.

The mapper' function is called in the expression line >>= mapper'. >>= is the bind operator, which is defined for a monad and therefore also for line, which is a list. (Lists are monads.)

When the bind operator is applied to line and mapper', it executes the function mapper' for each element of line and creates a string (not a list of strings) as a result. As an alternative, we could also use the function map as follows:

map mapper' line

Watch that mapper' is of type Int -> String; therefore map creates a list of strings (instead of a string). We could use concat to “flatten” the list of strings to one string. As an alternative, the function concatMap does the mapping, followed by the “flattening”, in one step.

map is a list-specific implementation of the function fmap, which is defined for functors. (Lists are functors.) concat is the list-specific implementation of the function join, which is also defined for functors.

The function composition join . fmap is exactly what the bind operator does for monads. Therefore, the following expressions are all equivalent for lists, since lists are functors and monads:

concatMap mapper' line
concat (map mapper' line)
join (fmap mapper' line)  -- needs 'import Control.Monad'
line >>= mapper'

Look, how the function mapper' is defined:

mapper' :: Int -> String
mapper' = mapper daysToEndActive $ scale start daysActive daysToEndActive

mapper' calls function mapper with two arguments instead of three (partial application). The result of this calling is a function that expects one argument. This function (mapper') is used as an argument of the higher order function (>>=).

Like function duringActive, the function duringPassive has also to create a date-dependent string, as described in section Requirements, but in a slightly different way:

duringPassive :: Day -> String
duringPassive day
  | day >= startDayPassive && day < startDayRetired = mconcat [prefixPassive, line >>= mapper',  suffixPassive]
  | otherwise = ""
  where
    dayInPassive = fromInteger $ day `diffDays` startDayPassive + 1
    end = timeLineSize - prefixSuffixLengthPassive - length (show dayInPassive)
    line = [0 .. end]
    mapper' :: Int -> String
    mapper' = mapper dayInPassive $ scale end daysPassive dayInPassive

Some parameters and the nested function mapper' are different.

Output a timeline string for a given MJD

The function timeLine expects a Day value and returns the timeline string, related to this Day value:

timeLine :: Day -> String
timeLine = mconcat [currentDate, beforeActive, duringActive, duringPassive, duringRetired]

Did you notice that the functions currentDate, beforeActive, duringActive, duringPassive, and duringRetired all expect a Day value and return a String? currentDate always returns a non-empty string, whereas only one of the remaining functions returns a non-empty string, too. These functions are monoids. This property allows us to use the mconcat function, which makes timeLine a “one-liner”.

The function timeLineIO returns the (timeline) string that is related to the current date:

timeLineIO :: IO String
timeLineIO = timeLine . localDay . zonedTimeToLocalTime <$> getZonedTime

getZonedTime is of type IO String, where IO is a monad, that is, getZonedTime delivers the current local time, together with a time zone, as IO ZonedTime (not ZonedTime). IO is a computational context that cannot be left. (This would contaminate our pure world with impure stuff–which is absolute no-go.) This is the reason, why timeLineIO must also be of type IO String (not just String). timeLineIO holds an IO String (not a String).

As mentioned, getZonedTime returns a monad. Since all monads are also functors, the function fmap (here as infix operator <$>) is implemented also for the result of getZonedTime. This allows us to apply a function to the computational context (the monad), where the function does not know anything about this computational context.

Here, we do not apply just one function, but a function composition: timeLine . localDay . zonedTimeToLocalTime. The composed function is a function that expects a ZonedTime. fmap lifts this composed function to the computational context and therefore calls the composed function with ZonedTime, which is what we need.

The composed function converts the ZonedTime to the LocalTime, gets the Day value from this LocalTime, and creates the timeline, related to this Day value. The resulting String value is “put back” to the computational context by fmap, that is, the final result is an IO String (as desired).

The current local time is used here instead of UTC, because I want to get a new (decreased or increased) number of days left exactly at midnight at my location (Germany) with daylight saving time taken into account. If UTC shall be used instead, the function timeLineIO looks slightly different:

timeLineIO :: IO String
timeLineIO = timeLine . utctDay <$> getCurrentTime

getCurrentTime returns an IO monad as well, but it delivers the current UTC time as IO UTCTime. The composed function converts UTCTime to a Day value and creates the timeline, as described above.

The main function

The main function outputs the timeline, related to the current date, or the timelines for all dates between startDayActive - 3 and startDayRetired + 2 (depending on the given command line):

main :: IO ()
main = do
  args <- getArgs
  if null args || null (head args)
    then timeLineIO >>= putStrLn
    else mapM_ (putStrLn . timeLine) [addDays (-3) startDayActive .. addDays 2 startDayRetired]

Since such an output is just a side effect without reasonable return value, it is also called performing an action.

The bind operator >>=, which is implemented for a monad, gets an IO String here (namely the result of timeLineIO), removes the computational context (the IO), and calls putStrLn. This function expects (and gets) a String value and outputs it on stdout (more precisely: it creates an IO action that is then performed by main):

timeLineIO >>= putStrLn

For no command line argument set, the output is the timeline, related to the current date. But for any (non-empty) command line argument, in

mapM_ (putStrLn . timeLine) [addDays (-3) startDayActive .. addDays 2 startDayRetired]

the function mapM_ maps the composition of the functions putStrLn after timeLine over all dates between startDayActive - 3 and startDayRetired + 2.

The do notation is just syntactic sugar for the bind operator (among other things). Therefore, the following expression has the same effect as the do notation:

main :: IO ()
main =
  getArgs >>= \args ->
    if null args || null (head args)
      then timeLineIO >>= putStrLn
      else mapM_ (putStrLn . timeLine) [addDays (-3) startDayActive .. addDays 2 startDayRetired]

getArgs returns IO [String]. The bind operator removes the computational context and calls the lambda abstraction \args -> ... with [String] (a list of Strings). This anonymous function checks, if the list of strings is empty (i.e. no command line arguments are given) or if the first list element is empty. The latter is necessary to catch callings like timeLine "" properly.

Watch that the term null (head args) is executed only, if the term null args is False. This short circuiting prevents head from being executed for an empty list. Since Haskell is a lazy language, you get short circuiting for free. In most languages, ||, &&, and similar operators must be built specially into the language in order for them to short circuit evaluation.

As a consequence, main executes timeLineIO for no (non-empty) command argument given, otherwise mapM_.

Conclusion

Phew! I can’t believe, how much can be told about such a small implementation! But all of this is a rock-solid foundation for a bunch of similar requirements. (Fun fact: The Markdown source of this documentation is converted to HTML by Pandoc, which is written in Haskell.)

Wu